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

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