···11+/**
22+ * AUTHENTICATION ROUTES
33+ *
44+ * Handles OAuth authentication flow for Bluesky/ATProto accounts
55+ * All routes are on the editor.wisp.place subdomain
66+ *
77+ * Routes:
88+ * POST /api/auth/signin - Initiate OAuth sign-in flow
99+ * GET /api/auth/callback - OAuth callback handler (redirect from PDS)
1010+ * GET /api/auth/status - Check current authentication status
1111+ * POST /api/auth/logout - Sign out and clear session
1212+ */
1313+1414+/**
1515+ * CUSTOM DOMAIN ROUTES
1616+ *
1717+ * Handles custom domain (BYOD - Bring Your Own Domain) management
1818+ * Users can claim custom domains with DNS verification (TXT + CNAME)
1919+ * and map them to their sites
2020+ *
2121+ * Routes:
2222+ * GET /api/check-domain - Fast verification check for routing (public)
2323+ * GET /api/custom-domains - List user's custom domains
2424+ * POST /api/custom-domains/check - Check domain availability and DNS config
2525+ * POST /api/custom-domains/claim - Claim a custom domain
2626+ * PUT /api/custom-domains/:id/site - Update site mapping
2727+ * DELETE /api/custom-domains/:id - Remove a custom domain
2828+ * POST /api/custom-domains/:id/verify - Manually trigger verification
2929+ */
3030+3131+/**
3232+ * WISP SITE MANAGEMENT ROUTES
3333+ *
3434+ * API endpoints for managing user's Wisp sites stored in ATProto repos
3535+ * Handles reading site metadata, fetching content, updating sites, and uploads
3636+ * All routes are on the editor.wisp.place subdomain
3737+ *
3838+ * Routes:
3939+ * GET /wisp/sites - List all sites for authenticated user
4040+ * GET /wisp/fs/:site - Get site record (metadata/manifest)
4141+ * GET /wisp/fs/:site/file/* - Get individual file content by path
4242+ * POST /wisp/upload-files - Upload and deploy files as a site
4343+ */
···11+import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
22+import { SQL } from "bun";
33+import { JoseKey } from "@atproto/jwk-jose";
44+import { BASE_HOST } from "./constants";
55+66+export const db = new SQL(
77+ process.env.NODE_ENV === 'production'
88+ ? process.env.DATABASE_URL || (() => {
99+ throw new Error('DATABASE_URL environment variable is required in production');
1010+ })()
1111+ : process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/wisp"
1212+);
1313+1414+await db`
1515+ CREATE TABLE IF NOT EXISTS oauth_states (
1616+ key TEXT PRIMARY KEY,
1717+ data TEXT NOT NULL,
1818+ created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
1919+ )
2020+`;
2121+2222+await db`
2323+ CREATE TABLE IF NOT EXISTS oauth_sessions (
2424+ sub TEXT PRIMARY KEY,
2525+ data TEXT NOT NULL,
2626+ updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
2727+ )
2828+`;
2929+3030+await db`
3131+ CREATE TABLE IF NOT EXISTS oauth_keys (
3232+ kid TEXT PRIMARY KEY,
3333+ jwk TEXT NOT NULL
3434+ )
3535+`;
3636+3737+// Domains table maps subdomain -> DID
3838+await db`
3939+ CREATE TABLE IF NOT EXISTS domains (
4040+ domain TEXT PRIMARY KEY,
4141+ did TEXT UNIQUE NOT NULL,
4242+ created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
4343+ )
4444+`;
4545+4646+// Custom domains table for BYOD (bring your own domain)
4747+await db`
4848+ CREATE TABLE IF NOT EXISTS custom_domains (
4949+ id TEXT PRIMARY KEY,
5050+ domain TEXT UNIQUE NOT NULL,
5151+ did TEXT NOT NULL,
5252+ rkey TEXT NOT NULL DEFAULT 'self',
5353+ verified BOOLEAN DEFAULT false,
5454+ last_verified_at BIGINT,
5555+ created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
5656+ )
5757+`;
5858+5959+// Sites table - cache of place.wisp.fs records from PDS
6060+await db`
6161+ CREATE TABLE IF NOT EXISTS sites (
6262+ did TEXT NOT NULL,
6363+ rkey TEXT NOT NULL,
6464+ display_name TEXT,
6565+ created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
6666+ updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
6767+ PRIMARY KEY (did, rkey)
6868+ )
6969+`;
7070+7171+const RESERVED_HANDLES = new Set([
7272+ "www",
7373+ "api",
7474+ "admin",
7575+ "static",
7676+ "public",
7777+ "preview"
7878+]);
7979+8080+export const isValidHandle = (handle: string): boolean => {
8181+ const h = handle.trim().toLowerCase();
8282+ if (h.length < 3 || h.length > 63) return false;
8383+ if (!/^[a-z0-9-]+$/.test(h)) return false;
8484+ if (h.startsWith('-') || h.endsWith('-')) return false;
8585+ if (h.includes('--')) return false;
8686+ if (RESERVED_HANDLES.has(h)) return false;
8787+ return true;
8888+};
8989+9090+export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
9191+9292+export const getDomainByDid = async (did: string): Promise<string | null> => {
9393+ const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
9494+ return rows[0]?.domain ?? null;
9595+};
9696+9797+export const getDidByDomain = async (domain: string): Promise<string | null> => {
9898+ const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
9999+ return rows[0]?.did ?? null;
100100+};
101101+102102+export const isDomainAvailable = async (handle: string): Promise<boolean> => {
103103+ const h = handle.trim().toLowerCase();
104104+ if (!isValidHandle(h)) return false;
105105+ const domain = toDomain(h);
106106+ const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`;
107107+ return rows.length === 0;
108108+};
109109+110110+export const claimDomain = async (did: string, handle: string): Promise<string> => {
111111+ const h = handle.trim().toLowerCase();
112112+ if (!isValidHandle(h)) throw new Error('invalid_handle');
113113+ const domain = toDomain(h);
114114+ try {
115115+ await db`
116116+ INSERT INTO domains (domain, did)
117117+ VALUES (${domain}, ${did})
118118+ `;
119119+ } catch (err) {
120120+ // Unique constraint violations -> already taken or DID already claimed
121121+ throw new Error('conflict');
122122+ }
123123+ return domain;
124124+};
125125+126126+export const updateDomain = async (did: string, handle: string): Promise<string> => {
127127+ const h = handle.trim().toLowerCase();
128128+ if (!isValidHandle(h)) throw new Error('invalid_handle');
129129+ const domain = toDomain(h);
130130+ try {
131131+ const rows = await db`
132132+ UPDATE domains SET domain = ${domain}
133133+ WHERE did = ${did}
134134+ RETURNING domain
135135+ `;
136136+ if (rows.length > 0) return rows[0].domain as string;
137137+ // No existing row, behave like claim
138138+ return await claimDomain(did, handle);
139139+ } catch (err) {
140140+ // Unique constraint violations -> already taken by someone else
141141+ throw new Error('conflict');
142142+ }
143143+};
144144+145145+const stateStore = {
146146+ async set(key: string, data: any) {
147147+ console.debug('[stateStore] set', key)
148148+ await db`
149149+ INSERT INTO oauth_states (key, data)
150150+ VALUES (${key}, ${JSON.stringify(data)})
151151+ ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
152152+ `;
153153+ },
154154+ async get(key: string) {
155155+ console.debug('[stateStore] get', key)
156156+ const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
157157+ return result[0] ? JSON.parse(result[0].data) : undefined;
158158+ },
159159+ async del(key: string) {
160160+ console.debug('[stateStore] del', key)
161161+ await db`DELETE FROM oauth_states WHERE key = ${key}`;
162162+ }
163163+};
164164+165165+const sessionStore = {
166166+ async set(sub: string, data: any) {
167167+ console.debug('[sessionStore] set', sub)
168168+ await db`
169169+ INSERT INTO oauth_sessions (sub, data)
170170+ VALUES (${sub}, ${JSON.stringify(data)})
171171+ ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
172172+ `;
173173+ },
174174+ async get(sub: string) {
175175+ console.debug('[sessionStore] get', sub)
176176+ const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
177177+ return result[0] ? JSON.parse(result[0].data) : undefined;
178178+ },
179179+ async del(sub: string) {
180180+ console.debug('[sessionStore] del', sub)
181181+ await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
182182+ }
183183+};
184184+185185+export { sessionStore };
186186+187187+export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
188188+ client_id: `${config.domain}/client-metadata.json`,
189189+ client_name: config.clientName,
190190+ client_uri: config.domain,
191191+ logo_uri: `${config.domain}/logo.png`,
192192+ tos_uri: `${config.domain}/tos`,
193193+ policy_uri: `${config.domain}/policy`,
194194+ redirect_uris: [`${config.domain}/api/auth/callback`],
195195+ grant_types: ['authorization_code', 'refresh_token'],
196196+ response_types: ['code'],
197197+ application_type: 'web',
198198+ token_endpoint_auth_method: 'private_key_jwt',
199199+ token_endpoint_auth_signing_alg: "ES256",
200200+ scope: "atproto transition:generic",
201201+ dpop_bound_access_tokens: true,
202202+ jwks_uri: `${config.domain}/jwks.json`,
203203+ subject_type: 'public',
204204+ authorization_signed_response_alg: 'ES256'
205205+});
206206+207207+const persistKey = async (key: JoseKey) => {
208208+ const priv = key.privateJwk;
209209+ if (!priv) return;
210210+ const kid = key.kid ?? crypto.randomUUID();
211211+ await db`
212212+ INSERT INTO oauth_keys (kid, jwk)
213213+ VALUES (${kid}, ${JSON.stringify(priv)})
214214+ ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
215215+ `;
216216+};
217217+218218+const loadPersistedKeys = async (): Promise<JoseKey[]> => {
219219+ const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
220220+ const keys: JoseKey[] = [];
221221+ for (const row of rows) {
222222+ try {
223223+ const obj = JSON.parse(row.jwk);
224224+ const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
225225+ keys.push(key);
226226+ } catch (err) {
227227+ console.error('Could not parse stored JWK', err);
228228+ }
229229+ }
230230+ return keys;
231231+};
232232+233233+const ensureKeys = async (): Promise<JoseKey[]> => {
234234+ let keys = await loadPersistedKeys();
235235+ const needed: string[] = [];
236236+ for (let i = 1; i <= 3; i++) {
237237+ const kid = `key${i}`;
238238+ if (!keys.some(k => k.kid === kid)) needed.push(kid);
239239+ }
240240+ for (const kid of needed) {
241241+ const newKey = await JoseKey.generate(['ES256'], kid);
242242+ await persistKey(newKey);
243243+ keys.push(newKey);
244244+ }
245245+ keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
246246+ return keys;
247247+};
248248+249249+let currentKeys: JoseKey[] = [];
250250+251251+export const getCurrentKeys = () => currentKeys;
252252+253253+export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
254254+ if (currentKeys.length === 0) {
255255+ currentKeys = await ensureKeys();
256256+ }
257257+258258+ return new NodeOAuthClient({
259259+ clientMetadata: createClientMetadata(config),
260260+ keyset: currentKeys,
261261+ stateStore,
262262+ sessionStore
263263+ });
264264+};
265265+266266+export const getCustomDomainsByDid = async (did: string) => {
267267+ const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`;
268268+ return rows;
269269+};
270270+271271+export const getCustomDomainInfo = async (domain: string) => {
272272+ const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`;
273273+ return rows[0] ?? null;
274274+};
275275+276276+export const getCustomDomainByHash = async (hash: string) => {
277277+ const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`;
278278+ return rows[0] ?? null;
279279+};
280280+281281+export const getCustomDomainById = async (id: string) => {
282282+ const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`;
283283+ return rows[0] ?? null;
284284+};
285285+286286+export const claimCustomDomain = async (did: string, domain: string, siteName: string, hash: string) => {
287287+ const domainLower = domain.toLowerCase();
288288+ try {
289289+ await db`
290290+ INSERT INTO custom_domains (id, domain, did, site_name, verified, created_at)
291291+ VALUES (${hash}, ${domainLower}, ${did}, ${siteName}, false, EXTRACT(EPOCH FROM NOW()))
292292+ `;
293293+ return { success: true, hash };
294294+ } catch (err) {
295295+ console.error('Failed to claim custom domain', err);
296296+ throw new Error('conflict');
297297+ }
298298+};
299299+300300+export const updateCustomDomainSite = async (id: string, siteName: string) => {
301301+ const rows = await db`
302302+ UPDATE custom_domains
303303+ SET site_name = ${siteName}
304304+ WHERE id = ${id}
305305+ RETURNING *
306306+ `;
307307+ return rows[0] ?? null;
308308+};
309309+310310+export const updateCustomDomainVerification = async (id: string, verified: boolean) => {
311311+ const rows = await db`
312312+ UPDATE custom_domains
313313+ SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW())
314314+ WHERE id = ${id}
315315+ RETURNING *
316316+ `;
317317+ return rows[0] ?? null;
318318+};
319319+320320+export const deleteCustomDomain = async (id: string) => {
321321+ await db`DELETE FROM custom_domains WHERE id = ${id}`;
322322+};
323323+324324+export const getSitesByDid = async (did: string) => {
325325+ const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`;
326326+ return rows;
327327+};
328328+329329+export const upsertSite = async (did: string, siteName: string, displayName?: string) => {
330330+ try {
331331+ await db`
332332+ INSERT INTO sites (did, site_name, display_name, created_at, updated_at)
333333+ VALUES (${did}, ${siteName}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
334334+ ON CONFLICT (did, site_name)
335335+ DO UPDATE SET
336336+ display_name = COALESCE(EXCLUDED.display_name, sites.display_name),
337337+ updated_at = EXTRACT(EPOCH FROM NOW())
338338+ `;
339339+ return { success: true };
340340+ } catch (err) {
341341+ console.error('Failed to upsert site', err);
342342+ return { success: false, error: err };
343343+ }
344344+};
+127
src/lib/oauth-client.ts
···11+import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
22+import { JoseKey } from "@atproto/jwk-jose";
33+import { db } from "./db";
44+55+const stateStore = {
66+ async set(key: string, data: any) {
77+ console.debug('[stateStore] set', key)
88+ await db`
99+ INSERT INTO oauth_states (key, data)
1010+ VALUES (${key}, ${JSON.stringify(data)})
1111+ ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
1212+ `;
1313+ },
1414+ async get(key: string) {
1515+ console.debug('[stateStore] get', key)
1616+ const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
1717+ return result[0] ? JSON.parse(result[0].data) : undefined;
1818+ },
1919+ async del(key: string) {
2020+ console.debug('[stateStore] del', key)
2121+ await db`DELETE FROM oauth_states WHERE key = ${key}`;
2222+ }
2323+};
2424+2525+const sessionStore = {
2626+ async set(sub: string, data: any) {
2727+ console.debug('[sessionStore] set', sub)
2828+ await db`
2929+ INSERT INTO oauth_sessions (sub, data)
3030+ VALUES (${sub}, ${JSON.stringify(data)})
3131+ ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
3232+ `;
3333+ },
3434+ async get(sub: string) {
3535+ console.debug('[sessionStore] get', sub)
3636+ const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
3737+ return result[0] ? JSON.parse(result[0].data) : undefined;
3838+ },
3939+ async del(sub: string) {
4040+ console.debug('[sessionStore] del', sub)
4141+ await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
4242+ }
4343+};
4444+4545+export { sessionStore };
4646+4747+export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => {
4848+ // Use editor.wisp.place for OAuth endpoints since that's where the API routes live
4949+ return {
5050+ client_id: `${config.domain}/client-metadata.json`,
5151+ client_name: config.clientName,
5252+ client_uri: `https://wisp.place`,
5353+ logo_uri: `${config.domain}/logo.png`,
5454+ tos_uri: `${config.domain}/tos`,
5555+ policy_uri: `${config.domain}/policy`,
5656+ redirect_uris: [`${config.domain}/api/auth/callback`],
5757+ grant_types: ['authorization_code', 'refresh_token'],
5858+ response_types: ['code'],
5959+ application_type: 'web',
6060+ token_endpoint_auth_method: 'private_key_jwt',
6161+ token_endpoint_auth_signing_alg: "ES256",
6262+ scope: "atproto transition:generic",
6363+ dpop_bound_access_tokens: true,
6464+ jwks_uri: `${config.domain}/jwks.json`,
6565+ subject_type: 'public',
6666+ authorization_signed_response_alg: 'ES256'
6767+ };
6868+};
6969+7070+const persistKey = async (key: JoseKey) => {
7171+ const priv = key.privateJwk;
7272+ if (!priv) return;
7373+ const kid = key.kid ?? crypto.randomUUID();
7474+ await db`
7575+ INSERT INTO oauth_keys (kid, jwk)
7676+ VALUES (${kid}, ${JSON.stringify(priv)})
7777+ ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
7878+ `;
7979+};
8080+8181+const loadPersistedKeys = async (): Promise<JoseKey[]> => {
8282+ const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
8383+ const keys: JoseKey[] = [];
8484+ for (const row of rows) {
8585+ try {
8686+ const obj = JSON.parse(row.jwk);
8787+ const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
8888+ keys.push(key);
8989+ } catch (err) {
9090+ console.error('Could not parse stored JWK', err);
9191+ }
9292+ }
9393+ return keys;
9494+};
9595+9696+const ensureKeys = async (): Promise<JoseKey[]> => {
9797+ let keys = await loadPersistedKeys();
9898+ const needed: string[] = [];
9999+ for (let i = 1; i <= 3; i++) {
100100+ const kid = `key${i}`;
101101+ if (!keys.some(k => k.kid === kid)) needed.push(kid);
102102+ }
103103+ for (const kid of needed) {
104104+ const newKey = await JoseKey.generate(['ES256'], kid);
105105+ await persistKey(newKey);
106106+ keys.push(newKey);
107107+ }
108108+ keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
109109+ return keys;
110110+};
111111+112112+let currentKeys: JoseKey[] = [];
113113+114114+export const getCurrentKeys = () => currentKeys;
115115+116116+export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
117117+ if (currentKeys.length === 0) {
118118+ currentKeys = await ensureKeys();
119119+ }
120120+121121+ return new NodeOAuthClient({
122122+ clientMetadata: createClientMetadata(config),
123123+ keyset: currentKeys,
124124+ stateStore,
125125+ sessionStore
126126+ });
127127+};
+12
src/lib/types.ts
···11+import type { BlobRef } from "@atproto/api";
22+33+/**
44+ * Configuration for the Wisp client
55+ * @typeParam Config
66+ */
77+export type Config = {
88+ /** The base domain URL with HTTPS protocol */
99+ domain: `https://${string}`,
1010+ /** Name of the client application */
1111+ clientName: string
1212+};