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