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 ) 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 claimDomain = async (did: string, handle: string): Promise<string> => { 124 const h = handle.trim().toLowerCase(); 125 if (!isValidHandle(h)) throw new Error('invalid_handle'); 126 const domain = toDomain(h); 127 try { 128 await db` 129 INSERT INTO domains (domain, did) 130 VALUES (${domain}, ${did}) 131 `; 132 } catch (err) { 133 // Unique constraint violations -> already taken or DID already claimed 134 throw new Error('conflict'); 135 } 136 return domain; 137}; 138 139export const updateDomain = async (did: string, handle: string): Promise<string> => { 140 const h = handle.trim().toLowerCase(); 141 if (!isValidHandle(h)) throw new Error('invalid_handle'); 142 const domain = toDomain(h); 143 try { 144 const rows = await db` 145 UPDATE domains SET domain = ${domain} 146 WHERE did = ${did} 147 RETURNING domain 148 `; 149 if (rows.length > 0) return rows[0].domain as string; 150 // No existing row, behave like claim 151 return await claimDomain(did, handle); 152 } catch (err) { 153 // Unique constraint violations -> already taken by someone else 154 throw new Error('conflict'); 155 } 156}; 157 158export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => { 159 await db` 160 UPDATE domains 161 SET rkey = ${siteRkey} 162 WHERE did = ${did} 163 `; 164}; 165 166export const getWispDomainSite = async (did: string): Promise<string | null> => { 167 const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`; 168 return rows[0]?.rkey ?? null; 169}; 170 171const stateStore = { 172 async set(key: string, data: any) { 173 console.debug('[stateStore] set', key) 174 await db` 175 INSERT INTO oauth_states (key, data) 176 VALUES (${key}, ${JSON.stringify(data)}) 177 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data 178 `; 179 }, 180 async get(key: string) { 181 console.debug('[stateStore] get', key) 182 const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`; 183 return result[0] ? JSON.parse(result[0].data) : undefined; 184 }, 185 async del(key: string) { 186 console.debug('[stateStore] del', key) 187 await db`DELETE FROM oauth_states WHERE key = ${key}`; 188 } 189}; 190 191const sessionStore = { 192 async set(sub: string, data: any) { 193 console.debug('[sessionStore] set', sub) 194 await db` 195 INSERT INTO oauth_sessions (sub, data) 196 VALUES (${sub}, ${JSON.stringify(data)}) 197 ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW()) 198 `; 199 }, 200 async get(sub: string) { 201 console.debug('[sessionStore] get', sub) 202 const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`; 203 return result[0] ? JSON.parse(result[0].data) : undefined; 204 }, 205 async del(sub: string) { 206 console.debug('[sessionStore] del', sub) 207 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 208 } 209}; 210 211export { sessionStore }; 212 213export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({ 214 client_id: `${config.domain}/client-metadata.json`, 215 client_name: config.clientName, 216 client_uri: config.domain, 217 logo_uri: `${config.domain}/logo.png`, 218 tos_uri: `${config.domain}/tos`, 219 policy_uri: `${config.domain}/policy`, 220 redirect_uris: [`${config.domain}/api/auth/callback`], 221 grant_types: ['authorization_code', 'refresh_token'], 222 response_types: ['code'], 223 application_type: 'web', 224 token_endpoint_auth_method: 'private_key_jwt', 225 token_endpoint_auth_signing_alg: "ES256", 226 scope: "atproto transition:generic", 227 dpop_bound_access_tokens: true, 228 jwks_uri: `${config.domain}/jwks.json`, 229 subject_type: 'public', 230 authorization_signed_response_alg: 'ES256' 231}); 232 233const persistKey = async (key: JoseKey) => { 234 const priv = key.privateJwk; 235 if (!priv) return; 236 const kid = key.kid ?? crypto.randomUUID(); 237 await db` 238 INSERT INTO oauth_keys (kid, jwk) 239 VALUES (${kid}, ${JSON.stringify(priv)}) 240 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 241 `; 242}; 243 244const loadPersistedKeys = async (): Promise<JoseKey[]> => { 245 const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`; 246 const keys: JoseKey[] = []; 247 for (const row of rows) { 248 try { 249 const obj = JSON.parse(row.jwk); 250 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid); 251 keys.push(key); 252 } catch (err) { 253 console.error('Could not parse stored JWK', err); 254 } 255 } 256 return keys; 257}; 258 259const ensureKeys = async (): Promise<JoseKey[]> => { 260 let keys = await loadPersistedKeys(); 261 const needed: string[] = []; 262 for (let i = 1; i <= 3; i++) { 263 const kid = `key${i}`; 264 if (!keys.some(k => k.kid === kid)) needed.push(kid); 265 } 266 for (const kid of needed) { 267 const newKey = await JoseKey.generate(['ES256'], kid); 268 await persistKey(newKey); 269 keys.push(newKey); 270 } 271 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? '')); 272 return keys; 273}; 274 275let currentKeys: JoseKey[] = []; 276 277export const getCurrentKeys = () => currentKeys; 278 279export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 280 if (currentKeys.length === 0) { 281 currentKeys = await ensureKeys(); 282 } 283 284 return new NodeOAuthClient({ 285 clientMetadata: createClientMetadata(config), 286 keyset: currentKeys, 287 stateStore, 288 sessionStore 289 }); 290}; 291 292export const getCustomDomainsByDid = async (did: string) => { 293 const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`; 294 return rows; 295}; 296 297export const getCustomDomainInfo = async (domain: string) => { 298 const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`; 299 return rows[0] ?? null; 300}; 301 302export const getCustomDomainByHash = async (hash: string) => { 303 const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`; 304 return rows[0] ?? null; 305}; 306 307export const getCustomDomainById = async (id: string) => { 308 const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`; 309 return rows[0] ?? null; 310}; 311 312export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string = 'self') => { 313 const domainLower = domain.toLowerCase(); 314 try { 315 await db` 316 INSERT INTO custom_domains (id, domain, did, rkey, verified, created_at) 317 VALUES (${hash}, ${domainLower}, ${did}, ${rkey}, false, EXTRACT(EPOCH FROM NOW())) 318 `; 319 return { success: true, hash }; 320 } catch (err) { 321 console.error('Failed to claim custom domain', err); 322 throw new Error('conflict'); 323 } 324}; 325 326export const updateCustomDomainRkey = async (id: string, rkey: string) => { 327 const rows = await db` 328 UPDATE custom_domains 329 SET rkey = ${rkey} 330 WHERE id = ${id} 331 RETURNING * 332 `; 333 return rows[0] ?? null; 334}; 335 336export const updateCustomDomainVerification = async (id: string, verified: boolean) => { 337 const rows = await db` 338 UPDATE custom_domains 339 SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW()) 340 WHERE id = ${id} 341 RETURNING * 342 `; 343 return rows[0] ?? null; 344}; 345 346export const deleteCustomDomain = async (id: string) => { 347 await db`DELETE FROM custom_domains WHERE id = ${id}`; 348}; 349 350export const getSitesByDid = async (did: string) => { 351 const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`; 352 return rows; 353}; 354 355export const upsertSite = async (did: string, rkey: string, displayName?: string) => { 356 try { 357 await db` 358 INSERT INTO sites (did, rkey, display_name, created_at, updated_at) 359 VALUES (${did}, ${rkey}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW())) 360 ON CONFLICT (did, rkey) 361 DO UPDATE SET 362 display_name = COALESCE(EXCLUDED.display_name, sites.display_name), 363 updated_at = EXTRACT(EPOCH FROM NOW()) 364 `; 365 return { success: true }; 366 } catch (err) { 367 console.error('Failed to upsert site', err); 368 return { success: false, error: err }; 369 } 370};