@hatk/hatk 0.0.1-alpha.3 → 0.0.1-alpha.30

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