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