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