@hatk/hatk 0.0.1-alpha.8 → 0.0.1-alpha.80

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 (175) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +110 -0
  4. package/dist/backfill.d.ts +21 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +102 -37
  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 +74 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +43 -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} +755 -552
  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 +119 -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 +163 -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 +109 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +338 -66
  62. package/dist/jetstream.d.ts +118 -0
  63. package/dist/jetstream.d.ts.map +1 -0
  64. package/dist/jetstream.js +225 -0
  65. package/dist/labels.d.ts +36 -0
  66. package/dist/labels.d.ts.map +1 -1
  67. package/dist/labels.js +71 -6
  68. package/dist/lexicon-resolve.d.ts.map +1 -1
  69. package/dist/lexicon-resolve.js +27 -112
  70. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  71. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  72. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  73. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  74. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  75. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  76. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  77. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  78. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  79. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  80. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  81. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  82. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  83. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  84. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  85. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  86. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  88. package/dist/logger.d.ts +29 -0
  89. package/dist/logger.d.ts.map +1 -1
  90. package/dist/logger.js +29 -0
  91. package/dist/main.js +161 -72
  92. package/dist/mst.d.ts +18 -1
  93. package/dist/mst.d.ts.map +1 -1
  94. package/dist/mst.js +19 -8
  95. package/dist/oauth/db.d.ts +3 -1
  96. package/dist/oauth/db.d.ts.map +1 -1
  97. package/dist/oauth/db.js +56 -19
  98. package/dist/oauth/discovery.d.ts.map +1 -1
  99. package/dist/oauth/discovery.js +13 -1
  100. package/dist/oauth/server.d.ts +92 -1
  101. package/dist/oauth/server.d.ts.map +1 -1
  102. package/dist/oauth/server.js +352 -37
  103. package/dist/oauth/service-describe.d.ts +31 -0
  104. package/dist/oauth/service-describe.d.ts.map +1 -0
  105. package/dist/oauth/service-describe.js +61 -0
  106. package/dist/oauth/session.d.ts +32 -0
  107. package/dist/oauth/session.d.ts.map +1 -0
  108. package/dist/oauth/session.js +131 -0
  109. package/dist/opengraph.d.ts +10 -0
  110. package/dist/opengraph.d.ts.map +1 -1
  111. package/dist/opengraph.js +80 -40
  112. package/dist/pds-proxy.d.ts +82 -0
  113. package/dist/pds-proxy.d.ts.map +1 -0
  114. package/dist/pds-proxy.js +356 -0
  115. package/dist/private-collections.d.ts +8 -0
  116. package/dist/private-collections.d.ts.map +1 -0
  117. package/dist/private-collections.js +22 -0
  118. package/dist/push.d.ts +54 -0
  119. package/dist/push.d.ts.map +1 -0
  120. package/dist/push.js +367 -0
  121. package/dist/renderer.d.ts +27 -0
  122. package/dist/renderer.d.ts.map +1 -0
  123. package/dist/renderer.js +46 -0
  124. package/dist/resolve-hatk.d.ts +6 -0
  125. package/dist/resolve-hatk.d.ts.map +1 -0
  126. package/dist/resolve-hatk.js +20 -0
  127. package/dist/response.d.ts +16 -0
  128. package/dist/response.d.ts.map +1 -0
  129. package/dist/response.js +69 -0
  130. package/dist/scanner.d.ts +21 -0
  131. package/dist/scanner.d.ts.map +1 -0
  132. package/dist/scanner.js +88 -0
  133. package/dist/seed.d.ts +19 -0
  134. package/dist/seed.d.ts.map +1 -1
  135. package/dist/seed.js +43 -4
  136. package/dist/server-init.d.ts +8 -0
  137. package/dist/server-init.d.ts.map +1 -0
  138. package/dist/server-init.js +62 -0
  139. package/dist/server.d.ts +26 -3
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +832 -638
  142. package/dist/setup.d.ts +28 -1
  143. package/dist/setup.d.ts.map +1 -1
  144. package/dist/setup.js +50 -3
  145. package/dist/templates/feed.tpl +14 -0
  146. package/dist/templates/hook.tpl +5 -0
  147. package/dist/templates/label.tpl +15 -0
  148. package/dist/templates/og.tpl +17 -0
  149. package/dist/templates/seed.tpl +11 -0
  150. package/dist/templates/setup.tpl +5 -0
  151. package/dist/templates/test-feed.tpl +19 -0
  152. package/dist/templates/test-xrpc.tpl +19 -0
  153. package/dist/templates/xrpc.tpl +41 -0
  154. package/dist/test.d.ts +1 -1
  155. package/dist/test.d.ts.map +1 -1
  156. package/dist/test.js +41 -32
  157. package/dist/views.js +1 -1
  158. package/dist/vite-plugin.d.ts +1 -1
  159. package/dist/vite-plugin.d.ts.map +1 -1
  160. package/dist/vite-plugin.js +254 -66
  161. package/dist/xrpc.d.ts +82 -11
  162. package/dist/xrpc.d.ts.map +1 -1
  163. package/dist/xrpc.js +196 -39
  164. package/package.json +17 -9
  165. package/public/admin-auth.js +271 -306
  166. package/public/admin.html +177 -61
  167. package/dist/db.d.ts.map +0 -1
  168. package/dist/fts.d.ts.map +0 -1
  169. package/dist/oauth/hooks.d.ts +0 -10
  170. package/dist/oauth/hooks.d.ts.map +0 -1
  171. package/dist/oauth/hooks.js +0 -40
  172. package/dist/schema.d.ts.map +0 -1
  173. package/dist/test-browser.d.ts +0 -14
  174. package/dist/test-browser.d.ts.map +0 -1
  175. package/dist/test-browser.js +0 -26
package/dist/server.js CHANGED
@@ -1,95 +1,362 @@
1
- import { createServer } from 'node:http';
2
- import { gzipSync } from 'node:zlib';
1
+ import { createHash } from 'node:crypto';
3
2
  import { existsSync } from 'node:fs';
4
3
  import { readFile } from 'node:fs/promises';
5
4
  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";
5
+ import { gzipSync } from 'node:zlib';
6
+ 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
7
  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';
8
+ import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler } from "./xrpc.js";
11
9
  import { resolveRecords } from "./hydrate.js";
12
10
  import { handleOpengraphRequest, buildOgMeta } from "./opengraph.js";
13
11
  import { getLabelDefinitions, rescanLabels } from "./labels.js";
14
12
  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";
17
- import { getOAuthRequest } from "./oauth/db.js";
18
- import { createDpopProof } from "./oauth/dpop.js";
19
- import { getServerKey, getSession } from "./oauth/db.js";
13
+ import { emit, timer } from "./logger.js";
14
+ import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, getClientJwks, handlePar, buildAuthorizeRedirect, handleCallback, handleCallbackError, serverLogin, handleToken, authenticate, OAuthError, } from "./oauth/server.js";
15
+ import { createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie, createAccountsCookie, accountsCookieHeader, clearAccountsCookieHeader, parseAccountsCookie, withAccount, withoutAccount, } from "./oauth/session.js";
16
+ import { getOAuthRequest, getSession } from "./oauth/db.js";
17
+ import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsApplyWrites, pdsUploadBlob, ProxyError, ScopeMissingProxyError, } from "./pds-proxy.js";
18
+ import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
19
+ import { collectionFromUri, isPrivateCollection } from "./private-collections.js";
20
+ import { serve } from "./adapter.js";
21
+ import { renderPage } from "./renderer.js";
22
+ function scopeMissingResponse(acceptEncoding, handle) {
23
+ const res = withCors(json({ error: 'ScopeMissingError', ...(handle ? { handle } : {}) }, 401, acceptEncoding));
24
+ res.headers.append('Set-Cookie', clearSessionCookieHeader());
25
+ return res;
26
+ }
20
27
  const MIME = {
21
28
  '.html': 'text/html',
22
29
  '.js': 'application/javascript',
23
30
  '.css': 'text/css',
24
31
  '.json': 'application/json',
25
32
  };
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);
33
+ /**
34
+ * Register built-in dev.hatk.* XRPC handlers in the handler registry.
35
+ * This makes them available to callXrpc() for use in SSR and server code.
36
+ */
37
+ export function registerCoreHandlers(collections, oauth) {
38
+ registerCoreXrpcHandler('dev.hatk.getRecords', async (params, cursor, limit) => {
39
+ const collection = params.collection;
40
+ if (!collection)
41
+ throw new InvalidRequestError('Missing collection parameter');
42
+ if (isPrivateCollection(collection))
43
+ throw new NotFoundError(`Unknown collection: ${collection}`);
44
+ if (!getSchema(collection))
45
+ throw new NotFoundError(`Unknown collection: ${collection}`);
46
+ const sort = params.sort || undefined;
47
+ const order = (params.order || undefined);
48
+ const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
49
+ const filters = {};
50
+ for (const [key, value] of Object.entries(params)) {
51
+ if (!reserved.has(key))
52
+ filters[key] = value;
53
+ }
54
+ const result = await queryRecords(collection, {
55
+ limit,
56
+ cursor,
57
+ sort,
58
+ order,
59
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
60
+ });
61
+ const uris = result.records.map((r) => r.uri);
62
+ const items = await resolveRecords(uris);
63
+ return { items, cursor: result.cursor };
32
64
  });
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);
65
+ registerCoreXrpcHandler('dev.hatk.getRecord', async (params) => {
66
+ const uri = params.uri;
67
+ if (!uri)
68
+ throw new InvalidRequestError('Missing uri parameter');
69
+ if (isPrivateCollection(collectionFromUri(uri)))
70
+ throw new NotFoundError('Record not found');
71
+ const record = await getRecordByUri(uri);
72
+ if (!record)
73
+ throw new NotFoundError('Record not found');
74
+ const shaped = reshapeRow(record, record?.__childData);
75
+ const labelsMap = await queryLabelsForUris([record.uri]);
76
+ if (shaped)
77
+ shaped.labels = labelsMap.get(record.uri) || [];
78
+ return { record: shaped };
79
+ });
80
+ registerCoreXrpcHandler('dev.hatk.getFeed', async (params, cursor, limit, viewer) => {
81
+ const feedName = params.feed;
82
+ if (!feedName)
83
+ throw new InvalidRequestError('Missing feed parameter');
84
+ const result = await executeFeed(feedName, params, cursor, limit, viewer);
85
+ if (!result)
86
+ throw new NotFoundError(`Unknown feed: ${feedName}`);
87
+ return result;
88
+ });
89
+ registerCoreXrpcHandler('dev.hatk.searchRecords', async (params, cursor, limit) => {
90
+ const collection = params.collection;
91
+ const q = params.q;
92
+ if (!collection)
93
+ throw new InvalidRequestError('Missing collection parameter');
94
+ if (!q)
95
+ throw new InvalidRequestError('Missing q parameter');
96
+ if (isPrivateCollection(collection))
97
+ throw new NotFoundError(`Unknown collection: ${collection}`);
98
+ if (!getSchema(collection))
99
+ throw new NotFoundError(`Unknown collection: ${collection}`);
100
+ const fuzzy = params.fuzzy !== 'false';
101
+ const result = await searchRecords(collection, q, { limit, cursor, fuzzy });
102
+ const uris = result.records.map((r) => r.uri);
103
+ const items = await resolveRecords(uris);
104
+ return { items, cursor: result.cursor };
105
+ });
106
+ registerCoreXrpcHandler('dev.hatk.describeFeeds', async () => {
107
+ return { feeds: listFeeds() };
108
+ });
109
+ registerCoreXrpcHandler('dev.hatk.describeCollections', async () => {
110
+ const collectionInfo = collections
111
+ .filter((c) => !isPrivateCollection(c))
112
+ .map((c) => {
113
+ const schema = getSchema(c);
114
+ return {
115
+ collection: c,
116
+ columns: schema?.columns.map((col) => ({
117
+ name: col.name,
118
+ originalName: col.originalName,
119
+ type: col.sqlType,
120
+ required: col.notNull,
121
+ })),
122
+ };
123
+ });
124
+ return { collections: collectionInfo };
125
+ });
126
+ registerCoreXrpcHandler('dev.hatk.describeLabels', async () => {
127
+ return { definitions: getLabelDefinitions() };
40
128
  });
129
+ // Write operations — proxy to user's PDS
130
+ if (oauth) {
131
+ registerCoreXrpcHandler('dev.hatk.getPreferences', async (_params, _cursor, _limit, viewer) => {
132
+ if (!viewer)
133
+ throw new InvalidRequestError('Authentication required');
134
+ const prefs = await getPreferences(viewer.did);
135
+ return { preferences: prefs };
136
+ });
137
+ registerCoreXrpcHandler('dev.hatk.putPreference', async (_params, _cursor, _limit, viewer, input) => {
138
+ if (!viewer)
139
+ throw new InvalidRequestError('Authentication required');
140
+ const body = input;
141
+ if (!body.key || typeof body.key !== 'string')
142
+ throw new InvalidRequestError('Missing or invalid key');
143
+ if (body.value === undefined)
144
+ throw new InvalidRequestError('Missing value');
145
+ await putPreference(viewer.did, body.key, body.value);
146
+ return { success: true };
147
+ });
148
+ registerCoreXrpcHandler('dev.hatk.createRecord', async (_params, _cursor, _limit, viewer, input) => {
149
+ if (!viewer)
150
+ throw new InvalidRequestError('Authentication required');
151
+ return pdsCreateRecord(oauth, viewer, input);
152
+ });
153
+ registerCoreXrpcHandler('dev.hatk.deleteRecord', async (_params, _cursor, _limit, viewer, input) => {
154
+ if (!viewer)
155
+ throw new InvalidRequestError('Authentication required');
156
+ return pdsDeleteRecord(oauth, viewer, input);
157
+ });
158
+ registerCoreXrpcHandler('dev.hatk.putRecord', async (_params, _cursor, _limit, viewer, input) => {
159
+ if (!viewer)
160
+ throw new InvalidRequestError('Authentication required');
161
+ return pdsPutRecord(oauth, viewer, input);
162
+ });
163
+ registerCoreXrpcHandler('dev.hatk.uploadBlob', async (_params, _cursor, _limit, viewer, input) => {
164
+ if (!viewer)
165
+ throw new InvalidRequestError('Authentication required');
166
+ return pdsUploadBlob(oauth, viewer, input, 'application/octet-stream');
167
+ });
168
+ registerCoreXrpcHandler('dev.hatk.applyWrites', async (_params, _cursor, _limit, viewer, input) => {
169
+ if (!viewer)
170
+ throw new InvalidRequestError('Authentication required');
171
+ return pdsApplyWrites(oauth, viewer, input);
172
+ });
173
+ registerCoreXrpcHandler('dev.hatk.push.registerToken', async (_params, _cursor, _limit, viewer, input) => {
174
+ if (!viewer)
175
+ throw new InvalidRequestError('Authentication required');
176
+ const body = input;
177
+ if (!body.token || typeof body.token !== 'string')
178
+ throw new InvalidRequestError('Missing or invalid token');
179
+ const platform = body.platform || 'apns';
180
+ if (!['apns', 'fcm', 'web'].includes(platform))
181
+ throw new InvalidRequestError('Invalid platform');
182
+ const { registerToken } = await import("./push.js");
183
+ await registerToken(viewer.did, body.token, platform);
184
+ return { success: true };
185
+ });
186
+ registerCoreXrpcHandler('dev.hatk.push.unregisterToken', async (_params, _cursor, _limit, viewer, input) => {
187
+ if (!viewer)
188
+ throw new InvalidRequestError('Authentication required');
189
+ const body = input;
190
+ if (!body.token || typeof body.token !== 'string')
191
+ throw new InvalidRequestError('Missing or invalid token');
192
+ const { unregisterToken } = await import("./push.js");
193
+ await unregisterToken(viewer.did, body.token);
194
+ return { success: true };
195
+ });
196
+ registerCoreXrpcHandler('dev.hatk.createReport', async (_params, _cursor, _limit, viewer, input) => {
197
+ if (!viewer)
198
+ throw new InvalidRequestError('Authentication required');
199
+ const body = input;
200
+ if (!body.subject)
201
+ throw new InvalidRequestError('Missing subject');
202
+ if (!body.label || typeof body.label !== 'string')
203
+ throw new InvalidRequestError('Missing or invalid label');
204
+ const defs = getLabelDefinitions();
205
+ if (!defs.some((d) => d.identifier === body.label)) {
206
+ throw new InvalidRequestError(`Unknown label: ${body.label}`);
207
+ }
208
+ if (body.reason && body.reason.length > 2000) {
209
+ throw new InvalidRequestError('Reason must be 2000 characters or less');
210
+ }
211
+ let subjectUri;
212
+ let subjectDid;
213
+ if (body.subject.uri) {
214
+ subjectUri = body.subject.uri;
215
+ const match = body.subject.uri.match(/^at:\/\/(did:[^/]+)/);
216
+ if (!match)
217
+ throw new InvalidRequestError('Invalid subject URI');
218
+ subjectDid = match[1];
219
+ }
220
+ else if (body.subject.did) {
221
+ subjectUri = `at://${body.subject.did}`;
222
+ subjectDid = body.subject.did;
223
+ }
224
+ else {
225
+ throw new InvalidRequestError('Subject must have uri or did');
226
+ }
227
+ const result = await insertReport({
228
+ subjectUri,
229
+ subjectDid,
230
+ label: body.label,
231
+ reason: body.reason,
232
+ reportedBy: viewer.did,
233
+ });
234
+ return {
235
+ id: result.id,
236
+ subject: body.subject,
237
+ label: body.label,
238
+ reason: body.reason || null,
239
+ reportedBy: viewer.did,
240
+ createdAt: new Date().toISOString(),
241
+ };
242
+ });
243
+ }
41
244
  }
42
- export function startServer(port, collections, publicDir, oauth, admins = [], resolveViewer) {
43
- const coreXrpc = (method) => `/xrpc/dev.hatk.${method}`;
245
+ /**
246
+ * Assets we ship inside the package never change while the process is alive, so
247
+ * read, gzip and hash them once. Repeat visits then cost a 304 instead of the
248
+ * full (uncompressed) payload.
249
+ */
250
+ const bundledAssets = new Map();
251
+ async function serveBundledAsset(path, contentType, request, devMode) {
252
+ let asset = devMode ? undefined : bundledAssets.get(path);
253
+ if (!asset) {
254
+ const raw = await readFile(path);
255
+ asset = {
256
+ raw,
257
+ gzip: gzipSync(raw),
258
+ etag: `"${createHash('sha256').update(raw).digest('base64url').slice(0, 16)}"`,
259
+ };
260
+ if (!devMode)
261
+ bundledAssets.set(path, asset);
262
+ }
263
+ const headers = { ETag: asset.etag, 'Cache-Control': 'no-cache' };
264
+ if (request.headers.get('if-none-match') === asset.etag) {
265
+ return new Response(null, { status: 304, headers });
266
+ }
267
+ const acceptEncoding = request.headers.get('accept-encoding');
268
+ const useGzip = !!acceptEncoding && /\bgzip\b/.test(acceptEncoding);
269
+ if (useGzip) {
270
+ headers['Content-Encoding'] = 'gzip';
271
+ headers.Vary = 'Accept-Encoding';
272
+ }
273
+ headers['Content-Type'] = contentType;
274
+ return new Response(Buffer.from(useGzip ? asset.gzip : asset.raw), { status: 200, headers });
275
+ }
276
+ /**
277
+ * Create a Web Standard request handler for all hatk routes.
278
+ * Returns a pure function: (Request) → Promise<Response>
279
+ */
280
+ export function createHandler(config) {
281
+ const { collections, publicDir, oauth, admins } = config;
44
282
  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;
283
+ const coreXrpc = (method) => `/xrpc/dev.hatk.${method}`;
284
+ function requireAdmin(viewer, acceptEncoding) {
285
+ if (!viewer)
286
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
287
+ if (!devMode && !admins.includes(viewer.did))
288
+ return withCors(jsonError(403, 'Admin access required', acceptEncoding));
289
+ return null; // auth OK
55
290
  }
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
- }
291
+ // The /admin/info rollups count every row in every collection table, so they get
292
+ // slower as the index grows. Cache them for a few seconds — that collapses the
293
+ // overview's repeat loads and any concurrent requests into one scan — and drop
294
+ // the cache whenever an admin action changes what they report.
295
+ const ADMIN_STATS_TTL_MS = 10_000;
296
+ let adminStats = null;
297
+ function getAdminStats() {
298
+ if (adminStats && Date.now() - adminStats.at < ADMIN_STATS_TTL_MS)
299
+ return adminStats.data;
300
+ const data = (async () => {
301
+ const [repos, duckdb, collections, openReports] = await Promise.all([
302
+ getRepoStatusCounts(),
303
+ getDatabaseSize(),
304
+ getCollectionCounts(),
305
+ getOpenReportCount(),
306
+ ]);
307
+ return { repos, duckdb, collections, openReports };
308
+ })();
309
+ const entry = { at: Date.now(), data };
310
+ adminStats = entry;
311
+ // Don't cache a failure — and keep the rejection handled so it surfaces only
312
+ // at the awaiting request.
313
+ data.catch(() => {
314
+ if (adminStats === entry)
315
+ adminStats = null;
316
+ });
317
+ return data;
318
+ }
319
+ function invalidateAdminStats() {
320
+ adminStats = null;
321
+ }
322
+ return async (request) => {
323
+ const url = new URL(request.url);
324
+ const acceptEncoding = request.headers.get('accept-encoding');
325
+ // CORS preflight
326
+ if (request.method === 'OPTIONS')
327
+ return cors();
66
328
  const isXrpc = url.pathname.startsWith('/xrpc/');
67
329
  const isAdmin = url.pathname.startsWith('/admin') && !url.pathname.endsWith('.html') && !url.pathname.endsWith('.js');
68
330
  const elapsed = isXrpc || isAdmin ? timer() : null;
69
331
  let error;
70
- const requestOrigin = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['host'] || `localhost:${port}`}`;
332
+ const requestOrigin = `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host') || 'localhost'}`;
71
333
  // Authenticate viewer (optional — unauthenticated requests still work)
72
- let viewer = resolveViewer?.(req) ?? null;
334
+ let viewer = config.resolveViewer?.(request) ?? null;
73
335
  if (!viewer && oauth) {
74
336
  try {
75
- viewer = await authenticate(req.headers['authorization'] || null, req.headers['dpop'] || null, req.method, `${requestOrigin}${url.pathname}`);
337
+ viewer = await authenticate(request.headers.get('authorization'), request.headers.get('dpop'), request.method, `${requestOrigin}${url.pathname}`);
76
338
  }
77
339
  catch (err) {
78
340
  emit('oauth', 'authenticate_error', { error: err.message });
79
341
  }
80
342
  }
343
+ // Fallback: resolve viewer from session cookie (for browser requests without DPoP)
344
+ if (!viewer && oauth) {
345
+ try {
346
+ viewer = await parseSessionCookie(request);
347
+ }
348
+ catch { }
349
+ }
81
350
  try {
82
351
  // GET /xrpc/dev.hatk.getRecords?collection=<nsid>&limit=N&cursor=C&<field>=<value>
83
352
  if (url.pathname === coreXrpc('getRecords')) {
84
353
  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
- }
354
+ if (!collection)
355
+ return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding));
356
+ if (isPrivateCollection(collection))
357
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
358
+ if (!getSchema(collection))
359
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
93
360
  const limit = parseInt(url.searchParams.get('limit') || '20');
94
361
  const cursor = url.searchParams.get('cursor') || undefined;
95
362
  const sort = url.searchParams.get('sort') || undefined;
@@ -110,35 +377,29 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
110
377
  });
111
378
  const uris = result.records.map((r) => r.uri);
112
379
  const items = await resolveRecords(uris);
113
- jsonResponse(res, { items, cursor: result.cursor });
114
- return;
380
+ return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding));
115
381
  }
116
382
  // GET /xrpc/dev.hatk.getRecord?uri=<at-uri>
117
383
  if (url.pathname === coreXrpc('getRecord')) {
118
384
  const uri = url.searchParams.get('uri');
119
- if (!uri) {
120
- jsonError(res, 400, 'Missing uri parameter');
121
- return;
122
- }
385
+ if (!uri)
386
+ return withCors(jsonError(400, 'Missing uri parameter', acceptEncoding));
387
+ if (isPrivateCollection(collectionFromUri(uri)))
388
+ return withCors(jsonError(404, 'Record not found', acceptEncoding));
123
389
  const record = await getRecordByUri(uri);
124
- if (!record) {
125
- jsonError(res, 404, 'Record not found');
126
- return;
127
- }
390
+ if (!record)
391
+ return withCors(jsonError(404, 'Record not found', acceptEncoding));
128
392
  const shaped = reshapeRow(record, record?.__childData);
129
393
  const labelsMap = await queryLabelsForUris([record.uri]);
130
394
  if (shaped)
131
395
  shaped.labels = labelsMap.get(record.uri) || [];
132
- jsonResponse(res, { record: shaped });
133
- return;
396
+ return withCors(json({ record: shaped }, 200, acceptEncoding));
134
397
  }
135
398
  // GET /xrpc/dev.hatk.getFeed?feed=<name>&limit=N&cursor=C
136
399
  if (url.pathname === coreXrpc('getFeed')) {
137
400
  const feedName = url.searchParams.get('feed');
138
- if (!feedName) {
139
- jsonError(res, 400, 'Missing feed parameter');
140
- return;
141
- }
401
+ if (!feedName)
402
+ return withCors(jsonError(400, 'Missing feed parameter', acceptEncoding));
142
403
  const limit = parseInt(url.searchParams.get('limit') || '30');
143
404
  const cursor = url.searchParams.get('cursor') || undefined;
144
405
  const params = {};
@@ -146,212 +407,182 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
146
407
  params[key] = value;
147
408
  }
148
409
  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;
410
+ if (!result)
411
+ return withCors(jsonError(404, `Unknown feed: ${feedName}`, acceptEncoding));
412
+ return withCors(json(result, 200, acceptEncoding));
155
413
  }
156
414
  // GET /xrpc/dev.hatk.searchRecords?collection=<nsid>&q=<query>&limit=N&cursor=C
157
415
  if (url.pathname === coreXrpc('searchRecords')) {
158
416
  const collection = url.searchParams.get('collection');
159
417
  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
- }
418
+ if (!collection)
419
+ return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding));
420
+ if (!q)
421
+ return withCors(jsonError(400, 'Missing q parameter', acceptEncoding));
422
+ if (isPrivateCollection(collection))
423
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
424
+ if (!getSchema(collection))
425
+ return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
172
426
  const limit = parseInt(url.searchParams.get('limit') || '20');
173
427
  const cursor = url.searchParams.get('cursor') || undefined;
174
428
  const fuzzy = url.searchParams.get('fuzzy') !== 'false';
175
429
  const result = await searchRecords(collection, q, { limit, cursor, fuzzy });
176
430
  const uris = result.records.map((r) => r.uri);
177
431
  const items = await resolveRecords(uris);
178
- jsonResponse(res, { items, cursor: result.cursor });
179
- return;
432
+ return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding));
180
433
  }
181
434
  // GET /xrpc/dev.hatk.describeFeeds
182
435
  if (url.pathname === coreXrpc('describeFeeds')) {
183
- jsonResponse(res, { feeds: listFeeds() });
184
- return;
436
+ return withCors(json({ feeds: listFeeds() }, 200, acceptEncoding));
185
437
  }
186
438
  // GET /xrpc/dev.hatk.describeCollections
187
439
  if (url.pathname === coreXrpc('describeCollections')) {
188
- const collectionInfo = collections.map((c) => {
440
+ const collectionInfo = collections
441
+ .filter((c) => !isPrivateCollection(c))
442
+ .map((c) => {
189
443
  const schema = getSchema(c);
190
444
  return {
191
445
  collection: c,
192
446
  columns: schema?.columns.map((col) => ({
193
447
  name: col.name,
194
448
  originalName: col.originalName,
195
- type: col.duckdbType,
449
+ type: col.sqlType,
196
450
  required: col.notNull,
197
451
  })),
198
452
  };
199
453
  });
200
- jsonResponse(res, { collections: collectionInfo });
201
- return;
454
+ return withCors(json({ collections: collectionInfo }, 200, acceptEncoding));
202
455
  }
203
456
  // GET /xrpc/dev.hatk.describeLabels
204
457
  if (url.pathname === coreXrpc('describeLabels')) {
205
- jsonResponse(res, { definitions: getLabelDefinitions() });
206
- return;
458
+ return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding));
207
459
  }
208
460
  // GET /xrpc/dev.hatk.getPreferences — get all preferences for authenticated user
209
461
  if (url.pathname === coreXrpc('getPreferences')) {
210
- if (!viewer) {
211
- jsonError(res, 401, 'Authentication required');
212
- return;
213
- }
462
+ if (!viewer)
463
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
214
464
  const prefs = await getPreferences(viewer.did);
215
- jsonResponse(res, { preferences: prefs });
216
- return;
465
+ return withCors(json({ preferences: prefs }, 200, acceptEncoding));
217
466
  }
218
467
  // 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
- }
468
+ if (url.pathname === coreXrpc('putPreference') && request.method === 'POST') {
469
+ if (!viewer)
470
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
471
+ const body = JSON.parse(await request.text());
472
+ if (!body.key || typeof body.key !== 'string')
473
+ return withCors(jsonError(400, 'Missing or invalid key', acceptEncoding));
474
+ if (body.value === undefined)
475
+ return withCors(jsonError(400, 'Missing value', acceptEncoding));
233
476
  await putPreference(viewer.did, body.key, body.value);
234
- jsonResponse(res, { success: true });
235
- return;
477
+ return withCors(json({ success: true }, 200, acceptEncoding));
236
478
  }
237
479
  // ── Admin Repo Management ──
238
480
  // 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
- }
481
+ if (url.pathname === '/admin/repos/add' && request.method === 'POST') {
482
+ const denied = requireAdmin(viewer, acceptEncoding);
483
+ if (denied)
484
+ return denied;
485
+ const { dids } = JSON.parse(await request.text());
486
+ if (!Array.isArray(dids))
487
+ return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
247
488
  for (const did of dids) {
248
489
  await setRepoStatus(did, 'pending');
249
490
  triggerAutoBackfill(did);
250
491
  }
251
- jsonResponse(res, { added: dids.length });
252
- return;
492
+ invalidateAdminStats();
493
+ return withCors(json({ added: dids.length }, 200, acceptEncoding));
253
494
  }
254
495
  // 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;
496
+ if (url.pathname === '/admin/labels/rescan' && request.method === 'POST') {
497
+ const denied = requireAdmin(viewer, acceptEncoding);
498
+ if (denied)
499
+ return denied;
258
500
  const result = await rescanLabels(collections);
259
- jsonResponse(res, result);
260
- return;
501
+ return withCors(json(result, 200, acceptEncoding));
261
502
  }
262
503
  // ── Admin Endpoints ──
263
504
  // GET /admin/whoami — check if current viewer is admin
264
505
  if (url.pathname === '/admin/whoami') {
265
- if (!requireAdmin(viewer, res))
266
- return;
267
- jsonResponse(res, { did: viewer.did, admin: true });
268
- return;
506
+ const denied = requireAdmin(viewer, acceptEncoding);
507
+ if (denied)
508
+ return denied;
509
+ return withCors(json({ did: viewer.did, admin: true }, 200, acceptEncoding));
269
510
  }
270
511
  // GET /admin/labels/definitions — get available label definitions
271
512
  if (url.pathname === '/admin/labels/definitions') {
272
- if (!requireAdmin(viewer, res))
273
- return;
274
- jsonResponse(res, { definitions: getLabelDefinitions() });
275
- return;
513
+ const denied = requireAdmin(viewer, acceptEncoding);
514
+ if (denied)
515
+ return denied;
516
+ return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding));
276
517
  }
277
518
  // 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
- }
519
+ if (url.pathname === '/admin/labels' && request.method === 'POST') {
520
+ const denied = requireAdmin(viewer, acceptEncoding);
521
+ if (denied)
522
+ return denied;
523
+ const { uri, val } = JSON.parse(await request.text());
524
+ if (!uri || !val)
525
+ return withCors(jsonError(400, 'Missing uri or val', acceptEncoding));
286
526
  await insertLabels([{ src: 'admin', uri, val }]);
287
- jsonResponse(res, { ok: true });
288
- return;
527
+ return withCors(json({ ok: true }, 200, acceptEncoding));
289
528
  }
290
529
  // 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;
530
+ if (url.pathname === '/admin/labels/reset' && request.method === 'POST') {
531
+ const denied = requireAdmin(viewer, acceptEncoding);
532
+ if (denied)
533
+ return denied;
534
+ const { val } = JSON.parse(await request.text());
535
+ if (!val)
536
+ return withCors(jsonError(400, 'Missing val', acceptEncoding));
537
+ const deleted = await deleteLabels(val);
538
+ return withCors(json({ deleted }, 200, acceptEncoding));
304
539
  }
305
540
  // 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
- }
541
+ if (url.pathname === '/admin/labels/negate' && request.method === 'POST') {
542
+ const denied = requireAdmin(viewer, acceptEncoding);
543
+ if (denied)
544
+ return denied;
545
+ const { uri, val } = JSON.parse(await request.text());
546
+ if (!uri || !val)
547
+ return withCors(jsonError(400, 'Missing uri or val', acceptEncoding));
314
548
  await insertLabels([{ src: 'admin', uri, val, neg: true }]);
315
- jsonResponse(res, { ok: true });
316
- return;
549
+ return withCors(json({ ok: true }, 200, acceptEncoding));
317
550
  }
318
551
  // 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
- }
552
+ if (url.pathname === '/admin/takedown' && request.method === 'POST') {
553
+ const denied = requireAdmin(viewer, acceptEncoding);
554
+ if (denied)
555
+ return denied;
556
+ const { did } = JSON.parse(await request.text());
557
+ if (!did)
558
+ return withCors(jsonError(400, 'Missing did', acceptEncoding));
327
559
  await setRepoStatus(did, 'takendown');
328
- jsonResponse(res, { ok: true });
329
- return;
560
+ invalidateAdminStats();
561
+ return withCors(json({ ok: true }, 200, acceptEncoding));
330
562
  }
331
563
  // 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
- }
564
+ if (url.pathname === '/admin/reverse-takedown' && request.method === 'POST') {
565
+ const denied = requireAdmin(viewer, acceptEncoding);
566
+ if (denied)
567
+ return denied;
568
+ const { did } = JSON.parse(await request.text());
569
+ if (!did)
570
+ return withCors(jsonError(400, 'Missing did', acceptEncoding));
340
571
  await setRepoStatus(did, 'active');
341
- jsonResponse(res, { ok: true });
342
- return;
572
+ invalidateAdminStats();
573
+ return withCors(json({ ok: true }, 200, acceptEncoding));
343
574
  }
344
575
  // GET /admin/search — search records or accounts
345
576
  if (url.pathname === '/admin/search') {
346
- if (!requireAdmin(viewer, res))
347
- return;
577
+ const denied = requireAdmin(viewer, acceptEncoding);
578
+ if (denied)
579
+ return denied;
348
580
  const q = url.searchParams.get('q') || '';
349
581
  const type = url.searchParams.get('type') || 'records';
350
582
  const limit = parseInt(url.searchParams.get('limit') || '20');
351
583
  if (type === 'accounts') {
352
584
  const accounts = await searchAccounts(q, limit);
353
- jsonResponse(res, { accounts });
354
- return;
585
+ return withCors(json({ accounts }, 200, acceptEncoding));
355
586
  }
356
587
  // No query — live firehose activity (excludes bulk backfill records)
357
588
  if (!q) {
@@ -359,11 +590,9 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
359
590
  const allResults = [];
360
591
  for (const col of collections) {
361
592
  try {
362
- const schema = getSchema(col);
363
- if (!schema)
593
+ const rows = await getRecentRecords(col, limit + offset);
594
+ if (!rows.length)
364
595
  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
596
  const uris = rows.map((r) => r.uri);
368
597
  const labelsMap = await queryLabelsForUris(uris);
369
598
  for (const rec of rows) {
@@ -378,22 +607,20 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
378
607
  return ta > tb ? -1 : ta < tb ? 1 : 0;
379
608
  });
380
609
  const page = allResults.slice(offset, offset + limit);
381
- jsonResponse(res, { records: page, total: allResults.length });
382
- return;
610
+ return withCors(json({ records: page, total: allResults.length }, 200, acceptEncoding));
383
611
  }
384
612
  // URI lookup
385
613
  if (q.startsWith('at://')) {
386
614
  const rec = await getRecordByUri(q);
387
615
  if (rec) {
388
616
  const labelsMap = await queryLabelsForUris([rec.uri]);
389
- jsonResponse(res, {
617
+ return withCors(json({
390
618
  records: [{ ...reshapeRow(rec, rec?.__childData), labels: labelsMap.get(rec.uri) || [] }],
391
- });
619
+ }, 200, acceptEncoding));
392
620
  }
393
621
  else {
394
- jsonResponse(res, { records: [] });
622
+ return withCors(json({ records: [] }, 200, acceptEncoding));
395
623
  }
396
- return;
397
624
  }
398
625
  // DID lookup — find all records by this DID
399
626
  if (q.startsWith('did:')) {
@@ -412,8 +639,7 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
412
639
  }
413
640
  catch { }
414
641
  }
415
- jsonResponse(res, { records: allResults.slice(0, limit) });
416
- return;
642
+ return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding));
417
643
  }
418
644
  // Default: full-text search records across all collections
419
645
  const allResults = [];
@@ -431,56 +657,62 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
431
657
  }
432
658
  catch { }
433
659
  }
434
- jsonResponse(res, { records: allResults.slice(0, limit) });
435
- return;
660
+ return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding));
436
661
  }
437
662
  // 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) : {};
663
+ if (url.pathname === '/admin/repos/resync' && request.method === 'POST') {
664
+ const denied = requireAdmin(viewer, acceptEncoding);
665
+ if (denied)
666
+ return denied;
667
+ const bodyText = await request.text();
668
+ const { dids } = bodyText ? JSON.parse(bodyText) : {};
443
669
  let repoList;
444
670
  if (Array.isArray(dids) && dids.length > 0) {
445
671
  repoList = dids;
446
672
  }
447
673
  else {
448
- const rows = await querySQL(`SELECT did FROM _repos WHERE status = 'active'`);
449
- repoList = rows.map((r) => r.did);
674
+ repoList = await listActiveRepoDids();
450
675
  }
676
+ const isTargeted = Array.isArray(dids) && dids.length > 0;
451
677
  for (const did of repoList) {
452
678
  await setRepoStatus(did, 'pending');
453
- triggerAutoBackfill(did);
454
679
  }
455
- jsonResponse(res, { resyncing: repoList.length });
456
- return;
680
+ if (isTargeted) {
681
+ for (const did of repoList) {
682
+ triggerAutoBackfill(did);
683
+ }
684
+ }
685
+ else if (config.onResync) {
686
+ config.onResync();
687
+ }
688
+ else {
689
+ for (const did of repoList) {
690
+ triggerAutoBackfill(did);
691
+ }
692
+ }
693
+ invalidateAdminStats();
694
+ return withCors(json({ resyncing: repoList.length }, 200, acceptEncoding));
457
695
  }
458
696
  // 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
- }
697
+ if (url.pathname === '/admin/repos/remove' && request.method === 'POST') {
698
+ const denied = requireAdmin(viewer, acceptEncoding);
699
+ if (denied)
700
+ return denied;
701
+ const { dids } = JSON.parse(await request.text());
702
+ if (!Array.isArray(dids))
703
+ return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
467
704
  for (const did of dids) {
468
- await querySQL(`DELETE FROM _repos WHERE did = $1`, [did]);
705
+ await removeRepo(did);
469
706
  }
470
- jsonResponse(res, { removed: dids.length });
471
- return;
707
+ invalidateAdminStats();
708
+ return withCors(json({ removed: dids.length }, 200, acceptEncoding));
472
709
  }
473
710
  // GET /admin/info — aggregate status + db size + collection counts
474
711
  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] ?? {};
483
- const collectionCounts = await getCollectionCounts();
712
+ const denied = requireAdmin(viewer, acceptEncoding);
713
+ if (denied)
714
+ return denied;
715
+ const stats = await getAdminStats();
484
716
  const mem = process.memoryUsage();
485
717
  const node = {
486
718
  rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MiB`,
@@ -488,360 +720,437 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
488
720
  heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`,
489
721
  external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`,
490
722
  };
491
- jsonResponse(res, { repos: counts, duckdb: dbInfo, node, collections: collectionCounts });
492
- return;
723
+ return withCors(json({
724
+ repos: stats.repos,
725
+ duckdb: stats.duckdb,
726
+ node,
727
+ collections: stats.collections,
728
+ openReports: stats.openReports,
729
+ }, 200, acceptEncoding));
730
+ }
731
+ // GET /admin/reports — list reports
732
+ if (url.pathname === '/admin/reports' && request.method === 'GET') {
733
+ const denied = requireAdmin(viewer, acceptEncoding);
734
+ if (denied)
735
+ return denied;
736
+ const status = url.searchParams.get('status') || 'open';
737
+ const label = url.searchParams.get('label') || undefined;
738
+ const limit = parseInt(url.searchParams.get('limit') || '50');
739
+ const offset = parseInt(url.searchParams.get('offset') || '0');
740
+ const result = await queryReports({ status, label, limit, offset });
741
+ return withCors(json(result, 200, acceptEncoding));
742
+ }
743
+ // POST /admin/reports/resolve — resolve or dismiss a report
744
+ if (url.pathname === '/admin/reports/resolve' && request.method === 'POST') {
745
+ const denied = requireAdmin(viewer, acceptEncoding);
746
+ if (denied)
747
+ return denied;
748
+ const { id, action } = JSON.parse(await request.text());
749
+ if (!id || !action)
750
+ return withCors(jsonError(400, 'Missing id or action', acceptEncoding));
751
+ if (action !== 'resolve' && action !== 'dismiss')
752
+ return withCors(jsonError(400, 'Action must be resolve or dismiss', acceptEncoding));
753
+ const report = await resolveReport(id, action === 'resolve' ? 'resolved' : 'dismissed', viewer.did);
754
+ if (!report)
755
+ return withCors(jsonError(404, 'Report not found or already resolved', acceptEncoding));
756
+ if (action === 'resolve') {
757
+ await insertLabels([{ src: 'admin', uri: report.subjectUri, val: report.label }]);
758
+ }
759
+ invalidateAdminStats();
760
+ return withCors(json({ ok: true }, 200, acceptEncoding));
493
761
  }
494
762
  // GET /admin/info/:did — repo status info
495
763
  if (url.pathname.startsWith('/admin/info/did:')) {
496
- if (!requireAdmin(viewer, res))
497
- return;
764
+ const denied = requireAdmin(viewer, acceptEncoding);
765
+ if (denied)
766
+ return denied;
498
767
  const did = url.pathname.slice('/admin/info/'.length);
499
768
  const status = await getRepoStatus(did);
500
- if (!status) {
501
- jsonError(res, 404, 'Repo not found');
502
- return;
503
- }
769
+ if (!status)
770
+ return withCors(jsonError(404, 'Repo not found', acceptEncoding));
504
771
  const retryInfo = await getRepoRetryInfo(did);
505
- jsonResponse(res, {
772
+ return withCors(json({
506
773
  did,
507
774
  status,
508
775
  retry_count: retryInfo?.retryCount ?? 0,
509
776
  retry_after: retryInfo?.retryAfter ?? 0,
510
- });
511
- return;
777
+ }, 200, acceptEncoding));
512
778
  }
513
779
  // GET /admin/repos — paginated repo listing
514
- if (url.pathname === '/admin/repos' && req.method === 'GET') {
515
- if (!requireAdmin(viewer, res))
516
- return;
780
+ if (url.pathname === '/admin/repos' && request.method === 'GET') {
781
+ const denied = requireAdmin(viewer, acceptEncoding);
782
+ if (denied)
783
+ return denied;
517
784
  const limit = parseInt(url.searchParams.get('limit') || '50');
518
785
  const offset = parseInt(url.searchParams.get('offset') || '0');
519
786
  const status = url.searchParams.get('status') || undefined;
520
787
  const q = url.searchParams.get('q') || undefined;
521
788
  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;
789
+ return withCors(json(result, 200, acceptEncoding));
533
790
  }
534
791
  // ── Public Repo Endpoints (used by hatk clients for auto-sync) ──
535
792
  // 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
- }
793
+ if (url.pathname === '/repos/add' && request.method === 'POST') {
794
+ const { dids } = JSON.parse(await request.text());
795
+ if (!Array.isArray(dids))
796
+ return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
542
797
  for (const did of dids) {
543
798
  await setRepoStatus(did, 'pending');
544
799
  triggerAutoBackfill(did);
545
800
  }
546
- jsonResponse(res, { added: dids.length });
547
- return;
801
+ return withCors(json({ added: dids.length }, 200, acceptEncoding));
548
802
  }
549
803
  // GET /info/:did — repo status info (public)
550
804
  if (url.pathname.startsWith('/info/did:')) {
551
805
  const did = url.pathname.slice('/info/'.length);
552
806
  const status = await getRepoStatus(did);
553
- if (!status) {
554
- jsonError(res, 404, 'Repo not found');
555
- return;
556
- }
807
+ if (!status)
808
+ return withCors(jsonError(404, 'Repo not found', acceptEncoding));
557
809
  const retryInfo = await getRepoRetryInfo(did);
558
- jsonResponse(res, {
810
+ return withCors(json({
559
811
  did,
560
812
  status,
561
813
  retry_count: retryInfo?.retryCount ?? 0,
562
814
  retry_after: retryInfo?.retryAfter ?? 0,
563
- });
564
- return;
815
+ }, 200, acceptEncoding));
565
816
  }
566
817
  // --- OAuth Endpoints ---
567
818
  // OAuth well-known endpoints
568
819
  if (url.pathname === '/.well-known/oauth-authorization-server' && oauth) {
569
- jsonResponse(res, getAuthServerMetadata(oauth.issuer, oauth));
570
- return;
820
+ return withCors(json(getAuthServerMetadata(oauth.issuer, oauth), 200, acceptEncoding));
571
821
  }
572
822
  if (url.pathname === '/.well-known/oauth-protected-resource' && oauth) {
573
- jsonResponse(res, getProtectedResourceMetadata(oauth.issuer, oauth));
574
- return;
823
+ return withCors(json(getProtectedResourceMetadata(oauth.issuer, oauth), 200, acceptEncoding));
575
824
  }
576
825
  if (url.pathname === '/oauth/jwks' && oauth) {
577
- jsonResponse(res, getJwks());
578
- return;
826
+ return withCors(json(getJwks(), 200, acceptEncoding));
579
827
  }
580
828
  if ((url.pathname === '/oauth/client-metadata.json' || url.pathname === '/oauth-client-metadata.json') && oauth) {
581
- jsonResponse(res, getClientMetadata(oauth.issuer, oauth));
582
- return;
829
+ return withCors(json(getClientMetadata(oauth.issuer, oauth), 200, acceptEncoding));
830
+ }
831
+ // Public key a PDS fetches to verify our client assertions.
832
+ if (url.pathname === '/oauth/client-jwks.json' && oauth) {
833
+ return withCors(json(getClientJwks(), 200, acceptEncoding));
834
+ }
835
+ // Dev-only: create a session cookie for any DID (for testing)
836
+ if (url.pathname === '/__dev/login' && devMode && oauth) {
837
+ const did = url.searchParams.get('did');
838
+ if (!did)
839
+ return withCors(jsonError(400, 'did required', acceptEncoding));
840
+ const handle = (await getRepoHandle(did)) ?? did;
841
+ const cookieValue = await createSessionCookie({ did, handle });
842
+ const secure = url.protocol === 'https:';
843
+ const accounts = await createAccountsCookie(withAccount(await parseAccountsCookie(request), { did, handle }));
844
+ return new Response(JSON.stringify({ ok: true }), {
845
+ status: 200,
846
+ headers: [
847
+ ['Content-Type', 'application/json'],
848
+ ['Set-Cookie', sessionCookieHeader(cookieValue, secure)],
849
+ ['Set-Cookie', accountsCookieHeader(accounts, secure)],
850
+ ],
851
+ });
852
+ }
853
+ // OAuth Login (server-initiated, no DPoP required)
854
+ if (url.pathname === '/oauth/login' && oauth) {
855
+ const handle = url.searchParams.get('handle') || '';
856
+ const prompt = url.searchParams.get('prompt') || undefined;
857
+ const pds = url.searchParams.get('pds') || undefined;
858
+ if (!handle && prompt !== 'create')
859
+ return withCors(jsonError(400, 'handle required', acceptEncoding));
860
+ try {
861
+ const redirectUrl = await serverLogin(oauth, handle, { prompt, pds });
862
+ return new Response(null, {
863
+ status: 302,
864
+ headers: { Location: redirectUrl, 'Set-Cookie': clearSessionCookieHeader() },
865
+ });
866
+ }
867
+ catch (err) {
868
+ const message = err instanceof Error ? err.message : 'Login failed';
869
+ return withCors(jsonError(400, message, acceptEncoding));
870
+ }
583
871
  }
584
872
  // OAuth PAR
585
- if (url.pathname === '/oauth/par' && req.method === 'POST' && oauth) {
586
- const rawBody = await readBody(req);
873
+ if (url.pathname === '/oauth/par' && request.method === 'POST' && oauth) {
874
+ const rawBody = await request.text();
587
875
  let body;
588
- if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) {
876
+ if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
589
877
  body = Object.fromEntries(new URLSearchParams(rawBody));
590
878
  }
591
879
  else {
592
880
  body = JSON.parse(rawBody);
593
881
  }
594
- const dpopHeader = req.headers['dpop'];
595
- if (!dpopHeader) {
596
- jsonError(res, 400, 'DPoP header required');
597
- return;
882
+ const dpopHeader = request.headers.get('dpop');
883
+ if (!dpopHeader)
884
+ return withCors(jsonError(400, 'DPoP header required', acceptEncoding));
885
+ try {
886
+ const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`);
887
+ return withCors(json(result, 200, acceptEncoding));
888
+ }
889
+ catch (err) {
890
+ const message = err instanceof Error ? err.message : 'Unknown error';
891
+ return withCors(jsonError(400, message, acceptEncoding));
598
892
  }
599
- const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`);
600
- jsonResponse(res, result);
601
- return;
602
893
  }
603
894
  // OAuth Authorize
604
895
  if (url.pathname === '/oauth/authorize' && oauth) {
605
896
  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;
897
+ if (!requestUri)
898
+ return withCors(jsonError(400, 'request_uri required', acceptEncoding));
899
+ const oauthRequest = await getOAuthRequest(requestUri);
900
+ if (!oauthRequest)
901
+ return withCors(jsonError(400, 'Invalid or expired request_uri', acceptEncoding));
902
+ const redirectUrl = buildAuthorizeRedirect(oauth, oauthRequest);
903
+ return new Response(null, { status: 302, headers: { Location: redirectUrl } });
619
904
  }
620
905
  // OAuth Callback (PDS redirects here after user approves)
621
906
  // Skip if iss matches our own issuer — that's the client-side redirect, let the SPA handle it
622
907
  if (url.pathname === '/oauth/callback' && oauth) {
623
908
  const iss = url.searchParams.get('iss');
624
- if (iss === oauth.issuer) {
625
- // Client-side callback — fall through to SPA
626
- }
627
- else {
909
+ if (iss !== oauth.issuer) {
628
910
  const code = url.searchParams.get('code');
629
911
  const state = url.searchParams.get('state');
630
- if (!code) {
631
- jsonError(res, 400, 'Missing code');
632
- return;
912
+ const oauthError = url.searchParams.get('error');
913
+ if (oauthError) {
914
+ const errorRedirect = await handleCallbackError(oauth, oauthError, url.searchParams.get('error_description'), state);
915
+ return new Response(null, {
916
+ status: 302,
917
+ headers: { Location: errorRedirect, 'Set-Cookie': clearSessionCookieHeader() },
918
+ });
633
919
  }
920
+ if (!code)
921
+ return withCors(jsonError(400, 'Missing code', acceptEncoding));
634
922
  const result = await handleCallback(oauth, code, state, iss);
635
- res.writeHead(302, { Location: result.clientRedirectUri });
636
- res.end();
637
- return;
923
+ const isSecure = requestOrigin.startsWith('https');
924
+ const handle = (await getRepoHandle(result.did)) ?? result.did;
925
+ const session = { did: result.did, handle };
926
+ const cookie = await createSessionCookie(session);
927
+ // Remember the account alongside the active session so the browser
928
+ // can switch back to it later without another trip to the PDS.
929
+ const accounts = await createAccountsCookie(withAccount(await parseAccountsCookie(request), session));
930
+ // Server-initiated login stores redirectUri as '/' — redirect cleanly without code/iss params
931
+ const redirectTo = result.clientRedirectUri.startsWith('/?code=') ? '/' : result.clientRedirectUri;
932
+ return new Response(null, {
933
+ status: 302,
934
+ headers: [
935
+ ['Location', redirectTo],
936
+ ['Set-Cookie', sessionCookieHeader(cookie, isSecure)],
937
+ ['Set-Cookie', accountsCookieHeader(accounts, isSecure)],
938
+ ],
939
+ });
638
940
  }
941
+ // Client-side callback — fall through to SPA
942
+ }
943
+ // Session cookie logout. Also drops the account from the switch list —
944
+ // leaving it there would keep a working path back into an account the
945
+ // user believes they signed out of, which matters on a shared machine.
946
+ if (url.pathname === '/auth/logout' && request.method === 'POST') {
947
+ const current = await parseSessionCookie(request);
948
+ const isSecure = requestOrigin.startsWith('https');
949
+ const headers = [['Set-Cookie', clearSessionCookieHeader()]];
950
+ if (current) {
951
+ const remaining = withoutAccount(await parseAccountsCookie(request), current.did);
952
+ headers.push([
953
+ 'Set-Cookie',
954
+ remaining.length
955
+ ? accountsCookieHeader(await createAccountsCookie(remaining), isSecure)
956
+ : clearAccountsCookieHeader(),
957
+ ]);
958
+ }
959
+ return new Response(null, { status: 200, headers });
960
+ }
961
+ // Accounts this browser may switch between. The cookie is HttpOnly, so
962
+ // this is the only way for a client to see what's in it.
963
+ if (url.pathname === '/auth/accounts' && request.method === 'GET') {
964
+ const stored = await parseAccountsCookie(request);
965
+ const active = await parseSessionCookie(request);
966
+ // Sessions created before this feature existed have a session cookie
967
+ // and no accounts cookie. Adopt the active account into the list rather
968
+ // than reporting it as unknown — otherwise the switcher looks empty and
969
+ // signing into a second account appears to drop the first.
970
+ const needsHealing = !!active && !stored.some((a) => a.did === active.did);
971
+ const accounts = needsHealing ? withAccount(stored, active) : stored;
972
+ // An account whose server-side OAuth session is gone can't be switched
973
+ // to — say so rather than letting the client offer a dead option.
974
+ // `__dev/login` fabricates a session with no OAuth grant behind it, so
975
+ // in dev every remembered account counts as switchable.
976
+ const resolved = await Promise.all(accounts.map(async (a) => ({ ...a, available: devMode || !!(await getSession(a.did)) })));
977
+ const res = withCors(json({ accounts: resolved, active: active?.did ?? null }, 200, acceptEncoding));
978
+ // Persist the adoption so the next switch has something to authorize
979
+ // against, instead of healing the list again on every load.
980
+ if (needsHealing) {
981
+ res.headers.append('Set-Cookie', accountsCookieHeader(await createAccountsCookie(accounts), requestOrigin.startsWith('https')));
982
+ }
983
+ return res;
984
+ }
985
+ // Switch the active account without re-authorizing at the PDS.
986
+ //
987
+ // The OAuth grant belongs to this server acting for the user, not to a
988
+ // browser session — so once a browser has proven it owns an account, we
989
+ // can re-issue its session cookie from the stored session. The accounts
990
+ // cookie is the proof: it's encrypted with the server key, so a client
991
+ // can't name a DID it never signed in as.
992
+ if (url.pathname === '/auth/switch' && request.method === 'POST') {
993
+ let did;
994
+ try {
995
+ const body = await request.json();
996
+ did = typeof body?.did === 'string' ? body.did : undefined;
997
+ }
998
+ catch {
999
+ return withCors(jsonError(400, 'Invalid JSON body', acceptEncoding));
1000
+ }
1001
+ if (!did)
1002
+ return withCors(jsonError(400, 'did required', acceptEncoding));
1003
+ const accounts = await parseAccountsCookie(request);
1004
+ const account = accounts.find((a) => a.did === did);
1005
+ if (!account)
1006
+ return withCors(jsonError(403, 'Not signed in as that account', acceptEncoding));
1007
+ if (!devMode && !(await getSession(account.did))) {
1008
+ // The stored grant is gone (revoked, or cleaned up). Drop it from the
1009
+ // list so the client stops offering it and can send the user through
1010
+ // a fresh sign-in instead.
1011
+ const isSecure = requestOrigin.startsWith('https');
1012
+ const remaining = withoutAccount(accounts, account.did);
1013
+ const res = withCors(jsonError(409, 'Session expired for that account', acceptEncoding));
1014
+ res.headers.append('Set-Cookie', remaining.length
1015
+ ? accountsCookieHeader(await createAccountsCookie(remaining), isSecure)
1016
+ : clearAccountsCookieHeader());
1017
+ return res;
1018
+ }
1019
+ // Refresh the handle in passing — it may have changed while dormant.
1020
+ const handle = (await getRepoHandle(account.did)) ?? account.handle;
1021
+ const session = { did: account.did, handle };
1022
+ const isSecure = requestOrigin.startsWith('https');
1023
+ const res = withCors(json({ did: session.did, handle: session.handle }, 200, acceptEncoding));
1024
+ res.headers.append('Set-Cookie', sessionCookieHeader(await createSessionCookie(session), isSecure));
1025
+ res.headers.append('Set-Cookie', accountsCookieHeader(await createAccountsCookie(withAccount(accounts, session)), isSecure));
1026
+ return res;
639
1027
  }
640
1028
  // OAuth Token
641
- if (url.pathname === '/oauth/token' && req.method === 'POST' && oauth) {
642
- const rawBody = await readBody(req);
1029
+ if (url.pathname === '/oauth/token' && request.method === 'POST' && oauth) {
1030
+ const rawBody = await request.text();
643
1031
  let body;
644
- if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) {
1032
+ if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
645
1033
  body = Object.fromEntries(new URLSearchParams(rawBody));
646
1034
  }
647
1035
  else {
648
1036
  body = JSON.parse(rawBody);
649
1037
  }
650
- const dpopHeader = req.headers['dpop'];
1038
+ const dpopHeader = request.headers.get('dpop');
651
1039
  if (!dpopHeader) {
652
- jsonError(res, 400, 'DPoP header required');
653
- return;
1040
+ return withCors(json({ error: 'invalid_request', error_description: 'DPoP header required' }, 400, acceptEncoding));
654
1041
  }
655
- const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`);
656
- jsonResponse(res, result);
657
- return;
658
- }
659
- // 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;
1042
+ try {
1043
+ const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`);
1044
+ return withCors(json(result, 200, acceptEncoding));
675
1045
  }
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;
1046
+ catch (err) {
1047
+ if (err instanceof OAuthError) {
1048
+ return withCors(json({ error: err.code, error_description: err.description }, err.status, acceptEncoding));
1049
+ }
1050
+ throw err;
687
1051
  }
688
- const result = pdsRes.body;
689
- // Index the record immediately
1052
+ }
1053
+ // POST /xrpc/dev.hatk.createRecord — proxy write to user's PDS
1054
+ if (url.pathname === coreXrpc('createRecord') && request.method === 'POST' && oauth) {
1055
+ if (!viewer)
1056
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
1057
+ const body = JSON.parse(await request.text());
690
1058
  try {
691
- await insertRecord(body.collection, result.uri, result.cid, viewer.did, body.record);
1059
+ const result = await pdsCreateRecord(oauth, viewer, body);
1060
+ return withCors(json(result, 200, acceptEncoding));
692
1061
  }
693
- catch {
694
- // Non-fatal — firehose will catch it
1062
+ catch (err) {
1063
+ if (err instanceof ScopeMissingProxyError)
1064
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
1065
+ if (err instanceof ProxyError)
1066
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
1067
+ throw err;
695
1068
  }
696
- jsonResponse(res, result);
697
- return;
698
1069
  }
699
1070
  // 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
1071
+ if (url.pathname === coreXrpc('deleteRecord') && request.method === 'POST' && oauth) {
1072
+ if (!viewer)
1073
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
1074
+ const body = JSON.parse(await request.text());
724
1075
  try {
725
- const uri = `at://${viewer.did}/${body.collection}/${body.rkey}`;
726
- await deleteRecord(body.collection, uri);
1076
+ const result = await pdsDeleteRecord(oauth, viewer, body);
1077
+ return withCors(json(result, 200, acceptEncoding));
727
1078
  }
728
- catch {
729
- // Non-fatal — firehose will catch it
1079
+ catch (err) {
1080
+ if (err instanceof ScopeMissingProxyError)
1081
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
1082
+ if (err instanceof ProxyError)
1083
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
1084
+ throw err;
730
1085
  }
731
- jsonResponse(res, result);
732
- return;
733
1086
  }
734
1087
  // 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)
1088
+ if (url.pathname === coreXrpc('putRecord') && request.method === 'POST' && oauth) {
1089
+ if (!viewer)
1090
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
1091
+ const body = JSON.parse(await request.text());
765
1092
  try {
766
- await insertRecord(body.collection, result.uri, result.cid, viewer.did, body.record);
1093
+ const result = await pdsPutRecord(oauth, viewer, body);
1094
+ return withCors(json(result, 200, acceptEncoding));
767
1095
  }
768
- catch {
769
- // Non-fatal — firehose will catch it
1096
+ catch (err) {
1097
+ if (err instanceof ScopeMissingProxyError)
1098
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
1099
+ if (err instanceof ProxyError)
1100
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
1101
+ throw err;
770
1102
  }
771
- jsonResponse(res, result);
772
- return;
773
1103
  }
774
1104
  // 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;
1105
+ if (url.pathname === coreXrpc('uploadBlob') && request.method === 'POST' && oauth) {
1106
+ if (!viewer)
1107
+ return withCors(jsonError(401, 'Authentication required', acceptEncoding));
1108
+ const contentType = request.headers.get('content-type') || 'application/octet-stream';
1109
+ const rawBody = new Uint8Array(await request.arrayBuffer());
1110
+ try {
1111
+ const result = await pdsUploadBlob(oauth, viewer, rawBody, contentType);
1112
+ return withCors(json(result, 200, acceptEncoding));
784
1113
  }
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;
1114
+ catch (err) {
1115
+ if (err instanceof ScopeMissingProxyError)
1116
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
1117
+ if (err instanceof ProxyError)
1118
+ return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
1119
+ throw err;
792
1120
  }
793
- jsonResponse(res, pdsRes.body);
794
- return;
795
1121
  }
796
1122
  // GET /admin — serve admin UI from hatk package
797
1123
  if (url.pathname === '/admin' || url.pathname === '/admin/') {
798
1124
  const adminPath = join(import.meta.dirname, '../public/admin.html');
799
1125
  try {
800
- const content = await readFile(adminPath);
801
- res.writeHead(200, { 'Content-Type': 'text/html' });
802
- res.end(content);
803
- return;
1126
+ return withCors(await serveBundledAsset(adminPath, 'text/html', request, devMode));
804
1127
  }
805
1128
  catch {
806
- res.writeHead(404);
807
- res.end('Admin page not found');
808
- return;
1129
+ return withCors(new Response('Admin page not found', { status: 404 }));
809
1130
  }
810
1131
  }
811
1132
  // GET /admin/admin-auth.js — serve bundled OAuth client
812
1133
  if (url.pathname === '/admin/admin-auth.js') {
813
1134
  const authPath = join(import.meta.dirname, '../public/admin-auth.js');
814
1135
  try {
815
- const content = await readFile(authPath);
816
- res.writeHead(200, { 'Content-Type': 'application/javascript' });
817
- res.end(content);
818
- return;
1136
+ return withCors(await serveBundledAsset(authPath, 'application/javascript', request, devMode));
819
1137
  }
820
1138
  catch {
821
- res.writeHead(404);
822
- res.end('Not found');
823
- return;
1139
+ return notFound();
824
1140
  }
825
1141
  }
826
1142
  // GET /_health
827
1143
  if (url.pathname === '/_health') {
828
- jsonResponse(res, { status: 'ok' });
829
- return;
1144
+ return withCors(json({ status: 'ok' }, 200, acceptEncoding));
830
1145
  }
831
1146
  // GET /og/* — OpenGraph image routes
832
- if (url.pathname.startsWith('/og/') && !res.writableEnded) {
1147
+ if (url.pathname.startsWith('/og/')) {
833
1148
  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
- }
1149
+ if (png)
1150
+ return withCors(file(png, 'image/png', 'public, max-age=300'));
842
1151
  }
843
1152
  // GET/POST /xrpc/{nsid} — custom XRPC handlers (matched by full NSID from folder structure)
844
- if (url.pathname.startsWith('/xrpc/') && !res.writableEnded) {
1153
+ if (url.pathname.startsWith('/xrpc/')) {
845
1154
  const method = url.pathname.slice('/xrpc/'.length);
846
1155
  const limit = parseInt(url.searchParams.get('limit') || '20');
847
1156
  const cursor = url.searchParams.get('cursor') || undefined;
@@ -851,9 +1160,9 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
851
1160
  }
852
1161
  // Parse request body for POST (procedures)
853
1162
  let input;
854
- if (req.method === 'POST') {
1163
+ if (request.method === 'POST') {
855
1164
  try {
856
- input = JSON.parse(await readBody(req));
1165
+ input = JSON.parse(await request.text());
857
1166
  }
858
1167
  catch {
859
1168
  input = {};
@@ -861,15 +1170,20 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
861
1170
  }
862
1171
  try {
863
1172
  const result = await executeXrpc(method, params, cursor, limit, viewer, input);
864
- if (result) {
865
- jsonResponse(res, result);
866
- return;
867
- }
1173
+ // A handler may answer with a Response of its own, which is the only
1174
+ // way to serve anything but JSON — com.atproto.sync.getBlob is the
1175
+ // shape in the wild, and a lexicon declaring `encoding: "*/*"` has no
1176
+ // other way to honour it. Everything else is serialized as before.
1177
+ if (result instanceof Response)
1178
+ return withCors(result);
1179
+ if (result)
1180
+ return withCors(json(result, 200, acceptEncoding));
868
1181
  }
869
1182
  catch (err) {
1183
+ if (err instanceof ScopeMissingProxyError)
1184
+ return scopeMissingResponse(acceptEncoding, viewer?.handle);
870
1185
  if (err instanceof InvalidRequestError) {
871
- jsonError(res, err.status, err.errorName || err.message);
872
- return;
1186
+ return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding));
873
1187
  }
874
1188
  throw err;
875
1189
  }
@@ -881,9 +1195,7 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
881
1195
  const robotsPath = userRobots && existsSync(userRobots) ? userRobots : defaultRobots;
882
1196
  try {
883
1197
  const content = await readFile(robotsPath);
884
- res.writeHead(200, { 'Content-Type': 'text/plain' });
885
- res.end(content);
886
- return;
1198
+ return withCors(file(content, 'text/plain'));
887
1199
  }
888
1200
  catch {
889
1201
  // fall through
@@ -894,38 +1206,45 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
894
1206
  try {
895
1207
  const filePath = join(publicDir, url.pathname === '/' ? 'index.html' : url.pathname);
896
1208
  const content = await readFile(filePath);
897
- res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'text/plain' });
898
- res.end(content);
899
- return;
1209
+ return withCors(file(content, MIME[extname(filePath)] || 'text/plain'));
900
1210
  }
901
1211
  catch { }
902
- // SPA fallback — serve index.html for client-side routes
1212
+ // SSR or SPA fallback — serve index.html for client-side routes
903
1213
  try {
904
- let content = await readFile(join(publicDir, 'index.html'), 'utf-8');
905
- // Inject OG meta tags for shareable routes
1214
+ const template = await readFile(join(publicDir, 'index.html'), 'utf-8');
906
1215
  const ogMeta = buildOgMeta(url.pathname, requestOrigin);
1216
+ globalThis.__hatk_viewer = viewer;
1217
+ let renderedHtml;
1218
+ try {
1219
+ renderedHtml = await renderPage(template, request, ogMeta);
1220
+ }
1221
+ finally {
1222
+ ;
1223
+ globalThis.__hatk_viewer = null;
1224
+ }
1225
+ if (renderedHtml) {
1226
+ return withCors(file(Buffer.from(renderedHtml), 'text/html'));
1227
+ }
1228
+ // SPA fallback — inject OG meta only
1229
+ let html = template;
907
1230
  if (ogMeta) {
908
- content = content.replace('</head>', `${ogMeta}\n</head>`);
1231
+ html = html.replace('</head>', `${ogMeta}\n</head>`);
909
1232
  }
910
- res.writeHead(200, { 'Content-Type': 'text/html' });
911
- res.end(content);
912
- return;
1233
+ return withCors(file(Buffer.from(html), 'text/html'));
913
1234
  }
914
1235
  catch { }
915
1236
  }
916
- res.writeHead(404);
917
- res.end('Not Found');
1237
+ return notFound();
918
1238
  }
919
1239
  catch (err) {
920
1240
  error = err.message;
921
- jsonError(res, 500, err.message);
1241
+ return withCors(jsonError(500, err.message, acceptEncoding));
922
1242
  }
923
1243
  finally {
924
- if (isXrpc || isAdmin) {
1244
+ if ((isXrpc || isAdmin) && elapsed) {
925
1245
  emit('server', 'request', {
926
- method: req.method,
1246
+ method: request.method,
927
1247
  path: url.pathname,
928
- status_code: res.statusCode,
929
1248
  duration_ms: elapsed(),
930
1249
  collection: url.searchParams.get('collection') || undefined,
931
1250
  query: url.searchParams.get('q') || undefined,
@@ -933,135 +1252,10 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
933
1252
  });
934
1253
  }
935
1254
  }
936
- });
937
- server.listen(port, () => log(`[server] ${oauth?.issuer || `http://localhost:${port}`}`));
938
- return server;
1255
+ };
939
1256
  }
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;
1018
- }
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;
1257
+ // Backward-compatible wrapper
1258
+ export function startServer(port, collections, publicDir, oauth, admins = [], resolveViewer, onResync) {
1259
+ const handler = createHandler({ collections, publicDir, oauth, admins, resolveViewer, onResync });
1260
+ return serve(handler, port);
1067
1261
  }