forked from
nekomimi.pet/wisp.place-monorepo
Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
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 )
28`;
29
30await db`
31 CREATE TABLE IF NOT EXISTS oauth_keys (
32 kid TEXT PRIMARY KEY,
33 jwk TEXT NOT NULL
34 )
35`;
36
37// Domains table maps subdomain -> DID
38await db`
39 CREATE TABLE IF NOT EXISTS domains (
40 domain TEXT PRIMARY KEY,
41 did TEXT UNIQUE NOT NULL,
42 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
43 )
44`;
45
46// Custom domains table for BYOD (bring your own domain)
47await db`
48 CREATE TABLE IF NOT EXISTS custom_domains (
49 id TEXT PRIMARY KEY,
50 domain TEXT UNIQUE NOT NULL,
51 did TEXT NOT NULL,
52 rkey TEXT NOT NULL DEFAULT 'self',
53 verified BOOLEAN DEFAULT false,
54 last_verified_at BIGINT,
55 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
56 )
57`;
58
59// Sites table - cache of place.wisp.fs records from PDS
60await db`
61 CREATE TABLE IF NOT EXISTS sites (
62 did TEXT NOT NULL,
63 rkey TEXT NOT NULL,
64 display_name TEXT,
65 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
66 updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
67 PRIMARY KEY (did, rkey)
68 )
69`;
70
71const RESERVED_HANDLES = new Set([
72 "www",
73 "api",
74 "admin",
75 "static",
76 "public",
77 "preview"
78]);
79
80export const isValidHandle = (handle: string): boolean => {
81 const h = handle.trim().toLowerCase();
82 if (h.length < 3 || h.length > 63) return false;
83 if (!/^[a-z0-9-]+$/.test(h)) return false;
84 if (h.startsWith('-') || h.endsWith('-')) return false;
85 if (h.includes('--')) return false;
86 if (RESERVED_HANDLES.has(h)) return false;
87 return true;
88};
89
90export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
91
92export const getDomainByDid = async (did: string): Promise<string | null> => {
93 const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
94 return rows[0]?.domain ?? null;
95};
96
97export const getDidByDomain = async (domain: string): Promise<string | null> => {
98 const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
99 return rows[0]?.did ?? null;
100};
101
102export const isDomainAvailable = async (handle: string): Promise<boolean> => {
103 const h = handle.trim().toLowerCase();
104 if (!isValidHandle(h)) return false;
105 const domain = toDomain(h);
106 const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`;
107 return rows.length === 0;
108};
109
110export const claimDomain = async (did: string, handle: string): Promise<string> => {
111 const h = handle.trim().toLowerCase();
112 if (!isValidHandle(h)) throw new Error('invalid_handle');
113 const domain = toDomain(h);
114 try {
115 await db`
116 INSERT INTO domains (domain, did)
117 VALUES (${domain}, ${did})
118 `;
119 } catch (err) {
120 // Unique constraint violations -> already taken or DID already claimed
121 throw new Error('conflict');
122 }
123 return domain;
124};
125
126export const updateDomain = async (did: string, handle: string): Promise<string> => {
127 const h = handle.trim().toLowerCase();
128 if (!isValidHandle(h)) throw new Error('invalid_handle');
129 const domain = toDomain(h);
130 try {
131 const rows = await db`
132 UPDATE domains SET domain = ${domain}
133 WHERE did = ${did}
134 RETURNING domain
135 `;
136 if (rows.length > 0) return rows[0].domain as string;
137 // No existing row, behave like claim
138 return await claimDomain(did, handle);
139 } catch (err) {
140 // Unique constraint violations -> already taken by someone else
141 throw new Error('conflict');
142 }
143};
144
145const stateStore = {
146 async set(key: string, data: any) {
147 console.debug('[stateStore] set', key)
148 await db`
149 INSERT INTO oauth_states (key, data)
150 VALUES (${key}, ${JSON.stringify(data)})
151 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
152 `;
153 },
154 async get(key: string) {
155 console.debug('[stateStore] get', key)
156 const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
157 return result[0] ? JSON.parse(result[0].data) : undefined;
158 },
159 async del(key: string) {
160 console.debug('[stateStore] del', key)
161 await db`DELETE FROM oauth_states WHERE key = ${key}`;
162 }
163};
164
165const sessionStore = {
166 async set(sub: string, data: any) {
167 console.debug('[sessionStore] set', sub)
168 await db`
169 INSERT INTO oauth_sessions (sub, data)
170 VALUES (${sub}, ${JSON.stringify(data)})
171 ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
172 `;
173 },
174 async get(sub: string) {
175 console.debug('[sessionStore] get', sub)
176 const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
177 return result[0] ? JSON.parse(result[0].data) : undefined;
178 },
179 async del(sub: string) {
180 console.debug('[sessionStore] del', sub)
181 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
182 }
183};
184
185export { sessionStore };
186
187export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
188 client_id: `${config.domain}/client-metadata.json`,
189 client_name: config.clientName,
190 client_uri: config.domain,
191 logo_uri: `${config.domain}/logo.png`,
192 tos_uri: `${config.domain}/tos`,
193 policy_uri: `${config.domain}/policy`,
194 redirect_uris: [`${config.domain}/api/auth/callback`],
195 grant_types: ['authorization_code', 'refresh_token'],
196 response_types: ['code'],
197 application_type: 'web',
198 token_endpoint_auth_method: 'private_key_jwt',
199 token_endpoint_auth_signing_alg: "ES256",
200 scope: "atproto transition:generic",
201 dpop_bound_access_tokens: true,
202 jwks_uri: `${config.domain}/jwks.json`,
203 subject_type: 'public',
204 authorization_signed_response_alg: 'ES256'
205});
206
207const persistKey = async (key: JoseKey) => {
208 const priv = key.privateJwk;
209 if (!priv) return;
210 const kid = key.kid ?? crypto.randomUUID();
211 await db`
212 INSERT INTO oauth_keys (kid, jwk)
213 VALUES (${kid}, ${JSON.stringify(priv)})
214 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
215 `;
216};
217
218const loadPersistedKeys = async (): Promise<JoseKey[]> => {
219 const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
220 const keys: JoseKey[] = [];
221 for (const row of rows) {
222 try {
223 const obj = JSON.parse(row.jwk);
224 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
225 keys.push(key);
226 } catch (err) {
227 console.error('Could not parse stored JWK', err);
228 }
229 }
230 return keys;
231};
232
233const ensureKeys = async (): Promise<JoseKey[]> => {
234 let keys = await loadPersistedKeys();
235 const needed: string[] = [];
236 for (let i = 1; i <= 3; i++) {
237 const kid = `key${i}`;
238 if (!keys.some(k => k.kid === kid)) needed.push(kid);
239 }
240 for (const kid of needed) {
241 const newKey = await JoseKey.generate(['ES256'], kid);
242 await persistKey(newKey);
243 keys.push(newKey);
244 }
245 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
246 return keys;
247};
248
249let currentKeys: JoseKey[] = [];
250
251export const getCurrentKeys = () => currentKeys;
252
253export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
254 if (currentKeys.length === 0) {
255 currentKeys = await ensureKeys();
256 }
257
258 return new NodeOAuthClient({
259 clientMetadata: createClientMetadata(config),
260 keyset: currentKeys,
261 stateStore,
262 sessionStore
263 });
264};
265
266export const getCustomDomainsByDid = async (did: string) => {
267 const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`;
268 return rows;
269};
270
271export const getCustomDomainInfo = async (domain: string) => {
272 const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`;
273 return rows[0] ?? null;
274};
275
276export const getCustomDomainByHash = async (hash: string) => {
277 const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`;
278 return rows[0] ?? null;
279};
280
281export const getCustomDomainById = async (id: string) => {
282 const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`;
283 return rows[0] ?? null;
284};
285
286export const claimCustomDomain = async (did: string, domain: string, siteName: string, hash: string) => {
287 const domainLower = domain.toLowerCase();
288 try {
289 await db`
290 INSERT INTO custom_domains (id, domain, did, site_name, verified, created_at)
291 VALUES (${hash}, ${domainLower}, ${did}, ${siteName}, false, EXTRACT(EPOCH FROM NOW()))
292 `;
293 return { success: true, hash };
294 } catch (err) {
295 console.error('Failed to claim custom domain', err);
296 throw new Error('conflict');
297 }
298};
299
300export const updateCustomDomainSite = async (id: string, siteName: string) => {
301 const rows = await db`
302 UPDATE custom_domains
303 SET site_name = ${siteName}
304 WHERE id = ${id}
305 RETURNING *
306 `;
307 return rows[0] ?? null;
308};
309
310export const updateCustomDomainVerification = async (id: string, verified: boolean) => {
311 const rows = await db`
312 UPDATE custom_domains
313 SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW())
314 WHERE id = ${id}
315 RETURNING *
316 `;
317 return rows[0] ?? null;
318};
319
320export const deleteCustomDomain = async (id: string) => {
321 await db`DELETE FROM custom_domains WHERE id = ${id}`;
322};
323
324export const getSitesByDid = async (did: string) => {
325 const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`;
326 return rows;
327};
328
329export const upsertSite = async (did: string, siteName: string, displayName?: string) => {
330 try {
331 await db`
332 INSERT INTO sites (did, site_name, display_name, created_at, updated_at)
333 VALUES (${did}, ${siteName}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
334 ON CONFLICT (did, site_name)
335 DO UPDATE SET
336 display_name = COALESCE(EXCLUDED.display_name, sites.display_name),
337 updated_at = EXTRACT(EPOCH FROM NOW())
338 `;
339 return { success: true };
340 } catch (err) {
341 console.error('Failed to upsert site', err);
342 return { success: false, error: err };
343 }
344};