Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
2import { SQL } from "bun";
3import { JoseKey } from "@atproto/jwk-jose";
4import { BASE_HOST } from "./constants";
5
6export const db = new SQL(
7 process.env.NODE_ENV === 'production'
8 ? process.env.DATABASE_URL || (() => {
9 throw new Error('DATABASE_URL environment variable is required in production');
10 })()
11 : process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/wisp"
12);
13
14await db`
15 CREATE TABLE IF NOT EXISTS oauth_states (
16 key TEXT PRIMARY KEY,
17 data TEXT NOT NULL,
18 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
19 )
20`;
21
22await db`
23 CREATE TABLE IF NOT EXISTS oauth_sessions (
24 sub TEXT PRIMARY KEY,
25 data TEXT NOT NULL,
26 updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
27 )
28`;
29
30await db`
31 CREATE TABLE IF NOT EXISTS oauth_keys (
32 kid TEXT PRIMARY KEY,
33 jwk TEXT NOT NULL
34 )
35`;
36
37// Domains table maps subdomain -> DID
38await db`
39 CREATE TABLE IF NOT EXISTS domains (
40 domain TEXT PRIMARY KEY,
41 did TEXT UNIQUE NOT NULL,
42 rkey TEXT,
43 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
44 )
45`;
46
47// Add rkey column if it doesn't exist (for existing databases)
48try {
49 await db`ALTER TABLE domains ADD COLUMN IF NOT EXISTS rkey TEXT`;
50} catch (err) {
51 // Column might already exist, ignore
52}
53
54// Custom domains table for BYOD (bring your own domain)
55await db`
56 CREATE TABLE IF NOT EXISTS custom_domains (
57 id TEXT PRIMARY KEY,
58 domain TEXT UNIQUE NOT NULL,
59 did TEXT NOT NULL,
60 rkey TEXT NOT NULL DEFAULT 'self',
61 verified BOOLEAN DEFAULT false,
62 last_verified_at BIGINT,
63 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
64 )
65`;
66
67// Sites table - cache of place.wisp.fs records from PDS
68await db`
69 CREATE TABLE IF NOT EXISTS sites (
70 did TEXT NOT NULL,
71 rkey TEXT NOT NULL,
72 display_name TEXT,
73 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
74 updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
75 PRIMARY KEY (did, rkey)
76 )
77`;
78
79const RESERVED_HANDLES = new Set([
80 "www",
81 "api",
82 "admin",
83 "static",
84 "public",
85 "preview"
86]);
87
88export const isValidHandle = (handle: string): boolean => {
89 const h = handle.trim().toLowerCase();
90 if (h.length < 3 || h.length > 63) return false;
91 if (!/^[a-z0-9-]+$/.test(h)) return false;
92 if (h.startsWith('-') || h.endsWith('-')) return false;
93 if (h.includes('--')) return false;
94 if (RESERVED_HANDLES.has(h)) return false;
95 return true;
96};
97
98export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
99
100export const getDomainByDid = async (did: string): Promise<string | null> => {
101 const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
102 return rows[0]?.domain ?? null;
103};
104
105export const getWispDomainInfo = async (did: string) => {
106 const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`;
107 return rows[0] ?? null;
108};
109
110export const getDidByDomain = async (domain: string): Promise<string | null> => {
111 const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
112 return rows[0]?.did ?? null;
113};
114
115export const isDomainAvailable = async (handle: string): Promise<boolean> => {
116 const h = handle.trim().toLowerCase();
117 if (!isValidHandle(h)) return false;
118 const domain = toDomain(h);
119 const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`;
120 return rows.length === 0;
121};
122
123export const isDomainRegistered = async (domain: string) => {
124 const domainLower = domain.toLowerCase().trim();
125
126 // Check wisp.place subdomains
127 const wispDomain = await db`
128 SELECT did, domain, rkey FROM domains WHERE domain = ${domainLower}
129 `;
130
131 if (wispDomain.length > 0) {
132 return {
133 registered: true,
134 type: 'wisp' as const,
135 domain: wispDomain[0].domain,
136 did: wispDomain[0].did,
137 rkey: wispDomain[0].rkey
138 };
139 }
140
141 // Check custom domains
142 const customDomain = await db`
143 SELECT id, domain, did, rkey, verified FROM custom_domains WHERE domain = ${domainLower}
144 `;
145
146 if (customDomain.length > 0) {
147 return {
148 registered: true,
149 type: 'custom' as const,
150 domain: customDomain[0].domain,
151 did: customDomain[0].did,
152 rkey: customDomain[0].rkey,
153 verified: customDomain[0].verified
154 };
155 }
156
157 return { registered: false };
158};
159
160export const claimDomain = async (did: string, handle: string): Promise<string> => {
161 const h = handle.trim().toLowerCase();
162 if (!isValidHandle(h)) throw new Error('invalid_handle');
163 const domain = toDomain(h);
164 try {
165 await db`
166 INSERT INTO domains (domain, did)
167 VALUES (${domain}, ${did})
168 `;
169 } catch (err) {
170 // Unique constraint violations -> already taken or DID already claimed
171 throw new Error('conflict');
172 }
173 return domain;
174};
175
176export const updateDomain = async (did: string, handle: string): Promise<string> => {
177 const h = handle.trim().toLowerCase();
178 if (!isValidHandle(h)) throw new Error('invalid_handle');
179 const domain = toDomain(h);
180 try {
181 const rows = await db`
182 UPDATE domains SET domain = ${domain}
183 WHERE did = ${did}
184 RETURNING domain
185 `;
186 if (rows.length > 0) return rows[0].domain as string;
187 // No existing row, behave like claim
188 return await claimDomain(did, handle);
189 } catch (err) {
190 // Unique constraint violations -> already taken by someone else
191 throw new Error('conflict');
192 }
193};
194
195export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => {
196 await db`
197 UPDATE domains
198 SET rkey = ${siteRkey}
199 WHERE did = ${did}
200 `;
201};
202
203export const getWispDomainSite = async (did: string): Promise<string | null> => {
204 const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`;
205 return rows[0]?.rkey ?? null;
206};
207
208const stateStore = {
209 async set(key: string, data: any) {
210 console.debug('[stateStore] set', key)
211 await db`
212 INSERT INTO oauth_states (key, data)
213 VALUES (${key}, ${JSON.stringify(data)})
214 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
215 `;
216 },
217 async get(key: string) {
218 console.debug('[stateStore] get', key)
219 const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
220 return result[0] ? JSON.parse(result[0].data) : undefined;
221 },
222 async del(key: string) {
223 console.debug('[stateStore] del', key)
224 await db`DELETE FROM oauth_states WHERE key = ${key}`;
225 }
226};
227
228const sessionStore = {
229 async set(sub: string, data: any) {
230 console.debug('[sessionStore] set', sub)
231 await db`
232 INSERT INTO oauth_sessions (sub, data)
233 VALUES (${sub}, ${JSON.stringify(data)})
234 ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
235 `;
236 },
237 async get(sub: string) {
238 console.debug('[sessionStore] get', sub)
239 const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
240 return result[0] ? JSON.parse(result[0].data) : undefined;
241 },
242 async del(sub: string) {
243 console.debug('[sessionStore] del', sub)
244 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
245 }
246};
247
248export { sessionStore };
249
250export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
251 client_id: `${config.domain}/client-metadata.json`,
252 client_name: config.clientName,
253 client_uri: config.domain,
254 logo_uri: `${config.domain}/logo.png`,
255 tos_uri: `${config.domain}/tos`,
256 policy_uri: `${config.domain}/policy`,
257 redirect_uris: [`${config.domain}/api/auth/callback`],
258 grant_types: ['authorization_code', 'refresh_token'],
259 response_types: ['code'],
260 application_type: 'web',
261 token_endpoint_auth_method: 'private_key_jwt',
262 token_endpoint_auth_signing_alg: "ES256",
263 scope: "atproto transition:generic",
264 dpop_bound_access_tokens: true,
265 jwks_uri: `${config.domain}/jwks.json`,
266 subject_type: 'public',
267 authorization_signed_response_alg: 'ES256'
268});
269
270const persistKey = async (key: JoseKey) => {
271 const priv = key.privateJwk;
272 if (!priv) return;
273 const kid = key.kid ?? crypto.randomUUID();
274 await db`
275 INSERT INTO oauth_keys (kid, jwk)
276 VALUES (${kid}, ${JSON.stringify(priv)})
277 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
278 `;
279};
280
281const loadPersistedKeys = async (): Promise<JoseKey[]> => {
282 const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
283 const keys: JoseKey[] = [];
284 for (const row of rows) {
285 try {
286 const obj = JSON.parse(row.jwk);
287 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
288 keys.push(key);
289 } catch (err) {
290 console.error('Could not parse stored JWK', err);
291 }
292 }
293 return keys;
294};
295
296const ensureKeys = async (): Promise<JoseKey[]> => {
297 let keys = await loadPersistedKeys();
298 const needed: string[] = [];
299 for (let i = 1; i <= 3; i++) {
300 const kid = `key${i}`;
301 if (!keys.some(k => k.kid === kid)) needed.push(kid);
302 }
303 for (const kid of needed) {
304 const newKey = await JoseKey.generate(['ES256'], kid);
305 await persistKey(newKey);
306 keys.push(newKey);
307 }
308 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
309 return keys;
310};
311
312let currentKeys: JoseKey[] = [];
313
314export const getCurrentKeys = () => currentKeys;
315
316export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
317 if (currentKeys.length === 0) {
318 currentKeys = await ensureKeys();
319 }
320
321 return new NodeOAuthClient({
322 clientMetadata: createClientMetadata(config),
323 keyset: currentKeys,
324 stateStore,
325 sessionStore
326 });
327};
328
329export const getCustomDomainsByDid = async (did: string) => {
330 const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`;
331 return rows;
332};
333
334export const getCustomDomainInfo = async (domain: string) => {
335 const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`;
336 return rows[0] ?? null;
337};
338
339export const getCustomDomainByHash = async (hash: string) => {
340 const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`;
341 return rows[0] ?? null;
342};
343
344export const getCustomDomainById = async (id: string) => {
345 const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`;
346 return rows[0] ?? null;
347};
348
349export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string = 'self') => {
350 const domainLower = domain.toLowerCase();
351 try {
352 await db`
353 INSERT INTO custom_domains (id, domain, did, rkey, verified, created_at)
354 VALUES (${hash}, ${domainLower}, ${did}, ${rkey}, false, EXTRACT(EPOCH FROM NOW()))
355 `;
356 return { success: true, hash };
357 } catch (err) {
358 console.error('Failed to claim custom domain', err);
359 throw new Error('conflict');
360 }
361};
362
363export const updateCustomDomainRkey = async (id: string, rkey: string) => {
364 const rows = await db`
365 UPDATE custom_domains
366 SET rkey = ${rkey}
367 WHERE id = ${id}
368 RETURNING *
369 `;
370 return rows[0] ?? null;
371};
372
373export const updateCustomDomainVerification = async (id: string, verified: boolean) => {
374 const rows = await db`
375 UPDATE custom_domains
376 SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW())
377 WHERE id = ${id}
378 RETURNING *
379 `;
380 return rows[0] ?? null;
381};
382
383export const deleteCustomDomain = async (id: string) => {
384 await db`DELETE FROM custom_domains WHERE id = ${id}`;
385};
386
387export const getSitesByDid = async (did: string) => {
388 const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`;
389 return rows;
390};
391
392export const upsertSite = async (did: string, rkey: string, displayName?: string) => {
393 try {
394 // Only set display_name if provided (not undefined/null/empty)
395 const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null;
396
397 await db`
398 INSERT INTO sites (did, rkey, display_name, created_at, updated_at)
399 VALUES (${did}, ${rkey}, ${cleanDisplayName}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
400 ON CONFLICT (did, rkey)
401 DO UPDATE SET
402 display_name = CASE
403 WHEN EXCLUDED.display_name IS NOT NULL THEN EXCLUDED.display_name
404 ELSE sites.display_name
405 END,
406 updated_at = EXTRACT(EPOCH FROM NOW())
407 `;
408 return { success: true };
409 } catch (err) {
410 console.error('Failed to upsert site', err);
411 return { success: false, error: err };
412 }
413};