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