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