forked from
nekomimi.pet/wisp.place-monorepo
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 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 NOT NULL DEFAULT 'self',
81 verified BOOLEAN DEFAULT false,
82 last_verified_at BIGINT,
83 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
84 )
85`;
86
87// Sites table - cache of place.wisp.fs records from PDS
88await db`
89 CREATE TABLE IF NOT EXISTS sites (
90 did TEXT NOT NULL,
91 rkey TEXT NOT NULL,
92 display_name TEXT,
93 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
94 updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
95 PRIMARY KEY (did, rkey)
96 )
97`;
98
99const RESERVED_HANDLES = new Set([
100 "www",
101 "api",
102 "admin",
103 "static",
104 "public",
105 "preview"
106]);
107
108export const isValidHandle = (handle: string): boolean => {
109 const h = handle.trim().toLowerCase();
110 if (h.length < 3 || h.length > 63) return false;
111 if (!/^[a-z0-9-]+$/.test(h)) return false;
112 if (h.startsWith('-') || h.endsWith('-')) return false;
113 if (h.includes('--')) return false;
114 if (RESERVED_HANDLES.has(h)) return false;
115 return true;
116};
117
118export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
119
120export const getDomainByDid = async (did: string): Promise<string | null> => {
121 const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
122 return rows[0]?.domain ?? null;
123};
124
125export const getWispDomainInfo = async (did: string) => {
126 const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`;
127 return rows[0] ?? null;
128};
129
130export const getDidByDomain = async (domain: string): Promise<string | null> => {
131 const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
132 return rows[0]?.did ?? null;
133};
134
135export const isDomainAvailable = async (handle: string): Promise<boolean> => {
136 const h = handle.trim().toLowerCase();
137 if (!isValidHandle(h)) return false;
138 const domain = toDomain(h);
139 const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`;
140 return rows.length === 0;
141};
142
143export const isDomainRegistered = async (domain: string) => {
144 const domainLower = domain.toLowerCase().trim();
145
146 // Check wisp.place subdomains
147 const wispDomain = await db`
148 SELECT did, domain, rkey FROM domains WHERE domain = ${domainLower}
149 `;
150
151 if (wispDomain.length > 0) {
152 return {
153 registered: true,
154 type: 'wisp' as const,
155 domain: wispDomain[0].domain,
156 did: wispDomain[0].did,
157 rkey: wispDomain[0].rkey
158 };
159 }
160
161 // Check custom domains
162 const customDomain = await db`
163 SELECT id, domain, did, rkey, verified FROM custom_domains WHERE domain = ${domainLower}
164 `;
165
166 if (customDomain.length > 0) {
167 return {
168 registered: true,
169 type: 'custom' as const,
170 domain: customDomain[0].domain,
171 did: customDomain[0].did,
172 rkey: customDomain[0].rkey,
173 verified: customDomain[0].verified
174 };
175 }
176
177 return { registered: false };
178};
179
180export const claimDomain = async (did: string, handle: string): Promise<string> => {
181 const h = handle.trim().toLowerCase();
182 if (!isValidHandle(h)) throw new Error('invalid_handle');
183 const domain = toDomain(h);
184 try {
185 await db`
186 INSERT INTO domains (domain, did)
187 VALUES (${domain}, ${did})
188 `;
189 } catch (err) {
190 // Unique constraint violations -> already taken or DID already claimed
191 throw new Error('conflict');
192 }
193 return domain;
194};
195
196export const updateDomain = async (did: string, handle: string): Promise<string> => {
197 const h = handle.trim().toLowerCase();
198 if (!isValidHandle(h)) throw new Error('invalid_handle');
199 const domain = toDomain(h);
200 try {
201 const rows = await db`
202 UPDATE domains SET domain = ${domain}
203 WHERE did = ${did}
204 RETURNING domain
205 `;
206 if (rows.length > 0) return rows[0].domain as string;
207 // No existing row, behave like claim
208 return await claimDomain(did, handle);
209 } catch (err) {
210 // Unique constraint violations -> already taken by someone else
211 throw new Error('conflict');
212 }
213};
214
215export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => {
216 await db`
217 UPDATE domains
218 SET rkey = ${siteRkey}
219 WHERE did = ${did}
220 `;
221};
222
223export const getWispDomainSite = async (did: string): Promise<string | null> => {
224 const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`;
225 return rows[0]?.rkey ?? null;
226};
227
228// Session timeout configuration (30 days in seconds)
229const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
230// OAuth state timeout (1 hour in seconds)
231const STATE_TIMEOUT = 60 * 60; // 3600 seconds
232
233const stateStore = {
234 async set(key: string, data: any) {
235 console.debug('[stateStore] set', key)
236 const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT;
237 await db`
238 INSERT INTO oauth_states (key, data, created_at, expires_at)
239 VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
240 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt}
241 `;
242 },
243 async get(key: string) {
244 console.debug('[stateStore] get', key)
245 const now = Math.floor(Date.now() / 1000);
246 const result = await db`
247 SELECT data, expires_at
248 FROM oauth_states
249 WHERE key = ${key}
250 `;
251 if (!result[0]) return undefined;
252
253 // Check if expired
254 const expiresAt = Number(result[0].expires_at);
255 if (expiresAt && now > expiresAt) {
256 console.debug('[stateStore] State expired, deleting', key);
257 await db`DELETE FROM oauth_states WHERE key = ${key}`;
258 return undefined;
259 }
260
261 return JSON.parse(result[0].data);
262 },
263 async del(key: string) {
264 console.debug('[stateStore] del', key)
265 await db`DELETE FROM oauth_states WHERE key = ${key}`;
266 }
267};
268
269const sessionStore = {
270 async set(sub: string, data: any) {
271 console.debug('[sessionStore] set', sub)
272 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT;
273 await db`
274 INSERT INTO oauth_sessions (sub, data, updated_at, expires_at)
275 VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
276 ON CONFLICT (sub) DO UPDATE SET
277 data = EXCLUDED.data,
278 updated_at = EXTRACT(EPOCH FROM NOW()),
279 expires_at = ${expiresAt}
280 `;
281 },
282 async get(sub: string) {
283 console.debug('[sessionStore] get', sub)
284 const now = Math.floor(Date.now() / 1000);
285 const result = await db`
286 SELECT data, expires_at
287 FROM oauth_sessions
288 WHERE sub = ${sub}
289 `;
290 if (!result[0]) return undefined;
291
292 // Check if expired
293 const expiresAt = Number(result[0].expires_at);
294 if (expiresAt && now > expiresAt) {
295 console.log('[sessionStore] Session expired, deleting', sub);
296 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
297 return undefined;
298 }
299
300 return JSON.parse(result[0].data);
301 },
302 async del(sub: string) {
303 console.debug('[sessionStore] del', sub)
304 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
305 }
306};
307
308export { sessionStore };
309
310// Cleanup expired sessions and states
311export const cleanupExpiredSessions = async () => {
312 const now = Math.floor(Date.now() / 1000);
313 try {
314 const sessionsDeleted = await db`
315 DELETE FROM oauth_sessions WHERE expires_at < ${now}
316 `;
317 const statesDeleted = await db`
318 DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now}
319 `;
320 console.log(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`);
321 return { sessions: sessionsDeleted.length, states: statesDeleted.length };
322 } catch (err) {
323 console.error('[Cleanup] Failed to cleanup expired data:', err);
324 return { sessions: 0, states: 0 };
325 }
326};
327
328export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
329 client_id: `${config.domain}/client-metadata.json`,
330 client_name: config.clientName,
331 client_uri: config.domain,
332 logo_uri: `${config.domain}/logo.png`,
333 tos_uri: `${config.domain}/tos`,
334 policy_uri: `${config.domain}/policy`,
335 redirect_uris: [`${config.domain}/api/auth/callback`],
336 grant_types: ['authorization_code', 'refresh_token'],
337 response_types: ['code'],
338 application_type: 'web',
339 token_endpoint_auth_method: 'private_key_jwt',
340 token_endpoint_auth_signing_alg: "ES256",
341 scope: "atproto transition:generic",
342 dpop_bound_access_tokens: true,
343 jwks_uri: `${config.domain}/jwks.json`,
344 subject_type: 'public',
345 authorization_signed_response_alg: 'ES256'
346});
347
348const persistKey = async (key: JoseKey) => {
349 const priv = key.privateJwk;
350 if (!priv) return;
351 const kid = key.kid ?? crypto.randomUUID();
352 await db`
353 INSERT INTO oauth_keys (kid, jwk, created_at)
354 VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW()))
355 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
356 `;
357};
358
359const loadPersistedKeys = async (): Promise<JoseKey[]> => {
360 const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`;
361 const keys: JoseKey[] = [];
362 for (const row of rows) {
363 try {
364 const obj = JSON.parse(row.jwk);
365 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
366 keys.push(key);
367 } catch (err) {
368 console.error('Could not parse stored JWK', err);
369 }
370 }
371 return keys;
372};
373
374const ensureKeys = async (): Promise<JoseKey[]> => {
375 let keys = await loadPersistedKeys();
376 const needed: string[] = [];
377 for (let i = 1; i <= 3; i++) {
378 const kid = `key${i}`;
379 if (!keys.some(k => k.kid === kid)) needed.push(kid);
380 }
381 for (const kid of needed) {
382 const newKey = await JoseKey.generate(['ES256'], kid);
383 await persistKey(newKey);
384 keys.push(newKey);
385 }
386 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
387 return keys;
388};
389
390// Load keys from database every time (stateless - safe for horizontal scaling)
391export const getCurrentKeys = async (): Promise<JoseKey[]> => {
392 return await loadPersistedKeys();
393};
394
395// Key rotation - rotate keys older than 30 days (monthly rotation)
396const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
397
398export const rotateKeysIfNeeded = async (): Promise<boolean> => {
399 const now = Math.floor(Date.now() / 1000);
400 const cutoffTime = now - KEY_MAX_AGE;
401
402 try {
403 // Find keys older than 30 days
404 const oldKeys = await db`
405 SELECT kid, created_at FROM oauth_keys
406 WHERE created_at IS NOT NULL AND created_at < ${cutoffTime}
407 ORDER BY created_at ASC
408 `;
409
410 if (oldKeys.length === 0) {
411 console.log('[KeyRotation] No keys need rotation');
412 return false;
413 }
414
415 console.log(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`);
416
417 // Rotate the oldest key
418 const oldestKey = oldKeys[0];
419 const oldKid = oldestKey.kid;
420
421 // Generate new key with same kid
422 const newKey = await JoseKey.generate(['ES256'], oldKid);
423 await persistKey(newKey);
424
425 console.log(`[KeyRotation] Rotated key ${oldKid}`);
426
427 return true;
428 } catch (err) {
429 console.error('[KeyRotation] Failed to rotate keys:', err);
430 return false;
431 }
432};
433
434export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
435 const keys = await ensureKeys();
436
437 return new NodeOAuthClient({
438 clientMetadata: createClientMetadata(config),
439 keyset: keys,
440 stateStore,
441 sessionStore
442 });
443};
444
445export const getCustomDomainsByDid = async (did: string) => {
446 const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`;
447 return rows;
448};
449
450export const getCustomDomainInfo = async (domain: string) => {
451 const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`;
452 return rows[0] ?? null;
453};
454
455export const getCustomDomainByHash = async (hash: string) => {
456 const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`;
457 return rows[0] ?? null;
458};
459
460export const getCustomDomainById = async (id: string) => {
461 const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`;
462 return rows[0] ?? null;
463};
464
465export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string = 'self') => {
466 const domainLower = domain.toLowerCase();
467 try {
468 await db`
469 INSERT INTO custom_domains (id, domain, did, rkey, verified, created_at)
470 VALUES (${hash}, ${domainLower}, ${did}, ${rkey}, false, EXTRACT(EPOCH FROM NOW()))
471 `;
472 return { success: true, hash };
473 } catch (err) {
474 console.error('Failed to claim custom domain', err);
475 throw new Error('conflict');
476 }
477};
478
479export const updateCustomDomainRkey = async (id: string, rkey: string) => {
480 const rows = await db`
481 UPDATE custom_domains
482 SET rkey = ${rkey}
483 WHERE id = ${id}
484 RETURNING *
485 `;
486 return rows[0] ?? null;
487};
488
489export const updateCustomDomainVerification = async (id: string, verified: boolean) => {
490 const rows = await db`
491 UPDATE custom_domains
492 SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW())
493 WHERE id = ${id}
494 RETURNING *
495 `;
496 return rows[0] ?? null;
497};
498
499export const deleteCustomDomain = async (id: string) => {
500 await db`DELETE FROM custom_domains WHERE id = ${id}`;
501};
502
503export const getSitesByDid = async (did: string) => {
504 const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`;
505 return rows;
506};
507
508export const upsertSite = async (did: string, rkey: string, displayName?: string) => {
509 try {
510 // Only set display_name if provided (not undefined/null/empty)
511 const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null;
512
513 await db`
514 INSERT INTO sites (did, rkey, display_name, created_at, updated_at)
515 VALUES (${did}, ${rkey}, ${cleanDisplayName}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
516 ON CONFLICT (did, rkey)
517 DO UPDATE SET
518 display_name = CASE
519 WHEN EXCLUDED.display_name IS NOT NULL THEN EXCLUDED.display_name
520 ELSE sites.display_name
521 END,
522 updated_at = EXTRACT(EPOCH FROM NOW())
523 `;
524 return { success: true };
525 } catch (err) {
526 console.error('Failed to upsert site', err);
527 return { success: false, error: err };
528 }
529};