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 expires_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) + 2592000
28 )
29`;
30
31await db`
32 CREATE TABLE IF NOT EXISTS oauth_keys (
33 kid TEXT PRIMARY KEY,
34 jwk TEXT NOT NULL,
35 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
36 )
37`;
38
39// Domains table maps subdomain -> DID
40await db`
41 CREATE TABLE IF NOT EXISTS domains (
42 domain TEXT PRIMARY KEY,
43 did TEXT UNIQUE NOT NULL,
44 rkey TEXT,
45 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
46 )
47`;
48
49// Add columns if they don't exist (for existing databases)
50try {
51 await db`ALTER TABLE domains ADD COLUMN IF NOT EXISTS rkey TEXT`;
52} catch (err) {
53 // Column might already exist, ignore
54}
55
56try {
57 await db`ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS expires_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) + 2592000`;
58} catch (err) {
59 // Column might already exist, ignore
60}
61
62try {
63 await db`ALTER TABLE oauth_keys ADD COLUMN IF NOT EXISTS created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())`;
64} catch (err) {
65 // Column might already exist, ignore
66}
67
68try {
69 await db`ALTER TABLE oauth_states ADD COLUMN IF NOT EXISTS expires_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) + 3600`;
70} catch (err) {
71 // Column might already exist, ignore
72}
73
74// Custom domains table for BYOD (bring your own domain)
75await db`
76 CREATE TABLE IF NOT EXISTS custom_domains (
77 id TEXT PRIMARY KEY,
78 domain TEXT UNIQUE NOT NULL,
79 did TEXT NOT NULL,
80 rkey TEXT,
81 verified BOOLEAN DEFAULT false,
82 last_verified_at BIGINT,
83 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
84 )
85`;
86
87// Migrate existing tables to make rkey nullable and remove default
88try {
89 await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP NOT NULL`;
90} catch (err) {
91 // Column might already be nullable, ignore
92}
93try {
94 await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP DEFAULT`;
95} catch (err) {
96 // Default might already be removed, ignore
97}
98
99// Sites table - cache of place.wisp.fs records from PDS
100await db`
101 CREATE TABLE IF NOT EXISTS sites (
102 did TEXT NOT NULL,
103 rkey TEXT NOT NULL,
104 display_name TEXT,
105 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
106 updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
107 PRIMARY KEY (did, rkey)
108 )
109`;
110
111const RESERVED_HANDLES = new Set([
112 "www",
113 "api",
114 "admin",
115 "static",
116 "public",
117 "preview"
118]);
119
120export const isValidHandle = (handle: string): boolean => {
121 const h = handle.trim().toLowerCase();
122 if (h.length < 3 || h.length > 63) return false;
123 if (!/^[a-z0-9-]+$/.test(h)) return false;
124 if (h.startsWith('-') || h.endsWith('-')) return false;
125 if (h.includes('--')) return false;
126 if (RESERVED_HANDLES.has(h)) return false;
127 return true;
128};
129
130export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
131
132export const getDomainByDid = async (did: string): Promise<string | null> => {
133 const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
134 return rows[0]?.domain ?? null;
135};
136
137export const getWispDomainInfo = async (did: string) => {
138 const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`;
139 return rows[0] ?? null;
140};
141
142export const getDidByDomain = async (domain: string): Promise<string | null> => {
143 const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
144 return rows[0]?.did ?? null;
145};
146
147export const isDomainAvailable = async (handle: string): Promise<boolean> => {
148 const h = handle.trim().toLowerCase();
149 if (!isValidHandle(h)) return false;
150 const domain = toDomain(h);
151 const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`;
152 return rows.length === 0;
153};
154
155export const isDomainRegistered = async (domain: string) => {
156 const domainLower = domain.toLowerCase().trim();
157
158 // Check wisp.place subdomains
159 const wispDomain = await db`
160 SELECT did, domain, rkey FROM domains WHERE domain = ${domainLower}
161 `;
162
163 if (wispDomain.length > 0) {
164 return {
165 registered: true,
166 type: 'wisp' as const,
167 domain: wispDomain[0].domain,
168 did: wispDomain[0].did,
169 rkey: wispDomain[0].rkey
170 };
171 }
172
173 // Check custom domains
174 const customDomain = await db`
175 SELECT id, domain, did, rkey, verified FROM custom_domains WHERE domain = ${domainLower}
176 `;
177
178 if (customDomain.length > 0) {
179 return {
180 registered: true,
181 type: 'custom' as const,
182 domain: customDomain[0].domain,
183 did: customDomain[0].did,
184 rkey: customDomain[0].rkey,
185 verified: customDomain[0].verified
186 };
187 }
188
189 return { registered: false };
190};
191
192export const claimDomain = async (did: string, handle: string): Promise<string> => {
193 const h = handle.trim().toLowerCase();
194 if (!isValidHandle(h)) throw new Error('invalid_handle');
195 const domain = toDomain(h);
196 try {
197 await db`
198 INSERT INTO domains (domain, did)
199 VALUES (${domain}, ${did})
200 `;
201 } catch (err) {
202 // Unique constraint violations -> already taken or DID already claimed
203 throw new Error('conflict');
204 }
205 return domain;
206};
207
208export const updateDomain = async (did: string, handle: string): Promise<string> => {
209 const h = handle.trim().toLowerCase();
210 if (!isValidHandle(h)) throw new Error('invalid_handle');
211 const domain = toDomain(h);
212 try {
213 const rows = await db`
214 UPDATE domains SET domain = ${domain}
215 WHERE did = ${did}
216 RETURNING domain
217 `;
218 if (rows.length > 0) return rows[0].domain as string;
219 // No existing row, behave like claim
220 return await claimDomain(did, handle);
221 } catch (err) {
222 // Unique constraint violations -> already taken by someone else
223 throw new Error('conflict');
224 }
225};
226
227export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => {
228 await db`
229 UPDATE domains
230 SET rkey = ${siteRkey}
231 WHERE did = ${did}
232 `;
233};
234
235export const getWispDomainSite = async (did: string): Promise<string | null> => {
236 const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`;
237 return rows[0]?.rkey ?? null;
238};
239
240// Session timeout configuration (30 days in seconds)
241const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
242// OAuth state timeout (1 hour in seconds)
243const STATE_TIMEOUT = 60 * 60; // 3600 seconds
244
245const stateStore = {
246 async set(key: string, data: any) {
247 console.debug('[stateStore] set', key)
248 const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT;
249 await db`
250 INSERT INTO oauth_states (key, data, created_at, expires_at)
251 VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
252 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt}
253 `;
254 },
255 async get(key: string) {
256 console.debug('[stateStore] get', key)
257 const now = Math.floor(Date.now() / 1000);
258 const result = await db`
259 SELECT data, expires_at
260 FROM oauth_states
261 WHERE key = ${key}
262 `;
263 if (!result[0]) return undefined;
264
265 // Check if expired
266 const expiresAt = Number(result[0].expires_at);
267 if (expiresAt && now > expiresAt) {
268 console.debug('[stateStore] State expired, deleting', key);
269 await db`DELETE FROM oauth_states WHERE key = ${key}`;
270 return undefined;
271 }
272
273 return JSON.parse(result[0].data);
274 },
275 async del(key: string) {
276 console.debug('[stateStore] del', key)
277 await db`DELETE FROM oauth_states WHERE key = ${key}`;
278 }
279};
280
281const sessionStore = {
282 async set(sub: string, data: any) {
283 console.debug('[sessionStore] set', sub)
284 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT;
285 await db`
286 INSERT INTO oauth_sessions (sub, data, updated_at, expires_at)
287 VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
288 ON CONFLICT (sub) DO UPDATE SET
289 data = EXCLUDED.data,
290 updated_at = EXTRACT(EPOCH FROM NOW()),
291 expires_at = ${expiresAt}
292 `;
293 },
294 async get(sub: string) {
295 console.debug('[sessionStore] get', sub)
296 const now = Math.floor(Date.now() / 1000);
297 const result = await db`
298 SELECT data, expires_at
299 FROM oauth_sessions
300 WHERE sub = ${sub}
301 `;
302 if (!result[0]) return undefined;
303
304 // Check if expired
305 const expiresAt = Number(result[0].expires_at);
306 if (expiresAt && now > expiresAt) {
307 console.log('[sessionStore] Session expired, deleting', sub);
308 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
309 return undefined;
310 }
311
312 return JSON.parse(result[0].data);
313 },
314 async del(sub: string) {
315 console.debug('[sessionStore] del', sub)
316 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
317 }
318};
319
320export { sessionStore };
321
322// Cleanup expired sessions and states
323export const cleanupExpiredSessions = async () => {
324 const now = Math.floor(Date.now() / 1000);
325 try {
326 const sessionsDeleted = await db`
327 DELETE FROM oauth_sessions WHERE expires_at < ${now}
328 `;
329 const statesDeleted = await db`
330 DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now}
331 `;
332 console.log(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`);
333 return { sessions: sessionsDeleted.length, states: statesDeleted.length };
334 } catch (err) {
335 console.error('[Cleanup] Failed to cleanup expired data:', err);
336 return { sessions: 0, states: 0 };
337 }
338};
339
340export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => {
341 const isLocalDev = process.env.LOCAL_DEV === 'true';
342
343 if (isLocalDev) {
344 // Loopback client for local development
345 // For loopback, scopes and redirect_uri must be in client_id query string
346 const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
347 const scope = 'atproto transition:generic';
348 const params = new URLSearchParams();
349 params.append('redirect_uri', redirectUri);
350 params.append('scope', scope);
351
352 return {
353 client_id: `http://localhost?${params.toString()}`,
354 client_name: config.clientName,
355 client_uri: config.domain,
356 redirect_uris: [redirectUri],
357 grant_types: ['authorization_code', 'refresh_token'],
358 response_types: ['code'],
359 application_type: 'web',
360 token_endpoint_auth_method: 'none',
361 scope: scope,
362 dpop_bound_access_tokens: false,
363 subject_type: 'public'
364 };
365 }
366
367 // Production client with private_key_jwt
368 return {
369 client_id: `${config.domain}/client-metadata.json`,
370 client_name: config.clientName,
371 client_uri: config.domain,
372 logo_uri: `${config.domain}/logo.png`,
373 tos_uri: `${config.domain}/tos`,
374 policy_uri: `${config.domain}/policy`,
375 redirect_uris: [`${config.domain}/api/auth/callback`],
376 grant_types: ['authorization_code', 'refresh_token'],
377 response_types: ['code'],
378 application_type: 'web',
379 token_endpoint_auth_method: 'private_key_jwt',
380 token_endpoint_auth_signing_alg: "ES256",
381 scope: "atproto transition:generic",
382 dpop_bound_access_tokens: true,
383 jwks_uri: `${config.domain}/jwks.json`,
384 subject_type: 'public',
385 authorization_signed_response_alg: 'ES256'
386 };
387};
388
389const persistKey = async (key: JoseKey) => {
390 const priv = key.privateJwk;
391 if (!priv) return;
392 const kid = key.kid ?? crypto.randomUUID();
393 await db`
394 INSERT INTO oauth_keys (kid, jwk, created_at)
395 VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW()))
396 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
397 `;
398};
399
400const loadPersistedKeys = async (): Promise<JoseKey[]> => {
401 const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`;
402 const keys: JoseKey[] = [];
403 for (const row of rows) {
404 try {
405 const obj = JSON.parse(row.jwk);
406 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
407 keys.push(key);
408 } catch (err) {
409 console.error('Could not parse stored JWK', err);
410 }
411 }
412 return keys;
413};
414
415const ensureKeys = async (): Promise<JoseKey[]> => {
416 let keys = await loadPersistedKeys();
417 const needed: string[] = [];
418 for (let i = 1; i <= 3; i++) {
419 const kid = `key${i}`;
420 if (!keys.some(k => k.kid === kid)) needed.push(kid);
421 }
422 for (const kid of needed) {
423 const newKey = await JoseKey.generate(['ES256'], kid);
424 await persistKey(newKey);
425 keys.push(newKey);
426 }
427 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
428 return keys;
429};
430
431// Load keys from database every time (stateless - safe for horizontal scaling)
432export const getCurrentKeys = async (): Promise<JoseKey[]> => {
433 return await loadPersistedKeys();
434};
435
436// Key rotation - rotate keys older than 30 days (monthly rotation)
437const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
438
439export const rotateKeysIfNeeded = async (): Promise<boolean> => {
440 const now = Math.floor(Date.now() / 1000);
441 const cutoffTime = now - KEY_MAX_AGE;
442
443 try {
444 // Find keys older than 30 days
445 const oldKeys = await db`
446 SELECT kid, created_at FROM oauth_keys
447 WHERE created_at IS NOT NULL AND created_at < ${cutoffTime}
448 ORDER BY created_at ASC
449 `;
450
451 if (oldKeys.length === 0) {
452 console.log('[KeyRotation] No keys need rotation');
453 return false;
454 }
455
456 console.log(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`);
457
458 // Rotate the oldest key
459 const oldestKey = oldKeys[0];
460 const oldKid = oldestKey.kid;
461
462 // Generate new key with same kid
463 const newKey = await JoseKey.generate(['ES256'], oldKid);
464 await persistKey(newKey);
465
466 console.log(`[KeyRotation] Rotated key ${oldKid}`);
467
468 return true;
469 } catch (err) {
470 console.error('[KeyRotation] Failed to rotate keys:', err);
471 return false;
472 }
473};
474
475export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
476 const keys = await ensureKeys();
477
478 return new NodeOAuthClient({
479 clientMetadata: createClientMetadata(config),
480 keyset: keys,
481 stateStore,
482 sessionStore
483 });
484};
485
486export const getCustomDomainsByDid = async (did: string) => {
487 const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`;
488 return rows;
489};
490
491export const getCustomDomainInfo = async (domain: string) => {
492 const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`;
493 return rows[0] ?? null;
494};
495
496export const getCustomDomainByHash = async (hash: string) => {
497 const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`;
498 return rows[0] ?? null;
499};
500
501export const getCustomDomainById = async (id: string) => {
502 const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`;
503 return rows[0] ?? null;
504};
505
506export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string | null = null) => {
507 const domainLower = domain.toLowerCase();
508 try {
509 await db`
510 INSERT INTO custom_domains (id, domain, did, rkey, verified, created_at)
511 VALUES (${hash}, ${domainLower}, ${did}, ${rkey}, false, EXTRACT(EPOCH FROM NOW()))
512 `;
513 return { success: true, hash };
514 } catch (err) {
515 console.error('Failed to claim custom domain', err);
516 throw new Error('conflict');
517 }
518};
519
520export const updateCustomDomainRkey = async (id: string, rkey: string | null) => {
521 const rows = await db`
522 UPDATE custom_domains
523 SET rkey = ${rkey}
524 WHERE id = ${id}
525 RETURNING *
526 `;
527 return rows[0] ?? null;
528};
529
530export const updateCustomDomainVerification = async (id: string, verified: boolean) => {
531 const rows = await db`
532 UPDATE custom_domains
533 SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW())
534 WHERE id = ${id}
535 RETURNING *
536 `;
537 return rows[0] ?? null;
538};
539
540export const deleteCustomDomain = async (id: string) => {
541 await db`DELETE FROM custom_domains WHERE id = ${id}`;
542};
543
544export const getSitesByDid = async (did: string) => {
545 const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`;
546 return rows;
547};
548
549export const upsertSite = async (did: string, rkey: string, displayName?: string) => {
550 try {
551 // Only set display_name if provided (not undefined/null/empty)
552 const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null;
553
554 await db`
555 INSERT INTO sites (did, rkey, display_name, created_at, updated_at)
556 VALUES (${did}, ${rkey}, ${cleanDisplayName}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
557 ON CONFLICT (did, rkey)
558 DO UPDATE SET
559 display_name = CASE
560 WHEN EXCLUDED.display_name IS NOT NULL THEN EXCLUDED.display_name
561 ELSE sites.display_name
562 END,
563 updated_at = EXTRACT(EPOCH FROM NOW())
564 `;
565 return { success: true };
566 } catch (err) {
567 console.error('Failed to upsert site', err);
568 return { success: false, error: err };
569 }
570};
571
572export const deleteSite = async (did: string, rkey: string) => {
573 try {
574 await db`DELETE FROM sites WHERE did = ${did} AND rkey = ${rkey}`;
575 return { success: true };
576 } catch (err) {
577 console.error('Failed to delete site', err);
578 return { success: false, error: err };
579 }
580};