Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
at main 20 kB view raw
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};