@hatk/hatk 0.0.1-alpha.3 → 0.0.1-alpha.30
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/backfill.d.ts +60 -1
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +166 -32
- 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 +356 -123
- 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 +18 -0
- package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
- package/dist/database/adapters/sqlite-search.js +38 -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 +87 -0
- package/dist/database/db.d.ts +149 -0
- package/dist/database/db.d.ts.map +1 -0
- package/dist/database/db.js +1460 -0
- package/dist/database/dialect.d.ts +45 -0
- package/dist/database/dialect.d.ts.map +1 -0
- package/dist/database/dialect.js +72 -0
- package/dist/database/fts.d.ts +24 -0
- package/dist/database/fts.d.ts.map +1 -0
- package/dist/database/fts.js +777 -0
- 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 +44 -0
- package/dist/database/ports.d.ts.map +1 -0
- package/dist/database/ports.js +1 -0
- package/dist/database/schema.d.ts +60 -0
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/database/schema.js +388 -0
- package/dist/db.d.ts +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +4 -38
- 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 +42 -3
- package/dist/fts.d.ts.map +1 -1
- package/dist/fts.js +5 -0
- package/dist/hooks.d.ts +22 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +75 -0
- package/dist/hydrate.js +1 -1
- package/dist/indexer.d.ts +20 -0
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +48 -6
- package/dist/labels.d.ts +34 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +63 -3
- 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 +131 -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.map +1 -1
- package/dist/oauth/db.js +41 -15
- package/dist/oauth/server.d.ts +2 -0
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +102 -7
- 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 +103 -5
- 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/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/schema.d.ts +8 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +29 -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 +59 -0
- package/dist/server.d.ts +26 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +487 -616
- package/dist/setup.d.ts +28 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +50 -3
- 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 +252 -66
- package/dist/xrpc.d.ts +36 -0
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +124 -3
- package/package.json +12 -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 "./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')) {
|
|
@@ -192,166 +285,142 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
192
285
|
columns: schema?.columns.map((col) => ({
|
|
193
286
|
name: col.name,
|
|
194
287
|
originalName: col.originalName,
|
|
195
|
-
type: col.
|
|
288
|
+
type: col.sqlType,
|
|
196
289
|
required: col.notNull,
|
|
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;
|
|
@@ -450,30 +515,29 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
450
515
|
}
|
|
451
516
|
for (const did of repoList) {
|
|
452
517
|
await setRepoStatus(did, 'pending');
|
|
453
|
-
triggerAutoBackfill(did);
|
|
454
518
|
}
|
|
455
|
-
|
|
456
|
-
|
|
519
|
+
if (config.onResync)
|
|
520
|
+
config.onResync();
|
|
521
|
+
return withCors(json({ resyncing: repoList.length }, 200, acceptEncoding));
|
|
457
522
|
}
|
|
458
523
|
// POST /admin/repos/remove — remove DIDs from tracking
|
|
459
|
-
if (url.pathname === '/admin/repos/remove' &&
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
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));
|
|
467
531
|
for (const did of dids) {
|
|
468
532
|
await querySQL(`DELETE FROM _repos WHERE did = $1`, [did]);
|
|
469
533
|
}
|
|
470
|
-
|
|
471
|
-
return;
|
|
534
|
+
return withCors(json({ removed: dids.length }, 200, acceptEncoding));
|
|
472
535
|
}
|
|
473
536
|
// GET /admin/info — aggregate status + db size + collection counts
|
|
474
537
|
if (url.pathname === '/admin/info') {
|
|
475
|
-
|
|
476
|
-
|
|
538
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
539
|
+
if (denied)
|
|
540
|
+
return denied;
|
|
477
541
|
const rows = await querySQL(`SELECT status, COUNT(*)::INTEGER as count FROM _repos GROUP BY status`);
|
|
478
542
|
const counts = {};
|
|
479
543
|
for (const row of rows)
|
|
@@ -481,324 +545,272 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
481
545
|
const sizeRows = await querySQL(`SELECT database_size, memory_usage, memory_limit FROM pragma_database_size()`);
|
|
482
546
|
const dbInfo = sizeRows[0] ?? {};
|
|
483
547
|
const collectionCounts = await getCollectionCounts();
|
|
484
|
-
|
|
485
|
-
|
|
548
|
+
const mem = process.memoryUsage();
|
|
549
|
+
const node = {
|
|
550
|
+
rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MiB`,
|
|
551
|
+
heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)} MiB`,
|
|
552
|
+
heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`,
|
|
553
|
+
external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`,
|
|
554
|
+
};
|
|
555
|
+
return withCors(json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts }, 200, acceptEncoding));
|
|
486
556
|
}
|
|
487
557
|
// GET /admin/info/:did — repo status info
|
|
488
558
|
if (url.pathname.startsWith('/admin/info/did:')) {
|
|
489
|
-
|
|
490
|
-
|
|
559
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
560
|
+
if (denied)
|
|
561
|
+
return denied;
|
|
491
562
|
const did = url.pathname.slice('/admin/info/'.length);
|
|
492
563
|
const status = await getRepoStatus(did);
|
|
493
|
-
if (!status)
|
|
494
|
-
jsonError(
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
564
|
+
if (!status)
|
|
565
|
+
return withCors(jsonError(404, 'Repo not found', acceptEncoding));
|
|
497
566
|
const retryInfo = await getRepoRetryInfo(did);
|
|
498
|
-
|
|
567
|
+
return withCors(json({
|
|
499
568
|
did,
|
|
500
569
|
status,
|
|
501
570
|
retry_count: retryInfo?.retryCount ?? 0,
|
|
502
571
|
retry_after: retryInfo?.retryAfter ?? 0,
|
|
503
|
-
});
|
|
504
|
-
return;
|
|
572
|
+
}, 200, acceptEncoding));
|
|
505
573
|
}
|
|
506
574
|
// GET /admin/repos — paginated repo listing
|
|
507
|
-
if (url.pathname === '/admin/repos' &&
|
|
508
|
-
|
|
509
|
-
|
|
575
|
+
if (url.pathname === '/admin/repos' && request.method === 'GET') {
|
|
576
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
577
|
+
if (denied)
|
|
578
|
+
return denied;
|
|
510
579
|
const limit = parseInt(url.searchParams.get('limit') || '50');
|
|
511
580
|
const offset = parseInt(url.searchParams.get('offset') || '0');
|
|
512
581
|
const status = url.searchParams.get('status') || undefined;
|
|
513
582
|
const q = url.searchParams.get('q') || undefined;
|
|
514
583
|
const result = await listReposPaginated({ limit, offset, status, q });
|
|
515
|
-
|
|
516
|
-
return;
|
|
584
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
517
585
|
}
|
|
518
586
|
// GET /admin/schema — full DuckDB DDL dump + lexicons
|
|
519
587
|
if (url.pathname === '/admin/schema') {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
588
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
589
|
+
if (denied)
|
|
590
|
+
return denied;
|
|
591
|
+
const { getAllLexicons } = await import("./database/schema.js");
|
|
523
592
|
const ddl = await getSchemaDump();
|
|
524
|
-
|
|
525
|
-
return;
|
|
593
|
+
return withCors(json({ ddl, lexicons: getAllLexicons() }, 200, acceptEncoding));
|
|
526
594
|
}
|
|
527
595
|
// ── Public Repo Endpoints (used by hatk clients for auto-sync) ──
|
|
528
596
|
// POST /repos/add — enqueue DIDs for backfill (public)
|
|
529
|
-
if (url.pathname === '/repos/add' &&
|
|
530
|
-
const { dids } = JSON.parse(await
|
|
531
|
-
if (!Array.isArray(dids))
|
|
532
|
-
jsonError(
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
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));
|
|
535
601
|
for (const did of dids) {
|
|
536
602
|
await setRepoStatus(did, 'pending');
|
|
537
603
|
triggerAutoBackfill(did);
|
|
538
604
|
}
|
|
539
|
-
|
|
540
|
-
return;
|
|
605
|
+
return withCors(json({ added: dids.length }, 200, acceptEncoding));
|
|
541
606
|
}
|
|
542
607
|
// GET /info/:did — repo status info (public)
|
|
543
608
|
if (url.pathname.startsWith('/info/did:')) {
|
|
544
609
|
const did = url.pathname.slice('/info/'.length);
|
|
545
610
|
const status = await getRepoStatus(did);
|
|
546
|
-
if (!status)
|
|
547
|
-
jsonError(
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
611
|
+
if (!status)
|
|
612
|
+
return withCors(jsonError(404, 'Repo not found', acceptEncoding));
|
|
550
613
|
const retryInfo = await getRepoRetryInfo(did);
|
|
551
|
-
|
|
614
|
+
return withCors(json({
|
|
552
615
|
did,
|
|
553
616
|
status,
|
|
554
617
|
retry_count: retryInfo?.retryCount ?? 0,
|
|
555
618
|
retry_after: retryInfo?.retryAfter ?? 0,
|
|
556
|
-
});
|
|
557
|
-
return;
|
|
619
|
+
}, 200, acceptEncoding));
|
|
558
620
|
}
|
|
559
621
|
// --- OAuth Endpoints ---
|
|
560
622
|
// OAuth well-known endpoints
|
|
561
623
|
if (url.pathname === '/.well-known/oauth-authorization-server' && oauth) {
|
|
562
|
-
|
|
563
|
-
return;
|
|
624
|
+
return withCors(json(getAuthServerMetadata(oauth.issuer, oauth), 200, acceptEncoding));
|
|
564
625
|
}
|
|
565
626
|
if (url.pathname === '/.well-known/oauth-protected-resource' && oauth) {
|
|
566
|
-
|
|
567
|
-
return;
|
|
627
|
+
return withCors(json(getProtectedResourceMetadata(oauth.issuer, oauth), 200, acceptEncoding));
|
|
568
628
|
}
|
|
569
629
|
if (url.pathname === '/oauth/jwks' && oauth) {
|
|
570
|
-
|
|
571
|
-
return;
|
|
630
|
+
return withCors(json(getJwks(), 200, acceptEncoding));
|
|
572
631
|
}
|
|
573
632
|
if ((url.pathname === '/oauth/client-metadata.json' || url.pathname === '/oauth-client-metadata.json') && oauth) {
|
|
574
|
-
|
|
575
|
-
|
|
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
|
+
}
|
|
576
663
|
}
|
|
577
664
|
// OAuth PAR
|
|
578
|
-
if (url.pathname === '/oauth/par' &&
|
|
579
|
-
const rawBody = await
|
|
665
|
+
if (url.pathname === '/oauth/par' && request.method === 'POST' && oauth) {
|
|
666
|
+
const rawBody = await request.text();
|
|
580
667
|
let body;
|
|
581
|
-
if (
|
|
668
|
+
if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
|
|
582
669
|
body = Object.fromEntries(new URLSearchParams(rawBody));
|
|
583
670
|
}
|
|
584
671
|
else {
|
|
585
672
|
body = JSON.parse(rawBody);
|
|
586
673
|
}
|
|
587
|
-
const dpopHeader =
|
|
588
|
-
if (!dpopHeader)
|
|
589
|
-
jsonError(
|
|
590
|
-
|
|
674
|
+
const dpopHeader = request.headers.get('dpop');
|
|
675
|
+
if (!dpopHeader)
|
|
676
|
+
return withCors(jsonError(400, 'DPoP header required', acceptEncoding));
|
|
677
|
+
try {
|
|
678
|
+
const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`);
|
|
679
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
680
|
+
}
|
|
681
|
+
catch (err) {
|
|
682
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
683
|
+
return withCors(jsonError(400, message, acceptEncoding));
|
|
591
684
|
}
|
|
592
|
-
const result = await handlePar(oauth, body, dpopHeader, `${requestOrigin}/oauth/par`);
|
|
593
|
-
jsonResponse(res, result);
|
|
594
|
-
return;
|
|
595
685
|
}
|
|
596
686
|
// OAuth Authorize
|
|
597
687
|
if (url.pathname === '/oauth/authorize' && oauth) {
|
|
598
688
|
const requestUri = url.searchParams.get('request_uri');
|
|
599
|
-
if (!requestUri)
|
|
600
|
-
jsonError(
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
const redirectUrl = buildAuthorizeRedirect(oauth, request);
|
|
609
|
-
res.writeHead(302, { Location: redirectUrl });
|
|
610
|
-
res.end();
|
|
611
|
-
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 } });
|
|
612
696
|
}
|
|
613
697
|
// OAuth Callback (PDS redirects here after user approves)
|
|
614
698
|
// Skip if iss matches our own issuer — that's the client-side redirect, let the SPA handle it
|
|
615
699
|
if (url.pathname === '/oauth/callback' && oauth) {
|
|
616
700
|
const iss = url.searchParams.get('iss');
|
|
617
|
-
if (iss
|
|
618
|
-
// Client-side callback — fall through to SPA
|
|
619
|
-
}
|
|
620
|
-
else {
|
|
701
|
+
if (iss !== oauth.issuer) {
|
|
621
702
|
const code = url.searchParams.get('code');
|
|
622
703
|
const state = url.searchParams.get('state');
|
|
623
|
-
if (!code)
|
|
624
|
-
jsonError(
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
704
|
+
if (!code)
|
|
705
|
+
return withCors(jsonError(400, 'Missing code', acceptEncoding));
|
|
627
706
|
const result = await handleCallback(oauth, code, state, iss);
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
+
});
|
|
631
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
|
+
});
|
|
632
727
|
}
|
|
633
728
|
// OAuth Token
|
|
634
|
-
if (url.pathname === '/oauth/token' &&
|
|
635
|
-
const rawBody = await
|
|
729
|
+
if (url.pathname === '/oauth/token' && request.method === 'POST' && oauth) {
|
|
730
|
+
const rawBody = await request.text();
|
|
636
731
|
let body;
|
|
637
|
-
if (
|
|
732
|
+
if (request.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) {
|
|
638
733
|
body = Object.fromEntries(new URLSearchParams(rawBody));
|
|
639
734
|
}
|
|
640
735
|
else {
|
|
641
736
|
body = JSON.parse(rawBody);
|
|
642
737
|
}
|
|
643
|
-
const dpopHeader =
|
|
644
|
-
if (!dpopHeader)
|
|
645
|
-
jsonError(
|
|
646
|
-
return;
|
|
647
|
-
}
|
|
738
|
+
const dpopHeader = request.headers.get('dpop');
|
|
739
|
+
if (!dpopHeader)
|
|
740
|
+
return withCors(jsonError(400, 'DPoP header required', acceptEncoding));
|
|
648
741
|
const result = await handleToken(oauth, body, dpopHeader, `${requestOrigin}/oauth/token`);
|
|
649
|
-
|
|
650
|
-
return;
|
|
742
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
651
743
|
}
|
|
652
744
|
// POST /xrpc/dev.hatk.createRecord — proxy write to user's PDS
|
|
653
|
-
if (url.pathname === coreXrpc('createRecord') &&
|
|
654
|
-
if (!viewer)
|
|
655
|
-
jsonError(
|
|
656
|
-
|
|
657
|
-
}
|
|
658
|
-
const body = JSON.parse(await readBody(req));
|
|
659
|
-
const validationError = validateRecord(getLexiconArray(), body.collection, body.record);
|
|
660
|
-
if (validationError) {
|
|
661
|
-
jsonError(res, 400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
const session = await getSession(viewer.did);
|
|
665
|
-
if (!session) {
|
|
666
|
-
jsonError(res, 401, 'No PDS session for user');
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.createRecord`;
|
|
670
|
-
const pdsBody = {
|
|
671
|
-
repo: viewer.did,
|
|
672
|
-
collection: body.collection,
|
|
673
|
-
rkey: body.rkey,
|
|
674
|
-
record: body.record,
|
|
675
|
-
};
|
|
676
|
-
const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody);
|
|
677
|
-
if (!pdsRes.ok) {
|
|
678
|
-
jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS write failed');
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
const result = pdsRes.body;
|
|
682
|
-
// 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());
|
|
683
749
|
try {
|
|
684
|
-
await
|
|
750
|
+
const result = await pdsCreateRecord(oauth, viewer, body);
|
|
751
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
685
752
|
}
|
|
686
|
-
catch {
|
|
687
|
-
|
|
753
|
+
catch (err) {
|
|
754
|
+
if (err instanceof ProxyError)
|
|
755
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
756
|
+
throw err;
|
|
688
757
|
}
|
|
689
|
-
jsonResponse(res, result);
|
|
690
|
-
return;
|
|
691
758
|
}
|
|
692
759
|
// POST /xrpc/dev.hatk.deleteRecord — proxy delete to user's PDS
|
|
693
|
-
if (url.pathname === coreXrpc('deleteRecord') &&
|
|
694
|
-
if (!viewer)
|
|
695
|
-
jsonError(
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
const body = JSON.parse(await readBody(req));
|
|
699
|
-
const session = await getSession(viewer.did);
|
|
700
|
-
if (!session) {
|
|
701
|
-
jsonError(res, 401, 'No PDS session for user');
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.deleteRecord`;
|
|
705
|
-
const pdsBody = {
|
|
706
|
-
repo: viewer.did,
|
|
707
|
-
collection: body.collection,
|
|
708
|
-
rkey: body.rkey,
|
|
709
|
-
};
|
|
710
|
-
const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody);
|
|
711
|
-
if (!pdsRes.ok) {
|
|
712
|
-
jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS delete failed');
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
const result = pdsRes.body;
|
|
716
|
-
// 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());
|
|
717
764
|
try {
|
|
718
|
-
const
|
|
719
|
-
|
|
765
|
+
const result = await pdsDeleteRecord(oauth, viewer, body);
|
|
766
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
720
767
|
}
|
|
721
|
-
catch {
|
|
722
|
-
|
|
768
|
+
catch (err) {
|
|
769
|
+
if (err instanceof ProxyError)
|
|
770
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
771
|
+
throw err;
|
|
723
772
|
}
|
|
724
|
-
jsonResponse(res, result);
|
|
725
|
-
return;
|
|
726
773
|
}
|
|
727
774
|
// POST /xrpc/dev.hatk.putRecord — proxy create-or-update to user's PDS
|
|
728
|
-
if (url.pathname === coreXrpc('putRecord') &&
|
|
729
|
-
if (!viewer)
|
|
730
|
-
jsonError(
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
const body = JSON.parse(await readBody(req));
|
|
734
|
-
const validationError = validateRecord(getLexiconArray(), body.collection, body.record);
|
|
735
|
-
if (validationError) {
|
|
736
|
-
jsonError(res, 400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`);
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
const session = await getSession(viewer.did);
|
|
740
|
-
if (!session) {
|
|
741
|
-
jsonError(res, 401, 'No PDS session for user');
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
const pdsUrl = `${session.pds_endpoint}/xrpc/com.atproto.repo.putRecord`;
|
|
745
|
-
const pdsBody = {
|
|
746
|
-
repo: viewer.did,
|
|
747
|
-
collection: body.collection,
|
|
748
|
-
rkey: body.rkey,
|
|
749
|
-
record: body.record,
|
|
750
|
-
};
|
|
751
|
-
const pdsRes = await proxyToPds(oauth, session, 'POST', pdsUrl, pdsBody);
|
|
752
|
-
if (!pdsRes.ok) {
|
|
753
|
-
jsonError(res, pdsRes.status, pdsRes.body.error || 'PDS write failed');
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
const result = pdsRes.body;
|
|
757
|
-
// 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());
|
|
758
779
|
try {
|
|
759
|
-
await
|
|
780
|
+
const result = await pdsPutRecord(oauth, viewer, body);
|
|
781
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
760
782
|
}
|
|
761
|
-
catch {
|
|
762
|
-
|
|
783
|
+
catch (err) {
|
|
784
|
+
if (err instanceof ProxyError)
|
|
785
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
786
|
+
throw err;
|
|
763
787
|
}
|
|
764
|
-
jsonResponse(res, result);
|
|
765
|
-
return;
|
|
766
788
|
}
|
|
767
789
|
// POST /xrpc/dev.hatk.uploadBlob — proxy blob upload to user's PDS
|
|
768
|
-
if (url.pathname === coreXrpc('uploadBlob') &&
|
|
769
|
-
if (!viewer)
|
|
770
|
-
jsonError(
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
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));
|
|
777
798
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
if (!pdsRes.ok) {
|
|
783
|
-
jsonError(res, pdsRes.status, String(pdsRes.body.error || 'PDS upload failed'));
|
|
784
|
-
return;
|
|
799
|
+
catch (err) {
|
|
800
|
+
if (err instanceof ProxyError)
|
|
801
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
802
|
+
throw err;
|
|
785
803
|
}
|
|
786
|
-
jsonResponse(res, pdsRes.body);
|
|
787
|
-
return;
|
|
788
804
|
}
|
|
789
805
|
// GET /admin — serve admin UI from hatk package
|
|
790
806
|
if (url.pathname === '/admin' || url.pathname === '/admin/') {
|
|
791
807
|
const adminPath = join(import.meta.dirname, '../public/admin.html');
|
|
792
808
|
try {
|
|
793
809
|
const content = await readFile(adminPath);
|
|
794
|
-
|
|
795
|
-
res.end(content);
|
|
796
|
-
return;
|
|
810
|
+
return withCors(file(content, 'text/html'));
|
|
797
811
|
}
|
|
798
812
|
catch {
|
|
799
|
-
|
|
800
|
-
res.end('Admin page not found');
|
|
801
|
-
return;
|
|
813
|
+
return withCors(new Response('Admin page not found', { status: 404 }));
|
|
802
814
|
}
|
|
803
815
|
}
|
|
804
816
|
// GET /admin/admin-auth.js — serve bundled OAuth client
|
|
@@ -806,35 +818,24 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
806
818
|
const authPath = join(import.meta.dirname, '../public/admin-auth.js');
|
|
807
819
|
try {
|
|
808
820
|
const content = await readFile(authPath);
|
|
809
|
-
|
|
810
|
-
res.end(content);
|
|
811
|
-
return;
|
|
821
|
+
return withCors(file(content, 'application/javascript'));
|
|
812
822
|
}
|
|
813
823
|
catch {
|
|
814
|
-
|
|
815
|
-
res.end('Not found');
|
|
816
|
-
return;
|
|
824
|
+
return notFound();
|
|
817
825
|
}
|
|
818
826
|
}
|
|
819
827
|
// GET /_health
|
|
820
828
|
if (url.pathname === '/_health') {
|
|
821
|
-
|
|
822
|
-
return;
|
|
829
|
+
return withCors(json({ status: 'ok' }, 200, acceptEncoding));
|
|
823
830
|
}
|
|
824
831
|
// GET /og/* — OpenGraph image routes
|
|
825
|
-
if (url.pathname.startsWith('/og/')
|
|
832
|
+
if (url.pathname.startsWith('/og/')) {
|
|
826
833
|
const png = await handleOpengraphRequest(url.pathname);
|
|
827
|
-
if (png)
|
|
828
|
-
|
|
829
|
-
'Content-Type': 'image/png',
|
|
830
|
-
'Cache-Control': 'public, max-age=300',
|
|
831
|
-
});
|
|
832
|
-
res.end(png);
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
834
|
+
if (png)
|
|
835
|
+
return withCors(file(png, 'image/png', 'public, max-age=300'));
|
|
835
836
|
}
|
|
836
837
|
// GET/POST /xrpc/{nsid} — custom XRPC handlers (matched by full NSID from folder structure)
|
|
837
|
-
if (url.pathname.startsWith('/xrpc/')
|
|
838
|
+
if (url.pathname.startsWith('/xrpc/')) {
|
|
838
839
|
const method = url.pathname.slice('/xrpc/'.length);
|
|
839
840
|
const limit = parseInt(url.searchParams.get('limit') || '20');
|
|
840
841
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
@@ -844,9 +845,9 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
844
845
|
}
|
|
845
846
|
// Parse request body for POST (procedures)
|
|
846
847
|
let input;
|
|
847
|
-
if (
|
|
848
|
+
if (request.method === 'POST') {
|
|
848
849
|
try {
|
|
849
|
-
input = JSON.parse(await
|
|
850
|
+
input = JSON.parse(await request.text());
|
|
850
851
|
}
|
|
851
852
|
catch {
|
|
852
853
|
input = {};
|
|
@@ -854,15 +855,12 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
854
855
|
}
|
|
855
856
|
try {
|
|
856
857
|
const result = await executeXrpc(method, params, cursor, limit, viewer, input);
|
|
857
|
-
if (result)
|
|
858
|
-
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
858
|
+
if (result)
|
|
859
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
861
860
|
}
|
|
862
861
|
catch (err) {
|
|
863
862
|
if (err instanceof InvalidRequestError) {
|
|
864
|
-
jsonError(
|
|
865
|
-
return;
|
|
863
|
+
return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding));
|
|
866
864
|
}
|
|
867
865
|
throw err;
|
|
868
866
|
}
|
|
@@ -874,9 +872,7 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
874
872
|
const robotsPath = userRobots && existsSync(userRobots) ? userRobots : defaultRobots;
|
|
875
873
|
try {
|
|
876
874
|
const content = await readFile(robotsPath);
|
|
877
|
-
|
|
878
|
-
res.end(content);
|
|
879
|
-
return;
|
|
875
|
+
return withCors(file(content, 'text/plain'));
|
|
880
876
|
}
|
|
881
877
|
catch {
|
|
882
878
|
// fall through
|
|
@@ -887,38 +883,38 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
887
883
|
try {
|
|
888
884
|
const filePath = join(publicDir, url.pathname === '/' ? 'index.html' : url.pathname);
|
|
889
885
|
const content = await readFile(filePath);
|
|
890
|
-
|
|
891
|
-
res.end(content);
|
|
892
|
-
return;
|
|
886
|
+
return withCors(file(content, MIME[extname(filePath)] || 'text/plain'));
|
|
893
887
|
}
|
|
894
888
|
catch { }
|
|
895
|
-
// SPA fallback — serve index.html for client-side routes
|
|
889
|
+
// SSR or SPA fallback — serve index.html for client-side routes
|
|
896
890
|
try {
|
|
897
|
-
|
|
898
|
-
// Inject OG meta tags for shareable routes
|
|
891
|
+
const template = await readFile(join(publicDir, 'index.html'), 'utf-8');
|
|
899
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;
|
|
900
900
|
if (ogMeta) {
|
|
901
|
-
|
|
901
|
+
html = html.replace('</head>', `${ogMeta}\n</head>`);
|
|
902
902
|
}
|
|
903
|
-
|
|
904
|
-
res.end(content);
|
|
905
|
-
return;
|
|
903
|
+
return withCors(file(Buffer.from(html), 'text/html'));
|
|
906
904
|
}
|
|
907
905
|
catch { }
|
|
908
906
|
}
|
|
909
|
-
|
|
910
|
-
res.end('Not Found');
|
|
907
|
+
return notFound();
|
|
911
908
|
}
|
|
912
909
|
catch (err) {
|
|
913
910
|
error = err.message;
|
|
914
|
-
jsonError(
|
|
911
|
+
return withCors(jsonError(500, err.message, acceptEncoding));
|
|
915
912
|
}
|
|
916
913
|
finally {
|
|
917
|
-
if (isXrpc || isAdmin) {
|
|
914
|
+
if ((isXrpc || isAdmin) && elapsed) {
|
|
918
915
|
emit('server', 'request', {
|
|
919
|
-
method:
|
|
916
|
+
method: request.method,
|
|
920
917
|
path: url.pathname,
|
|
921
|
-
status_code: res.statusCode,
|
|
922
918
|
duration_ms: elapsed(),
|
|
923
919
|
collection: url.searchParams.get('collection') || undefined,
|
|
924
920
|
query: url.searchParams.get('q') || undefined,
|
|
@@ -926,135 +922,10 @@ export function startServer(port, collections, publicDir, oauth, admins = [], re
|
|
|
926
922
|
});
|
|
927
923
|
}
|
|
928
924
|
}
|
|
929
|
-
}
|
|
930
|
-
server.listen(port, () => log(`[server] ${oauth?.issuer || `http://localhost:${port}`}`));
|
|
931
|
-
return server;
|
|
932
|
-
}
|
|
933
|
-
function sendJson(res, status, body) {
|
|
934
|
-
const acceptEncoding = res.req?.headers['accept-encoding'] || '';
|
|
935
|
-
if (body.length > 1024 && /\bgzip\b/.test(acceptEncoding)) {
|
|
936
|
-
const compressed = gzipSync(body);
|
|
937
|
-
res.writeHead(status, {
|
|
938
|
-
'Content-Type': 'application/json',
|
|
939
|
-
'Content-Encoding': 'gzip',
|
|
940
|
-
Vary: 'Accept-Encoding',
|
|
941
|
-
...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
|
|
942
|
-
});
|
|
943
|
-
res.end(compressed);
|
|
944
|
-
}
|
|
945
|
-
else {
|
|
946
|
-
res.writeHead(status, {
|
|
947
|
-
'Content-Type': 'application/json',
|
|
948
|
-
...(status === 200 ? { 'Cache-Control': 'no-store' } : {}),
|
|
949
|
-
});
|
|
950
|
-
res.end(body);
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
function jsonResponse(res, data) {
|
|
954
|
-
sendJson(res, 200, Buffer.from(JSON.stringify(data, (_, v) => normalizeValue(v))));
|
|
955
|
-
}
|
|
956
|
-
function jsonError(res, status, message) {
|
|
957
|
-
if (res.headersSent)
|
|
958
|
-
return;
|
|
959
|
-
sendJson(res, status, Buffer.from(JSON.stringify({ error: message })));
|
|
960
|
-
}
|
|
961
|
-
/** Proxy a request to the user's PDS with DPoP + automatic nonce retry + token refresh. */
|
|
962
|
-
async function proxyToPds(oauthConfig, session, method, pdsUrl, body) {
|
|
963
|
-
const serverKey = await getServerKey('appview-oauth-key');
|
|
964
|
-
const privateJwk = JSON.parse(serverKey.privateKey);
|
|
965
|
-
const publicJwk = JSON.parse(serverKey.publicKey);
|
|
966
|
-
let accessToken = session.access_token;
|
|
967
|
-
async function doFetch(token, nonce) {
|
|
968
|
-
const proof = await createDpopProof(privateJwk, publicJwk, method, pdsUrl, token, nonce);
|
|
969
|
-
const res = await fetch(pdsUrl, {
|
|
970
|
-
method,
|
|
971
|
-
headers: {
|
|
972
|
-
'Content-Type': 'application/json',
|
|
973
|
-
Authorization: `DPoP ${token}`,
|
|
974
|
-
DPoP: proof,
|
|
975
|
-
},
|
|
976
|
-
body: JSON.stringify(body),
|
|
977
|
-
});
|
|
978
|
-
const resBody = await res.json().catch(() => ({}));
|
|
979
|
-
return { ok: res.ok, status: res.status, body: resBody, headers: res.headers };
|
|
980
|
-
}
|
|
981
|
-
let result = await doFetch(accessToken);
|
|
982
|
-
if (result.ok)
|
|
983
|
-
return result;
|
|
984
|
-
let nonce;
|
|
985
|
-
// Step 1: handle DPoP nonce requirement
|
|
986
|
-
if (result.body.error === 'use_dpop_nonce') {
|
|
987
|
-
nonce = result.headers.get('DPoP-Nonce') || undefined;
|
|
988
|
-
if (nonce) {
|
|
989
|
-
result = await doFetch(accessToken, nonce);
|
|
990
|
-
if (result.ok)
|
|
991
|
-
return result;
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
// Step 2: handle expired PDS token — refresh and retry
|
|
995
|
-
if (result.body.error === 'invalid_token') {
|
|
996
|
-
const refreshed = await refreshPdsSession(oauthConfig, session);
|
|
997
|
-
if (refreshed) {
|
|
998
|
-
accessToken = refreshed.accessToken;
|
|
999
|
-
result = await doFetch(accessToken, nonce);
|
|
1000
|
-
if (result.ok)
|
|
1001
|
-
return result;
|
|
1002
|
-
// May need DPoP nonce after refresh
|
|
1003
|
-
if (result.body.error === 'use_dpop_nonce') {
|
|
1004
|
-
nonce = result.headers.get('DPoP-Nonce') || undefined;
|
|
1005
|
-
if (nonce)
|
|
1006
|
-
result = await doFetch(accessToken, nonce);
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
return result;
|
|
925
|
+
};
|
|
1011
926
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1016
|
-
const publicJwk = JSON.parse(serverKey.publicKey);
|
|
1017
|
-
let accessToken = session.access_token;
|
|
1018
|
-
async function doFetch(token, nonce) {
|
|
1019
|
-
const proof = await createDpopProof(privateJwk, publicJwk, 'POST', pdsUrl, token, nonce);
|
|
1020
|
-
const res = await fetch(pdsUrl, {
|
|
1021
|
-
method: 'POST',
|
|
1022
|
-
headers: {
|
|
1023
|
-
'Content-Type': contentType,
|
|
1024
|
-
'Content-Length': String(body.length),
|
|
1025
|
-
Authorization: `DPoP ${token}`,
|
|
1026
|
-
DPoP: proof,
|
|
1027
|
-
},
|
|
1028
|
-
body: Buffer.from(body),
|
|
1029
|
-
});
|
|
1030
|
-
const resBody = await res.json().catch(() => ({}));
|
|
1031
|
-
return { ok: res.ok, status: res.status, body: resBody, headers: res.headers };
|
|
1032
|
-
}
|
|
1033
|
-
let result = await doFetch(accessToken);
|
|
1034
|
-
if (result.ok)
|
|
1035
|
-
return result;
|
|
1036
|
-
let nonce;
|
|
1037
|
-
if (result.body.error === 'use_dpop_nonce') {
|
|
1038
|
-
nonce = result.headers.get('DPoP-Nonce') || undefined;
|
|
1039
|
-
if (nonce) {
|
|
1040
|
-
result = await doFetch(accessToken, nonce);
|
|
1041
|
-
if (result.ok)
|
|
1042
|
-
return result;
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
if (result.body.error === 'invalid_token') {
|
|
1046
|
-
const refreshed = await refreshPdsSession(oauthConfig, session);
|
|
1047
|
-
if (refreshed) {
|
|
1048
|
-
accessToken = refreshed.accessToken;
|
|
1049
|
-
result = await doFetch(accessToken, nonce);
|
|
1050
|
-
if (result.ok)
|
|
1051
|
-
return result;
|
|
1052
|
-
if (result.body.error === 'use_dpop_nonce') {
|
|
1053
|
-
nonce = result.headers.get('DPoP-Nonce') || undefined;
|
|
1054
|
-
if (nonce)
|
|
1055
|
-
result = await doFetch(accessToken, nonce);
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
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);
|
|
1060
931
|
}
|