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