@hatk/hatk 0.0.1-alpha.0

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