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