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