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};