@hatk/hatk 0.0.1-alpha.5 → 0.0.1-alpha.51

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