@hatk/hatk 0.0.1-alpha.11 → 0.0.1-alpha.111
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 +113 -0
- package/dist/backfill.d.ts +36 -2
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +153 -37
- package/dist/car.js +1 -1
- package/dist/classifiers.d.ts +228 -0
- package/dist/classifiers.d.ts.map +1 -0
- package/dist/classifiers.js +688 -0
- package/dist/cli.js +248 -1043
- package/dist/config.d.ts +179 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +78 -8
- 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/database/db.d.ts +290 -0
- package/dist/database/db.d.ts.map +1 -0
- package/dist/database/db.js +2073 -0
- 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} +122 -34
- 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/database/schema.d.ts +91 -0
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/{schema.js → database/schema.js} +153 -54
- package/dist/dev-entry.d.ts +8 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +131 -0
- package/dist/feeds.d.ts +26 -8
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +72 -16
- package/dist/hooks.d.ts +102 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +186 -0
- package/dist/hydrate.d.ts +55 -6
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +21 -20
- package/dist/indexer.d.ts +194 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +627 -92
- package/dist/jetstream.d.ts +118 -0
- package/dist/jetstream.d.ts.map +1 -0
- package/dist/jetstream.js +238 -0
- package/dist/labels.d.ts +36 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +71 -6
- package/dist/lex-types.d.ts +2 -0
- package/dist/lex-types.d.ts.map +1 -1
- 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 +212 -75
- package/dist/mst.d.ts +15 -0
- package/dist/mst.d.ts.map +1 -1
- package/dist/mst.js +13 -0
- package/dist/oauth/db.d.ts +27 -1
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +80 -19
- package/dist/oauth/discovery.d.ts +10 -0
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +54 -2
- package/dist/oauth/server.d.ts +131 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +504 -39
- package/dist/oauth/service-describe.d.ts +42 -0
- package/dist/oauth/service-describe.d.ts.map +1 -0
- package/dist/oauth/service-describe.js +108 -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 +94 -42
- package/dist/pds-proxy.d.ts +92 -0
- package/dist/pds-proxy.d.ts.map +1 -0
- package/dist/pds-proxy.js +396 -0
- package/dist/private-collections.d.ts +13 -0
- package/dist/private-collections.d.ts.map +1 -0
- package/dist/private-collections.js +27 -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 +22 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +92 -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 +75 -0
- package/dist/server.d.ts +46 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1137 -649
- package/dist/setup.d.ts +28 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +50 -3
- package/dist/spaces/blob.d.ts +112 -0
- package/dist/spaces/blob.d.ts.map +1 -0
- package/dist/spaces/blob.js +460 -0
- package/dist/spaces/credential.d.ts +82 -0
- package/dist/spaces/credential.d.ts.map +1 -0
- package/dist/spaces/credential.js +220 -0
- package/dist/spaces/engine.d.ts +97 -0
- package/dist/spaces/engine.d.ts.map +1 -0
- package/dist/spaces/engine.js +563 -0
- package/dist/spaces/guard.d.ts +41 -0
- package/dist/spaces/guard.d.ts.map +1 -0
- package/dist/spaces/guard.js +79 -0
- package/dist/spaces/identity.d.ts +37 -0
- package/dist/spaces/identity.d.ts.map +1 -0
- package/dist/spaces/identity.js +93 -0
- package/dist/spaces/index.d.ts +56 -0
- package/dist/spaces/index.d.ts.map +1 -0
- package/dist/spaces/index.js +134 -0
- package/dist/spaces/notify.d.ts +60 -0
- package/dist/spaces/notify.d.ts.map +1 -0
- package/dist/spaces/notify.js +128 -0
- package/dist/spaces/records.d.ts +37 -0
- package/dist/spaces/records.d.ts.map +1 -0
- package/dist/spaces/records.js +63 -0
- package/dist/spaces/signature.d.ts +36 -0
- package/dist/spaces/signature.d.ts.map +1 -0
- package/dist/spaces/signature.js +83 -0
- package/dist/spaces/store.d.ts +51 -0
- package/dist/spaces/store.d.ts.map +1 -0
- package/dist/spaces/store.js +99 -0
- package/dist/spaces/uri.d.ts +60 -0
- package/dist/spaces/uri.d.ts.map +1 -0
- package/dist/spaces/uri.js +97 -0
- package/dist/spaces/verify.d.ts +46 -0
- package/dist/spaces/verify.d.ts.map +1 -0
- package/dist/spaces/verify.js +94 -0
- package/dist/spaces/viewer.d.ts +51 -0
- package/dist/spaces/viewer.d.ts.map +1 -0
- package/dist/spaces/viewer.js +167 -0
- package/dist/spaces/visibility.d.ts +57 -0
- package/dist/spaces/visibility.d.ts.map +1 -0
- package/dist/spaces/visibility.js +81 -0
- package/dist/templates/feed.tpl +18 -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 +53 -0
- package/dist/test.d.ts +3 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +60 -36
- 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 +143 -12
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +282 -55
- package/package.json +29 -9
- package/public/admin-auth.js +271 -306
- package/public/admin.html +652 -61
- package/dist/db.d.ts +0 -135
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js +0 -1365
- 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 +0 -51
- 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,100 +1,453 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { gzipSync } from 'node:zlib';
|
|
1
|
+
import { createHash, timingSafeEqual } 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, queryClassifications, getClassificationStats, getRecordsByDid, getRecordCountsByDid, findOpenReportsForSubject, } from "./database/db.js";
|
|
7
|
+
import { runScan, getScanProgress, listClassifiers, isClefConfigured } from "./classifiers.js";
|
|
7
8
|
import { executeFeed, listFeeds } from "./feeds.js";
|
|
8
|
-
import { executeXrpc, InvalidRequestError } from "./xrpc.js";
|
|
9
|
-
import {
|
|
10
|
-
import { validateRecord } from '@bigmoves/lexicon';
|
|
9
|
+
import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler, isLocalRelay, paramsFromSearch, } from "./xrpc.js";
|
|
10
|
+
import { pdsFor } from "./backfill.js";
|
|
11
11
|
import { resolveRecords } from "./hydrate.js";
|
|
12
12
|
import { handleOpengraphRequest, buildOgMeta } from "./opengraph.js";
|
|
13
13
|
import { getLabelDefinitions, rescanLabels } from "./labels.js";
|
|
14
14
|
import { triggerAutoBackfill } from "./indexer.js";
|
|
15
|
-
import {
|
|
16
|
-
import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, handlePar, buildAuthorizeRedirect, handleCallback, handleToken, authenticate,
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
15
|
+
import { emit, timer } from "./logger.js";
|
|
16
|
+
import { getAuthServerMetadata, getProtectedResourceMetadata, getJwks, getClientMetadata, getClientJwks, handlePar, buildAuthorizeRedirect, handleCallback, handleCallbackError, serverLogin, handleToken, authenticate, OAuthError, } from "./oauth/server.js";
|
|
17
|
+
import { createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie, createAccountsCookie, accountsCookieHeader, clearAccountsCookieHeader, parseAccountsCookie, withAccount, withoutAccount, } from "./oauth/session.js";
|
|
18
|
+
import { getOAuthRequest, getSession } from "./oauth/db.js";
|
|
19
|
+
import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsApplyWrites, pdsUploadBlob, ProxyError, ScopeMissingProxyError, } from "./pds-proxy.js";
|
|
20
|
+
import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
|
|
21
|
+
import { collectionFromUri, isPrivateCollection } from "./private-collections.js";
|
|
22
|
+
import { enterReadableSpaces } from "./spaces/visibility.js";
|
|
23
|
+
import { readableSpacesFor } from "./spaces/viewer.js";
|
|
24
|
+
import { parseSpaceBlobRequest, serveSpaceBlob } from "./spaces/blob.js";
|
|
25
|
+
import { handleWriteNotice, NoticeError, parseWriteNotice, scheduleNoticeSync, spaceDidDocument, spaceServiceId, unwatchSpace, verifyNotice, } from "./spaces/index.js";
|
|
26
|
+
import { parseSpaceRef } from "./spaces/uri.js";
|
|
27
|
+
import { serve } from "./adapter.js";
|
|
28
|
+
import { renderPage } from "./renderer.js";
|
|
29
|
+
/**
|
|
30
|
+
* Parse a client-supplied `limit`, or null if it is not a usable one.
|
|
31
|
+
*
|
|
32
|
+
* `parseInt` alone lets `limit=abc` through as NaN and `limit=-5` through as a
|
|
33
|
+
* negative bound, both of which reach the database driver: the first as a
|
|
34
|
+
* datatype error, the second as "no limit at all" on SQLite. Neither is a
|
|
35
|
+
* server fault, so the caller turns a null into a 400.
|
|
36
|
+
*/
|
|
37
|
+
function parseLimit(raw, fallback) {
|
|
38
|
+
if (raw === null || raw === '')
|
|
39
|
+
return fallback;
|
|
40
|
+
if (!/^\d+$/.test(raw))
|
|
41
|
+
return null;
|
|
42
|
+
const value = Number(raw);
|
|
43
|
+
return value >= 1 ? value : null;
|
|
44
|
+
}
|
|
45
|
+
/** Whether `sort` names a column queryRecords can order this collection by. */
|
|
46
|
+
function isSortableField(collection, sort) {
|
|
47
|
+
if (sort === 'indexed_at')
|
|
48
|
+
return true;
|
|
49
|
+
const schema = getSchema(collection);
|
|
50
|
+
return !!schema?.columns.some((c) => c.originalName === sort || c.name === sort);
|
|
51
|
+
}
|
|
52
|
+
/** Narrow a client-supplied `order` to the two values that may reach ORDER BY. */
|
|
53
|
+
function parseOrder(raw) {
|
|
54
|
+
if (!raw)
|
|
55
|
+
return undefined;
|
|
56
|
+
const lowered = raw.toLowerCase();
|
|
57
|
+
return lowered === 'asc' || lowered === 'desc' ? lowered : null;
|
|
58
|
+
}
|
|
59
|
+
function scopeMissingResponse(acceptEncoding, handle) {
|
|
60
|
+
const res = withCors(json({ error: 'ScopeMissingError', ...(handle ? { handle } : {}) }, 401, acceptEncoding));
|
|
61
|
+
res.headers.append('Set-Cookie', clearSessionCookieHeader());
|
|
62
|
+
return res;
|
|
63
|
+
}
|
|
20
64
|
const MIME = {
|
|
21
65
|
'.html': 'text/html',
|
|
22
66
|
'.js': 'application/javascript',
|
|
23
67
|
'.css': 'text/css',
|
|
24
68
|
'.json': 'application/json',
|
|
25
69
|
};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Register built-in dev.hatk.* XRPC handlers in the handler registry.
|
|
72
|
+
* This makes them available to callXrpc() for use in SSR and server code.
|
|
73
|
+
*/
|
|
74
|
+
export function registerCoreHandlers(collections, oauth) {
|
|
75
|
+
registerCoreXrpcHandler('dev.hatk.getRecords', async (params, cursor, limit) => {
|
|
76
|
+
const collection = params.collection;
|
|
77
|
+
if (!collection)
|
|
78
|
+
throw new InvalidRequestError('Missing collection parameter');
|
|
79
|
+
if (isPrivateCollection(collection))
|
|
80
|
+
throw new NotFoundError(`Unknown collection: ${collection}`);
|
|
81
|
+
if (!getSchema(collection))
|
|
82
|
+
throw new NotFoundError(`Unknown collection: ${collection}`);
|
|
83
|
+
const sort = params.sort || undefined;
|
|
84
|
+
if (sort && !isSortableField(collection, sort))
|
|
85
|
+
throw new InvalidRequestError(`Invalid sort field: ${sort}`);
|
|
86
|
+
const order = parseOrder(params.order);
|
|
87
|
+
if (order === null)
|
|
88
|
+
throw new InvalidRequestError(`Invalid order: ${params.order}`);
|
|
89
|
+
const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
|
|
90
|
+
const filters = {};
|
|
91
|
+
for (const [key, value] of Object.entries(params)) {
|
|
92
|
+
if (!reserved.has(key))
|
|
93
|
+
filters[key] = value;
|
|
94
|
+
}
|
|
95
|
+
const result = await queryRecords(collection, {
|
|
96
|
+
limit,
|
|
97
|
+
cursor,
|
|
98
|
+
sort,
|
|
99
|
+
order,
|
|
100
|
+
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
|
101
|
+
});
|
|
102
|
+
const uris = result.records.map((r) => r.uri);
|
|
103
|
+
const items = await resolveRecords(uris);
|
|
104
|
+
return { items, cursor: result.cursor };
|
|
32
105
|
});
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
106
|
+
registerCoreXrpcHandler('dev.hatk.getRecord', async (params) => {
|
|
107
|
+
const uri = params.uri;
|
|
108
|
+
if (!uri)
|
|
109
|
+
throw new InvalidRequestError('Missing uri parameter');
|
|
110
|
+
if (isPrivateCollection(collectionFromUri(uri)))
|
|
111
|
+
throw new NotFoundError('Record not found');
|
|
112
|
+
const record = await getRecordByUri(uri);
|
|
113
|
+
if (!record)
|
|
114
|
+
throw new NotFoundError('Record not found');
|
|
115
|
+
const shaped = reshapeRow(record, record?.__childData);
|
|
116
|
+
const labelsMap = await queryLabelsForUris([record.uri]);
|
|
117
|
+
if (shaped)
|
|
118
|
+
shaped.labels = labelsMap.get(record.uri) || [];
|
|
119
|
+
return { record: shaped };
|
|
120
|
+
});
|
|
121
|
+
registerCoreXrpcHandler('dev.hatk.getFeed', async (params, cursor, limit, viewer) => {
|
|
122
|
+
const feedName = params.feed;
|
|
123
|
+
if (!feedName)
|
|
124
|
+
throw new InvalidRequestError('Missing feed parameter');
|
|
125
|
+
const result = await executeFeed(feedName, params, cursor, limit, viewer);
|
|
126
|
+
if (!result)
|
|
127
|
+
throw new NotFoundError(`Unknown feed: ${feedName}`);
|
|
128
|
+
return result;
|
|
129
|
+
});
|
|
130
|
+
registerCoreXrpcHandler('dev.hatk.searchRecords', async (params, cursor, limit) => {
|
|
131
|
+
const collection = params.collection;
|
|
132
|
+
const q = params.q;
|
|
133
|
+
if (!collection)
|
|
134
|
+
throw new InvalidRequestError('Missing collection parameter');
|
|
135
|
+
if (!q)
|
|
136
|
+
throw new InvalidRequestError('Missing q parameter');
|
|
137
|
+
if (isPrivateCollection(collection))
|
|
138
|
+
throw new NotFoundError(`Unknown collection: ${collection}`);
|
|
139
|
+
if (!getSchema(collection))
|
|
140
|
+
throw new NotFoundError(`Unknown collection: ${collection}`);
|
|
141
|
+
const fuzzy = params.fuzzy !== 'false';
|
|
142
|
+
const result = await searchRecords(collection, q, { limit, cursor, fuzzy });
|
|
143
|
+
const uris = result.records.map((r) => r.uri);
|
|
144
|
+
const items = await resolveRecords(uris);
|
|
145
|
+
return { items, cursor: result.cursor };
|
|
146
|
+
});
|
|
147
|
+
registerCoreXrpcHandler('dev.hatk.describeFeeds', async () => {
|
|
148
|
+
return { feeds: listFeeds() };
|
|
40
149
|
});
|
|
150
|
+
registerCoreXrpcHandler('dev.hatk.describeCollections', async () => {
|
|
151
|
+
const collectionInfo = collections
|
|
152
|
+
.filter((c) => !isPrivateCollection(c))
|
|
153
|
+
.map((c) => {
|
|
154
|
+
const schema = getSchema(c);
|
|
155
|
+
return {
|
|
156
|
+
collection: c,
|
|
157
|
+
columns: schema?.columns.map((col) => ({
|
|
158
|
+
name: col.name,
|
|
159
|
+
originalName: col.originalName,
|
|
160
|
+
type: col.sqlType,
|
|
161
|
+
required: col.notNull,
|
|
162
|
+
})),
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
return { collections: collectionInfo };
|
|
166
|
+
});
|
|
167
|
+
registerCoreXrpcHandler('dev.hatk.describeLabels', async () => {
|
|
168
|
+
return { definitions: getLabelDefinitions() };
|
|
169
|
+
});
|
|
170
|
+
// Write operations — proxy to user's PDS
|
|
171
|
+
if (oauth) {
|
|
172
|
+
registerCoreXrpcHandler('dev.hatk.getPreferences', async (_params, _cursor, _limit, viewer) => {
|
|
173
|
+
if (!viewer)
|
|
174
|
+
throw new InvalidRequestError('Authentication required');
|
|
175
|
+
const prefs = await getPreferences(viewer.did);
|
|
176
|
+
return { preferences: prefs };
|
|
177
|
+
});
|
|
178
|
+
registerCoreXrpcHandler('dev.hatk.putPreference', async (_params, _cursor, _limit, viewer, input) => {
|
|
179
|
+
if (!viewer)
|
|
180
|
+
throw new InvalidRequestError('Authentication required');
|
|
181
|
+
const body = input;
|
|
182
|
+
if (!body.key || typeof body.key !== 'string')
|
|
183
|
+
throw new InvalidRequestError('Missing or invalid key');
|
|
184
|
+
if (body.value === undefined)
|
|
185
|
+
throw new InvalidRequestError('Missing value');
|
|
186
|
+
await putPreference(viewer.did, body.key, body.value);
|
|
187
|
+
return { success: true };
|
|
188
|
+
});
|
|
189
|
+
registerCoreXrpcHandler('dev.hatk.createRecord', async (_params, _cursor, _limit, viewer, input) => {
|
|
190
|
+
if (!viewer)
|
|
191
|
+
throw new InvalidRequestError('Authentication required');
|
|
192
|
+
return pdsCreateRecord(oauth, viewer, input);
|
|
193
|
+
});
|
|
194
|
+
registerCoreXrpcHandler('dev.hatk.deleteRecord', async (_params, _cursor, _limit, viewer, input) => {
|
|
195
|
+
if (!viewer)
|
|
196
|
+
throw new InvalidRequestError('Authentication required');
|
|
197
|
+
return pdsDeleteRecord(oauth, viewer, input);
|
|
198
|
+
});
|
|
199
|
+
registerCoreXrpcHandler('dev.hatk.putRecord', async (_params, _cursor, _limit, viewer, input) => {
|
|
200
|
+
if (!viewer)
|
|
201
|
+
throw new InvalidRequestError('Authentication required');
|
|
202
|
+
return pdsPutRecord(oauth, viewer, input);
|
|
203
|
+
});
|
|
204
|
+
registerCoreXrpcHandler('dev.hatk.uploadBlob', async (_params, _cursor, _limit, viewer, input) => {
|
|
205
|
+
if (!viewer)
|
|
206
|
+
throw new InvalidRequestError('Authentication required');
|
|
207
|
+
return pdsUploadBlob(oauth, viewer, input, 'application/octet-stream');
|
|
208
|
+
});
|
|
209
|
+
registerCoreXrpcHandler('dev.hatk.applyWrites', async (_params, _cursor, _limit, viewer, input) => {
|
|
210
|
+
if (!viewer)
|
|
211
|
+
throw new InvalidRequestError('Authentication required');
|
|
212
|
+
return pdsApplyWrites(oauth, viewer, input);
|
|
213
|
+
});
|
|
214
|
+
registerCoreXrpcHandler('dev.hatk.push.registerToken', async (_params, _cursor, _limit, viewer, input) => {
|
|
215
|
+
if (!viewer)
|
|
216
|
+
throw new InvalidRequestError('Authentication required');
|
|
217
|
+
const body = input;
|
|
218
|
+
if (!body.token || typeof body.token !== 'string')
|
|
219
|
+
throw new InvalidRequestError('Missing or invalid token');
|
|
220
|
+
const platform = body.platform || 'apns';
|
|
221
|
+
if (!['apns', 'fcm', 'web'].includes(platform))
|
|
222
|
+
throw new InvalidRequestError('Invalid platform');
|
|
223
|
+
const { registerToken } = await import("./push.js");
|
|
224
|
+
await registerToken(viewer.did, body.token, platform);
|
|
225
|
+
return { success: true };
|
|
226
|
+
});
|
|
227
|
+
registerCoreXrpcHandler('dev.hatk.push.unregisterToken', async (_params, _cursor, _limit, viewer, input) => {
|
|
228
|
+
if (!viewer)
|
|
229
|
+
throw new InvalidRequestError('Authentication required');
|
|
230
|
+
const body = input;
|
|
231
|
+
if (!body.token || typeof body.token !== 'string')
|
|
232
|
+
throw new InvalidRequestError('Missing or invalid token');
|
|
233
|
+
const { unregisterToken } = await import("./push.js");
|
|
234
|
+
await unregisterToken(viewer.did, body.token);
|
|
235
|
+
return { success: true };
|
|
236
|
+
});
|
|
237
|
+
registerCoreXrpcHandler('dev.hatk.createReport', async (_params, _cursor, _limit, viewer, input) => {
|
|
238
|
+
if (!viewer)
|
|
239
|
+
throw new InvalidRequestError('Authentication required');
|
|
240
|
+
const body = input;
|
|
241
|
+
if (!body.subject)
|
|
242
|
+
throw new InvalidRequestError('Missing subject');
|
|
243
|
+
if (!body.label || typeof body.label !== 'string')
|
|
244
|
+
throw new InvalidRequestError('Missing or invalid label');
|
|
245
|
+
const defs = getLabelDefinitions();
|
|
246
|
+
if (!defs.some((d) => d.identifier === body.label)) {
|
|
247
|
+
throw new InvalidRequestError(`Unknown label: ${body.label}`);
|
|
248
|
+
}
|
|
249
|
+
if (body.reason && body.reason.length > 2000) {
|
|
250
|
+
throw new InvalidRequestError('Reason must be 2000 characters or less');
|
|
251
|
+
}
|
|
252
|
+
let subjectUri;
|
|
253
|
+
let subjectDid;
|
|
254
|
+
if (body.subject.uri) {
|
|
255
|
+
subjectUri = body.subject.uri;
|
|
256
|
+
const match = body.subject.uri.match(/^at:\/\/(did:[^/]+)/);
|
|
257
|
+
if (!match)
|
|
258
|
+
throw new InvalidRequestError('Invalid subject URI');
|
|
259
|
+
subjectDid = match[1];
|
|
260
|
+
}
|
|
261
|
+
else if (body.subject.did) {
|
|
262
|
+
// The bare DID, not `at://<did>`: resolving this report labels
|
|
263
|
+
// `subjectUri`, and an account label is keyed by DID alone. Ozone
|
|
264
|
+
// writes it that way and every appview reads it that way, so the
|
|
265
|
+
// `at://` form would apply a label nothing ever looks up.
|
|
266
|
+
subjectUri = body.subject.did;
|
|
267
|
+
subjectDid = body.subject.did;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
throw new InvalidRequestError('Subject must have uri or did');
|
|
271
|
+
}
|
|
272
|
+
const result = await insertReport({
|
|
273
|
+
subjectUri,
|
|
274
|
+
subjectDid,
|
|
275
|
+
label: body.label,
|
|
276
|
+
reason: body.reason,
|
|
277
|
+
reportedBy: viewer.did,
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
id: result.id,
|
|
281
|
+
subject: body.subject,
|
|
282
|
+
label: body.label,
|
|
283
|
+
reason: body.reason || null,
|
|
284
|
+
reportedBy: viewer.did,
|
|
285
|
+
createdAt: new Date().toISOString(),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
}
|
|
41
289
|
}
|
|
42
|
-
|
|
43
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Assets we ship inside the package never change while the process is alive, so
|
|
292
|
+
* read, gzip and hash them once. Repeat visits then cost a 304 instead of the
|
|
293
|
+
* full (uncompressed) payload.
|
|
294
|
+
*/
|
|
295
|
+
const bundledAssets = new Map();
|
|
296
|
+
async function serveBundledAsset(path, contentType, request, devMode) {
|
|
297
|
+
let asset = devMode ? undefined : bundledAssets.get(path);
|
|
298
|
+
if (!asset) {
|
|
299
|
+
const raw = await readFile(path);
|
|
300
|
+
asset = {
|
|
301
|
+
raw,
|
|
302
|
+
gzip: gzipSync(raw),
|
|
303
|
+
etag: `"${createHash('sha256').update(raw).digest('base64url').slice(0, 16)}"`,
|
|
304
|
+
};
|
|
305
|
+
if (!devMode)
|
|
306
|
+
bundledAssets.set(path, asset);
|
|
307
|
+
}
|
|
308
|
+
const headers = { ETag: asset.etag, 'Cache-Control': 'no-cache' };
|
|
309
|
+
if (request.headers.get('if-none-match') === asset.etag) {
|
|
310
|
+
return new Response(null, { status: 304, headers });
|
|
311
|
+
}
|
|
312
|
+
const acceptEncoding = request.headers.get('accept-encoding');
|
|
313
|
+
const useGzip = !!acceptEncoding && /\bgzip\b/.test(acceptEncoding);
|
|
314
|
+
if (useGzip) {
|
|
315
|
+
headers['Content-Encoding'] = 'gzip';
|
|
316
|
+
headers.Vary = 'Accept-Encoding';
|
|
317
|
+
}
|
|
318
|
+
headers['Content-Type'] = contentType;
|
|
319
|
+
return new Response(Buffer.from(useGzip ? asset.gzip : asset.raw), { status: 200, headers });
|
|
320
|
+
}
|
|
321
|
+
function bearerMatches(header, token) {
|
|
322
|
+
if (!header?.startsWith('Bearer '))
|
|
323
|
+
return false;
|
|
324
|
+
const given = Buffer.from(header.slice('Bearer '.length));
|
|
325
|
+
const want = Buffer.from(token);
|
|
326
|
+
return given.length === want.length && timingSafeEqual(given, want);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Create a Web Standard request handler for all hatk routes.
|
|
330
|
+
* Returns a pure function: (Request) → Promise<Response>
|
|
331
|
+
*/
|
|
332
|
+
export function createHandler(config) {
|
|
333
|
+
const { collections, publicDir, oauth, admins } = config;
|
|
44
334
|
const devMode = process.env.DEV_MODE === '1';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (!
|
|
51
|
-
jsonError(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
335
|
+
const coreXrpc = (method) => `/xrpc/dev.hatk.${method}`;
|
|
336
|
+
const adminToken = config.adminToken ?? null;
|
|
337
|
+
if (adminToken?.actor.startsWith('did:'))
|
|
338
|
+
throw new Error('adminToken.actor must not be a DID');
|
|
339
|
+
function requireAdmin(viewer, acceptEncoding) {
|
|
340
|
+
if (!viewer)
|
|
341
|
+
return withCors(jsonError(401, 'Authentication required', acceptEncoding));
|
|
342
|
+
// The token's actor is never a DID, so no signed-in account can be it.
|
|
343
|
+
if (!devMode && !admins.includes(viewer.did) && viewer.did !== adminToken?.actor)
|
|
344
|
+
return withCors(jsonError(403, 'Admin access required', acceptEncoding));
|
|
345
|
+
return null; // auth OK
|
|
55
346
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return;
|
|
65
|
-
|
|
347
|
+
// The /admin/info rollups count every row in every collection table, so they get
|
|
348
|
+
// slower as the index grows. Cache them for a few seconds — that collapses the
|
|
349
|
+
// overview's repeat loads and any concurrent requests into one scan — and drop
|
|
350
|
+
// the cache whenever an admin action changes what they report.
|
|
351
|
+
const ADMIN_STATS_TTL_MS = 10_000;
|
|
352
|
+
let adminStats = null;
|
|
353
|
+
function getAdminStats() {
|
|
354
|
+
if (adminStats && Date.now() - adminStats.at < ADMIN_STATS_TTL_MS)
|
|
355
|
+
return adminStats.data;
|
|
356
|
+
const data = (async () => {
|
|
357
|
+
const [repos, duckdb, collections, openReports] = await Promise.all([
|
|
358
|
+
getRepoStatusCounts(),
|
|
359
|
+
getDatabaseSize(),
|
|
360
|
+
getCollectionCounts(),
|
|
361
|
+
getOpenReportCount(),
|
|
362
|
+
]);
|
|
363
|
+
return { repos, duckdb, collections, openReports };
|
|
364
|
+
})();
|
|
365
|
+
const entry = { at: Date.now(), data };
|
|
366
|
+
adminStats = entry;
|
|
367
|
+
// Don't cache a failure — and keep the rejection handled so it surfaces only
|
|
368
|
+
// at the awaiting request.
|
|
369
|
+
data.catch(() => {
|
|
370
|
+
if (adminStats === entry)
|
|
371
|
+
adminStats = null;
|
|
372
|
+
});
|
|
373
|
+
return data;
|
|
374
|
+
}
|
|
375
|
+
function invalidateAdminStats() {
|
|
376
|
+
adminStats = null;
|
|
377
|
+
}
|
|
378
|
+
return async (request) => {
|
|
379
|
+
const url = new URL(request.url);
|
|
380
|
+
const acceptEncoding = request.headers.get('accept-encoding');
|
|
381
|
+
// CORS preflight
|
|
382
|
+
if (request.method === 'OPTIONS')
|
|
383
|
+
return cors();
|
|
66
384
|
const isXrpc = url.pathname.startsWith('/xrpc/');
|
|
67
385
|
const isAdmin = url.pathname.startsWith('/admin') && !url.pathname.endsWith('.html') && !url.pathname.endsWith('.js');
|
|
68
386
|
const elapsed = isXrpc || isAdmin ? timer() : null;
|
|
69
387
|
let error;
|
|
70
|
-
const requestOrigin = `${
|
|
388
|
+
const requestOrigin = `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host') || 'localhost'}`;
|
|
71
389
|
// Authenticate viewer (optional — unauthenticated requests still work)
|
|
72
|
-
let viewer = resolveViewer?.(
|
|
390
|
+
let viewer = config.resolveViewer?.(request) ?? null;
|
|
391
|
+
// The admin token reaches /admin and nothing else: on an XRPC route it is
|
|
392
|
+
// not a viewer, so a service holding it cannot act as an account.
|
|
393
|
+
if (!viewer && isAdmin && adminToken && bearerMatches(request.headers.get('authorization'), adminToken.token)) {
|
|
394
|
+
viewer = { did: adminToken.actor };
|
|
395
|
+
}
|
|
73
396
|
if (!viewer && oauth) {
|
|
74
397
|
try {
|
|
75
|
-
viewer = await authenticate(
|
|
398
|
+
viewer = await authenticate(request.headers.get('authorization'), request.headers.get('dpop'), request.method, `${requestOrigin}${url.pathname}`);
|
|
76
399
|
}
|
|
77
400
|
catch (err) {
|
|
78
401
|
emit('oauth', 'authenticate_error', { error: err.message });
|
|
79
402
|
}
|
|
80
403
|
}
|
|
404
|
+
// Fallback: resolve viewer from session cookie (for browser requests without DPoP)
|
|
405
|
+
if (!viewer && oauth) {
|
|
406
|
+
try {
|
|
407
|
+
viewer = await parseSessionCookie(request);
|
|
408
|
+
}
|
|
409
|
+
catch { }
|
|
410
|
+
}
|
|
411
|
+
// Which permissioned spaces this request may be shown, established once and
|
|
412
|
+
// read by every query below. A signed-out visitor gets none — there is no
|
|
413
|
+
// anonymous read path into a space — and so does an instance that indexes
|
|
414
|
+
// none, which is every deployment that has not configured `spaces`.
|
|
415
|
+
try {
|
|
416
|
+
enterReadableSpaces(await readableSpacesFor(oauth, viewer));
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
// Failing to establish the scope must not widen it: the default is to
|
|
420
|
+
// serve no space row, and that is what an unresolved viewer gets.
|
|
421
|
+
enterReadableSpaces([]);
|
|
422
|
+
emit('spaces', 'viewer_resolve_error', { error: err.message });
|
|
423
|
+
}
|
|
81
424
|
try {
|
|
82
425
|
// GET /xrpc/dev.hatk.getRecords?collection=<nsid>&limit=N&cursor=C&<field>=<value>
|
|
83
426
|
if (url.pathname === coreXrpc('getRecords')) {
|
|
84
427
|
const collection = url.searchParams.get('collection');
|
|
85
|
-
if (!collection)
|
|
86
|
-
jsonError(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (!getSchema(collection))
|
|
90
|
-
jsonError(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
428
|
+
if (!collection)
|
|
429
|
+
return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding));
|
|
430
|
+
if (isPrivateCollection(collection))
|
|
431
|
+
return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
|
|
432
|
+
if (!getSchema(collection))
|
|
433
|
+
return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
|
|
434
|
+
// Every one of these is attacker-controlled, and queryRecords trusts
|
|
435
|
+
// what it is handed: `sort` and `order` reach the ORDER BY clause and a
|
|
436
|
+
// non-numeric `limit` reaches the driver. Rejecting them here keeps a
|
|
437
|
+
// bad query string a 400 instead of a 500 (or worse).
|
|
438
|
+
const limit = parseLimit(url.searchParams.get('limit'), 20);
|
|
439
|
+
if (limit === null)
|
|
440
|
+
return withCors(jsonError(400, 'Invalid limit parameter', acceptEncoding));
|
|
94
441
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
95
442
|
const sort = url.searchParams.get('sort') || undefined;
|
|
96
|
-
const order = (url.searchParams.get('order')
|
|
97
|
-
|
|
443
|
+
const order = parseOrder(url.searchParams.get('order'));
|
|
444
|
+
if (order === null)
|
|
445
|
+
return withCors(jsonError(400, `Invalid order: ${url.searchParams.get('order')}`, acceptEncoding));
|
|
446
|
+
if (sort && !isSortableField(collection, sort))
|
|
447
|
+
return withCors(jsonError(400, `Invalid sort field: ${sort}`, acceptEncoding));
|
|
448
|
+
// Collect field filters (everything except reserved params). An
|
|
449
|
+
// unknown field is ignored rather than rejected — queryRecords does
|
|
450
|
+
// the same, and clients rely on passing through extra params.
|
|
98
451
|
const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
|
|
99
452
|
const filters = {};
|
|
100
453
|
for (const [key, value] of url.searchParams) {
|
|
@@ -110,248 +463,209 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
110
463
|
});
|
|
111
464
|
const uris = result.records.map((r) => r.uri);
|
|
112
465
|
const items = await resolveRecords(uris);
|
|
113
|
-
|
|
114
|
-
return;
|
|
466
|
+
return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding));
|
|
115
467
|
}
|
|
116
468
|
// GET /xrpc/dev.hatk.getRecord?uri=<at-uri>
|
|
117
469
|
if (url.pathname === coreXrpc('getRecord')) {
|
|
118
470
|
const uri = url.searchParams.get('uri');
|
|
119
|
-
if (!uri)
|
|
120
|
-
jsonError(
|
|
121
|
-
|
|
122
|
-
|
|
471
|
+
if (!uri)
|
|
472
|
+
return withCors(jsonError(400, 'Missing uri parameter', acceptEncoding));
|
|
473
|
+
if (isPrivateCollection(collectionFromUri(uri)))
|
|
474
|
+
return withCors(jsonError(404, 'Record not found', acceptEncoding));
|
|
123
475
|
const record = await getRecordByUri(uri);
|
|
124
|
-
if (!record)
|
|
125
|
-
jsonError(
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
476
|
+
if (!record)
|
|
477
|
+
return withCors(jsonError(404, 'Record not found', acceptEncoding));
|
|
128
478
|
const shaped = reshapeRow(record, record?.__childData);
|
|
129
479
|
const labelsMap = await queryLabelsForUris([record.uri]);
|
|
130
480
|
if (shaped)
|
|
131
481
|
shaped.labels = labelsMap.get(record.uri) || [];
|
|
132
|
-
|
|
133
|
-
return;
|
|
482
|
+
return withCors(json({ record: shaped }, 200, acceptEncoding));
|
|
134
483
|
}
|
|
135
484
|
// GET /xrpc/dev.hatk.getFeed?feed=<name>&limit=N&cursor=C
|
|
136
485
|
if (url.pathname === coreXrpc('getFeed')) {
|
|
137
486
|
const feedName = url.searchParams.get('feed');
|
|
138
|
-
if (!feedName)
|
|
139
|
-
jsonError(
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
487
|
+
if (!feedName)
|
|
488
|
+
return withCors(jsonError(400, 'Missing feed parameter', acceptEncoding));
|
|
142
489
|
const limit = parseInt(url.searchParams.get('limit') || '30');
|
|
143
490
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
144
|
-
const params =
|
|
145
|
-
for (const [key, value] of url.searchParams) {
|
|
146
|
-
params[key] = value;
|
|
147
|
-
}
|
|
491
|
+
const params = paramsFromSearch(url.searchParams);
|
|
148
492
|
const result = await executeFeed(feedName, params, cursor, limit, viewer);
|
|
149
|
-
if (!result)
|
|
150
|
-
jsonError(
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
jsonResponse(res, result);
|
|
154
|
-
return;
|
|
493
|
+
if (!result)
|
|
494
|
+
return withCors(jsonError(404, `Unknown feed: ${feedName}`, acceptEncoding));
|
|
495
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
155
496
|
}
|
|
156
497
|
// GET /xrpc/dev.hatk.searchRecords?collection=<nsid>&q=<query>&limit=N&cursor=C
|
|
157
498
|
if (url.pathname === coreXrpc('searchRecords')) {
|
|
158
499
|
const collection = url.searchParams.get('collection');
|
|
159
500
|
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
|
-
}
|
|
501
|
+
if (!collection)
|
|
502
|
+
return withCors(jsonError(400, 'Missing collection parameter', acceptEncoding));
|
|
503
|
+
if (!q)
|
|
504
|
+
return withCors(jsonError(400, 'Missing q parameter', acceptEncoding));
|
|
505
|
+
if (isPrivateCollection(collection))
|
|
506
|
+
return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
|
|
507
|
+
if (!getSchema(collection))
|
|
508
|
+
return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
|
|
172
509
|
const limit = parseInt(url.searchParams.get('limit') || '20');
|
|
173
510
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
174
511
|
const fuzzy = url.searchParams.get('fuzzy') !== 'false';
|
|
175
512
|
const result = await searchRecords(collection, q, { limit, cursor, fuzzy });
|
|
176
513
|
const uris = result.records.map((r) => r.uri);
|
|
177
514
|
const items = await resolveRecords(uris);
|
|
178
|
-
|
|
179
|
-
return;
|
|
515
|
+
return withCors(json({ items, cursor: result.cursor }, 200, acceptEncoding));
|
|
180
516
|
}
|
|
181
517
|
// GET /xrpc/dev.hatk.describeFeeds
|
|
182
518
|
if (url.pathname === coreXrpc('describeFeeds')) {
|
|
183
|
-
|
|
184
|
-
return;
|
|
519
|
+
return withCors(json({ feeds: listFeeds() }, 200, acceptEncoding));
|
|
185
520
|
}
|
|
186
521
|
// GET /xrpc/dev.hatk.describeCollections
|
|
187
522
|
if (url.pathname === coreXrpc('describeCollections')) {
|
|
188
|
-
const collectionInfo = collections
|
|
523
|
+
const collectionInfo = collections
|
|
524
|
+
.filter((c) => !isPrivateCollection(c))
|
|
525
|
+
.map((c) => {
|
|
189
526
|
const schema = getSchema(c);
|
|
190
527
|
return {
|
|
191
528
|
collection: c,
|
|
192
529
|
columns: schema?.columns.map((col) => ({
|
|
193
530
|
name: col.name,
|
|
194
531
|
originalName: col.originalName,
|
|
195
|
-
type: col.
|
|
532
|
+
type: col.sqlType,
|
|
196
533
|
required: col.notNull,
|
|
197
534
|
})),
|
|
198
535
|
};
|
|
199
536
|
});
|
|
200
|
-
|
|
201
|
-
return;
|
|
537
|
+
return withCors(json({ collections: collectionInfo }, 200, acceptEncoding));
|
|
202
538
|
}
|
|
203
539
|
// GET /xrpc/dev.hatk.describeLabels
|
|
204
540
|
if (url.pathname === coreXrpc('describeLabels')) {
|
|
205
|
-
|
|
206
|
-
return;
|
|
541
|
+
return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding));
|
|
207
542
|
}
|
|
208
543
|
// GET /xrpc/dev.hatk.getPreferences — get all preferences for authenticated user
|
|
209
544
|
if (url.pathname === coreXrpc('getPreferences')) {
|
|
210
|
-
if (!viewer)
|
|
211
|
-
jsonError(
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
545
|
+
if (!viewer)
|
|
546
|
+
return withCors(jsonError(401, 'Authentication required', acceptEncoding));
|
|
214
547
|
const prefs = await getPreferences(viewer.did);
|
|
215
|
-
|
|
216
|
-
return;
|
|
548
|
+
return withCors(json({ preferences: prefs }, 200, acceptEncoding));
|
|
217
549
|
}
|
|
218
550
|
// 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
|
-
}
|
|
551
|
+
if (url.pathname === coreXrpc('putPreference') && request.method === 'POST') {
|
|
552
|
+
if (!viewer)
|
|
553
|
+
return withCors(jsonError(401, 'Authentication required', acceptEncoding));
|
|
554
|
+
const body = JSON.parse(await request.text());
|
|
555
|
+
if (!body.key || typeof body.key !== 'string')
|
|
556
|
+
return withCors(jsonError(400, 'Missing or invalid key', acceptEncoding));
|
|
557
|
+
if (body.value === undefined)
|
|
558
|
+
return withCors(jsonError(400, 'Missing value', acceptEncoding));
|
|
233
559
|
await putPreference(viewer.did, body.key, body.value);
|
|
234
|
-
|
|
235
|
-
return;
|
|
560
|
+
return withCors(json({ success: true }, 200, acceptEncoding));
|
|
236
561
|
}
|
|
237
562
|
// ── Admin Repo Management ──
|
|
238
563
|
// POST /admin/repos/add — enqueue DIDs for backfill
|
|
239
|
-
if (url.pathname === '/admin/repos/add' &&
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
564
|
+
if (url.pathname === '/admin/repos/add' && request.method === 'POST') {
|
|
565
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
566
|
+
if (denied)
|
|
567
|
+
return denied;
|
|
568
|
+
const { dids } = JSON.parse(await request.text());
|
|
569
|
+
if (!Array.isArray(dids))
|
|
570
|
+
return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
|
|
247
571
|
for (const did of dids) {
|
|
248
572
|
await setRepoStatus(did, 'pending');
|
|
249
573
|
triggerAutoBackfill(did);
|
|
250
574
|
}
|
|
251
|
-
|
|
252
|
-
return;
|
|
575
|
+
invalidateAdminStats();
|
|
576
|
+
return withCors(json({ added: dids.length }, 200, acceptEncoding));
|
|
253
577
|
}
|
|
254
578
|
// POST /admin/labels/rescan — retroactively apply label rules
|
|
255
|
-
if (url.pathname === '/admin/labels/rescan' &&
|
|
256
|
-
|
|
257
|
-
|
|
579
|
+
if (url.pathname === '/admin/labels/rescan' && request.method === 'POST') {
|
|
580
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
581
|
+
if (denied)
|
|
582
|
+
return denied;
|
|
258
583
|
const result = await rescanLabels(collections);
|
|
259
|
-
|
|
260
|
-
return;
|
|
584
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
261
585
|
}
|
|
262
586
|
// ── Admin Endpoints ──
|
|
263
587
|
// GET /admin/whoami — check if current viewer is admin
|
|
264
588
|
if (url.pathname === '/admin/whoami') {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
return;
|
|
589
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
590
|
+
if (denied)
|
|
591
|
+
return denied;
|
|
592
|
+
return withCors(json({ did: viewer.did, admin: true }, 200, acceptEncoding));
|
|
269
593
|
}
|
|
270
594
|
// GET /admin/labels/definitions — get available label definitions
|
|
271
595
|
if (url.pathname === '/admin/labels/definitions') {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return;
|
|
596
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
597
|
+
if (denied)
|
|
598
|
+
return denied;
|
|
599
|
+
return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding));
|
|
276
600
|
}
|
|
277
601
|
// POST /admin/labels — apply a label
|
|
278
|
-
if (url.pathname === '/admin/labels' &&
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
602
|
+
if (url.pathname === '/admin/labels' && request.method === 'POST') {
|
|
603
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
604
|
+
if (denied)
|
|
605
|
+
return denied;
|
|
606
|
+
const { uri, val } = JSON.parse(await request.text());
|
|
607
|
+
if (!uri || !val)
|
|
608
|
+
return withCors(jsonError(400, 'Missing uri or val', acceptEncoding));
|
|
286
609
|
await insertLabels([{ src: 'admin', uri, val }]);
|
|
287
|
-
|
|
288
|
-
return;
|
|
610
|
+
return withCors(json({ ok: true }, 200, acceptEncoding));
|
|
289
611
|
}
|
|
290
612
|
// 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;
|
|
613
|
+
if (url.pathname === '/admin/labels/reset' && request.method === 'POST') {
|
|
614
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
615
|
+
if (denied)
|
|
616
|
+
return denied;
|
|
617
|
+
const { val } = JSON.parse(await request.text());
|
|
618
|
+
if (!val)
|
|
619
|
+
return withCors(jsonError(400, 'Missing val', acceptEncoding));
|
|
620
|
+
const deleted = await deleteLabels(val);
|
|
621
|
+
return withCors(json({ deleted }, 200, acceptEncoding));
|
|
304
622
|
}
|
|
305
623
|
// POST /admin/labels/negate — negate a label
|
|
306
|
-
if (url.pathname === '/admin/labels/negate' &&
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
624
|
+
if (url.pathname === '/admin/labels/negate' && request.method === 'POST') {
|
|
625
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
626
|
+
if (denied)
|
|
627
|
+
return denied;
|
|
628
|
+
const { uri, val } = JSON.parse(await request.text());
|
|
629
|
+
if (!uri || !val)
|
|
630
|
+
return withCors(jsonError(400, 'Missing uri or val', acceptEncoding));
|
|
314
631
|
await insertLabels([{ src: 'admin', uri, val, neg: true }]);
|
|
315
|
-
|
|
316
|
-
return;
|
|
632
|
+
return withCors(json({ ok: true }, 200, acceptEncoding));
|
|
317
633
|
}
|
|
318
634
|
// POST /admin/takedown — takedown an account
|
|
319
|
-
if (url.pathname === '/admin/takedown' &&
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
635
|
+
if (url.pathname === '/admin/takedown' && request.method === 'POST') {
|
|
636
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
637
|
+
if (denied)
|
|
638
|
+
return denied;
|
|
639
|
+
const { did } = JSON.parse(await request.text());
|
|
640
|
+
if (!did)
|
|
641
|
+
return withCors(jsonError(400, 'Missing did', acceptEncoding));
|
|
327
642
|
await setRepoStatus(did, 'takendown');
|
|
328
|
-
|
|
329
|
-
return;
|
|
643
|
+
invalidateAdminStats();
|
|
644
|
+
return withCors(json({ ok: true }, 200, acceptEncoding));
|
|
330
645
|
}
|
|
331
646
|
// POST /admin/reverse-takedown — reverse a takedown
|
|
332
|
-
if (url.pathname === '/admin/reverse-takedown' &&
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
647
|
+
if (url.pathname === '/admin/reverse-takedown' && request.method === 'POST') {
|
|
648
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
649
|
+
if (denied)
|
|
650
|
+
return denied;
|
|
651
|
+
const { did } = JSON.parse(await request.text());
|
|
652
|
+
if (!did)
|
|
653
|
+
return withCors(jsonError(400, 'Missing did', acceptEncoding));
|
|
340
654
|
await setRepoStatus(did, 'active');
|
|
341
|
-
|
|
342
|
-
return;
|
|
655
|
+
invalidateAdminStats();
|
|
656
|
+
return withCors(json({ ok: true }, 200, acceptEncoding));
|
|
343
657
|
}
|
|
344
658
|
// GET /admin/search — search records or accounts
|
|
345
659
|
if (url.pathname === '/admin/search') {
|
|
346
|
-
|
|
347
|
-
|
|
660
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
661
|
+
if (denied)
|
|
662
|
+
return denied;
|
|
348
663
|
const q = url.searchParams.get('q') || '';
|
|
349
664
|
const type = url.searchParams.get('type') || 'records';
|
|
350
665
|
const limit = parseInt(url.searchParams.get('limit') || '20');
|
|
351
666
|
if (type === 'accounts') {
|
|
352
667
|
const accounts = await searchAccounts(q, limit);
|
|
353
|
-
|
|
354
|
-
return;
|
|
668
|
+
return withCors(json({ accounts }, 200, acceptEncoding));
|
|
355
669
|
}
|
|
356
670
|
// No query — live firehose activity (excludes bulk backfill records)
|
|
357
671
|
if (!q) {
|
|
@@ -359,11 +673,9 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
359
673
|
const allResults = [];
|
|
360
674
|
for (const col of collections) {
|
|
361
675
|
try {
|
|
362
|
-
const
|
|
363
|
-
if (!
|
|
676
|
+
const rows = await getRecentRecords(col, limit + offset);
|
|
677
|
+
if (!rows.length)
|
|
364
678
|
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
679
|
const uris = rows.map((r) => r.uri);
|
|
368
680
|
const labelsMap = await queryLabelsForUris(uris);
|
|
369
681
|
for (const rec of rows) {
|
|
@@ -378,22 +690,20 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
378
690
|
return ta > tb ? -1 : ta < tb ? 1 : 0;
|
|
379
691
|
});
|
|
380
692
|
const page = allResults.slice(offset, offset + limit);
|
|
381
|
-
|
|
382
|
-
return;
|
|
693
|
+
return withCors(json({ records: page, total: allResults.length }, 200, acceptEncoding));
|
|
383
694
|
}
|
|
384
695
|
// URI lookup
|
|
385
696
|
if (q.startsWith('at://')) {
|
|
386
697
|
const rec = await getRecordByUri(q);
|
|
387
698
|
if (rec) {
|
|
388
699
|
const labelsMap = await queryLabelsForUris([rec.uri]);
|
|
389
|
-
|
|
700
|
+
return withCors(json({
|
|
390
701
|
records: [{ ...reshapeRow(rec, rec?.__childData), labels: labelsMap.get(rec.uri) || [] }],
|
|
391
|
-
});
|
|
702
|
+
}, 200, acceptEncoding));
|
|
392
703
|
}
|
|
393
704
|
else {
|
|
394
|
-
|
|
705
|
+
return withCors(json({ records: [] }, 200, acceptEncoding));
|
|
395
706
|
}
|
|
396
|
-
return;
|
|
397
707
|
}
|
|
398
708
|
// DID lookup — find all records by this DID
|
|
399
709
|
if (q.startsWith('did:')) {
|
|
@@ -412,8 +722,7 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
412
722
|
}
|
|
413
723
|
catch { }
|
|
414
724
|
}
|
|
415
|
-
|
|
416
|
-
return;
|
|
725
|
+
return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding));
|
|
417
726
|
}
|
|
418
727
|
// Default: full-text search records across all collections
|
|
419
728
|
const allResults = [];
|
|
@@ -431,56 +740,62 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
431
740
|
}
|
|
432
741
|
catch { }
|
|
433
742
|
}
|
|
434
|
-
|
|
435
|
-
return;
|
|
743
|
+
return withCors(json({ records: allResults.slice(0, limit) }, 200, acceptEncoding));
|
|
436
744
|
}
|
|
437
745
|
// POST /admin/repos/resync — re-download repos
|
|
438
|
-
if (url.pathname === '/admin/repos/resync' &&
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const
|
|
746
|
+
if (url.pathname === '/admin/repos/resync' && request.method === 'POST') {
|
|
747
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
748
|
+
if (denied)
|
|
749
|
+
return denied;
|
|
750
|
+
const bodyText = await request.text();
|
|
751
|
+
const { dids } = bodyText ? JSON.parse(bodyText) : {};
|
|
443
752
|
let repoList;
|
|
444
753
|
if (Array.isArray(dids) && dids.length > 0) {
|
|
445
754
|
repoList = dids;
|
|
446
755
|
}
|
|
447
756
|
else {
|
|
448
|
-
|
|
449
|
-
repoList = rows.map((r) => r.did);
|
|
757
|
+
repoList = await listActiveRepoDids();
|
|
450
758
|
}
|
|
759
|
+
const isTargeted = Array.isArray(dids) && dids.length > 0;
|
|
451
760
|
for (const did of repoList) {
|
|
452
761
|
await setRepoStatus(did, 'pending');
|
|
453
|
-
triggerAutoBackfill(did);
|
|
454
762
|
}
|
|
455
|
-
|
|
456
|
-
|
|
763
|
+
if (isTargeted) {
|
|
764
|
+
for (const did of repoList) {
|
|
765
|
+
triggerAutoBackfill(did);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
else if (config.onResync) {
|
|
769
|
+
config.onResync();
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
for (const did of repoList) {
|
|
773
|
+
triggerAutoBackfill(did);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
invalidateAdminStats();
|
|
777
|
+
return withCors(json({ resyncing: repoList.length }, 200, acceptEncoding));
|
|
457
778
|
}
|
|
458
779
|
// POST /admin/repos/remove — remove DIDs from tracking
|
|
459
|
-
if (url.pathname === '/admin/repos/remove' &&
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
780
|
+
if (url.pathname === '/admin/repos/remove' && request.method === 'POST') {
|
|
781
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
782
|
+
if (denied)
|
|
783
|
+
return denied;
|
|
784
|
+
const { dids } = JSON.parse(await request.text());
|
|
785
|
+
if (!Array.isArray(dids))
|
|
786
|
+
return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
|
|
467
787
|
for (const did of dids) {
|
|
468
|
-
await
|
|
788
|
+
await removeRepo(did);
|
|
469
789
|
}
|
|
470
|
-
|
|
471
|
-
return;
|
|
790
|
+
invalidateAdminStats();
|
|
791
|
+
return withCors(json({ removed: dids.length }, 200, acceptEncoding));
|
|
472
792
|
}
|
|
473
793
|
// GET /admin/info — aggregate status + db size + collection counts
|
|
474
794
|
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();
|
|
795
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
796
|
+
if (denied)
|
|
797
|
+
return denied;
|
|
798
|
+
const stats = await getAdminStats();
|
|
484
799
|
const mem = process.memoryUsage();
|
|
485
800
|
const node = {
|
|
486
801
|
rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MiB`,
|
|
@@ -488,372 +803,624 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
488
803
|
heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`,
|
|
489
804
|
external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`,
|
|
490
805
|
};
|
|
491
|
-
|
|
492
|
-
|
|
806
|
+
return withCors(json({
|
|
807
|
+
repos: stats.repos,
|
|
808
|
+
duckdb: stats.duckdb,
|
|
809
|
+
node,
|
|
810
|
+
collections: stats.collections,
|
|
811
|
+
openReports: stats.openReports,
|
|
812
|
+
}, 200, acceptEncoding));
|
|
813
|
+
}
|
|
814
|
+
// GET /admin/reports — list reports
|
|
815
|
+
if (url.pathname === '/admin/reports' && request.method === 'GET') {
|
|
816
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
817
|
+
if (denied)
|
|
818
|
+
return denied;
|
|
819
|
+
const status = url.searchParams.get('status') || 'open';
|
|
820
|
+
const label = url.searchParams.get('label') || undefined;
|
|
821
|
+
const limit = parseInt(url.searchParams.get('limit') || '50');
|
|
822
|
+
const offset = parseInt(url.searchParams.get('offset') || '0');
|
|
823
|
+
const result = await queryReports({ status, label, limit, offset });
|
|
824
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
825
|
+
}
|
|
826
|
+
// POST /admin/reports/resolve — resolve or dismiss a report
|
|
827
|
+
if (url.pathname === '/admin/reports/resolve' && request.method === 'POST') {
|
|
828
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
829
|
+
if (denied)
|
|
830
|
+
return denied;
|
|
831
|
+
const { id, action } = JSON.parse(await request.text());
|
|
832
|
+
if (!id || !action)
|
|
833
|
+
return withCors(jsonError(400, 'Missing id or action', acceptEncoding));
|
|
834
|
+
if (action !== 'resolve' && action !== 'dismiss')
|
|
835
|
+
return withCors(jsonError(400, 'Action must be resolve or dismiss', acceptEncoding));
|
|
836
|
+
const report = await resolveReport(id, action === 'resolve' ? 'resolved' : 'dismissed', viewer.did);
|
|
837
|
+
if (!report)
|
|
838
|
+
return withCors(jsonError(404, 'Report not found or already resolved', acceptEncoding));
|
|
839
|
+
if (action === 'resolve') {
|
|
840
|
+
await insertLabels([{ src: 'admin', uri: report.subjectUri, val: report.label }]);
|
|
841
|
+
}
|
|
842
|
+
invalidateAdminStats();
|
|
843
|
+
return withCors(json({ ok: true }, 200, acceptEncoding));
|
|
844
|
+
}
|
|
845
|
+
// GET /admin/review — the classifier review queue
|
|
846
|
+
if (url.pathname === '/admin/review' && request.method === 'GET') {
|
|
847
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
848
|
+
if (denied)
|
|
849
|
+
return denied;
|
|
850
|
+
const result = await queryClassifications({
|
|
851
|
+
classifier: url.searchParams.get('classifier') || undefined,
|
|
852
|
+
signal: url.searchParams.get('signal') || undefined,
|
|
853
|
+
minScore: url.searchParams.has('minScore') ? Number(url.searchParams.get('minScore')) : undefined,
|
|
854
|
+
state: url.searchParams.get('state') || 'pending',
|
|
855
|
+
limit: parseInt(url.searchParams.get('limit') || '25'),
|
|
856
|
+
offset: parseInt(url.searchParams.get('offset') || '0'),
|
|
857
|
+
});
|
|
858
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
859
|
+
}
|
|
860
|
+
// GET /admin/review/stats — rollups, classifier names, scan progress
|
|
861
|
+
if (url.pathname === '/admin/review/stats' && request.method === 'GET') {
|
|
862
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
863
|
+
if (denied)
|
|
864
|
+
return denied;
|
|
865
|
+
const stats = await getClassificationStats();
|
|
866
|
+
return withCors(json({ ...stats, classifiers: listClassifiers(), configured: isClefConfigured(), scan: getScanProgress() }, 200, acceptEncoding));
|
|
867
|
+
}
|
|
868
|
+
// POST /admin/review/scan — start a scan. Returns as soon as it is under
|
|
869
|
+
// way; a scan of a whole network outlives any sensible request timeout, so
|
|
870
|
+
// progress is polled from /admin/review/stats rather than awaited here.
|
|
871
|
+
if (url.pathname === '/admin/review/scan' && request.method === 'POST') {
|
|
872
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
873
|
+
if (denied)
|
|
874
|
+
return denied;
|
|
875
|
+
if (!isClefConfigured())
|
|
876
|
+
return withCors(jsonError(400, 'Clef is not configured — set `clef` in hatk.config.ts', acceptEncoding));
|
|
877
|
+
const body = JSON.parse((await request.text()) || '{}');
|
|
878
|
+
const running = getScanProgress();
|
|
879
|
+
if (running?.running)
|
|
880
|
+
return withCors(jsonError(409, 'A scan is already running', acceptEncoding));
|
|
881
|
+
runScan({ classifier: body.classifier, force: body.force === true, limit: body.limit })
|
|
882
|
+
.then(() => invalidateAdminStats())
|
|
883
|
+
.catch(() => { });
|
|
884
|
+
return withCors(json({ ok: true, started: true }, 202, acceptEncoding));
|
|
885
|
+
}
|
|
886
|
+
// GET /admin/review/subject/:did — everything needed to judge one subject
|
|
887
|
+
if (url.pathname.startsWith('/admin/review/subject/')) {
|
|
888
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
889
|
+
if (denied)
|
|
890
|
+
return denied;
|
|
891
|
+
const did = decodeURIComponent(url.pathname.slice('/admin/review/subject/'.length));
|
|
892
|
+
if (!did.startsWith('did:'))
|
|
893
|
+
return withCors(jsonError(400, 'Expected a DID', acceptEncoding));
|
|
894
|
+
const [records, counts, handle, status, labels] = await Promise.all([
|
|
895
|
+
getRecordsByDid(did, collections, 6),
|
|
896
|
+
getRecordCountsByDid(did, collections),
|
|
897
|
+
getRepoHandle(did),
|
|
898
|
+
getRepoStatus(did),
|
|
899
|
+
queryLabelsForUris([did]),
|
|
900
|
+
]);
|
|
901
|
+
return withCors(json({ did, handle, status, records, counts, labels }, 200, acceptEncoding));
|
|
902
|
+
}
|
|
903
|
+
// POST /admin/review/act — resolve the report and act, in one step
|
|
904
|
+
if (url.pathname === '/admin/review/act' && request.method === 'POST') {
|
|
905
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
906
|
+
if (denied)
|
|
907
|
+
return denied;
|
|
908
|
+
const { did, uri, action, label } = JSON.parse(await request.text());
|
|
909
|
+
const subjectUri = uri || did;
|
|
910
|
+
if (!subjectUri || !action)
|
|
911
|
+
return withCors(jsonError(400, 'Missing subject or action', acceptEncoding));
|
|
912
|
+
if (!['takedown', 'label', 'dismiss'].includes(action))
|
|
913
|
+
return withCors(jsonError(400, 'Action must be takedown, label or dismiss', acceptEncoding));
|
|
914
|
+
if (action === 'takedown') {
|
|
915
|
+
if (!did)
|
|
916
|
+
return withCors(jsonError(400, 'Takedown needs a did', acceptEncoding));
|
|
917
|
+
await setRepoStatus(did, 'takendown');
|
|
918
|
+
}
|
|
919
|
+
else if (action === 'label') {
|
|
920
|
+
if (!label)
|
|
921
|
+
return withCors(jsonError(400, 'Label action needs a label', acceptEncoding));
|
|
922
|
+
await insertLabels([{ src: 'admin', uri: subjectUri, val: label }]);
|
|
923
|
+
}
|
|
924
|
+
// Close whatever the classifier filed, so the subject leaves the queue
|
|
925
|
+
// whichever way it was decided.
|
|
926
|
+
for (const id of await findOpenReportsForSubject(subjectUri)) {
|
|
927
|
+
await resolveReport(id, action === 'dismiss' ? 'dismissed' : 'resolved', viewer.did);
|
|
928
|
+
}
|
|
929
|
+
invalidateAdminStats();
|
|
930
|
+
return withCors(json({ ok: true }, 200, acceptEncoding));
|
|
493
931
|
}
|
|
494
932
|
// GET /admin/info/:did — repo status info
|
|
495
933
|
if (url.pathname.startsWith('/admin/info/did:')) {
|
|
496
|
-
|
|
497
|
-
|
|
934
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
935
|
+
if (denied)
|
|
936
|
+
return denied;
|
|
498
937
|
const did = url.pathname.slice('/admin/info/'.length);
|
|
499
938
|
const status = await getRepoStatus(did);
|
|
500
|
-
if (!status)
|
|
501
|
-
jsonError(
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
939
|
+
if (!status)
|
|
940
|
+
return withCors(jsonError(404, 'Repo not found', acceptEncoding));
|
|
504
941
|
const retryInfo = await getRepoRetryInfo(did);
|
|
505
|
-
|
|
942
|
+
return withCors(json({
|
|
506
943
|
did,
|
|
507
944
|
status,
|
|
508
945
|
retry_count: retryInfo?.retryCount ?? 0,
|
|
509
946
|
retry_after: retryInfo?.retryAfter ?? 0,
|
|
510
|
-
});
|
|
511
|
-
return;
|
|
947
|
+
}, 200, acceptEncoding));
|
|
512
948
|
}
|
|
513
949
|
// GET /admin/repos — paginated repo listing
|
|
514
|
-
if (url.pathname === '/admin/repos' &&
|
|
515
|
-
|
|
516
|
-
|
|
950
|
+
if (url.pathname === '/admin/repos' && request.method === 'GET') {
|
|
951
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
952
|
+
if (denied)
|
|
953
|
+
return denied;
|
|
517
954
|
const limit = parseInt(url.searchParams.get('limit') || '50');
|
|
518
955
|
const offset = parseInt(url.searchParams.get('offset') || '0');
|
|
519
956
|
const status = url.searchParams.get('status') || undefined;
|
|
520
957
|
const q = url.searchParams.get('q') || undefined;
|
|
521
958
|
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;
|
|
959
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
533
960
|
}
|
|
534
961
|
// ── Public Repo Endpoints (used by hatk clients for auto-sync) ──
|
|
535
962
|
// 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
|
-
}
|
|
963
|
+
if (url.pathname === '/repos/add' && request.method === 'POST') {
|
|
964
|
+
const { dids } = JSON.parse(await request.text());
|
|
965
|
+
if (!Array.isArray(dids))
|
|
966
|
+
return withCors(jsonError(400, 'Missing dids array', acceptEncoding));
|
|
542
967
|
for (const did of dids) {
|
|
543
968
|
await setRepoStatus(did, 'pending');
|
|
544
969
|
triggerAutoBackfill(did);
|
|
545
970
|
}
|
|
546
|
-
|
|
547
|
-
return;
|
|
971
|
+
return withCors(json({ added: dids.length }, 200, acceptEncoding));
|
|
548
972
|
}
|
|
549
973
|
// GET /info/:did — repo status info (public)
|
|
550
974
|
if (url.pathname.startsWith('/info/did:')) {
|
|
551
975
|
const did = url.pathname.slice('/info/'.length);
|
|
552
976
|
const status = await getRepoStatus(did);
|
|
553
|
-
if (!status)
|
|
554
|
-
jsonError(
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
977
|
+
if (!status)
|
|
978
|
+
return withCors(jsonError(404, 'Repo not found', acceptEncoding));
|
|
557
979
|
const retryInfo = await getRepoRetryInfo(did);
|
|
558
|
-
|
|
980
|
+
return withCors(json({
|
|
559
981
|
did,
|
|
560
982
|
status,
|
|
561
983
|
retry_count: retryInfo?.retryCount ?? 0,
|
|
562
984
|
retry_after: retryInfo?.retryAfter ?? 0,
|
|
563
|
-
});
|
|
564
|
-
return;
|
|
985
|
+
}, 200, acceptEncoding));
|
|
565
986
|
}
|
|
566
987
|
// --- OAuth Endpoints ---
|
|
988
|
+
// GET /.well-known/did.json — this instance's own DID document, so a space
|
|
989
|
+
// authority can resolve where to deliver notices. Published only when a
|
|
990
|
+
// service DID is configured; it carries a service entry and no key.
|
|
991
|
+
if (url.pathname === '/.well-known/did.json' && config.spaces?.serviceDid) {
|
|
992
|
+
return withCors(json(spaceDidDocument(config.spaces.serviceDid, config.spaces.publicUrl ?? requestOrigin, config.spaces.serviceFragment ?? 'atproto_space_syncer'), 200, acceptEncoding));
|
|
993
|
+
}
|
|
567
994
|
// OAuth well-known endpoints
|
|
568
995
|
if (url.pathname === '/.well-known/oauth-authorization-server' && oauth) {
|
|
569
|
-
|
|
570
|
-
return;
|
|
996
|
+
return withCors(json(getAuthServerMetadata(oauth.issuer, oauth), 200, acceptEncoding));
|
|
571
997
|
}
|
|
572
998
|
if (url.pathname === '/.well-known/oauth-protected-resource' && oauth) {
|
|
573
|
-
|
|
574
|
-
return;
|
|
999
|
+
return withCors(json(getProtectedResourceMetadata(oauth.issuer, oauth), 200, acceptEncoding));
|
|
575
1000
|
}
|
|
576
1001
|
if (url.pathname === '/oauth/jwks' && oauth) {
|
|
577
|
-
|
|
578
|
-
return;
|
|
1002
|
+
return withCors(json(getJwks(), 200, acceptEncoding));
|
|
579
1003
|
}
|
|
580
1004
|
if ((url.pathname === '/oauth/client-metadata.json' || url.pathname === '/oauth-client-metadata.json') && oauth) {
|
|
581
|
-
|
|
582
|
-
|
|
1005
|
+
return withCors(json(getClientMetadata(oauth.issuer, oauth), 200, acceptEncoding));
|
|
1006
|
+
}
|
|
1007
|
+
// Public key a PDS fetches to verify our client assertions.
|
|
1008
|
+
if (url.pathname === '/oauth/client-jwks.json' && oauth) {
|
|
1009
|
+
return withCors(json(getClientJwks(), 200, acceptEncoding));
|
|
1010
|
+
}
|
|
1011
|
+
// Dev-only: create a session cookie for any DID (for testing)
|
|
1012
|
+
if (url.pathname === '/__dev/login' && devMode && oauth) {
|
|
1013
|
+
const did = url.searchParams.get('did');
|
|
1014
|
+
if (!did)
|
|
1015
|
+
return withCors(jsonError(400, 'did required', acceptEncoding));
|
|
1016
|
+
const handle = (await getRepoHandle(did)) ?? did;
|
|
1017
|
+
const cookieValue = await createSessionCookie({ did, handle });
|
|
1018
|
+
const secure = url.protocol === 'https:';
|
|
1019
|
+
const accounts = await createAccountsCookie(withAccount(await parseAccountsCookie(request), { did, handle }));
|
|
1020
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
1021
|
+
status: 200,
|
|
1022
|
+
headers: [
|
|
1023
|
+
['Content-Type', 'application/json'],
|
|
1024
|
+
['Set-Cookie', sessionCookieHeader(cookieValue, secure)],
|
|
1025
|
+
['Set-Cookie', accountsCookieHeader(accounts, secure)],
|
|
1026
|
+
],
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
// OAuth Login (server-initiated, no DPoP required)
|
|
1030
|
+
if (url.pathname === '/oauth/login' && oauth) {
|
|
1031
|
+
const handle = url.searchParams.get('handle') || '';
|
|
1032
|
+
const prompt = url.searchParams.get('prompt') || undefined;
|
|
1033
|
+
const pds = url.searchParams.get('pds') || undefined;
|
|
1034
|
+
if (!handle && prompt !== 'create')
|
|
1035
|
+
return withCors(jsonError(400, 'handle required', acceptEncoding));
|
|
1036
|
+
try {
|
|
1037
|
+
const redirectUrl = await serverLogin(oauth, handle, { prompt, pds });
|
|
1038
|
+
return new Response(null, {
|
|
1039
|
+
status: 302,
|
|
1040
|
+
headers: { Location: redirectUrl, 'Set-Cookie': clearSessionCookieHeader() },
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
catch (err) {
|
|
1044
|
+
const message = err instanceof Error ? err.message : 'Login failed';
|
|
1045
|
+
return withCors(jsonError(400, message, acceptEncoding));
|
|
1046
|
+
}
|
|
583
1047
|
}
|
|
584
1048
|
// OAuth PAR
|
|
585
|
-
if (url.pathname === '/oauth/par' &&
|
|
586
|
-
const rawBody = await
|
|
1049
|
+
if (url.pathname === '/oauth/par' && request.method === 'POST' && oauth) {
|
|
1050
|
+
const rawBody = await request.text();
|
|
587
1051
|
let body;
|
|
588
|
-
if (
|
|
1052
|
+
if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
|
|
589
1053
|
body = Object.fromEntries(new URLSearchParams(rawBody));
|
|
590
1054
|
}
|
|
591
1055
|
else {
|
|
592
1056
|
body = JSON.parse(rawBody);
|
|
593
1057
|
}
|
|
594
|
-
const dpopHeader =
|
|
595
|
-
if (!dpopHeader)
|
|
596
|
-
jsonError(
|
|
597
|
-
|
|
1058
|
+
const dpopHeader = request.headers.get('dpop');
|
|
1059
|
+
if (!dpopHeader)
|
|
1060
|
+
return withCors(jsonError(400, 'DPoP header required', acceptEncoding));
|
|
1061
|
+
try {
|
|
1062
|
+
const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`);
|
|
1063
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
1064
|
+
}
|
|
1065
|
+
catch (err) {
|
|
1066
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
1067
|
+
return withCors(jsonError(400, message, acceptEncoding));
|
|
598
1068
|
}
|
|
599
|
-
const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`);
|
|
600
|
-
jsonResponse(res, result);
|
|
601
|
-
return;
|
|
602
1069
|
}
|
|
603
1070
|
// OAuth Authorize
|
|
604
1071
|
if (url.pathname === '/oauth/authorize' && oauth) {
|
|
605
1072
|
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;
|
|
1073
|
+
if (!requestUri)
|
|
1074
|
+
return withCors(jsonError(400, 'request_uri required', acceptEncoding));
|
|
1075
|
+
const oauthRequest = await getOAuthRequest(requestUri);
|
|
1076
|
+
if (!oauthRequest)
|
|
1077
|
+
return withCors(jsonError(400, 'Invalid or expired request_uri', acceptEncoding));
|
|
1078
|
+
const redirectUrl = buildAuthorizeRedirect(oauth, oauthRequest);
|
|
1079
|
+
return new Response(null, { status: 302, headers: { Location: redirectUrl } });
|
|
619
1080
|
}
|
|
620
1081
|
// OAuth Callback (PDS redirects here after user approves)
|
|
621
1082
|
// Skip if iss matches our own issuer — that's the client-side redirect, let the SPA handle it
|
|
622
1083
|
if (url.pathname === '/oauth/callback' && oauth) {
|
|
623
1084
|
const iss = url.searchParams.get('iss');
|
|
624
|
-
if (iss
|
|
625
|
-
// Client-side callback — fall through to SPA
|
|
626
|
-
}
|
|
627
|
-
else {
|
|
1085
|
+
if (iss !== oauth.issuer) {
|
|
628
1086
|
const code = url.searchParams.get('code');
|
|
629
1087
|
const state = url.searchParams.get('state');
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
1088
|
+
const oauthError = url.searchParams.get('error');
|
|
1089
|
+
if (oauthError) {
|
|
1090
|
+
const errorRedirect = await handleCallbackError(oauth, oauthError, url.searchParams.get('error_description'), state);
|
|
1091
|
+
return new Response(null, {
|
|
1092
|
+
status: 302,
|
|
1093
|
+
headers: { Location: errorRedirect, 'Set-Cookie': clearSessionCookieHeader() },
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
if (!code)
|
|
1097
|
+
return withCors(jsonError(400, 'Missing code', acceptEncoding));
|
|
1098
|
+
let result;
|
|
1099
|
+
try {
|
|
1100
|
+
result = await handleCallback(oauth, code, state, iss);
|
|
633
1101
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1102
|
+
catch (err) {
|
|
1103
|
+
// Same convention as the token endpoint below: an error that names
|
|
1104
|
+
// its own status keeps it, anything else is a genuine failure and
|
|
1105
|
+
// falls through to the 500.
|
|
1106
|
+
if (err instanceof OAuthError)
|
|
1107
|
+
return withCors(json({ error: err.code, error_description: err.description }, err.status, acceptEncoding));
|
|
1108
|
+
// A callback matching no pending authorization request is a stale
|
|
1109
|
+
// redirect, not a server fault — the request expired while a login
|
|
1110
|
+
// tab sat open, or the user came back to a login they already
|
|
1111
|
+
// finished. Answer like the other client errors on this route.
|
|
1112
|
+
if (err instanceof Error && err.message === 'No matching authorization request found')
|
|
1113
|
+
return withCors(jsonError(400, err.message, acceptEncoding));
|
|
1114
|
+
throw err;
|
|
1115
|
+
}
|
|
1116
|
+
const isSecure = requestOrigin.startsWith('https');
|
|
1117
|
+
const handle = (await getRepoHandle(result.did)) ?? result.did;
|
|
1118
|
+
const session = { did: result.did, handle };
|
|
1119
|
+
const cookie = await createSessionCookie(session);
|
|
1120
|
+
// Remember the account alongside the active session so the browser
|
|
1121
|
+
// can switch back to it later without another trip to the PDS.
|
|
1122
|
+
const accounts = await createAccountsCookie(withAccount(await parseAccountsCookie(request), session));
|
|
1123
|
+
// Server-initiated login stores redirectUri as '/' — redirect cleanly without code/iss params
|
|
1124
|
+
const redirectTo = result.clientRedirectUri.startsWith('/?code=') ? '/' : result.clientRedirectUri;
|
|
1125
|
+
return new Response(null, {
|
|
1126
|
+
status: 302,
|
|
1127
|
+
headers: [
|
|
1128
|
+
['Location', redirectTo],
|
|
1129
|
+
['Set-Cookie', sessionCookieHeader(cookie, isSecure)],
|
|
1130
|
+
['Set-Cookie', accountsCookieHeader(accounts, isSecure)],
|
|
1131
|
+
],
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
// Client-side callback — fall through to SPA
|
|
1135
|
+
}
|
|
1136
|
+
// Session cookie logout. Also drops the account from the switch list —
|
|
1137
|
+
// leaving it there would keep a working path back into an account the
|
|
1138
|
+
// user believes they signed out of, which matters on a shared machine.
|
|
1139
|
+
if (url.pathname === '/auth/logout' && request.method === 'POST') {
|
|
1140
|
+
const current = await parseSessionCookie(request);
|
|
1141
|
+
const isSecure = requestOrigin.startsWith('https');
|
|
1142
|
+
const headers = [['Set-Cookie', clearSessionCookieHeader()]];
|
|
1143
|
+
if (current) {
|
|
1144
|
+
const remaining = withoutAccount(await parseAccountsCookie(request), current.did);
|
|
1145
|
+
headers.push([
|
|
1146
|
+
'Set-Cookie',
|
|
1147
|
+
remaining.length
|
|
1148
|
+
? accountsCookieHeader(await createAccountsCookie(remaining), isSecure)
|
|
1149
|
+
: clearAccountsCookieHeader(),
|
|
1150
|
+
]);
|
|
1151
|
+
}
|
|
1152
|
+
return new Response(null, { status: 200, headers });
|
|
1153
|
+
}
|
|
1154
|
+
// Accounts this browser may switch between. The cookie is HttpOnly, so
|
|
1155
|
+
// this is the only way for a client to see what's in it.
|
|
1156
|
+
if (url.pathname === '/auth/accounts' && request.method === 'GET') {
|
|
1157
|
+
const stored = await parseAccountsCookie(request);
|
|
1158
|
+
const active = await parseSessionCookie(request);
|
|
1159
|
+
// Sessions created before this feature existed have a session cookie
|
|
1160
|
+
// and no accounts cookie. Adopt the active account into the list rather
|
|
1161
|
+
// than reporting it as unknown — otherwise the switcher looks empty and
|
|
1162
|
+
// signing into a second account appears to drop the first.
|
|
1163
|
+
const needsHealing = !!active && !stored.some((a) => a.did === active.did);
|
|
1164
|
+
const accounts = needsHealing ? withAccount(stored, active) : stored;
|
|
1165
|
+
// An account whose server-side OAuth session is gone can't be switched
|
|
1166
|
+
// to — say so rather than letting the client offer a dead option.
|
|
1167
|
+
// `__dev/login` fabricates a session with no OAuth grant behind it, so
|
|
1168
|
+
// in dev every remembered account counts as switchable.
|
|
1169
|
+
const resolved = await Promise.all(accounts.map(async (a) => ({ ...a, available: devMode || !!(await getSession(a.did)) })));
|
|
1170
|
+
const res = withCors(json({ accounts: resolved, active: active?.did ?? null }, 200, acceptEncoding));
|
|
1171
|
+
// Persist the adoption so the next switch has something to authorize
|
|
1172
|
+
// against, instead of healing the list again on every load.
|
|
1173
|
+
if (needsHealing) {
|
|
1174
|
+
res.headers.append('Set-Cookie', accountsCookieHeader(await createAccountsCookie(accounts), requestOrigin.startsWith('https')));
|
|
1175
|
+
}
|
|
1176
|
+
return res;
|
|
1177
|
+
}
|
|
1178
|
+
// Switch the active account without re-authorizing at the PDS.
|
|
1179
|
+
//
|
|
1180
|
+
// The OAuth grant belongs to this server acting for the user, not to a
|
|
1181
|
+
// browser session — so once a browser has proven it owns an account, we
|
|
1182
|
+
// can re-issue its session cookie from the stored session. The accounts
|
|
1183
|
+
// cookie is the proof: it's encrypted with the server key, so a client
|
|
1184
|
+
// can't name a DID it never signed in as.
|
|
1185
|
+
if (url.pathname === '/auth/switch' && request.method === 'POST') {
|
|
1186
|
+
let did;
|
|
1187
|
+
try {
|
|
1188
|
+
const body = await request.json();
|
|
1189
|
+
did = typeof body?.did === 'string' ? body.did : undefined;
|
|
638
1190
|
}
|
|
1191
|
+
catch {
|
|
1192
|
+
return withCors(jsonError(400, 'Invalid JSON body', acceptEncoding));
|
|
1193
|
+
}
|
|
1194
|
+
if (!did)
|
|
1195
|
+
return withCors(jsonError(400, 'did required', acceptEncoding));
|
|
1196
|
+
const accounts = await parseAccountsCookie(request);
|
|
1197
|
+
const account = accounts.find((a) => a.did === did);
|
|
1198
|
+
if (!account)
|
|
1199
|
+
return withCors(jsonError(403, 'Not signed in as that account', acceptEncoding));
|
|
1200
|
+
if (!devMode && !(await getSession(account.did))) {
|
|
1201
|
+
// The stored grant is gone (revoked, or cleaned up). Drop it from the
|
|
1202
|
+
// list so the client stops offering it and can send the user through
|
|
1203
|
+
// a fresh sign-in instead.
|
|
1204
|
+
const isSecure = requestOrigin.startsWith('https');
|
|
1205
|
+
const remaining = withoutAccount(accounts, account.did);
|
|
1206
|
+
const res = withCors(jsonError(409, 'Session expired for that account', acceptEncoding));
|
|
1207
|
+
res.headers.append('Set-Cookie', remaining.length
|
|
1208
|
+
? accountsCookieHeader(await createAccountsCookie(remaining), isSecure)
|
|
1209
|
+
: clearAccountsCookieHeader());
|
|
1210
|
+
return res;
|
|
1211
|
+
}
|
|
1212
|
+
// Refresh the handle in passing — it may have changed while dormant.
|
|
1213
|
+
const handle = (await getRepoHandle(account.did)) ?? account.handle;
|
|
1214
|
+
const session = { did: account.did, handle };
|
|
1215
|
+
const isSecure = requestOrigin.startsWith('https');
|
|
1216
|
+
const res = withCors(json({ did: session.did, handle: session.handle }, 200, acceptEncoding));
|
|
1217
|
+
res.headers.append('Set-Cookie', sessionCookieHeader(await createSessionCookie(session), isSecure));
|
|
1218
|
+
res.headers.append('Set-Cookie', accountsCookieHeader(await createAccountsCookie(withAccount(accounts, session)), isSecure));
|
|
1219
|
+
return res;
|
|
639
1220
|
}
|
|
640
1221
|
// OAuth Token
|
|
641
|
-
if (url.pathname === '/oauth/token' &&
|
|
642
|
-
const rawBody = await
|
|
1222
|
+
if (url.pathname === '/oauth/token' && request.method === 'POST' && oauth) {
|
|
1223
|
+
const rawBody = await request.text();
|
|
643
1224
|
let body;
|
|
644
|
-
if (
|
|
1225
|
+
if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
|
|
645
1226
|
body = Object.fromEntries(new URLSearchParams(rawBody));
|
|
646
1227
|
}
|
|
647
1228
|
else {
|
|
648
1229
|
body = JSON.parse(rawBody);
|
|
649
1230
|
}
|
|
650
|
-
const dpopHeader =
|
|
1231
|
+
const dpopHeader = request.headers.get('dpop');
|
|
651
1232
|
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;
|
|
1233
|
+
return withCors(json({ error: 'invalid_request', error_description: 'DPoP header required' }, 400, acceptEncoding));
|
|
664
1234
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
jsonError(res, 400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
const session = await getSession(viewer.did);
|
|
672
|
-
if (!session) {
|
|
673
|
-
jsonError(res, 401, 'No PDS session for user');
|
|
674
|
-
return;
|
|
1235
|
+
try {
|
|
1236
|
+
const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`);
|
|
1237
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
675
1238
|
}
|
|
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;
|
|
1239
|
+
catch (err) {
|
|
1240
|
+
if (err instanceof OAuthError) {
|
|
1241
|
+
return withCors(json({ error: err.code, error_description: err.description }, err.status, acceptEncoding));
|
|
1242
|
+
}
|
|
1243
|
+
throw err;
|
|
687
1244
|
}
|
|
688
|
-
|
|
689
|
-
|
|
1245
|
+
}
|
|
1246
|
+
// POST /xrpc/dev.hatk.createRecord — proxy write to user's PDS
|
|
1247
|
+
if (url.pathname === coreXrpc('createRecord') && request.method === 'POST' && oauth) {
|
|
1248
|
+
if (!viewer)
|
|
1249
|
+
return withCors(jsonError(401, 'Authentication required', acceptEncoding));
|
|
1250
|
+
const body = JSON.parse(await request.text());
|
|
690
1251
|
try {
|
|
691
|
-
await
|
|
1252
|
+
const result = await pdsCreateRecord(oauth, viewer, body);
|
|
1253
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
692
1254
|
}
|
|
693
|
-
catch {
|
|
694
|
-
|
|
1255
|
+
catch (err) {
|
|
1256
|
+
if (err instanceof ScopeMissingProxyError)
|
|
1257
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
1258
|
+
if (err instanceof ProxyError)
|
|
1259
|
+
return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
|
|
1260
|
+
throw err;
|
|
695
1261
|
}
|
|
696
|
-
jsonResponse(res, result);
|
|
697
|
-
return;
|
|
698
1262
|
}
|
|
699
1263
|
// 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
|
|
1264
|
+
if (url.pathname === coreXrpc('deleteRecord') && request.method === 'POST' && oauth) {
|
|
1265
|
+
if (!viewer)
|
|
1266
|
+
return withCors(jsonError(401, 'Authentication required', acceptEncoding));
|
|
1267
|
+
const body = JSON.parse(await request.text());
|
|
724
1268
|
try {
|
|
725
|
-
const
|
|
726
|
-
|
|
1269
|
+
const result = await pdsDeleteRecord(oauth, viewer, body);
|
|
1270
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
727
1271
|
}
|
|
728
|
-
catch {
|
|
729
|
-
|
|
1272
|
+
catch (err) {
|
|
1273
|
+
if (err instanceof ScopeMissingProxyError)
|
|
1274
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
1275
|
+
if (err instanceof ProxyError)
|
|
1276
|
+
return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
|
|
1277
|
+
throw err;
|
|
730
1278
|
}
|
|
731
|
-
jsonResponse(res, result);
|
|
732
|
-
return;
|
|
733
1279
|
}
|
|
734
1280
|
// 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)
|
|
1281
|
+
if (url.pathname === coreXrpc('putRecord') && request.method === 'POST' && oauth) {
|
|
1282
|
+
if (!viewer)
|
|
1283
|
+
return withCors(jsonError(401, 'Authentication required', acceptEncoding));
|
|
1284
|
+
const body = JSON.parse(await request.text());
|
|
765
1285
|
try {
|
|
766
|
-
await
|
|
1286
|
+
const result = await pdsPutRecord(oauth, viewer, body);
|
|
1287
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
767
1288
|
}
|
|
768
|
-
catch {
|
|
769
|
-
|
|
1289
|
+
catch (err) {
|
|
1290
|
+
if (err instanceof ScopeMissingProxyError)
|
|
1291
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
1292
|
+
if (err instanceof ProxyError)
|
|
1293
|
+
return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
|
|
1294
|
+
throw err;
|
|
770
1295
|
}
|
|
771
|
-
jsonResponse(res, result);
|
|
772
|
-
return;
|
|
773
1296
|
}
|
|
774
1297
|
// 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;
|
|
1298
|
+
if (url.pathname === coreXrpc('uploadBlob') && request.method === 'POST' && oauth) {
|
|
1299
|
+
if (!viewer)
|
|
1300
|
+
return withCors(jsonError(401, 'Authentication required', acceptEncoding));
|
|
1301
|
+
const contentType = request.headers.get('content-type') || 'application/octet-stream';
|
|
1302
|
+
const rawBody = new Uint8Array(await request.arrayBuffer());
|
|
1303
|
+
try {
|
|
1304
|
+
const result = await pdsUploadBlob(oauth, viewer, rawBody, contentType);
|
|
1305
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
784
1306
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
return;
|
|
1307
|
+
catch (err) {
|
|
1308
|
+
if (err instanceof ScopeMissingProxyError)
|
|
1309
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
1310
|
+
if (err instanceof ProxyError)
|
|
1311
|
+
return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
|
|
1312
|
+
throw err;
|
|
792
1313
|
}
|
|
793
|
-
jsonResponse(res, pdsRes.body);
|
|
794
|
-
return;
|
|
795
1314
|
}
|
|
796
1315
|
// GET /admin — serve admin UI from hatk package
|
|
797
1316
|
if (url.pathname === '/admin' || url.pathname === '/admin/') {
|
|
798
1317
|
const adminPath = join(import.meta.dirname, '../public/admin.html');
|
|
799
1318
|
try {
|
|
800
|
-
|
|
801
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
802
|
-
res.end(content);
|
|
803
|
-
return;
|
|
1319
|
+
return withCors(await serveBundledAsset(adminPath, 'text/html', request, devMode));
|
|
804
1320
|
}
|
|
805
1321
|
catch {
|
|
806
|
-
|
|
807
|
-
res.end('Admin page not found');
|
|
808
|
-
return;
|
|
1322
|
+
return withCors(new Response('Admin page not found', { status: 404 }));
|
|
809
1323
|
}
|
|
810
1324
|
}
|
|
811
1325
|
// GET /admin/admin-auth.js — serve bundled OAuth client
|
|
812
1326
|
if (url.pathname === '/admin/admin-auth.js') {
|
|
813
1327
|
const authPath = join(import.meta.dirname, '../public/admin-auth.js');
|
|
814
1328
|
try {
|
|
815
|
-
|
|
816
|
-
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
817
|
-
res.end(content);
|
|
818
|
-
return;
|
|
1329
|
+
return withCors(await serveBundledAsset(authPath, 'application/javascript', request, devMode));
|
|
819
1330
|
}
|
|
820
1331
|
catch {
|
|
821
|
-
|
|
822
|
-
res.end('Not found');
|
|
823
|
-
return;
|
|
1332
|
+
return notFound();
|
|
824
1333
|
}
|
|
825
1334
|
}
|
|
826
1335
|
// GET /_health
|
|
827
1336
|
if (url.pathname === '/_health') {
|
|
828
|
-
|
|
829
|
-
return;
|
|
1337
|
+
return withCors(json({ status: 'ok' }, 200, acceptEncoding));
|
|
830
1338
|
}
|
|
831
1339
|
// GET /og/* — OpenGraph image routes
|
|
832
|
-
if (url.pathname.startsWith('/og/')
|
|
1340
|
+
if (url.pathname.startsWith('/og/')) {
|
|
833
1341
|
const png = await handleOpengraphRequest(url.pathname);
|
|
834
|
-
if (png)
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1342
|
+
if (png)
|
|
1343
|
+
return withCors(file(png, 'image/png', 'public, max-age=300'));
|
|
1344
|
+
}
|
|
1345
|
+
// GET/POST /xrpc/{nsid} — custom XRPC handlers (matched by full NSID from folder structure)
|
|
1346
|
+
// POST /xrpc/com.atproto.space.notifyWrite — a space authority telling us
|
|
1347
|
+
// one of its repos moved. Carries no records: it says look again, and the
|
|
1348
|
+
// read that follows uses our own credential like any other.
|
|
1349
|
+
//
|
|
1350
|
+
// Best-effort by protocol, so this answers before syncing and never lets
|
|
1351
|
+
// a sync failure become the authority's problem. A missed notice is
|
|
1352
|
+
// recovered by the reconcile sweep.
|
|
1353
|
+
if (url.pathname === '/xrpc/com.atproto.space.notifyWrite' && request.method === 'POST') {
|
|
1354
|
+
const serviceId = spaceServiceId();
|
|
1355
|
+
if (!serviceId)
|
|
1356
|
+
return withCors(jsonError(404, 'This instance receives no space notices', acceptEncoding));
|
|
1357
|
+
const notice = parseWriteNotice(await request.json().catch(() => null));
|
|
1358
|
+
if (!notice)
|
|
1359
|
+
return withCors(jsonError(400, 'Expected space, repo and repoRev', acceptEncoding));
|
|
1360
|
+
try {
|
|
1361
|
+
await verifyNotice(request.headers.get('authorization'), {
|
|
1362
|
+
iss: parseSpaceRef(notice.space).authority,
|
|
1363
|
+
aud: serviceId,
|
|
1364
|
+
lxm: 'com.atproto.space.notifyWrite',
|
|
838
1365
|
});
|
|
839
|
-
res.end(png);
|
|
840
|
-
return;
|
|
841
1366
|
}
|
|
1367
|
+
catch (err) {
|
|
1368
|
+
if (err instanceof NoticeError) {
|
|
1369
|
+
// Loud on this side too. A refused notice is otherwise visible
|
|
1370
|
+
// only in the authority's log, which belongs to somebody else —
|
|
1371
|
+
// so an appview silently falls back to the sweep and looks merely
|
|
1372
|
+
// slow rather than broken.
|
|
1373
|
+
emit('spaces', 'notice_refused', {
|
|
1374
|
+
space: notice.space,
|
|
1375
|
+
repo: notice.repo,
|
|
1376
|
+
status: err.status,
|
|
1377
|
+
reason: err.message,
|
|
1378
|
+
});
|
|
1379
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
1380
|
+
}
|
|
1381
|
+
throw err;
|
|
1382
|
+
}
|
|
1383
|
+
// Debounced per repo: one member writing a gallery sends a notice per
|
|
1384
|
+
// record, and each is an invitation to read the same repo again.
|
|
1385
|
+
scheduleNoticeSync(`${notice.space}|${notice.repo}`, () => handleWriteNotice(notice).then(() => { }));
|
|
1386
|
+
return withCors(json({}, 200, acceptEncoding));
|
|
842
1387
|
}
|
|
843
|
-
//
|
|
844
|
-
|
|
1388
|
+
// POST /xrpc/com.atproto.space.notifySpaceDeleted — the authority saying a
|
|
1389
|
+
// space is gone and every copy of it should go too.
|
|
1390
|
+
if (url.pathname === '/xrpc/com.atproto.space.notifySpaceDeleted' && request.method === 'POST') {
|
|
1391
|
+
const serviceId = spaceServiceId();
|
|
1392
|
+
if (!serviceId)
|
|
1393
|
+
return withCors(jsonError(404, 'This instance receives no space notices', acceptEncoding));
|
|
1394
|
+
const body = (await request.json().catch(() => null));
|
|
1395
|
+
const space = typeof body?.space === 'string' ? body.space : null;
|
|
1396
|
+
const ref = space ? parseSpaceRef(space) : null;
|
|
1397
|
+
if (!space || !ref)
|
|
1398
|
+
return withCors(jsonError(400, 'Expected a space', acceptEncoding));
|
|
1399
|
+
try {
|
|
1400
|
+
await verifyNotice(request.headers.get('authorization'), {
|
|
1401
|
+
iss: ref.authority,
|
|
1402
|
+
aud: serviceId,
|
|
1403
|
+
lxm: 'com.atproto.space.notifySpaceDeleted',
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
catch (err) {
|
|
1407
|
+
if (err instanceof NoticeError)
|
|
1408
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
1409
|
+
throw err;
|
|
1410
|
+
}
|
|
1411
|
+
await unwatchSpace(space);
|
|
1412
|
+
return withCors(json({}, 200, acceptEncoding));
|
|
1413
|
+
}
|
|
1414
|
+
if (url.pathname.startsWith('/xrpc/')) {
|
|
845
1415
|
const method = url.pathname.slice('/xrpc/'.length);
|
|
846
1416
|
const limit = parseInt(url.searchParams.get('limit') || '20');
|
|
847
1417
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
848
|
-
const params =
|
|
849
|
-
for (const [key, value] of url.searchParams) {
|
|
850
|
-
params[key] = value;
|
|
851
|
-
}
|
|
1418
|
+
const params = paramsFromSearch(url.searchParams);
|
|
852
1419
|
// Parse request body for POST (procedures)
|
|
853
1420
|
let input;
|
|
854
|
-
if (
|
|
1421
|
+
if (request.method === 'POST') {
|
|
855
1422
|
try {
|
|
856
|
-
input = JSON.parse(await
|
|
1423
|
+
input = JSON.parse(await request.text());
|
|
857
1424
|
}
|
|
858
1425
|
catch {
|
|
859
1426
|
input = {};
|
|
@@ -861,19 +1428,60 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
861
1428
|
}
|
|
862
1429
|
try {
|
|
863
1430
|
const result = await executeXrpc(method, params, cursor, limit, viewer, input);
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1431
|
+
// A handler may answer with a Response of its own, which is the only
|
|
1432
|
+
// way to serve anything but JSON — com.atproto.sync.getBlob is the
|
|
1433
|
+
// shape in the wild, and a lexicon declaring `encoding: "*/*"` has no
|
|
1434
|
+
// other way to honour it. Everything else is serialized as before.
|
|
1435
|
+
if (result instanceof Response)
|
|
1436
|
+
return withCors(result);
|
|
1437
|
+
if (result)
|
|
1438
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
868
1439
|
}
|
|
869
1440
|
catch (err) {
|
|
1441
|
+
if (err instanceof ScopeMissingProxyError)
|
|
1442
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
870
1443
|
if (err instanceof InvalidRequestError) {
|
|
871
|
-
jsonError(
|
|
872
|
-
return;
|
|
1444
|
+
return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding));
|
|
873
1445
|
}
|
|
874
1446
|
throw err;
|
|
875
1447
|
}
|
|
876
1448
|
}
|
|
1449
|
+
// GET /space-blob?space=&repo=&cid= — a blob inside a permissioned space.
|
|
1450
|
+
// Unlike /blob/ below it is not dev-only, and every request is authorized
|
|
1451
|
+
// with the viewer's own credential for the space: a space blob has no
|
|
1452
|
+
// public URL by design. `if-none-match` is passed along because the
|
|
1453
|
+
// answer may be that the viewer already holds it.
|
|
1454
|
+
if (url.pathname === '/space-blob') {
|
|
1455
|
+
const parsed = parseSpaceBlobRequest(url.searchParams);
|
|
1456
|
+
if (!parsed)
|
|
1457
|
+
return withCors(jsonError(400, 'Expected space, repo and cid', acceptEncoding));
|
|
1458
|
+
return withCors(await serveSpaceBlob(oauth, viewer, parsed, request.headers.get('if-none-match')));
|
|
1459
|
+
}
|
|
1460
|
+
// GET /blob/:did/:cid — dev-only image proxy (see blobUrl in xrpc.ts).
|
|
1461
|
+
// A PDS serves getBlob as an attachment with nosniff, which a browser
|
|
1462
|
+
// refuses to render in <img>; re-serving it from here drops that and
|
|
1463
|
+
// lets one origin cover repos on any number of local PDSes.
|
|
1464
|
+
if (url.pathname.startsWith('/blob/') && isLocalRelay()) {
|
|
1465
|
+
const [did, cid] = url.pathname.slice('/blob/'.length).split('/');
|
|
1466
|
+
if (!did || !cid)
|
|
1467
|
+
return withCors(jsonError(400, 'Expected /blob/:did/:cid', acceptEncoding));
|
|
1468
|
+
try {
|
|
1469
|
+
const pds = await pdsFor(did);
|
|
1470
|
+
const upstream = await fetch(`${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`);
|
|
1471
|
+
if (!upstream.ok)
|
|
1472
|
+
return withCors(jsonError(upstream.status, 'Blob not found', acceptEncoding));
|
|
1473
|
+
return withCors(new Response(upstream.body, {
|
|
1474
|
+
status: 200,
|
|
1475
|
+
headers: {
|
|
1476
|
+
'content-type': upstream.headers.get('content-type') || 'application/octet-stream',
|
|
1477
|
+
'cache-control': 'public, max-age=3600',
|
|
1478
|
+
},
|
|
1479
|
+
}));
|
|
1480
|
+
}
|
|
1481
|
+
catch (err) {
|
|
1482
|
+
return withCors(jsonError(502, `Blob fetch failed: ${err.message}`, acceptEncoding));
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
877
1485
|
// GET /robots.txt — serve from user's public dir or fall back to hatk default
|
|
878
1486
|
if (url.pathname === '/robots.txt') {
|
|
879
1487
|
const userRobots = publicDir ? join(publicDir, 'robots.txt') : null;
|
|
@@ -881,9 +1489,7 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
881
1489
|
const robotsPath = userRobots && existsSync(userRobots) ? userRobots : defaultRobots;
|
|
882
1490
|
try {
|
|
883
1491
|
const content = await readFile(robotsPath);
|
|
884
|
-
|
|
885
|
-
res.end(content);
|
|
886
|
-
return;
|
|
1492
|
+
return withCors(file(content, 'text/plain'));
|
|
887
1493
|
}
|
|
888
1494
|
catch {
|
|
889
1495
|
// fall through
|
|
@@ -894,38 +1500,45 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
894
1500
|
try {
|
|
895
1501
|
const filePath = join(publicDir, url.pathname === '/' ? 'index.html' : url.pathname);
|
|
896
1502
|
const content = await readFile(filePath);
|
|
897
|
-
|
|
898
|
-
res.end(content);
|
|
899
|
-
return;
|
|
1503
|
+
return withCors(file(content, MIME[extname(filePath)] || 'text/plain'));
|
|
900
1504
|
}
|
|
901
1505
|
catch { }
|
|
902
|
-
// SPA fallback — serve index.html for client-side routes
|
|
1506
|
+
// SSR or SPA fallback — serve index.html for client-side routes
|
|
903
1507
|
try {
|
|
904
|
-
|
|
905
|
-
// Inject OG meta tags for shareable routes
|
|
1508
|
+
const template = await readFile(join(publicDir, 'index.html'), 'utf-8');
|
|
906
1509
|
const ogMeta = buildOgMeta(url.pathname, requestOrigin);
|
|
1510
|
+
globalThis.__hatk_viewer = viewer;
|
|
1511
|
+
let renderedHtml;
|
|
1512
|
+
try {
|
|
1513
|
+
renderedHtml = await renderPage(template, request, ogMeta);
|
|
1514
|
+
}
|
|
1515
|
+
finally {
|
|
1516
|
+
;
|
|
1517
|
+
globalThis.__hatk_viewer = null;
|
|
1518
|
+
}
|
|
1519
|
+
if (renderedHtml) {
|
|
1520
|
+
return withCors(file(Buffer.from(renderedHtml), 'text/html'));
|
|
1521
|
+
}
|
|
1522
|
+
// SPA fallback — inject OG meta only
|
|
1523
|
+
let html = template;
|
|
907
1524
|
if (ogMeta) {
|
|
908
|
-
|
|
1525
|
+
html = html.replace('</head>', `${ogMeta}\n</head>`);
|
|
909
1526
|
}
|
|
910
|
-
|
|
911
|
-
res.end(content);
|
|
912
|
-
return;
|
|
1527
|
+
return withCors(file(Buffer.from(html), 'text/html'));
|
|
913
1528
|
}
|
|
914
1529
|
catch { }
|
|
915
1530
|
}
|
|
916
|
-
|
|
917
|
-
res.end('Not Found');
|
|
1531
|
+
return notFound();
|
|
918
1532
|
}
|
|
919
1533
|
catch (err) {
|
|
920
1534
|
error = err.message;
|
|
921
|
-
jsonError(
|
|
1535
|
+
return withCors(jsonError(500, err.message, acceptEncoding));
|
|
922
1536
|
}
|
|
923
1537
|
finally {
|
|
924
|
-
if (isXrpc || isAdmin) {
|
|
1538
|
+
if ((isXrpc || isAdmin) && elapsed) {
|
|
925
1539
|
emit('server', 'request', {
|
|
926
|
-
method:
|
|
1540
|
+
method: request.method,
|
|
927
1541
|
path: url.pathname,
|
|
928
|
-
status_code: res.statusCode,
|
|
929
1542
|
duration_ms: elapsed(),
|
|
930
1543
|
collection: url.searchParams.get('collection') || undefined,
|
|
931
1544
|
query: url.searchParams.get('q') || undefined,
|
|
@@ -933,135 +1546,10 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
933
1546
|
});
|
|
934
1547
|
}
|
|
935
1548
|
}
|
|
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))));
|
|
1549
|
+
};
|
|
962
1550
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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;
|
|
1018
|
-
}
|
|
1019
|
-
/** Proxy a raw binary request to the user's PDS with DPoP + nonce retry + token refresh. */
|
|
1020
|
-
async function proxyToPdsRaw(oauthConfig, session, pdsUrl, body, contentType) {
|
|
1021
|
-
const serverKey = await getServerKey('appview-oauth-key');
|
|
1022
|
-
const privateJwk = JSON.parse(serverKey.privateKey);
|
|
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;
|
|
1551
|
+
// Backward-compatible wrapper
|
|
1552
|
+
export function startServer(port, collections, publicDir, oauth, admins = [], resolveViewer, onResync) {
|
|
1553
|
+
const handler = createHandler({ collections, publicDir, oauth, admins, resolveViewer, onResync });
|
|
1554
|
+
return serve(handler, port);
|
|
1067
1555
|
}
|