@hatk/hatk 0.0.1-alpha.24 → 0.0.1-alpha.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +94 -0
  4. package/dist/cli.js +186 -66
  5. package/dist/config.d.ts +1 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +1 -1
  8. package/dist/database/db.d.ts.map +1 -1
  9. package/dist/database/db.js +5 -1
  10. package/dist/dev-entry.d.ts +8 -0
  11. package/dist/dev-entry.d.ts.map +1 -0
  12. package/dist/dev-entry.js +109 -0
  13. package/dist/feeds.d.ts +4 -0
  14. package/dist/feeds.d.ts.map +1 -1
  15. package/dist/feeds.js +41 -2
  16. package/dist/hooks.d.ts +7 -0
  17. package/dist/hooks.d.ts.map +1 -1
  18. package/dist/hooks.js +11 -1
  19. package/dist/labels.d.ts +14 -0
  20. package/dist/labels.d.ts.map +1 -1
  21. package/dist/labels.js +13 -1
  22. package/dist/main.js +49 -17
  23. package/dist/oauth/server.d.ts +2 -0
  24. package/dist/oauth/server.d.ts.map +1 -1
  25. package/dist/oauth/server.js +91 -1
  26. package/dist/oauth/session.d.ts +9 -0
  27. package/dist/oauth/session.d.ts.map +1 -0
  28. package/dist/oauth/session.js +65 -0
  29. package/dist/opengraph.d.ts +10 -0
  30. package/dist/opengraph.d.ts.map +1 -1
  31. package/dist/opengraph.js +102 -4
  32. package/dist/pds-proxy.d.ts +39 -0
  33. package/dist/pds-proxy.d.ts.map +1 -0
  34. package/dist/pds-proxy.js +173 -0
  35. package/dist/renderer.d.ts +27 -0
  36. package/dist/renderer.d.ts.map +1 -0
  37. package/dist/renderer.js +46 -0
  38. package/dist/response.d.ts +16 -0
  39. package/dist/response.d.ts.map +1 -0
  40. package/dist/response.js +69 -0
  41. package/dist/scanner.d.ts +21 -0
  42. package/dist/scanner.d.ts.map +1 -0
  43. package/dist/scanner.js +88 -0
  44. package/dist/server-init.d.ts +8 -0
  45. package/dist/server-init.d.ts.map +1 -0
  46. package/dist/server-init.js +59 -0
  47. package/dist/server.d.ts +26 -3
  48. package/dist/server.d.ts.map +1 -1
  49. package/dist/server.js +473 -616
  50. package/dist/setup.d.ts +7 -0
  51. package/dist/setup.d.ts.map +1 -1
  52. package/dist/setup.js +13 -1
  53. package/dist/test.d.ts.map +1 -1
  54. package/dist/test.js +12 -22
  55. package/dist/vite-plugin.d.ts +1 -1
  56. package/dist/vite-plugin.d.ts.map +1 -1
  57. package/dist/vite-plugin.js +245 -75
  58. package/dist/xrpc.d.ts +13 -0
  59. package/dist/xrpc.d.ts.map +1 -1
  60. package/dist/xrpc.js +87 -1
  61. package/package.json +8 -5
package/dist/server.js CHANGED
@@ -1,95 +1,207 @@
1
- import { createServer } from 'node:http';
2
- import { gzipSync } from 'node:zlib';
3
1
  import { existsSync } from 'node:fs';
4
2
  import { readFile } from 'node:fs/promises';
5
3
  import { join, extname } from 'node:path';
6
- import { queryRecords, getRecordByUri, searchRecords, getSchema, reshapeRow, setRepoStatus, getRepoStatus, getRepoRetryInfo, querySQL, insertRecord, deleteRecord, queryLabelsForUris, insertLabels, searchAccounts, listReposPaginated, getCollectionCounts, normalizeValue, getSchemaDump, getPreferences, putPreference, } from "./database/db.js";
4
+ import { queryRecords, getRecordByUri, searchRecords, getSchema, reshapeRow, setRepoStatus, getRepoStatus, getRepoRetryInfo, querySQL, queryLabelsForUris, insertLabels, searchAccounts, listReposPaginated, getCollectionCounts, getSchemaDump, getPreferences, putPreference, } from "./database/db.js";
7
5
  import { executeFeed, listFeeds } from "./feeds.js";
8
- import { executeXrpc, InvalidRequestError } from "./xrpc.js";
9
- import { getLexiconArray } from "./database/schema.js";
10
- import { validateRecord } from '@bigmoves/lexicon';
6
+ import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler } from "./xrpc.js";
11
7
  import { resolveRecords } from "./hydrate.js";
12
8
  import { handleOpengraphRequest, buildOgMeta } from "./opengraph.js";
13
9
  import { getLabelDefinitions, rescanLabels } from "./labels.js";
14
10
  import { triggerAutoBackfill } from "./indexer.js";
15
- import { log, emit, timer } from "./logger.js";
16
- import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, handlePar, buildAuthorizeRedirect, handleCallback, handleToken, authenticate, refreshPdsSession, } from "./oauth/server.js";
11
+ import { emit, timer } from "./logger.js";
12
+ import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, handlePar, buildAuthorizeRedirect, handleCallback, serverLogin, handleToken, authenticate, } from "./oauth/server.js";
13
+ import { createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie } from "./oauth/session.js";
17
14
  import { getOAuthRequest } from "./oauth/db.js";
18
- import { createDpopProof } from "./oauth/dpop.js";
19
- import { getServerKey, getSession } from "./oauth/db.js";
15
+ import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsUploadBlob, ProxyError } from "./pds-proxy.js";
16
+ import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
17
+ import { serve } from "./adapter.js";
18
+ import { renderPage } from "./renderer.js";
20
19
  const MIME = {
21
20
  '.html': 'text/html',
22
21
  '.js': 'application/javascript',
23
22
  '.css': 'text/css',
24
23
  '.json': 'application/json',
25
24
  };
26
- function readBody(req) {
27
- return new Promise((resolve, reject) => {
28
- let body = '';
29
- req.on('data', (chunk) => (body += chunk));
30
- req.on('end', () => resolve(body));
31
- req.on('error', reject);
25
+ /**
26
+ * Register built-in dev.hatk.* XRPC handlers in the handler registry.
27
+ * This makes them available to callXrpc() for use in SSR and server code.
28
+ */
29
+ export function registerCoreHandlers(collections, oauth) {
30
+ registerCoreXrpcHandler('dev.hatk.getRecords', async (params, cursor, limit) => {
31
+ const collection = params.collection;
32
+ if (!collection)
33
+ throw new InvalidRequestError('Missing collection parameter');
34
+ if (!getSchema(collection))
35
+ throw new NotFoundError(`Unknown collection: ${collection}`);
36
+ const sort = params.sort || undefined;
37
+ const order = (params.order || undefined);
38
+ const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
39
+ const filters = {};
40
+ for (const [key, value] of Object.entries(params)) {
41
+ if (!reserved.has(key))
42
+ filters[key] = value;
43
+ }
44
+ const result = await queryRecords(collection, {
45
+ limit,
46
+ cursor,
47
+ sort,
48
+ order,
49
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
50
+ });
51
+ const uris = result.records.map((r) => r.uri);
52
+ const items = await resolveRecords(uris);
53
+ return { items, cursor: result.cursor };
32
54
  });
33
- }
34
- function readBodyRaw(req) {
35
- return new Promise((resolve, reject) => {
36
- const chunks = [];
37
- req.on('data', (chunk) => chunks.push(chunk));
38
- req.on('end', () => resolve(Buffer.concat(chunks)));
39
- req.on('error', reject);
55
+ registerCoreXrpcHandler('dev.hatk.getRecord', async (params) => {
56
+ const uri = params.uri;
57
+ if (!uri)
58
+ throw new InvalidRequestError('Missing uri parameter');
59
+ const record = await getRecordByUri(uri);
60
+ if (!record)
61
+ throw new NotFoundError('Record not found');
62
+ const shaped = reshapeRow(record, record?.__childData);
63
+ const labelsMap = await queryLabelsForUris([record.uri]);
64
+ if (shaped)
65
+ shaped.labels = labelsMap.get(record.uri) || [];
66
+ return { record: shaped };
67
+ });
68
+ registerCoreXrpcHandler('dev.hatk.getFeed', async (params, cursor, limit, viewer) => {
69
+ const feedName = params.feed;
70
+ if (!feedName)
71
+ throw new InvalidRequestError('Missing feed parameter');
72
+ const result = await executeFeed(feedName, params, cursor, limit, viewer);
73
+ if (!result)
74
+ throw new NotFoundError(`Unknown feed: ${feedName}`);
75
+ return result;
76
+ });
77
+ registerCoreXrpcHandler('dev.hatk.searchRecords', async (params, cursor, limit) => {
78
+ const collection = params.collection;
79
+ const q = params.q;
80
+ if (!collection)
81
+ throw new InvalidRequestError('Missing collection parameter');
82
+ if (!q)
83
+ throw new InvalidRequestError('Missing q parameter');
84
+ if (!getSchema(collection))
85
+ throw new NotFoundError(`Unknown collection: ${collection}`);
86
+ const fuzzy = params.fuzzy !== 'false';
87
+ const result = await searchRecords(collection, q, { limit, cursor, fuzzy });
88
+ const uris = result.records.map((r) => r.uri);
89
+ const items = await resolveRecords(uris);
90
+ return { items, cursor: result.cursor };
91
+ });
92
+ registerCoreXrpcHandler('dev.hatk.describeFeeds', async () => {
93
+ return { feeds: listFeeds() };
94
+ });
95
+ registerCoreXrpcHandler('dev.hatk.describeCollections', async () => {
96
+ const collectionInfo = collections.map((c) => {
97
+ const schema = getSchema(c);
98
+ return {
99
+ collection: c,
100
+ columns: schema?.columns.map((col) => ({
101
+ name: col.name,
102
+ originalName: col.originalName,
103
+ type: col.sqlType,
104
+ required: col.notNull,
105
+ })),
106
+ };
107
+ });
108
+ return { collections: collectionInfo };
109
+ });
110
+ registerCoreXrpcHandler('dev.hatk.describeLabels', async () => {
111
+ return { definitions: getLabelDefinitions() };
40
112
  });
113
+ // Write operations — proxy to user's PDS
114
+ if (oauth) {
115
+ registerCoreXrpcHandler('dev.hatk.getPreferences', async (_params, _cursor, _limit, viewer) => {
116
+ if (!viewer)
117
+ throw new InvalidRequestError('Authentication required');
118
+ const prefs = await getPreferences(viewer.did);
119
+ return { preferences: prefs };
120
+ });
121
+ registerCoreXrpcHandler('dev.hatk.putPreference', async (_params, _cursor, _limit, viewer, input) => {
122
+ if (!viewer)
123
+ throw new InvalidRequestError('Authentication required');
124
+ const body = input;
125
+ if (!body.key || typeof body.key !== 'string')
126
+ throw new InvalidRequestError('Missing or invalid key');
127
+ if (body.value === undefined)
128
+ throw new InvalidRequestError('Missing value');
129
+ await putPreference(viewer.did, body.key, body.value);
130
+ return { success: true };
131
+ });
132
+ registerCoreXrpcHandler('dev.hatk.createRecord', async (_params, _cursor, _limit, viewer, input) => {
133
+ if (!viewer)
134
+ throw new InvalidRequestError('Authentication required');
135
+ return pdsCreateRecord(oauth, viewer, input);
136
+ });
137
+ registerCoreXrpcHandler('dev.hatk.deleteRecord', async (_params, _cursor, _limit, viewer, input) => {
138
+ if (!viewer)
139
+ throw new InvalidRequestError('Authentication required');
140
+ return pdsDeleteRecord(oauth, viewer, input);
141
+ });
142
+ registerCoreXrpcHandler('dev.hatk.putRecord', async (_params, _cursor, _limit, viewer, input) => {
143
+ if (!viewer)
144
+ throw new InvalidRequestError('Authentication required');
145
+ return pdsPutRecord(oauth, viewer, input);
146
+ });
147
+ registerCoreXrpcHandler('dev.hatk.uploadBlob', async (_params, _cursor, _limit, viewer, input) => {
148
+ if (!viewer)
149
+ throw new InvalidRequestError('Authentication required');
150
+ return pdsUploadBlob(oauth, viewer, input, 'application/octet-stream');
151
+ });
152
+ }
41
153
  }
42
- export function startServer(port, collections, publicDir, oauth, admins = [], resolveViewer, onResync) {
43
- const coreXrpc = (method) => `/xrpc/dev.hatk.${method}`;
154
+ /**
155
+ * Create a Web Standard request handler for all hatk routes.
156
+ * Returns a pure function: (Request) → Promise<Response>
157
+ */
158
+ export function createHandler(config) {
159
+ const { collections, publicDir, oauth, admins } = config;
44
160
  const devMode = process.env.DEV_MODE === '1';
45
- function requireAdmin(viewer, res) {
46
- if (!viewer) {
47
- jsonError(res, 401, 'Authentication required');
48
- return false;
49
- }
50
- if (!devMode && !admins.includes(viewer.did)) {
51
- jsonError(res, 403, 'Admin access required');
52
- return false;
53
- }
54
- return true;
161
+ const coreXrpc = (method) => `/xrpc/dev.hatk.${method}`;
162
+ function requireAdmin(viewer, acceptEncoding) {
163
+ if (!viewer)
164
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
165
+ if (!devMode && !admins.includes(viewer.did))
166
+ return withCors(jsonError(403, 'Admin access required', acceptEncoding));
167
+ return null; // auth OK
55
168
  }
56
- const server = createServer(async (req, res) => {
57
- const url = new URL(req.url, `http://localhost:${port}`);
58
- res.setHeader('Access-Control-Allow-Origin', '*');
59
- res.setHeader('Access-Control-Allow-Headers', '*');
60
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
61
- if (req.method === 'OPTIONS') {
62
- res.writeHead(200);
63
- res.end();
64
- return;
65
- }
169
+ return async (request) => {
170
+ const url = new URL(request.url);
171
+ const acceptEncoding = request.headers.get('accept-encoding');
172
+ // CORS preflight
173
+ if (request.method === 'OPTIONS')
174
+ return cors();
66
175
  const isXrpc = url.pathname.startsWith('/xrpc/');
67
176
  const isAdmin = url.pathname.startsWith('/admin') && !url.pathname.endsWith('.html') && !url.pathname.endsWith('.js');
68
177
  const elapsed = isXrpc || isAdmin ? timer() : null;
69
178
  let error;
70
- const requestOrigin = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['host'] || `localhost:${port}`}`;
179
+ const requestOrigin = `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host') || 'localhost'}`;
71
180
  // Authenticate viewer (optional — unauthenticated requests still work)
72
- let viewer = resolveViewer?.(req) ?? null;
181
+ let viewer = config.resolveViewer?.(request) ?? null;
73
182
  if (!viewer && oauth) {
74
183
  try {
75
- viewer = await authenticate(req.headers['authorization'] || null, req.headers['dpop'] || null, req.method, `${requestOrigin}${url.pathname}`);
184
+ viewer = await authenticate(request.headers.get('authorization'), request.headers.get('dpop'), request.method, `${requestOrigin}${url.pathname}`);
76
185
  }
77
186
  catch (err) {
78
187
  emit('oauth', 'authenticate_error', { error: err.message });
79
188
  }
80
189
  }
190
+ // Fallback: resolve viewer from session cookie (for browser requests without DPoP)
191
+ if (!viewer && oauth) {
192
+ try {
193
+ viewer = await parseSessionCookie(request);
194
+ }
195
+ catch { }
196
+ }
81
197
  try {
82
198
  // GET /xrpc/dev.hatk.getRecords?collection=<nsid>&limit=N&cursor=C&<field>=<value>
83
199
  if (url.pathname === coreXrpc('getRecords')) {
84
200
  const collection = url.searchParams.get('collection');
85
- if (!collection) {
86
- jsonError(res, 400, 'Missing collection parameter');
87
- return;
88
- }
89
- if (!getSchema(collection)) {
90
- jsonError(res, 404, `Unknown collection: ${collection}`);
91
- return;
92
- }
201
+ if (!collection)
202
+ return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding));
203
+ if (!getSchema(collection))
204
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
93
205
  const limit = parseInt(url.searchParams.get('limit') || '20');
94
206
  const cursor = url.searchParams.get('cursor') || undefined;
95
207
  const sort = url.searchParams.get('sort') || undefined;
@@ -110,35 +222,27 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
110
222
  });
111
223
  const uris = result.records.map((r) => r.uri);
112
224
  const items = await resolveRecords(uris);
113
- jsonResponse(res, { items, cursor: result.cursor });
114
- return;
225
+ return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding));
115
226
  }
116
227
  // GET /xrpc/dev.hatk.getRecord?uri=<at-uri>
117
228
  if (url.pathname === coreXrpc('getRecord')) {
118
229
  const uri = url.searchParams.get('uri');
119
- if (!uri) {
120
- jsonError(res, 400, 'Missing uri parameter');
121
- return;
122
- }
230
+ if (!uri)
231
+ return withCors(jsonError(400, 'Missing uri parameter', acceptEncoding));
123
232
  const record = await getRecordByUri(uri);
124
- if (!record) {
125
- jsonError(res, 404, 'Record not found');
126
- return;
127
- }
233
+ if (!record)
234
+ return withCors(jsonError(404, 'Record not found', acceptEncoding));
128
235
  const shaped = reshapeRow(record, record?.__childData);
129
236
  const labelsMap = await queryLabelsForUris([record.uri]);
130
237
  if (shaped)
131
238
  shaped.labels = labelsMap.get(record.uri) || [];
132
- jsonResponse(res, { record: shaped });
133
- return;
239
+ return withCors(json({ record: shaped }, 200, acceptEncoding));
134
240
  }
135
241
  // GET /xrpc/dev.hatk.getFeed?feed=<name>&limit=N&cursor=C
136
242
  if (url.pathname === coreXrpc('getFeed')) {
137
243
  const feedName = url.searchParams.get('feed');
138
- if (!feedName) {
139
- jsonError(res, 400, 'Missing feed parameter');
140
- return;
141
- }
244
+ if (!feedName)
245
+ return withCors(jsonError(400, 'Missing feed parameter', acceptEncoding));
142
246
  const limit = parseInt(url.searchParams.get('limit') || '30');
143
247
  const cursor = url.searchParams.get('cursor') || undefined;
144
248
  const params = {};
@@ -146,42 +250,31 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
146
250
  params[key] = value;
147
251
  }
148
252
  const result = await executeFeed(feedName, params, cursor, limit, viewer);
149
- if (!result) {
150
- jsonError(res, 404, `Unknown feed: ${feedName}`);
151
- return;
152
- }
153
- jsonResponse(res, result);
154
- return;
253
+ if (!result)
254
+ return withCors(jsonError(404, `Unknown feed: ${feedName}`, acceptEncoding));
255
+ return withCors(json(result, 200, acceptEncoding));
155
256
  }
156
257
  // GET /xrpc/dev.hatk.searchRecords?collection=<nsid>&q=<query>&limit=N&cursor=C
157
258
  if (url.pathname === coreXrpc('searchRecords')) {
158
259
  const collection = url.searchParams.get('collection');
159
260
  const q = url.searchParams.get('q');
160
- if (!collection) {
161
- jsonError(res, 400, 'Missing collection parameter');
162
- return;
163
- }
164
- if (!q) {
165
- jsonError(res, 400, 'Missing q parameter');
166
- return;
167
- }
168
- if (!getSchema(collection)) {
169
- jsonError(res, 404, `Unknown collection: ${collection}`);
170
- return;
171
- }
261
+ if (!collection)
262
+ return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding));
263
+ if (!q)
264
+ return withCors(jsonError(400, 'Missing q parameter', acceptEncoding));
265
+ if (!getSchema(collection))
266
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
172
267
  const limit = parseInt(url.searchParams.get('limit') || '20');
173
268
  const cursor = url.searchParams.get('cursor') || undefined;
174
269
  const fuzzy = url.searchParams.get('fuzzy') !== 'false';
175
270
  const result = await searchRecords(collection, q, { limit, cursor, fuzzy });
176
271
  const uris = result.records.map((r) => r.uri);
177
272
  const items = await resolveRecords(uris);
178
- jsonResponse(res, { items, cursor: result.cursor });
179
- return;
273
+ return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding));
180
274
  }
181
275
  // GET /xrpc/dev.hatk.describeFeeds
182
276
  if (url.pathname === coreXrpc('describeFeeds')) {
183
- jsonResponse(res, { feeds: listFeeds() });
184
- return;
277
+ return withCors(json({ feeds: listFeeds() }, 200, acceptEncoding));
185
278
  }
186
279
  // GET /xrpc/dev.hatk.describeCollections
187
280
  if (url.pathname === coreXrpc('describeCollections')) {
@@ -197,161 +290,137 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
197
290
  })),
198
291
  };
199
292
  });
200
- jsonResponse(res, { collections: collectionInfo });
201
- return;
293
+ return withCors(json({ collections: collectionInfo }, 200, acceptEncoding));
202
294
  }
203
295
  // GET /xrpc/dev.hatk.describeLabels
204
296
  if (url.pathname === coreXrpc('describeLabels')) {
205
- jsonResponse(res, { definitions: getLabelDefinitions() });
206
- return;
297
+ return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding));
207
298
  }
208
299
  // GET /xrpc/dev.hatk.getPreferences — get all preferences for authenticated user
209
300
  if (url.pathname === coreXrpc('getPreferences')) {
210
- if (!viewer) {
211
- jsonError(res, 401, 'Authentication required');
212
- return;
213
- }
301
+ if (!viewer)
302
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
214
303
  const prefs = await getPreferences(viewer.did);
215
- jsonResponse(res, { preferences: prefs });
216
- return;
304
+ return withCors(json({ preferences: prefs }, 200, acceptEncoding));
217
305
  }
218
306
  // POST /xrpc/dev.hatk.putPreference — set a single preference
219
- if (url.pathname === coreXrpc('putPreference') && req.method === 'POST') {
220
- if (!viewer) {
221
- jsonError(res, 401, 'Authentication required');
222
- return;
223
- }
224
- const body = JSON.parse(await readBody(req));
225
- if (!body.key || typeof body.key !== 'string') {
226
- jsonError(res, 400, 'Missing or invalid key');
227
- return;
228
- }
229
- if (body.value === undefined) {
230
- jsonError(res, 400, 'Missing value');
231
- return;
232
- }
307
+ if (url.pathname === coreXrpc('putPreference') && request.method === 'POST') {
308
+ if (!viewer)
309
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
310
+ const body = JSON.parse(await request.text());
311
+ if (!body.key || typeof body.key !== 'string')
312
+ return withCors(jsonError(400, 'Missing or invalid key', acceptEncoding));
313
+ if (body.value === undefined)
314
+ return withCors(jsonError(400, 'Missing value', acceptEncoding));
233
315
  await putPreference(viewer.did, body.key, body.value);
234
- jsonResponse(res, { success: true });
235
- return;
316
+ return withCors(json({ success: true }, 200, acceptEncoding));
236
317
  }
237
318
  // ── Admin Repo Management ──
238
319
  // POST /admin/repos/add — enqueue DIDs for backfill
239
- if (url.pathname === '/admin/repos/add' && req.method === 'POST') {
240
- if (!requireAdmin(viewer, res))
241
- return;
242
- const { dids } = JSON.parse(await readBody(req));
243
- if (!Array.isArray(dids)) {
244
- jsonError(res, 400, 'Missing dids array');
245
- return;
246
- }
320
+ if (url.pathname === '/admin/repos/add' && request.method === 'POST') {
321
+ const denied = requireAdmin(viewer, acceptEncoding);
322
+ if (denied)
323
+ return denied;
324
+ const { dids } = JSON.parse(await request.text());
325
+ if (!Array.isArray(dids))
326
+ return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
247
327
  for (const did of dids) {
248
328
  await setRepoStatus(did, 'pending');
249
329
  triggerAutoBackfill(did);
250
330
  }
251
- jsonResponse(res, { added: dids.length });
252
- return;
331
+ return withCors(json({ added: dids.length }, 200, acceptEncoding));
253
332
  }
254
333
  // POST /admin/labels/rescan — retroactively apply label rules
255
- if (url.pathname === '/admin/labels/rescan' && req.method === 'POST') {
256
- if (!requireAdmin(viewer, res))
257
- return;
334
+ if (url.pathname === '/admin/labels/rescan' && request.method === 'POST') {
335
+ const denied = requireAdmin(viewer, acceptEncoding);
336
+ if (denied)
337
+ return denied;
258
338
  const result = await rescanLabels(collections);
259
- jsonResponse(res, result);
260
- return;
339
+ return withCors(json(result, 200, acceptEncoding));
261
340
  }
262
341
  // ── Admin Endpoints ──
263
342
  // GET /admin/whoami — check if current viewer is admin
264
343
  if (url.pathname === '/admin/whoami') {
265
- if (!requireAdmin(viewer, res))
266
- return;
267
- jsonResponse(res, { did: viewer.did, admin: true });
268
- return;
344
+ const denied = requireAdmin(viewer, acceptEncoding);
345
+ if (denied)
346
+ return denied;
347
+ return withCors(json({ did: viewer.did, admin: true }, 200, acceptEncoding));
269
348
  }
270
349
  // GET /admin/labels/definitions — get available label definitions
271
350
  if (url.pathname === '/admin/labels/definitions') {
272
- if (!requireAdmin(viewer, res))
273
- return;
274
- jsonResponse(res, { definitions: getLabelDefinitions() });
275
- return;
351
+ const denied = requireAdmin(viewer, acceptEncoding);
352
+ if (denied)
353
+ return denied;
354
+ return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding));
276
355
  }
277
356
  // POST /admin/labels — apply a label
278
- if (url.pathname === '/admin/labels' && req.method === 'POST') {
279
- if (!requireAdmin(viewer, res))
280
- return;
281
- const { uri, val } = JSON.parse(await readBody(req));
282
- if (!uri || !val) {
283
- jsonError(res, 400, 'Missing uri or val');
284
- return;
285
- }
357
+ if (url.pathname === '/admin/labels' && request.method === 'POST') {
358
+ const denied = requireAdmin(viewer, acceptEncoding);
359
+ if (denied)
360
+ return denied;
361
+ const { uri, val } = JSON.parse(await request.text());
362
+ if (!uri || !val)
363
+ return withCors(jsonError(400, 'Missing uri or val', acceptEncoding));
286
364
  await insertLabels([{ src: 'admin', uri, val }]);
287
- jsonResponse(res, { ok: true });
288
- return;
365
+ return withCors(json({ ok: true }, 200, acceptEncoding));
289
366
  }
290
367
  // POST /admin/labels/reset — delete all labels of a given type
291
- if (url.pathname === '/admin/labels/reset' && req.method === 'POST') {
292
- if (!requireAdmin(viewer, res))
293
- return;
294
- const { val } = JSON.parse(await readBody(req));
295
- if (!val) {
296
- jsonError(res, 400, 'Missing val');
297
- return;
298
- }
368
+ if (url.pathname === '/admin/labels/reset' && request.method === 'POST') {
369
+ const denied = requireAdmin(viewer, acceptEncoding);
370
+ if (denied)
371
+ return denied;
372
+ const { val } = JSON.parse(await request.text());
373
+ if (!val)
374
+ return withCors(jsonError(400, 'Missing val', acceptEncoding));
299
375
  const result = await querySQL(`SELECT COUNT(*)::INTEGER as count FROM _labels WHERE val = $1`, [val]);
300
376
  const count = Number(result[0]?.count || 0);
301
377
  await querySQL(`DELETE FROM _labels WHERE val = $1`, [val]);
302
- jsonResponse(res, { deleted: count });
303
- return;
378
+ return withCors(json({ deleted: count }, 200, acceptEncoding));
304
379
  }
305
380
  // POST /admin/labels/negate — negate a label
306
- if (url.pathname === '/admin/labels/negate' && req.method === 'POST') {
307
- if (!requireAdmin(viewer, res))
308
- return;
309
- const { uri, val } = JSON.parse(await readBody(req));
310
- if (!uri || !val) {
311
- jsonError(res, 400, 'Missing uri or val');
312
- return;
313
- }
381
+ if (url.pathname === '/admin/labels/negate' && request.method === 'POST') {
382
+ const denied = requireAdmin(viewer, acceptEncoding);
383
+ if (denied)
384
+ return denied;
385
+ const { uri, val } = JSON.parse(await request.text());
386
+ if (!uri || !val)
387
+ return withCors(jsonError(400, 'Missing uri or val', acceptEncoding));
314
388
  await insertLabels([{ src: 'admin', uri, val, neg: true }]);
315
- jsonResponse(res, { ok: true });
316
- return;
389
+ return withCors(json({ ok: true }, 200, acceptEncoding));
317
390
  }
318
391
  // POST /admin/takedown — takedown an account
319
- if (url.pathname === '/admin/takedown' && req.method === 'POST') {
320
- if (!requireAdmin(viewer, res))
321
- return;
322
- const { did } = JSON.parse(await readBody(req));
323
- if (!did) {
324
- jsonError(res, 400, 'Missing did');
325
- return;
326
- }
392
+ if (url.pathname === '/admin/takedown' && request.method === 'POST') {
393
+ const denied = requireAdmin(viewer, acceptEncoding);
394
+ if (denied)
395
+ return denied;
396
+ const { did } = JSON.parse(await request.text());
397
+ if (!did)
398
+ return withCors(jsonError(400, 'Missing did', acceptEncoding));
327
399
  await setRepoStatus(did, 'takendown');
328
- jsonResponse(res, { ok: true });
329
- return;
400
+ return withCors(json({ ok: true }, 200, acceptEncoding));
330
401
  }
331
402
  // POST /admin/reverse-takedown — reverse a takedown
332
- if (url.pathname === '/admin/reverse-takedown' && req.method === 'POST') {
333
- if (!requireAdmin(viewer, res))
334
- return;
335
- const { did } = JSON.parse(await readBody(req));
336
- if (!did) {
337
- jsonError(res, 400, 'Missing did');
338
- return;
339
- }
403
+ if (url.pathname === '/admin/reverse-takedown' && request.method === 'POST') {
404
+ const denied = requireAdmin(viewer, acceptEncoding);
405
+ if (denied)
406
+ return denied;
407
+ const { did } = JSON.parse(await request.text());
408
+ if (!did)
409
+ return withCors(jsonError(400, 'Missing did', acceptEncoding));
340
410
  await setRepoStatus(did, 'active');
341
- jsonResponse(res, { ok: true });
342
- return;
411
+ return withCors(json({ ok: true }, 200, acceptEncoding));
343
412
  }
344
413
  // GET /admin/search — search records or accounts
345
414
  if (url.pathname === '/admin/search') {
346
- if (!requireAdmin(viewer, res))
347
- return;
415
+ const denied = requireAdmin(viewer, acceptEncoding);
416
+ if (denied)
417
+ return denied;
348
418
  const q = url.searchParams.get('q') || '';
349
419
  const type = url.searchParams.get('type') || 'records';
350
420
  const limit = parseInt(url.searchParams.get('limit') || '20');
351
421
  if (type === 'accounts') {
352
422
  const accounts = await searchAccounts(q, limit);
353
- jsonResponse(res, { accounts });
354
- return;
423
+ return withCors(json({ accounts }, 200, acceptEncoding));
355
424
  }
356
425
  // No query — live firehose activity (excludes bulk backfill records)
357
426
  if (!q) {
@@ -362,7 +431,6 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
362
431
  const schema = getSchema(col);
363
432
  if (!schema)
364
433
  continue;
365
- // Only show records indexed after the repo's backfill completed (live activity)
366
434
  const rows = await querySQL(`SELECT t.* FROM ${schema.tableName} t JOIN _repos r ON t.did = r.did WHERE t.indexed_at > r.backfilled_at ORDER BY t.indexed_at DESC LIMIT $1`, [limit + offset]);
367
435
  const uris = rows.map((r) => r.uri);
368
436
  const labelsMap = await queryLabelsForUris(uris);
@@ -378,22 +446,20 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
378
446
  return ta > tb ? -1 : ta < tb ? 1 : 0;
379
447
  });
380
448
  const page = allResults.slice(offset, offset + limit);
381
- jsonResponse(res, { records: page, total: allResults.length });
382
- return;
449
+ return withCors(json({ records: page, total: allResults.length }, 200, acceptEncoding));
383
450
  }
384
451
  // URI lookup
385
452
  if (q.startsWith('at://')) {
386
453
  const rec = await getRecordByUri(q);
387
454
  if (rec) {
388
455
  const labelsMap = await queryLabelsForUris([rec.uri]);
389
- jsonResponse(res, {
456
+ return withCors(json({
390
457
  records: [{ ...reshapeRow(rec, rec?.__childData), labels: labelsMap.get(rec.uri) || [] }],
391
- });
458
+ }, 200, acceptEncoding));
392
459
  }
393
460
  else {
394
- jsonResponse(res, { records: [] });
461
+ return withCors(json({ records: [] }, 200, acceptEncoding));
395
462
  }
396
- return;
397
463
  }
398
464
  // DID lookup — find all records by this DID
399
465
  if (q.startsWith('did:')) {
@@ -412,8 +478,7 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
412
478
  }
413
479
  catch { }
414
480
  }
415
- jsonResponse(res, { records: allResults.slice(0, limit) });
416
- return;
481
+ return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding));
417
482
  }
418
483
  // Default: full-text search records across all collections
419
484
  const allResults = [];
@@ -431,15 +496,15 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
431
496
  }
432
497
  catch { }
433
498
  }
434
- jsonResponse(res, { records: allResults.slice(0, limit) });
435
- return;
499
+ return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding));
436
500
  }
437
501
  // POST /admin/repos/resync — re-download repos
438
- if (url.pathname === '/admin/repos/resync' && req.method === 'POST') {
439
- if (!requireAdmin(viewer, res))
440
- return;
441
- const body = await readBody(req);
442
- const { dids } = body ? JSON.parse(body) : {};
502
+ if (url.pathname === '/admin/repos/resync' && request.method === 'POST') {
503
+ const denied = requireAdmin(viewer, acceptEncoding);
504
+ if (denied)
505
+ return denied;
506
+ const bodyText = await request.text();
507
+ const { dids } = bodyText ? JSON.parse(bodyText) : {};
443
508
  let repoList;
444
509
  if (Array.isArray(dids) && dids.length > 0) {
445
510
  repoList = dids;
@@ -451,30 +516,28 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
451
516
  for (const did of repoList) {
452
517
  await setRepoStatus(did, 'pending');
453
518
  }
454
- jsonResponse(res, { resyncing: repoList.length });
455
- if (onResync)
456
- onResync();
457
- return;
519
+ if (config.onResync)
520
+ config.onResync();
521
+ return withCors(json({ resyncing: repoList.length }, 200, acceptEncoding));
458
522
  }
459
523
  // POST /admin/repos/remove — remove DIDs from tracking
460
- if (url.pathname === '/admin/repos/remove' && req.method === 'POST') {
461
- if (!requireAdmin(viewer, res))
462
- return;
463
- const { dids } = JSON.parse(await readBody(req));
464
- if (!Array.isArray(dids)) {
465
- jsonError(res, 400, 'Missing dids array');
466
- return;
467
- }
524
+ if (url.pathname === '/admin/repos/remove' && request.method === 'POST') {
525
+ const denied = requireAdmin(viewer, acceptEncoding);
526
+ if (denied)
527
+ return denied;
528
+ const { dids } = JSON.parse(await request.text());
529
+ if (!Array.isArray(dids))
530
+ return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
468
531
  for (const did of dids) {
469
532
  await querySQL(`DELETE FROM _repos WHERE did = $1`, [did]);
470
533
  }
471
- jsonResponse(res, { removed: dids.length });
472
- return;
534
+ return withCors(json({ removed: dids.length }, 200, acceptEncoding));
473
535
  }
474
536
  // GET /admin/info — aggregate status + db size + collection counts
475
537
  if (url.pathname === '/admin/info') {
476
- if (!requireAdmin(viewer, res))
477
- return;
538
+ const denied = requireAdmin(viewer, acceptEncoding);
539
+ if (denied)
540
+ return denied;
478
541
  const rows = await querySQL(`SELECT status, COUNT(*)::INTEGER as count FROM _repos GROUP BY status`);
479
542
  const counts = {};
480
543
  for (const row of rows)
@@ -489,330 +552,265 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
489
552
  heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`,
490
553
  external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`,
491
554
  };
492
- jsonResponse(res, { repos: counts, duckdb: dbInfo, node, collections: collectionCounts });
493
- return;
555
+ return withCors(json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts }, 200, acceptEncoding));
494
556
  }
495
557
  // GET /admin/info/:did — repo status info
496
558
  if (url.pathname.startsWith('/admin/info/did:')) {
497
- if (!requireAdmin(viewer, res))
498
- return;
559
+ const denied = requireAdmin(viewer, acceptEncoding);
560
+ if (denied)
561
+ return denied;
499
562
  const did = url.pathname.slice('/admin/info/'.length);
500
563
  const status = await getRepoStatus(did);
501
- if (!status) {
502
- jsonError(res, 404, 'Repo not found');
503
- return;
504
- }
564
+ if (!status)
565
+ return withCors(jsonError(404, 'Repo not found', acceptEncoding));
505
566
  const retryInfo = await getRepoRetryInfo(did);
506
- jsonResponse(res, {
567
+ return withCors(json({
507
568
  did,
508
569
  status,
509
570
  retry_count: retryInfo?.retryCount ?? 0,
510
571
  retry_after: retryInfo?.retryAfter ?? 0,
511
- });
512
- return;
572
+ }, 200, acceptEncoding));
513
573
  }
514
574
  // GET /admin/repos — paginated repo listing
515
- if (url.pathname === '/admin/repos' && req.method === 'GET') {
516
- if (!requireAdmin(viewer, res))
517
- return;
575
+ if (url.pathname === '/admin/repos' && request.method === 'GET') {
576
+ const denied = requireAdmin(viewer, acceptEncoding);
577
+ if (denied)
578
+ return denied;
518
579
  const limit = parseInt(url.searchParams.get('limit') || '50');
519
580
  const offset = parseInt(url.searchParams.get('offset') || '0');
520
581
  const status = url.searchParams.get('status') || undefined;
521
582
  const q = url.searchParams.get('q') || undefined;
522
583
  const result = await listReposPaginated({ limit, offset, status, q });
523
- jsonResponse(res, result);
524
- return;
584
+ return withCors(json(result, 200, acceptEncoding));
525
585
  }
526
586
  // GET /admin/schema — full DuckDB DDL dump + lexicons
527
587
  if (url.pathname === '/admin/schema') {
528
- if (!requireAdmin(viewer, res))
529
- return;
588
+ const denied = requireAdmin(viewer, acceptEncoding);
589
+ if (denied)
590
+ return denied;
530
591
  const { getAllLexicons } = await import("./database/schema.js");
531
592
  const ddl = await getSchemaDump();
532
- jsonResponse(res, { ddl, lexicons: getAllLexicons() });
533
- return;
593
+ return withCors(json({ ddl, lexicons: getAllLexicons() }, 200, acceptEncoding));
534
594
  }
535
595
  // ── Public Repo Endpoints (used by hatk clients for auto-sync) ──
536
596
  // POST /repos/add — enqueue DIDs for backfill (public)
537
- if (url.pathname === '/repos/add' && req.method === 'POST') {
538
- const { dids } = JSON.parse(await readBody(req));
539
- if (!Array.isArray(dids)) {
540
- jsonError(res, 400, 'Missing dids array');
541
- return;
542
- }
597
+ if (url.pathname === '/repos/add' && request.method === 'POST') {
598
+ const { dids } = JSON.parse(await request.text());
599
+ if (!Array.isArray(dids))
600
+ return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
543
601
  for (const did of dids) {
544
602
  await setRepoStatus(did, 'pending');
545
603
  triggerAutoBackfill(did);
546
604
  }
547
- jsonResponse(res, { added: dids.length });
548
- return;
605
+ return withCors(json({ added: dids.length }, 200, acceptEncoding));
549
606
  }
550
607
  // GET /info/:did — repo status info (public)
551
608
  if (url.pathname.startsWith('/info/did:')) {
552
609
  const did = url.pathname.slice('/info/'.length);
553
610
  const status = await getRepoStatus(did);
554
- if (!status) {
555
- jsonError(res, 404, 'Repo not found');
556
- return;
557
- }
611
+ if (!status)
612
+ return withCors(jsonError(404, 'Repo not found', acceptEncoding));
558
613
  const retryInfo = await getRepoRetryInfo(did);
559
- jsonResponse(res, {
614
+ return withCors(json({
560
615
  did,
561
616
  status,
562
617
  retry_count: retryInfo?.retryCount ?? 0,
563
618
  retry_after: retryInfo?.retryAfter ?? 0,
564
- });
565
- return;
619
+ }, 200, acceptEncoding));
566
620
  }
567
621
  // --- OAuth Endpoints ---
568
622
  // OAuth well-known endpoints
569
623
  if (url.pathname === '/.well-known/oauth-authorization-server' && oauth) {
570
- jsonResponse(res, getAuthServerMetadata(oauth.issuer, oauth));
571
- return;
624
+ return withCors(json(getAuthServerMetadata(oauth.issuer, oauth), 200, acceptEncoding));
572
625
  }
573
626
  if (url.pathname === '/.well-known/oauth-protected-resource' && oauth) {
574
- jsonResponse(res, getProtectedResourceMetadata(oauth.issuer, oauth));
575
- return;
627
+ return withCors(json(getProtectedResourceMetadata(oauth.issuer, oauth), 200, acceptEncoding));
576
628
  }
577
629
  if (url.pathname === '/oauth/jwks' && oauth) {
578
- jsonResponse(res, getJwks());
579
- return;
630
+ return withCors(json(getJwks(), 200, acceptEncoding));
580
631
  }
581
632
  if ((url.pathname === '/oauth/client-metadata.json' || url.pathname === '/oauth-client-metadata.json') && oauth) {
582
- jsonResponse(res, getClientMetadata(oauth.issuer, oauth));
583
- return;
633
+ return withCors(json(getClientMetadata(oauth.issuer, oauth), 200, acceptEncoding));
634
+ }
635
+ // Dev-only: create a session cookie for any DID (for testing)
636
+ if (url.pathname === '/__dev/login' && devMode && oauth) {
637
+ const did = url.searchParams.get('did');
638
+ if (!did)
639
+ return withCors(jsonError(400, 'did required', acceptEncoding));
640
+ const cookieValue = await createSessionCookie(did);
641
+ const secure = url.protocol === 'https:';
642
+ return new Response(JSON.stringify({ ok: true }), {
643
+ status: 200,
644
+ headers: {
645
+ 'Content-Type': 'application/json',
646
+ 'Set-Cookie': sessionCookieHeader(cookieValue, secure),
647
+ },
648
+ });
649
+ }
650
+ // OAuth Login (server-initiated, no DPoP required)
651
+ if (url.pathname === '/oauth/login' && oauth) {
652
+ const handle = url.searchParams.get('handle');
653
+ if (!handle)
654
+ return withCors(jsonError(400, 'handle required', acceptEncoding));
655
+ try {
656
+ const redirectUrl = await serverLogin(oauth, handle);
657
+ return new Response(null, { status: 302, headers: { Location: redirectUrl } });
658
+ }
659
+ catch (err) {
660
+ const message = err instanceof Error ? err.message : 'Login failed';
661
+ return withCors(jsonError(400, message, acceptEncoding));
662
+ }
584
663
  }
585
664
  // OAuth PAR
586
- if (url.pathname === '/oauth/par' && req.method === 'POST' && oauth) {
587
- const rawBody = await readBody(req);
665
+ if (url.pathname === '/oauth/par' && request.method === 'POST' && oauth) {
666
+ const rawBody = await request.text();
588
667
  let body;
589
- if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) {
668
+ if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
590
669
  body = Object.fromEntries(new URLSearchParams(rawBody));
591
670
  }
592
671
  else {
593
672
  body = JSON.parse(rawBody);
594
673
  }
595
- const dpopHeader = req.headers['dpop'];
596
- if (!dpopHeader) {
597
- jsonError(res, 400, 'DPoP header required');
598
- return;
599
- }
674
+ const dpopHeader = request.headers.get('dpop');
675
+ if (!dpopHeader)
676
+ return withCors(jsonError(400, 'DPoP header required', acceptEncoding));
600
677
  try {
601
678
  const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`);
602
- jsonResponse(res, result);
679
+ return withCors(json(result, 200, acceptEncoding));
603
680
  }
604
681
  catch (err) {
605
682
  const message = err instanceof Error ? err.message : 'Unknown error';
606
- jsonError(res, 400, message);
683
+ return withCors(jsonError(400, message, acceptEncoding));
607
684
  }
608
- return;
609
685
  }
610
686
  // OAuth Authorize
611
687
  if (url.pathname === '/oauth/authorize' && oauth) {
612
688
  const requestUri = url.searchParams.get('request_uri');
613
- if (!requestUri) {
614
- jsonError(res, 400, 'request_uri required');
615
- return;
616
- }
617
- const request = await getOAuthRequest(requestUri);
618
- if (!request) {
619
- jsonError(res, 400, 'Invalid or expired request_uri');
620
- return;
621
- }
622
- const redirectUrl = buildAuthorizeRedirect(oauth, request);
623
- res.writeHead(302, { Location: redirectUrl });
624
- res.end();
625
- return;
689
+ if (!requestUri)
690
+ return withCors(jsonError(400, 'request_uri required', acceptEncoding));
691
+ const oauthRequest = await getOAuthRequest(requestUri);
692
+ if (!oauthRequest)
693
+ return withCors(jsonError(400, 'Invalid or expired request_uri', acceptEncoding));
694
+ const redirectUrl = buildAuthorizeRedirect(oauth, oauthRequest);
695
+ return new Response(null, { status: 302, headers: { Location: redirectUrl } });
626
696
  }
627
697
  // OAuth Callback (PDS redirects here after user approves)
628
698
  // Skip if iss matches our own issuer — that's the client-side redirect, let the SPA handle it
629
699
  if (url.pathname === '/oauth/callback' && oauth) {
630
700
  const iss = url.searchParams.get('iss');
631
- if (iss === oauth.issuer) {
632
- // Client-side callback — fall through to SPA
633
- }
634
- else {
701
+ if (iss !== oauth.issuer) {
635
702
  const code = url.searchParams.get('code');
636
703
  const state = url.searchParams.get('state');
637
- if (!code) {
638
- jsonError(res, 400, 'Missing code');
639
- return;
640
- }
704
+ if (!code)
705
+ return withCors(jsonError(400, 'Missing code', acceptEncoding));
641
706
  const result = await handleCallback(oauth, code, state, iss);
642
- res.writeHead(302, { Location: result.clientRedirectUri });
643
- res.end();
644
- return;
707
+ const isSecure = requestOrigin.startsWith('https');
708
+ const cookie = await createSessionCookie(result.did);
709
+ // Server-initiated login stores redirectUri as '/' — redirect cleanly without code/iss params
710
+ const redirectTo = result.clientRedirectUri.startsWith('/?code=') ? '/' : result.clientRedirectUri;
711
+ return new Response(null, {
712
+ status: 302,
713
+ headers: [
714
+ ['Location', redirectTo],
715
+ ['Set-Cookie', sessionCookieHeader(cookie, isSecure)],
716
+ ],
717
+ });
645
718
  }
719
+ // Client-side callback — fall through to SPA
720
+ }
721
+ // Session cookie logout
722
+ if (url.pathname === '/auth/logout' && request.method === 'POST') {
723
+ return new Response(null, {
724
+ status: 200,
725
+ headers: { 'Set-Cookie': clearSessionCookieHeader() },
726
+ });
646
727
  }
647
728
  // OAuth Token
648
- if (url.pathname === '/oauth/token' && req.method === 'POST' && oauth) {
649
- const rawBody = await readBody(req);
729
+ if (url.pathname === '/oauth/token' && request.method === 'POST' && oauth) {
730
+ const rawBody = await request.text();
650
731
  let body;
651
- if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) {
732
+ if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
652
733
  body = Object.fromEntries(new URLSearchParams(rawBody));
653
734
  }
654
735
  else {
655
736
  body = JSON.parse(rawBody);
656
737
  }
657
- const dpopHeader = req.headers['dpop'];
658
- if (!dpopHeader) {
659
- jsonError(res, 400, 'DPoP header required');
660
- return;
661
- }
738
+ const dpopHeader = request.headers.get('dpop');
739
+ if (!dpopHeader)
740
+ return withCors(jsonError(400, 'DPoP header required', acceptEncoding));
662
741
  const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`);
663
- jsonResponse(res, result);
664
- return;
742
+ return withCors(json(result, 200, acceptEncoding));
665
743
  }
666
744
  // POST /xrpc/dev.hatk.createRecord — proxy write to user's PDS
667
- if (url.pathname === coreXrpc('createRecord') && req.method === 'POST' && oauth) {
668
- if (!viewer) {
669
- jsonError(res, 401, 'Authentication required');
670
- return;
671
- }
672
- const body = JSON.parse(await readBody(req));
673
- const validationError = validateRecord(getLexiconArray(), body.collection, body.record);
674
- if (validationError) {
675
- jsonError(res, 400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
676
- return;
677
- }
678
- const session = await getSession(viewer.did);
679
- if (!session) {
680
- jsonError(res, 401, 'No PDS session for user');
681
- return;
682
- }
683
- const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.createRecord`;
684
- const pdsBody = {
685
- repo: viewer.did,
686
- collection: body.collection,
687
- rkey: body.rkey,
688
- record: body.record,
689
- };
690
- const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody);
691
- if (!pdsRes.ok) {
692
- jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS write failed');
693
- return;
694
- }
695
- const result = pdsRes.body;
696
- // Index the record immediately
745
+ if (url.pathname === coreXrpc('createRecord') && request.method === 'POST' && oauth) {
746
+ if (!viewer)
747
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
748
+ const body = JSON.parse(await request.text());
697
749
  try {
698
- await insertRecord(body.collection, result.uri, result.cid, viewer.did, body.record);
750
+ const result = await pdsCreateRecord(oauth, viewer, body);
751
+ return withCors(json(result, 200, acceptEncoding));
699
752
  }
700
- catch {
701
- // Non-fatal firehose will catch it
753
+ catch (err) {
754
+ if (err instanceof ProxyError)
755
+ return withCors(jsonError(err.status, err.message, acceptEncoding));
756
+ throw err;
702
757
  }
703
- jsonResponse(res, result);
704
- return;
705
758
  }
706
759
  // POST /xrpc/dev.hatk.deleteRecord — proxy delete to user's PDS
707
- if (url.pathname === coreXrpc('deleteRecord') && req.method === 'POST' && oauth) {
708
- if (!viewer) {
709
- jsonError(res, 401, 'Authentication required');
710
- return;
711
- }
712
- const body = JSON.parse(await readBody(req));
713
- const session = await getSession(viewer.did);
714
- if (!session) {
715
- jsonError(res, 401, 'No PDS session for user');
716
- return;
717
- }
718
- const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.deleteRecord`;
719
- const pdsBody = {
720
- repo: viewer.did,
721
- collection: body.collection,
722
- rkey: body.rkey,
723
- };
724
- const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody);
725
- if (!pdsRes.ok) {
726
- jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS delete failed');
727
- return;
728
- }
729
- const result = pdsRes.body;
730
- // Delete the record locally
760
+ if (url.pathname === coreXrpc('deleteRecord') && request.method === 'POST' && oauth) {
761
+ if (!viewer)
762
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
763
+ const body = JSON.parse(await request.text());
731
764
  try {
732
- const uri = `at://${viewer.did}/${body.collection}/${body.rkey}`;
733
- await deleteRecord(body.collection, uri);
765
+ const result = await pdsDeleteRecord(oauth, viewer, body);
766
+ return withCors(json(result, 200, acceptEncoding));
734
767
  }
735
- catch {
736
- // Non-fatal firehose will catch it
768
+ catch (err) {
769
+ if (err instanceof ProxyError)
770
+ return withCors(jsonError(err.status, err.message, acceptEncoding));
771
+ throw err;
737
772
  }
738
- jsonResponse(res, result);
739
- return;
740
773
  }
741
774
  // POST /xrpc/dev.hatk.putRecord — proxy create-or-update to user's PDS
742
- if (url.pathname === coreXrpc('putRecord') && req.method === 'POST' && oauth) {
743
- if (!viewer) {
744
- jsonError(res, 401, 'Authentication required');
745
- return;
746
- }
747
- const body = JSON.parse(await readBody(req));
748
- const validationError = validateRecord(getLexiconArray(), body.collection, body.record);
749
- if (validationError) {
750
- jsonError(res, 400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
751
- return;
752
- }
753
- const session = await getSession(viewer.did);
754
- if (!session) {
755
- jsonError(res, 401, 'No PDS session for user');
756
- return;
757
- }
758
- const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.putRecord`;
759
- const pdsBody = {
760
- repo: viewer.did,
761
- collection: body.collection,
762
- rkey: body.rkey,
763
- record: body.record,
764
- };
765
- const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody);
766
- if (!pdsRes.ok) {
767
- jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS write failed');
768
- return;
769
- }
770
- const result = pdsRes.body;
771
- // Re-index (insertRecord uses INSERT OR REPLACE so this handles both create and update)
775
+ if (url.pathname === coreXrpc('putRecord') && request.method === 'POST' && oauth) {
776
+ if (!viewer)
777
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
778
+ const body = JSON.parse(await request.text());
772
779
  try {
773
- await insertRecord(body.collection, result.uri, result.cid, viewer.did, body.record);
780
+ const result = await pdsPutRecord(oauth, viewer, body);
781
+ return withCors(json(result, 200, acceptEncoding));
774
782
  }
775
- catch {
776
- // Non-fatal firehose will catch it
783
+ catch (err) {
784
+ if (err instanceof ProxyError)
785
+ return withCors(jsonError(err.status, err.message, acceptEncoding));
786
+ throw err;
777
787
  }
778
- jsonResponse(res, result);
779
- return;
780
788
  }
781
789
  // POST /xrpc/dev.hatk.uploadBlob — proxy blob upload to user's PDS
782
- if (url.pathname === coreXrpc('uploadBlob') && req.method === 'POST' && oauth) {
783
- if (!viewer) {
784
- jsonError(res, 401, 'Authentication required');
785
- return;
786
- }
787
- const session = await getSession(viewer.did);
788
- if (!session) {
789
- jsonError(res, 401, 'No PDS session for user');
790
- return;
790
+ if (url.pathname === coreXrpc('uploadBlob') && request.method === 'POST' && oauth) {
791
+ if (!viewer)
792
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
793
+ const contentType = request.headers.get('content-type') || 'application/octet-stream';
794
+ const rawBody = new Uint8Array(await request.arrayBuffer());
795
+ try {
796
+ const result = await pdsUploadBlob(oauth, viewer, rawBody, contentType);
797
+ return withCors(json(result, 200, acceptEncoding));
791
798
  }
792
- const contentType = req.headers['content-type'] || 'application/octet-stream';
793
- const rawBody = await readBodyRaw(req);
794
- const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.uploadBlob`;
795
- const pdsRes = await proxyToPdsRaw(oauth, session, pdsUrl, rawBody, contentType);
796
- if (!pdsRes.ok) {
797
- jsonError(res, pdsRes.status, String(pdsRes.body.error || 'PDS upload failed'));
798
- return;
799
+ catch (err) {
800
+ if (err instanceof ProxyError)
801
+ return withCors(jsonError(err.status, err.message, acceptEncoding));
802
+ throw err;
799
803
  }
800
- jsonResponse(res, pdsRes.body);
801
- return;
802
804
  }
803
805
  // GET /admin — serve admin UI from hatk package
804
806
  if (url.pathname === '/admin' || url.pathname === '/admin/') {
805
807
  const adminPath = join(import.meta.dirname, '../public/admin.html');
806
808
  try {
807
809
  const content = await readFile(adminPath);
808
- res.writeHead(200, { 'Content-Type': 'text/html' });
809
- res.end(content);
810
- return;
810
+ return withCors(file(content, 'text/html'));
811
811
  }
812
812
  catch {
813
- res.writeHead(404);
814
- res.end('Admin page not found');
815
- return;
813
+ return withCors(new Response('Admin page not found', { status: 404 }));
816
814
  }
817
815
  }
818
816
  // GET /admin/admin-auth.js — serve bundled OAuth client
@@ -820,35 +818,24 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
820
818
  const authPath = join(import.meta.dirname, '../public/admin-auth.js');
821
819
  try {
822
820
  const content = await readFile(authPath);
823
- res.writeHead(200, { 'Content-Type': 'application/javascript' });
824
- res.end(content);
825
- return;
821
+ return withCors(file(content, 'application/javascript'));
826
822
  }
827
823
  catch {
828
- res.writeHead(404);
829
- res.end('Not found');
830
- return;
824
+ return notFound();
831
825
  }
832
826
  }
833
827
  // GET /_health
834
828
  if (url.pathname === '/_health') {
835
- jsonResponse(res, { status: 'ok' });
836
- return;
829
+ return withCors(json({ status: 'ok' }, 200, acceptEncoding));
837
830
  }
838
831
  // GET /og/* — OpenGraph image routes
839
- if (url.pathname.startsWith('/og/') && !res.writableEnded) {
832
+ if (url.pathname.startsWith('/og/')) {
840
833
  const png = await handleOpengraphRequest(url.pathname);
841
- if (png) {
842
- res.writeHead(200, {
843
- 'Content-Type': 'image/png',
844
- 'Cache-Control': 'public, max-age=300',
845
- });
846
- res.end(png);
847
- return;
848
- }
834
+ if (png)
835
+ return withCors(file(png, 'image/png', 'public, max-age=300'));
849
836
  }
850
837
  // GET/POST /xrpc/{nsid} — custom XRPC handlers (matched by full NSID from folder structure)
851
- if (url.pathname.startsWith('/xrpc/') && !res.writableEnded) {
838
+ if (url.pathname.startsWith('/xrpc/')) {
852
839
  const method = url.pathname.slice('/xrpc/'.length);
853
840
  const limit = parseInt(url.searchParams.get('limit') || '20');
854
841
  const cursor = url.searchParams.get('cursor') || undefined;
@@ -858,9 +845,9 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
858
845
  }
859
846
  // Parse request body for POST (procedures)
860
847
  let input;
861
- if (req.method === 'POST') {
848
+ if (request.method === 'POST') {
862
849
  try {
863
- input = JSON.parse(await readBody(req));
850
+ input = JSON.parse(await request.text());
864
851
  }
865
852
  catch {
866
853
  input = {};
@@ -868,15 +855,12 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
868
855
  }
869
856
  try {
870
857
  const result = await executeXrpc(method, params, cursor, limit, viewer, input);
871
- if (result) {
872
- jsonResponse(res, result);
873
- return;
874
- }
858
+ if (result)
859
+ return withCors(json(result, 200, acceptEncoding));
875
860
  }
876
861
  catch (err) {
877
862
  if (err instanceof InvalidRequestError) {
878
- jsonError(res, err.status, err.errorName || err.message);
879
- return;
863
+ return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding));
880
864
  }
881
865
  throw err;
882
866
  }
@@ -888,9 +872,7 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
888
872
  const robotsPath = userRobots && existsSync(userRobots) ? userRobots : defaultRobots;
889
873
  try {
890
874
  const content = await readFile(robotsPath);
891
- res.writeHead(200, { 'Content-Type': 'text/plain' });
892
- res.end(content);
893
- return;
875
+ return withCors(file(content, 'text/plain'));
894
876
  }
895
877
  catch {
896
878
  // fall through
@@ -901,38 +883,38 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
901
883
  try {
902
884
  const filePath = join(publicDir, url.pathname === '/' ? 'index.html' : url.pathname);
903
885
  const content = await readFile(filePath);
904
- res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'text/plain' });
905
- res.end(content);
906
- return;
886
+ return withCors(file(content, MIME[extname(filePath)] || 'text/plain'));
907
887
  }
908
888
  catch { }
909
- // SPA fallback — serve index.html for client-side routes
889
+ // SSR or SPA fallback — serve index.html for client-side routes
910
890
  try {
911
- let content = await readFile(join(publicDir, 'index.html'), 'utf-8');
912
- // Inject OG meta tags for shareable routes
891
+ const template = await readFile(join(publicDir, 'index.html'), 'utf-8');
913
892
  const ogMeta = buildOgMeta(url.pathname, requestOrigin);
893
+ // Try SSR first
894
+ const renderedHtml = await renderPage(template, request, ogMeta);
895
+ if (renderedHtml) {
896
+ return withCors(file(Buffer.from(renderedHtml), 'text/html'));
897
+ }
898
+ // SPA fallback — inject OG meta only
899
+ let html = template;
914
900
  if (ogMeta) {
915
- content = content.replace('</head>', `${ogMeta}\n</head>`);
901
+ html = html.replace('</head>', `${ogMeta}\n</head>`);
916
902
  }
917
- res.writeHead(200, { 'Content-Type': 'text/html' });
918
- res.end(content);
919
- return;
903
+ return withCors(file(Buffer.from(html), 'text/html'));
920
904
  }
921
905
  catch { }
922
906
  }
923
- res.writeHead(404);
924
- res.end('Not Found');
907
+ return notFound();
925
908
  }
926
909
  catch (err) {
927
910
  error = err.message;
928
- jsonError(res, 500, err.message);
911
+ return withCors(jsonError(500, err.message, acceptEncoding));
929
912
  }
930
913
  finally {
931
- if (isXrpc || isAdmin) {
914
+ if ((isXrpc || isAdmin) && elapsed) {
932
915
  emit('server', 'request', {
933
- method: req.method,
916
+ method: request.method,
934
917
  path: url.pathname,
935
- status_code: res.statusCode,
936
918
  duration_ms: elapsed(),
937
919
  collection: url.searchParams.get('collection') || undefined,
938
920
  query: url.searchParams.get('q') || undefined,
@@ -940,135 +922,10 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
940
922
  });
941
923
  }
942
924
  }
943
- });
944
- server.listen(port, () => log(`[server] ${oauth?.issuer || `http://localhost:${port}`}`));
945
- return server;
946
- }
947
- function sendJson(res, status, body) {
948
- const acceptEncoding = res.req?.headers['accept-encoding'] || '';
949
- if (body.length > 1024 && /\bgzip\b/.test(acceptEncoding)) {
950
- const compressed = gzipSync(body);
951
- res.writeHead(status, {
952
- 'Content-Type': 'application/json',
953
- 'Content-Encoding': 'gzip',
954
- Vary: 'Accept-Encoding',
955
- ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
956
- });
957
- res.end(compressed);
958
- }
959
- else {
960
- res.writeHead(status, {
961
- 'Content-Type': 'application/json',
962
- ...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
963
- });
964
- res.end(body);
965
- }
966
- }
967
- function jsonResponse(res, data) {
968
- sendJson(res, 200, Buffer.from(JSON.stringify(data, (_, v) => normalizeValue(v))));
925
+ };
969
926
  }
970
- function jsonError(res, status, message) {
971
- if (res.headersSent)
972
- return;
973
- sendJson(res, status, Buffer.from(JSON.stringify({ error: message })));
974
- }
975
- /** Proxy a request to the user's PDS with DPoP + automatic nonce retry + token refresh. */
976
- async function proxyToPds(oauthConfig, session, method, pdsUrl, body) {
977
- const serverKey = await getServerKey('appview-oauth-key');
978
- const privateJwk = JSON.parse(serverKey.privateKey);
979
- const publicJwk = JSON.parse(serverKey.publicKey);
980
- let accessToken = session.access_token;
981
- async function doFetch(token, nonce) {
982
- const proof = await createDpopProof(privateJwk, publicJwk, method, pdsUrl, token, nonce);
983
- const res = await fetch(pdsUrl, {
984
- method,
985
- headers: {
986
- 'Content-Type': 'application/json',
987
- Authorization: `DPoP ${token}`,
988
- DPoP: proof,
989
- },
990
- body: JSON.stringify(body),
991
- });
992
- const resBody = await res.json().catch(() => ({}));
993
- return { ok: res.ok, status: res.status, body: resBody, headers: res.headers };
994
- }
995
- let result = await doFetch(accessToken);
996
- if (result.ok)
997
- return result;
998
- let nonce;
999
- // Step 1: handle DPoP nonce requirement
1000
- if (result.body.error === 'use_dpop_nonce') {
1001
- nonce = result.headers.get('DPoP-Nonce') || undefined;
1002
- if (nonce) {
1003
- result = await doFetch(accessToken, nonce);
1004
- if (result.ok)
1005
- return result;
1006
- }
1007
- }
1008
- // Step 2: handle expired PDS token — refresh and retry
1009
- if (result.body.error === 'invalid_token') {
1010
- const refreshed = await refreshPdsSession(oauthConfig, session);
1011
- if (refreshed) {
1012
- accessToken = refreshed.accessToken;
1013
- result = await doFetch(accessToken, nonce);
1014
- if (result.ok)
1015
- return result;
1016
- // May need DPoP nonce after refresh
1017
- if (result.body.error === 'use_dpop_nonce') {
1018
- nonce = result.headers.get('DPoP-Nonce') || undefined;
1019
- if (nonce)
1020
- result = await doFetch(accessToken, nonce);
1021
- }
1022
- }
1023
- }
1024
- return result;
1025
- }
1026
- /** Proxy a raw binary request to the user's PDS with DPoP + nonce retry + token refresh. */
1027
- async function proxyToPdsRaw(oauthConfig, session, pdsUrl, body, contentType) {
1028
- const serverKey = await getServerKey('appview-oauth-key');
1029
- const privateJwk = JSON.parse(serverKey.privateKey);
1030
- const publicJwk = JSON.parse(serverKey.publicKey);
1031
- let accessToken = session.access_token;
1032
- async function doFetch(token, nonce) {
1033
- const proof = await createDpopProof(privateJwk, publicJwk, 'POST', pdsUrl, token, nonce);
1034
- const res = await fetch(pdsUrl, {
1035
- method: 'POST',
1036
- headers: {
1037
- 'Content-Type': contentType,
1038
- 'Content-Length': String(body.length),
1039
- Authorization: `DPoP ${token}`,
1040
- DPoP: proof,
1041
- },
1042
- body: Buffer.from(body),
1043
- });
1044
- const resBody = await res.json().catch(() => ({}));
1045
- return { ok: res.ok, status: res.status, body: resBody, headers: res.headers };
1046
- }
1047
- let result = await doFetch(accessToken);
1048
- if (result.ok)
1049
- return result;
1050
- let nonce;
1051
- if (result.body.error === 'use_dpop_nonce') {
1052
- nonce = result.headers.get('DPoP-Nonce') || undefined;
1053
- if (nonce) {
1054
- result = await doFetch(accessToken, nonce);
1055
- if (result.ok)
1056
- return result;
1057
- }
1058
- }
1059
- if (result.body.error === 'invalid_token') {
1060
- const refreshed = await refreshPdsSession(oauthConfig, session);
1061
- if (refreshed) {
1062
- accessToken = refreshed.accessToken;
1063
- result = await doFetch(accessToken, nonce);
1064
- if (result.ok)
1065
- return result;
1066
- if (result.body.error === 'use_dpop_nonce') {
1067
- nonce = result.headers.get('DPoP-Nonce') || undefined;
1068
- if (nonce)
1069
- result = await doFetch(accessToken, nonce);
1070
- }
1071
- }
1072
- }
1073
- return result;
927
+ // Backward-compatible wrapper
928
+ export function startServer(port, collections, publicDir, oauth, admins = [], resolveViewer, onResync) {
929
+ const handler = createHandler({ collections, publicDir, oauth, admins, resolveViewer, onResync });
930
+ return serve(handler, port);
1074
931
  }