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