Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
96
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 162bb63a9b294717217a6662d655cb58e699e825 580 lines 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};