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};