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 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 43 ) 44`; 45 46// Custom domains table for BYOD (bring your own domain) 47await db` 48 CREATE TABLE IF NOT EXISTS custom_domains ( 49 id TEXT PRIMARY KEY, 50 domain TEXT UNIQUE NOT NULL, 51 did TEXT NOT NULL, 52 rkey TEXT NOT NULL DEFAULT 'self', 53 verified BOOLEAN DEFAULT false, 54 last_verified_at BIGINT, 55 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 56 ) 57`; 58 59// Sites table - cache of place.wisp.fs records from PDS 60await db` 61 CREATE TABLE IF NOT EXISTS sites ( 62 did TEXT NOT NULL, 63 rkey TEXT NOT NULL, 64 display_name TEXT, 65 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()), 66 updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()), 67 PRIMARY KEY (did, rkey) 68 ) 69`; 70 71const RESERVED_HANDLES = new Set([ 72 "www", 73 "api", 74 "admin", 75 "static", 76 "public", 77 "preview" 78]); 79 80export const isValidHandle = (handle: string): boolean => { 81 const h = handle.trim().toLowerCase(); 82 if (h.length < 3 || h.length > 63) return false; 83 if (!/^[a-z0-9-]+$/.test(h)) return false; 84 if (h.startsWith('-') || h.endsWith('-')) return false; 85 if (h.includes('--')) return false; 86 if (RESERVED_HANDLES.has(h)) return false; 87 return true; 88}; 89 90export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`; 91 92export const getDomainByDid = async (did: string): Promise<string | null> => { 93 const rows = await db`SELECT domain FROM domains WHERE did = ${did}`; 94 return rows[0]?.domain ?? null; 95}; 96 97export const getDidByDomain = async (domain: string): Promise<string | null> => { 98 const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`; 99 return rows[0]?.did ?? null; 100}; 101 102export const isDomainAvailable = async (handle: string): Promise<boolean> => { 103 const h = handle.trim().toLowerCase(); 104 if (!isValidHandle(h)) return false; 105 const domain = toDomain(h); 106 const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`; 107 return rows.length === 0; 108}; 109 110export const claimDomain = async (did: string, handle: string): Promise<string> => { 111 const h = handle.trim().toLowerCase(); 112 if (!isValidHandle(h)) throw new Error('invalid_handle'); 113 const domain = toDomain(h); 114 try { 115 await db` 116 INSERT INTO domains (domain, did) 117 VALUES (${domain}, ${did}) 118 `; 119 } catch (err) { 120 // Unique constraint violations -> already taken or DID already claimed 121 throw new Error('conflict'); 122 } 123 return domain; 124}; 125 126export const updateDomain = async (did: string, handle: string): Promise<string> => { 127 const h = handle.trim().toLowerCase(); 128 if (!isValidHandle(h)) throw new Error('invalid_handle'); 129 const domain = toDomain(h); 130 try { 131 const rows = await db` 132 UPDATE domains SET domain = ${domain} 133 WHERE did = ${did} 134 RETURNING domain 135 `; 136 if (rows.length > 0) return rows[0].domain as string; 137 // No existing row, behave like claim 138 return await claimDomain(did, handle); 139 } catch (err) { 140 // Unique constraint violations -> already taken by someone else 141 throw new Error('conflict'); 142 } 143}; 144 145const stateStore = { 146 async set(key: string, data: any) { 147 console.debug('[stateStore] set', key) 148 await db` 149 INSERT INTO oauth_states (key, data) 150 VALUES (${key}, ${JSON.stringify(data)}) 151 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data 152 `; 153 }, 154 async get(key: string) { 155 console.debug('[stateStore] get', key) 156 const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`; 157 return result[0] ? JSON.parse(result[0].data) : undefined; 158 }, 159 async del(key: string) { 160 console.debug('[stateStore] del', key) 161 await db`DELETE FROM oauth_states WHERE key = ${key}`; 162 } 163}; 164 165const sessionStore = { 166 async set(sub: string, data: any) { 167 console.debug('[sessionStore] set', sub) 168 await db` 169 INSERT INTO oauth_sessions (sub, data) 170 VALUES (${sub}, ${JSON.stringify(data)}) 171 ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW()) 172 `; 173 }, 174 async get(sub: string) { 175 console.debug('[sessionStore] get', sub) 176 const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`; 177 return result[0] ? JSON.parse(result[0].data) : undefined; 178 }, 179 async del(sub: string) { 180 console.debug('[sessionStore] del', sub) 181 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 182 } 183}; 184 185export { sessionStore }; 186 187export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({ 188 client_id: `${config.domain}/client-metadata.json`, 189 client_name: config.clientName, 190 client_uri: config.domain, 191 logo_uri: `${config.domain}/logo.png`, 192 tos_uri: `${config.domain}/tos`, 193 policy_uri: `${config.domain}/policy`, 194 redirect_uris: [`${config.domain}/api/auth/callback`], 195 grant_types: ['authorization_code', 'refresh_token'], 196 response_types: ['code'], 197 application_type: 'web', 198 token_endpoint_auth_method: 'private_key_jwt', 199 token_endpoint_auth_signing_alg: "ES256", 200 scope: "atproto transition:generic", 201 dpop_bound_access_tokens: true, 202 jwks_uri: `${config.domain}/jwks.json`, 203 subject_type: 'public', 204 authorization_signed_response_alg: 'ES256' 205}); 206 207const persistKey = async (key: JoseKey) => { 208 const priv = key.privateJwk; 209 if (!priv) return; 210 const kid = key.kid ?? crypto.randomUUID(); 211 await db` 212 INSERT INTO oauth_keys (kid, jwk) 213 VALUES (${kid}, ${JSON.stringify(priv)}) 214 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 215 `; 216}; 217 218const loadPersistedKeys = async (): Promise<JoseKey[]> => { 219 const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`; 220 const keys: JoseKey[] = []; 221 for (const row of rows) { 222 try { 223 const obj = JSON.parse(row.jwk); 224 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid); 225 keys.push(key); 226 } catch (err) { 227 console.error('Could not parse stored JWK', err); 228 } 229 } 230 return keys; 231}; 232 233const ensureKeys = async (): Promise<JoseKey[]> => { 234 let keys = await loadPersistedKeys(); 235 const needed: string[] = []; 236 for (let i = 1; i <= 3; i++) { 237 const kid = `key${i}`; 238 if (!keys.some(k => k.kid === kid)) needed.push(kid); 239 } 240 for (const kid of needed) { 241 const newKey = await JoseKey.generate(['ES256'], kid); 242 await persistKey(newKey); 243 keys.push(newKey); 244 } 245 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? '')); 246 return keys; 247}; 248 249let currentKeys: JoseKey[] = []; 250 251export const getCurrentKeys = () => currentKeys; 252 253export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 254 if (currentKeys.length === 0) { 255 currentKeys = await ensureKeys(); 256 } 257 258 return new NodeOAuthClient({ 259 clientMetadata: createClientMetadata(config), 260 keyset: currentKeys, 261 stateStore, 262 sessionStore 263 }); 264}; 265 266export const getCustomDomainsByDid = async (did: string) => { 267 const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`; 268 return rows; 269}; 270 271export const getCustomDomainInfo = async (domain: string) => { 272 const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`; 273 return rows[0] ?? null; 274}; 275 276export const getCustomDomainByHash = async (hash: string) => { 277 const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`; 278 return rows[0] ?? null; 279}; 280 281export const getCustomDomainById = async (id: string) => { 282 const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`; 283 return rows[0] ?? null; 284}; 285 286export const claimCustomDomain = async (did: string, domain: string, siteName: string, hash: string) => { 287 const domainLower = domain.toLowerCase(); 288 try { 289 await db` 290 INSERT INTO custom_domains (id, domain, did, site_name, verified, created_at) 291 VALUES (${hash}, ${domainLower}, ${did}, ${siteName}, false, EXTRACT(EPOCH FROM NOW())) 292 `; 293 return { success: true, hash }; 294 } catch (err) { 295 console.error('Failed to claim custom domain', err); 296 throw new Error('conflict'); 297 } 298}; 299 300export const updateCustomDomainSite = async (id: string, siteName: string) => { 301 const rows = await db` 302 UPDATE custom_domains 303 SET site_name = ${siteName} 304 WHERE id = ${id} 305 RETURNING * 306 `; 307 return rows[0] ?? null; 308}; 309 310export const updateCustomDomainVerification = async (id: string, verified: boolean) => { 311 const rows = await db` 312 UPDATE custom_domains 313 SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW()) 314 WHERE id = ${id} 315 RETURNING * 316 `; 317 return rows[0] ?? null; 318}; 319 320export const deleteCustomDomain = async (id: string) => { 321 await db`DELETE FROM custom_domains WHERE id = ${id}`; 322}; 323 324export const getSitesByDid = async (did: string) => { 325 const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`; 326 return rows; 327}; 328 329export const upsertSite = async (did: string, siteName: string, displayName?: string) => { 330 try { 331 await db` 332 INSERT INTO sites (did, site_name, display_name, created_at, updated_at) 333 VALUES (${did}, ${siteName}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW())) 334 ON CONFLICT (did, site_name) 335 DO UPDATE SET 336 display_name = COALESCE(EXCLUDED.display_name, sites.display_name), 337 updated_at = EXTRACT(EPOCH FROM NOW()) 338 `; 339 return { success: true }; 340 } catch (err) { 341 console.error('Failed to upsert site', err); 342 return { success: false, error: err }; 343 } 344};