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 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// Cookie secrets table for signed cookies
40await db`
41 CREATE TABLE IF NOT EXISTS cookie_secrets (
42 id TEXT PRIMARY KEY DEFAULT 'default',
43 secret TEXT NOT NULL,
44 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
45 )
46`;
47
48// Domains table maps subdomain -> DID (now supports up to 3 domains per user)
49await db`
50 CREATE TABLE IF NOT EXISTS domains (
51 domain TEXT PRIMARY KEY,
52 did TEXT NOT NULL,
53 rkey TEXT,
54 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
55 )
56`;
57
58// Add columns if they don't exist (for existing databases)
59try {
60 await db`ALTER TABLE domains ADD COLUMN IF NOT EXISTS rkey TEXT`;
61} catch (err) {
62 // Column might already exist, ignore
63}
64
65try {
66 await db`ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS expires_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) + 2592000`;
67} catch (err) {
68 // Column might already exist, ignore
69}
70
71try {
72 await db`ALTER TABLE oauth_keys ADD COLUMN IF NOT EXISTS created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())`;
73} catch (err) {
74 // Column might already exist, ignore
75}
76
77try {
78 await db`ALTER TABLE oauth_states ADD COLUMN IF NOT EXISTS expires_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) + 3600`;
79} catch (err) {
80 // Column might already exist, ignore
81}
82
83// Remove the unique constraint on domains.did to allow multiple domains per user
84try {
85 await db`ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_did_key`;
86} catch (err) {
87 // Constraint might already be removed, ignore
88}
89
90// Custom domains table for BYOD (bring your own domain)
91await db`
92 CREATE TABLE IF NOT EXISTS custom_domains (
93 id TEXT PRIMARY KEY,
94 domain TEXT UNIQUE NOT NULL,
95 did TEXT NOT NULL,
96 rkey TEXT,
97 verified BOOLEAN DEFAULT false,
98 last_verified_at BIGINT,
99 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
100 )
101`;
102
103// Migrate existing tables to make rkey nullable and remove default
104try {
105 await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP NOT NULL`;
106} catch (err) {
107 // Column might already be nullable, ignore
108}
109try {
110 await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP DEFAULT`;
111} catch (err) {
112 // Default might already be removed, ignore
113}
114
115// Sites table - cache of place.wisp.fs records from PDS
116await db`
117 CREATE TABLE IF NOT EXISTS sites (
118 did TEXT NOT NULL,
119 rkey TEXT NOT NULL,
120 display_name TEXT,
121 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
122 updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
123 PRIMARY KEY (did, rkey)
124 )
125`;
126
127// Create indexes for common query patterns
128await Promise.all([
129 // oauth_states cleanup queries
130 db`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires_at ON oauth_states(expires_at)`.catch(err => {
131 if (!err.message?.includes('already exists')) {
132 console.error('Failed to create idx_oauth_states_expires_at:', err);
133 }
134 }),
135
136 // oauth_sessions cleanup queries
137 db`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires_at ON oauth_sessions(expires_at)`.catch(err => {
138 if (!err.message?.includes('already exists')) {
139 console.error('Failed to create idx_oauth_sessions_expires_at:', err);
140 }
141 }),
142
143 // oauth_keys key rotation queries
144 db`CREATE INDEX IF NOT EXISTS idx_oauth_keys_created_at ON oauth_keys(created_at)`.catch(err => {
145 if (!err.message?.includes('already exists')) {
146 console.error('Failed to create idx_oauth_keys_created_at:', err);
147 }
148 }),
149
150 // domains queries by (did, rkey)
151 db`CREATE INDEX IF NOT EXISTS idx_domains_did_rkey ON domains(did, rkey)`.catch(err => {
152 if (!err.message?.includes('already exists')) {
153 console.error('Failed to create idx_domains_did_rkey:', err);
154 }
155 }),
156
157 // custom_domains queries by did
158 db`CREATE INDEX IF NOT EXISTS idx_custom_domains_did ON custom_domains(did)`.catch(err => {
159 if (!err.message?.includes('already exists')) {
160 console.error('Failed to create idx_custom_domains_did:', err);
161 }
162 }),
163
164 // custom_domains queries by (did, rkey)
165 db`CREATE INDEX IF NOT EXISTS idx_custom_domains_did_rkey ON custom_domains(did, rkey)`.catch(err => {
166 if (!err.message?.includes('already exists')) {
167 console.error('Failed to create idx_custom_domains_did_rkey:', err);
168 }
169 }),
170
171 // custom_domains DNS verification worker queries
172 db`CREATE INDEX IF NOT EXISTS idx_custom_domains_verified ON custom_domains(verified)`.catch(err => {
173 if (!err.message?.includes('already exists')) {
174 console.error('Failed to create idx_custom_domains_verified:', err);
175 }
176 }),
177
178 // sites queries by did
179 db`CREATE INDEX IF NOT EXISTS idx_sites_did ON sites(did)`.catch(err => {
180 if (!err.message?.includes('already exists')) {
181 console.error('Failed to create idx_sites_did:', err);
182 }
183 })
184]);
185
186const RESERVED_HANDLES = new Set([
187 "www",
188 "api",
189 "admin",
190 "static",
191 "public",
192 "preview",
193 "slingshot",
194 "plc",
195 "constellation",
196 "cdn",
197 "pds",
198 "staging",
199 "auth"
200]);
201
202export const isValidHandle = (handle: string): boolean => {
203 const h = handle.trim().toLowerCase();
204 if (h.length < 3 || h.length > 63) return false;
205 if (!/^[a-z0-9-]+$/.test(h)) return false;
206 if (h.startsWith('-') || h.endsWith('-')) return false;
207 if (h.includes('--')) return false;
208 if (RESERVED_HANDLES.has(h)) return false;
209 return true;
210};
211
212export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
213
214export const getDomainByDid = async (did: string): Promise<string | null> => {
215 const rows = await db`SELECT domain FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
216 return rows[0]?.domain ?? null;
217};
218
219export const getWispDomainInfo = async (did: string) => {
220 const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
221 return rows[0] ?? null;
222};
223
224export const getAllWispDomains = async (did: string) => {
225 const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC`;
226 return rows;
227};
228
229export const countWispDomains = async (did: string): Promise<number> => {
230 const rows = await db`SELECT COUNT(*) as count FROM domains WHERE did = ${did}`;
231 return Number(rows[0]?.count ?? 0);
232};
233
234export const getDidByDomain = async (domain: string): Promise<string | null> => {
235 const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
236 return rows[0]?.did ?? null;
237};
238
239export const isDomainAvailable = async (handle: string): Promise<boolean> => {
240 const h = handle.trim().toLowerCase();
241 if (!isValidHandle(h)) return false;
242 const domain = toDomain(h);
243 const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`;
244 return rows.length === 0;
245};
246
247export const isDomainRegistered = async (domain: string) => {
248 const domainLower = domain.toLowerCase().trim();
249
250 // Check wisp.place subdomains
251 const wispDomain = await db`
252 SELECT did, domain, rkey FROM domains WHERE domain = ${domainLower}
253 `;
254
255 if (wispDomain.length > 0) {
256 return {
257 registered: true,
258 type: 'wisp' as const,
259 domain: wispDomain[0].domain,
260 did: wispDomain[0].did,
261 rkey: wispDomain[0].rkey
262 };
263 }
264
265 // Check custom domains
266 const customDomain = await db`
267 SELECT id, domain, did, rkey, verified FROM custom_domains WHERE domain = ${domainLower}
268 `;
269
270 if (customDomain.length > 0) {
271 return {
272 registered: true,
273 type: 'custom' as const,
274 domain: customDomain[0].domain,
275 did: customDomain[0].did,
276 rkey: customDomain[0].rkey,
277 verified: customDomain[0].verified
278 };
279 }
280
281 return { registered: false };
282};
283
284export const claimDomain = async (did: string, handle: string): Promise<string> => {
285 const h = handle.trim().toLowerCase();
286 if (!isValidHandle(h)) throw new Error('invalid_handle');
287
288 // Check if user already has 3 domains
289 const existingCount = await countWispDomains(did);
290 if (existingCount >= 3) {
291 throw new Error('domain_limit_reached');
292 }
293
294 const domain = toDomain(h);
295 try {
296 await db`
297 INSERT INTO domains (domain, did)
298 VALUES (${domain}, ${did})
299 `;
300 } catch (err) {
301 // Unique constraint violations -> already taken
302 throw new Error('conflict');
303 }
304 return domain;
305};
306
307export const updateDomain = async (did: string, handle: string): Promise<string> => {
308 const h = handle.trim().toLowerCase();
309 if (!isValidHandle(h)) throw new Error('invalid_handle');
310 const domain = toDomain(h);
311 try {
312 const rows = await db`
313 UPDATE domains SET domain = ${domain}
314 WHERE did = ${did}
315 RETURNING domain
316 `;
317 if (rows.length > 0) return rows[0].domain as string;
318 // No existing row, behave like claim
319 return await claimDomain(did, handle);
320 } catch (err) {
321 // Unique constraint violations -> already taken by someone else
322 throw new Error('conflict');
323 }
324};
325
326export const updateWispDomainSite = async (domain: string, siteRkey: string | null): Promise<void> => {
327 await db`
328 UPDATE domains
329 SET rkey = ${siteRkey}
330 WHERE domain = ${domain}
331 `;
332};
333
334export const getWispDomainSite = async (did: string): Promise<string | null> => {
335 const rows = await db`SELECT rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
336 return rows[0]?.rkey ?? null;
337};
338
339export const deleteWispDomain = async (domain: string): Promise<void> => {
340 await db`DELETE FROM domains WHERE domain = ${domain}`;
341};
342
343// Session timeout configuration (30 days in seconds)
344const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
345// OAuth state timeout (1 hour in seconds)
346const STATE_TIMEOUT = 60 * 60; // 3600 seconds
347
348const stateStore = {
349 async set(key: string, data: any) {
350 const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT;
351 await db`
352 INSERT INTO oauth_states (key, data, created_at, expires_at)
353 VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
354 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt}
355 `;
356 },
357 async get(key: string) {
358 const now = Math.floor(Date.now() / 1000);
359 const result = await db`
360 SELECT data, expires_at
361 FROM oauth_states
362 WHERE key = ${key}
363 `;
364 if (!result[0]) return undefined;
365
366 // Check if expired
367 const expiresAt = Number(result[0].expires_at);
368 if (expiresAt && now > expiresAt) {
369 await db`DELETE FROM oauth_states WHERE key = ${key}`;
370 return undefined;
371 }
372
373 return JSON.parse(result[0].data);
374 },
375 async del(key: string) {
376 await db`DELETE FROM oauth_states WHERE key = ${key}`;
377 }
378};
379
380const sessionStore = {
381 async set(sub: string, data: any) {
382 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT;
383 await db`
384 INSERT INTO oauth_sessions (sub, data, updated_at, expires_at)
385 VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
386 ON CONFLICT (sub) DO UPDATE SET
387 data = EXCLUDED.data,
388 updated_at = EXTRACT(EPOCH FROM NOW()),
389 expires_at = ${expiresAt}
390 `;
391 },
392 async get(sub: string) {
393 const now = Math.floor(Date.now() / 1000);
394 const result = await db`
395 SELECT data, expires_at
396 FROM oauth_sessions
397 WHERE sub = ${sub}
398 `;
399 if (!result[0]) return undefined;
400
401 // Check if expired
402 const expiresAt = Number(result[0].expires_at);
403 if (expiresAt && now > expiresAt) {
404 console.log('[sessionStore] Session expired, deleting', sub);
405 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
406 return undefined;
407 }
408
409 return JSON.parse(result[0].data);
410 },
411 async del(sub: string) {
412 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
413 }
414};
415
416export { sessionStore };
417
418// Cleanup expired sessions and states
419export const cleanupExpiredSessions = async () => {
420 const now = Math.floor(Date.now() / 1000);
421 try {
422 const sessionsDeleted = await db`
423 DELETE FROM oauth_sessions WHERE expires_at < ${now}
424 `;
425 const statesDeleted = await db`
426 DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now}
427 `;
428 console.log(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`);
429 return { sessions: sessionsDeleted.length, states: statesDeleted.length };
430 } catch (err) {
431 console.error('[Cleanup] Failed to cleanup expired data:', err);
432 return { sessions: 0, states: 0 };
433 }
434};
435
436export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => {
437 const isLocalDev = process.env.LOCAL_DEV === 'true';
438
439 if (isLocalDev) {
440 // Loopback client for local development
441 // For loopback, scopes and redirect_uri must be in client_id query string
442 const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
443 const scope = 'atproto transition:generic';
444 const params = new URLSearchParams();
445 params.append('redirect_uri', redirectUri);
446 params.append('scope', scope);
447
448 return {
449 client_id: `http://localhost?${params.toString()}`,
450 client_name: config.clientName,
451 client_uri: config.domain,
452 redirect_uris: [redirectUri],
453 grant_types: ['authorization_code', 'refresh_token'],
454 response_types: ['code'],
455 application_type: 'web',
456 token_endpoint_auth_method: 'none',
457 scope: scope,
458 dpop_bound_access_tokens: false,
459 subject_type: 'public'
460 };
461 }
462
463 // Production client with private_key_jwt
464 return {
465 client_id: `${config.domain}/client-metadata.json`,
466 client_name: config.clientName,
467 client_uri: config.domain,
468 logo_uri: `${config.domain}/logo.png`,
469 tos_uri: `${config.domain}/tos`,
470 policy_uri: `${config.domain}/policy`,
471 redirect_uris: [`${config.domain}/api/auth/callback`],
472 grant_types: ['authorization_code', 'refresh_token'],
473 response_types: ['code'],
474 application_type: 'web',
475 token_endpoint_auth_method: 'private_key_jwt',
476 token_endpoint_auth_signing_alg: "ES256",
477 scope: "atproto transition:generic",
478 dpop_bound_access_tokens: true,
479 jwks_uri: `${config.domain}/jwks.json`,
480 subject_type: 'public',
481 authorization_signed_response_alg: 'ES256'
482 };
483};
484
485const persistKey = async (key: JoseKey) => {
486 const priv = key.privateJwk;
487 if (!priv) return;
488 const kid = key.kid ?? crypto.randomUUID();
489 await db`
490 INSERT INTO oauth_keys (kid, jwk, created_at)
491 VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW()))
492 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
493 `;
494};
495
496const loadPersistedKeys = async (): Promise<JoseKey[]> => {
497 const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`;
498 const keys: JoseKey[] = [];
499 for (const row of rows) {
500 try {
501 const obj = JSON.parse(row.jwk);
502 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
503 keys.push(key);
504 } catch (err) {
505 console.error('Could not parse stored JWK', err);
506 }
507 }
508 return keys;
509};
510
511const ensureKeys = async (): Promise<JoseKey[]> => {
512 let keys = await loadPersistedKeys();
513 const needed: string[] = [];
514 for (let i = 1; i <= 3; i++) {
515 const kid = `key${i}`;
516 if (!keys.some(k => k.kid === kid)) needed.push(kid);
517 }
518 for (const kid of needed) {
519 const newKey = await JoseKey.generate(['ES256'], kid);
520 await persistKey(newKey);
521 keys.push(newKey);
522 }
523 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
524 return keys;
525};
526
527// Load keys from database every time (stateless - safe for horizontal scaling)
528export const getCurrentKeys = async (): Promise<JoseKey[]> => {
529 return await loadPersistedKeys();
530};
531
532// Key rotation - rotate keys older than 30 days (monthly rotation)
533const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
534
535export const rotateKeysIfNeeded = async (): Promise<boolean> => {
536 const now = Math.floor(Date.now() / 1000);
537 const cutoffTime = now - KEY_MAX_AGE;
538
539 try {
540 // Find keys older than 30 days
541 const oldKeys = await db`
542 SELECT kid, created_at FROM oauth_keys
543 WHERE created_at IS NOT NULL AND created_at < ${cutoffTime}
544 ORDER BY created_at ASC
545 `;
546
547 if (oldKeys.length === 0) {
548 console.log('[KeyRotation] No keys need rotation');
549 return false;
550 }
551
552 console.log(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`);
553
554 // Rotate the oldest key
555 const oldestKey = oldKeys[0];
556 const oldKid = oldestKey.kid;
557
558 // Generate new key with same kid
559 const newKey = await JoseKey.generate(['ES256'], oldKid);
560 await persistKey(newKey);
561
562 console.log(`[KeyRotation] Rotated key ${oldKid}`);
563
564 return true;
565 } catch (err) {
566 console.error('[KeyRotation] Failed to rotate keys:', err);
567 return false;
568 }
569};
570
571export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
572 const keys = await ensureKeys();
573
574 return new NodeOAuthClient({
575 clientMetadata: createClientMetadata(config),
576 keyset: keys,
577 stateStore,
578 sessionStore
579 });
580};
581
582export const getCustomDomainsByDid = async (did: string) => {
583 const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`;
584 return rows;
585};
586
587export const getCustomDomainInfo = async (domain: string) => {
588 const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`;
589 return rows[0] ?? null;
590};
591
592export const getCustomDomainByHash = async (hash: string) => {
593 const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`;
594 return rows[0] ?? null;
595};
596
597export const getCustomDomainById = async (id: string) => {
598 const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`;
599 return rows[0] ?? null;
600};
601
602export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string | null = null) => {
603 const domainLower = domain.toLowerCase();
604 try {
605 // Use UPSERT with ON CONFLICT to handle existing pending domains
606 const result = await db`
607 INSERT INTO custom_domains (id, domain, did, rkey, verified, created_at)
608 VALUES (${hash}, ${domainLower}, ${did}, ${rkey}, false, EXTRACT(EPOCH FROM NOW()))
609 ON CONFLICT (domain) DO UPDATE SET
610 id = EXCLUDED.id,
611 did = EXCLUDED.did,
612 rkey = EXCLUDED.rkey,
613 verified = EXCLUDED.verified,
614 created_at = EXCLUDED.created_at
615 WHERE custom_domains.verified = false
616 RETURNING *
617 `;
618
619 if (result.length === 0) {
620 // No rows were updated, meaning the domain exists and is verified
621 throw new Error('conflict');
622 }
623
624 return { success: true, hash };
625 } catch (err) {
626 console.error('Failed to claim custom domain', err);
627 throw new Error('conflict');
628 }
629};
630
631export const updateCustomDomainRkey = async (id: string, rkey: string | null) => {
632 const rows = await db`
633 UPDATE custom_domains
634 SET rkey = ${rkey}
635 WHERE id = ${id}
636 RETURNING *
637 `;
638 return rows[0] ?? null;
639};
640
641export const updateCustomDomainVerification = async (id: string, verified: boolean) => {
642 const rows = await db`
643 UPDATE custom_domains
644 SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW())
645 WHERE id = ${id}
646 RETURNING *
647 `;
648 return rows[0] ?? null;
649};
650
651export const deleteCustomDomain = async (id: string) => {
652 await db`DELETE FROM custom_domains WHERE id = ${id}`;
653};
654
655export const getSitesByDid = async (did: string) => {
656 const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`;
657 return rows;
658};
659
660export const upsertSite = async (did: string, rkey: string, displayName?: string) => {
661 try {
662 // Only set display_name if provided (not undefined/null/empty)
663 const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null;
664
665 await db`
666 INSERT INTO sites (did, rkey, display_name, created_at, updated_at)
667 VALUES (${did}, ${rkey}, ${cleanDisplayName}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
668 ON CONFLICT (did, rkey)
669 DO UPDATE SET
670 display_name = CASE
671 WHEN EXCLUDED.display_name IS NOT NULL THEN EXCLUDED.display_name
672 ELSE sites.display_name
673 END,
674 updated_at = EXTRACT(EPOCH FROM NOW())
675 `;
676 return { success: true };
677 } catch (err) {
678 console.error('Failed to upsert site', err);
679 return { success: false, error: err };
680 }
681};
682
683export const deleteSite = async (did: string, rkey: string) => {
684 try {
685 await db`DELETE FROM sites WHERE did = ${did} AND rkey = ${rkey}`;
686 return { success: true };
687 } catch (err) {
688 console.error('Failed to delete site', err);
689 return { success: false, error: err };
690 }
691};
692
693// Get all domains (wisp + custom) mapped to a specific site
694export const getDomainsBySite = async (did: string, rkey: string) => {
695 const domains: Array<{
696 type: 'wisp' | 'custom';
697 domain: string;
698 verified?: boolean;
699 id?: string;
700 }> = [];
701
702 // Check wisp domain
703 const wispDomain = await db`
704 SELECT domain, rkey FROM domains
705 WHERE did = ${did} AND rkey = ${rkey}
706 `;
707 if (wispDomain.length > 0) {
708 domains.push({
709 type: 'wisp',
710 domain: wispDomain[0].domain,
711 });
712 }
713
714 // Check custom domains
715 const customDomains = await db`
716 SELECT id, domain, verified FROM custom_domains
717 WHERE did = ${did} AND rkey = ${rkey}
718 ORDER BY created_at DESC
719 `;
720 for (const cd of customDomains) {
721 domains.push({
722 type: 'custom',
723 domain: cd.domain,
724 verified: cd.verified,
725 id: cd.id,
726 });
727 }
728
729 return domains;
730};
731
732// Get count of domains mapped to a specific site
733export const getDomainCountBySite = async (did: string, rkey: string) => {
734 const wispCount = await db`
735 SELECT COUNT(*) as count FROM domains
736 WHERE did = ${did} AND rkey = ${rkey}
737 `;
738
739 const customCount = await db`
740 SELECT COUNT(*) as count FROM custom_domains
741 WHERE did = ${did} AND rkey = ${rkey}
742 `;
743
744 return {
745 wisp: Number(wispCount[0]?.count || 0),
746 custom: Number(customCount[0]?.count || 0),
747 total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0),
748 };
749};
750
751// Cookie secret management - ensure we have a secret for signing cookies
752export const getCookieSecret = async (): Promise<string> => {
753 // Check if secret already exists
754 const rows = await db`SELECT secret FROM cookie_secrets WHERE id = 'default' LIMIT 1`;
755
756 if (rows.length > 0) {
757 return rows[0].secret as string;
758 }
759
760 // Generate new secret if none exists
761 const secret = crypto.randomUUID() + crypto.randomUUID(); // 72 character random string
762 await db`
763 INSERT INTO cookie_secrets (id, secret, created_at)
764 VALUES ('default', ${secret}, EXTRACT(EPOCH FROM NOW()))
765 `;
766
767 console.log('[CookieSecret] Generated new cookie signing secret');
768 return secret;
769};