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