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