@hatk/hatk 0.0.1-alpha.6 → 0.0.1-alpha.61

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