···1+/**
2+ * AUTHENTICATION ROUTES
3+ *
4+ * Handles OAuth authentication flow for Bluesky/ATProto accounts
5+ * All routes are on the editor.wisp.place subdomain
6+ *
7+ * Routes:
8+ * POST /api/auth/signin - Initiate OAuth sign-in flow
9+ * GET /api/auth/callback - OAuth callback handler (redirect from PDS)
10+ * GET /api/auth/status - Check current authentication status
11+ * POST /api/auth/logout - Sign out and clear session
12+ */
13+14+/**
15+ * CUSTOM DOMAIN ROUTES
16+ *
17+ * Handles custom domain (BYOD - Bring Your Own Domain) management
18+ * Users can claim custom domains with DNS verification (TXT + CNAME)
19+ * and map them to their sites
20+ *
21+ * Routes:
22+ * GET /api/check-domain - Fast verification check for routing (public)
23+ * GET /api/custom-domains - List user's custom domains
24+ * POST /api/custom-domains/check - Check domain availability and DNS config
25+ * POST /api/custom-domains/claim - Claim a custom domain
26+ * PUT /api/custom-domains/:id/site - Update site mapping
27+ * DELETE /api/custom-domains/:id - Remove a custom domain
28+ * POST /api/custom-domains/:id/verify - Manually trigger verification
29+ */
30+31+/**
32+ * WISP SITE MANAGEMENT ROUTES
33+ *
34+ * API endpoints for managing user's Wisp sites stored in ATProto repos
35+ * Handles reading site metadata, fetching content, updating sites, and uploads
36+ * All routes are on the editor.wisp.place subdomain
37+ *
38+ * Routes:
39+ * GET /wisp/sites - List all sites for authenticated user
40+ * GET /wisp/fs/:site - Get site record (metadata/manifest)
41+ * GET /wisp/fs/:site/file/* - Get individual file content by path
42+ * POST /wisp/upload-files - Upload and deploy files as a site
43+ */
···1+import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
2+import { JoseKey } from "@atproto/jwk-jose";
3+import { db } from "./db";
4+5+const stateStore = {
6+ async set(key: string, data: any) {
7+ console.debug('[stateStore] set', key)
8+ await db`
9+ INSERT INTO oauth_states (key, data)
10+ VALUES (${key}, ${JSON.stringify(data)})
11+ ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
12+ `;
13+ },
14+ async get(key: string) {
15+ console.debug('[stateStore] get', key)
16+ const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
17+ return result[0] ? JSON.parse(result[0].data) : undefined;
18+ },
19+ async del(key: string) {
20+ console.debug('[stateStore] del', key)
21+ await db`DELETE FROM oauth_states WHERE key = ${key}`;
22+ }
23+};
24+25+const sessionStore = {
26+ async set(sub: string, data: any) {
27+ console.debug('[sessionStore] set', sub)
28+ await db`
29+ INSERT INTO oauth_sessions (sub, data)
30+ VALUES (${sub}, ${JSON.stringify(data)})
31+ ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
32+ `;
33+ },
34+ async get(sub: string) {
35+ console.debug('[sessionStore] get', sub)
36+ const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
37+ return result[0] ? JSON.parse(result[0].data) : undefined;
38+ },
39+ async del(sub: string) {
40+ console.debug('[sessionStore] del', sub)
41+ await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
42+ }
43+};
44+45+export { sessionStore };
46+47+export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => {
48+ // Use editor.wisp.place for OAuth endpoints since that's where the API routes live
49+ return {
50+ client_id: `${config.domain}/client-metadata.json`,
51+ client_name: config.clientName,
52+ client_uri: `https://wisp.place`,
53+ logo_uri: `${config.domain}/logo.png`,
54+ tos_uri: `${config.domain}/tos`,
55+ policy_uri: `${config.domain}/policy`,
56+ redirect_uris: [`${config.domain}/api/auth/callback`],
57+ grant_types: ['authorization_code', 'refresh_token'],
58+ response_types: ['code'],
59+ application_type: 'web',
60+ token_endpoint_auth_method: 'private_key_jwt',
61+ token_endpoint_auth_signing_alg: "ES256",
62+ scope: "atproto transition:generic",
63+ dpop_bound_access_tokens: true,
64+ jwks_uri: `${config.domain}/jwks.json`,
65+ subject_type: 'public',
66+ authorization_signed_response_alg: 'ES256'
67+ };
68+};
69+70+const persistKey = async (key: JoseKey) => {
71+ const priv = key.privateJwk;
72+ if (!priv) return;
73+ const kid = key.kid ?? crypto.randomUUID();
74+ await db`
75+ INSERT INTO oauth_keys (kid, jwk)
76+ VALUES (${kid}, ${JSON.stringify(priv)})
77+ ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
78+ `;
79+};
80+81+const loadPersistedKeys = async (): Promise<JoseKey[]> => {
82+ const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
83+ const keys: JoseKey[] = [];
84+ for (const row of rows) {
85+ try {
86+ const obj = JSON.parse(row.jwk);
87+ const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
88+ keys.push(key);
89+ } catch (err) {
90+ console.error('Could not parse stored JWK', err);
91+ }
92+ }
93+ return keys;
94+};
95+96+const ensureKeys = async (): Promise<JoseKey[]> => {
97+ let keys = await loadPersistedKeys();
98+ const needed: string[] = [];
99+ for (let i = 1; i <= 3; i++) {
100+ const kid = `key${i}`;
101+ if (!keys.some(k => k.kid === kid)) needed.push(kid);
102+ }
103+ for (const kid of needed) {
104+ const newKey = await JoseKey.generate(['ES256'], kid);
105+ await persistKey(newKey);
106+ keys.push(newKey);
107+ }
108+ keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
109+ return keys;
110+};
111+112+let currentKeys: JoseKey[] = [];
113+114+export const getCurrentKeys = () => currentKeys;
115+116+export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
117+ if (currentKeys.length === 0) {
118+ currentKeys = await ensureKeys();
119+ }
120+121+ return new NodeOAuthClient({
122+ clientMetadata: createClientMetadata(config),
123+ keyset: currentKeys,
124+ stateStore,
125+ sessionStore
126+ });
127+};
+12
src/lib/types.ts
···000000000000
···1+import type { BlobRef } from "@atproto/api";
2+3+/**
4+ * Configuration for the Wisp client
5+ * @typeParam Config
6+ */
7+export type Config = {
8+ /** The base domain URL with HTTPS protocol */
9+ domain: `https://${string}`,
10+ /** Name of the client application */
11+ clientName: string
12+};