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