@hatk/hatk 0.0.1-alpha.4 → 0.0.1-alpha.40

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