@hatk/hatk 0.0.1-alpha.84 → 0.0.1-alpha.86
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.map +1 -1
- package/dist/adapter.js +2 -0
- package/dist/cli.js +5 -1
- package/dist/config.d.ts +47 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -1
- package/dist/database/db.d.ts +11 -0
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +356 -160
- package/dist/database/dialect.js +2 -2
- package/dist/database/fts.d.ts.map +1 -1
- package/dist/database/fts.js +6 -2
- package/dist/database/index.d.ts +1 -1
- package/dist/database/index.d.ts.map +1 -1
- package/dist/database/index.js +1 -1
- package/dist/database/schema.d.ts +2 -0
- package/dist/database/schema.d.ts.map +1 -1
- package/dist/database/schema.js +46 -4
- package/dist/dev-entry.d.ts.map +1 -1
- package/dist/dev-entry.js +2 -2
- package/dist/feeds.d.ts +14 -0
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +24 -13
- package/dist/hooks.d.ts +17 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +26 -3
- package/dist/hydrate.d.ts +42 -0
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +18 -5
- package/dist/indexer.d.ts +4 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +81 -64
- package/dist/lex-types.d.ts +2 -0
- package/dist/lex-types.d.ts.map +1 -1
- package/dist/main.js +26 -2
- package/dist/oauth/db.d.ts +9 -0
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +12 -0
- package/dist/opengraph.d.ts.map +1 -1
- package/dist/opengraph.js +16 -4
- package/dist/private-collections.d.ts +7 -2
- package/dist/private-collections.d.ts.map +1 -1
- package/dist/private-collections.js +9 -4
- package/dist/server.d.ts +8 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +167 -15
- package/dist/spaces/blob.d.ts +51 -0
- package/dist/spaces/blob.d.ts.map +1 -0
- package/dist/spaces/blob.js +111 -0
- package/dist/spaces/credential.d.ts +62 -0
- package/dist/spaces/credential.d.ts.map +1 -0
- package/dist/spaces/credential.js +184 -0
- package/dist/spaces/engine.d.ts +97 -0
- package/dist/spaces/engine.d.ts.map +1 -0
- package/dist/spaces/engine.js +552 -0
- package/dist/spaces/guard.d.ts +41 -0
- package/dist/spaces/guard.d.ts.map +1 -0
- package/dist/spaces/guard.js +79 -0
- package/dist/spaces/identity.d.ts +37 -0
- package/dist/spaces/identity.d.ts.map +1 -0
- package/dist/spaces/identity.js +88 -0
- package/dist/spaces/index.d.ts +55 -0
- package/dist/spaces/index.d.ts.map +1 -0
- package/dist/spaces/index.js +133 -0
- package/dist/spaces/notify.d.ts +51 -0
- package/dist/spaces/notify.d.ts.map +1 -0
- package/dist/spaces/notify.js +121 -0
- package/dist/spaces/records.d.ts +37 -0
- package/dist/spaces/records.d.ts.map +1 -0
- package/dist/spaces/records.js +63 -0
- package/dist/spaces/store.d.ts +51 -0
- package/dist/spaces/store.d.ts.map +1 -0
- package/dist/spaces/store.js +99 -0
- package/dist/spaces/uri.d.ts +60 -0
- package/dist/spaces/uri.d.ts.map +1 -0
- package/dist/spaces/uri.js +97 -0
- package/dist/spaces/verify.d.ts +39 -0
- package/dist/spaces/verify.d.ts.map +1 -0
- package/dist/spaces/verify.js +87 -0
- package/dist/spaces/viewer.d.ts +51 -0
- package/dist/spaces/viewer.d.ts.map +1 -0
- package/dist/spaces/viewer.js +167 -0
- package/dist/spaces/visibility.d.ts +57 -0
- package/dist/spaces/visibility.d.ts.map +1 -0
- package/dist/spaces/visibility.js +81 -0
- package/dist/templates/feed.tpl +4 -0
- package/dist/templates/xrpc.tpl +13 -1
- package/dist/test.d.ts +2 -0
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +8 -5
- package/dist/xrpc.d.ts +19 -3
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +62 -32
- package/package.json +8 -4
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join, extname } from 'node:path';
|
|
|
5
5
|
import { gzipSync } from 'node:zlib';
|
|
6
6
|
import { queryRecords, getRecordByUri, searchRecords, getSchema, reshapeRow, setRepoStatus, getRepoStatus, getRepoRetryInfo, queryLabelsForUris, insertLabels, searchAccounts, listReposPaginated, getCollectionCounts, getRepoStatusCounts, getDatabaseSize, deleteLabels, getRecentRecords, listActiveRepoDids, removeRepo, getRepoHandle, getPreferences, putPreference, insertReport, queryReports, resolveReport, getOpenReportCount, } from "./database/db.js";
|
|
7
7
|
import { executeFeed, listFeeds } from "./feeds.js";
|
|
8
|
-
import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler, isLocalRelay } from "./xrpc.js";
|
|
8
|
+
import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler, isLocalRelay, paramsFromSearch } from "./xrpc.js";
|
|
9
9
|
import { pdsFor } from "./backfill.js";
|
|
10
10
|
import { resolveRecords } from "./hydrate.js";
|
|
11
11
|
import { handleOpengraphRequest, buildOgMeta } from "./opengraph.js";
|
|
@@ -18,8 +18,43 @@ import { getOAuthRequest, getSession } from "./oauth/db.js";
|
|
|
18
18
|
import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsApplyWrites, pdsUploadBlob, ProxyError, ScopeMissingProxyError, } from "./pds-proxy.js";
|
|
19
19
|
import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
|
|
20
20
|
import { collectionFromUri, isPrivateCollection } from "./private-collections.js";
|
|
21
|
+
import { enterReadableSpaces } from "./spaces/visibility.js";
|
|
22
|
+
import { readableSpacesFor } from "./spaces/viewer.js";
|
|
23
|
+
import { parseSpaceBlobRequest, serveSpaceBlob } from "./spaces/blob.js";
|
|
24
|
+
import { handleWriteNotice, NoticeError, parseWriteNotice, scheduleNoticeSync, spaceDidDocument, spaceServiceId, unwatchSpace, verifyNotice, } from "./spaces/index.js";
|
|
25
|
+
import { parseSpaceRef } from "./spaces/uri.js";
|
|
21
26
|
import { serve } from "./adapter.js";
|
|
22
27
|
import { renderPage } from "./renderer.js";
|
|
28
|
+
/**
|
|
29
|
+
* Parse a client-supplied `limit`, or null if it is not a usable one.
|
|
30
|
+
*
|
|
31
|
+
* `parseInt` alone lets `limit=abc` through as NaN and `limit=-5` through as a
|
|
32
|
+
* negative bound, both of which reach the database driver: the first as a
|
|
33
|
+
* datatype error, the second as "no limit at all" on SQLite. Neither is a
|
|
34
|
+
* server fault, so the caller turns a null into a 400.
|
|
35
|
+
*/
|
|
36
|
+
function parseLimit(raw, fallback) {
|
|
37
|
+
if (raw === null || raw === '')
|
|
38
|
+
return fallback;
|
|
39
|
+
if (!/^\d+$/.test(raw))
|
|
40
|
+
return null;
|
|
41
|
+
const value = Number(raw);
|
|
42
|
+
return value >= 1 ? value : null;
|
|
43
|
+
}
|
|
44
|
+
/** Whether `sort` names a column queryRecords can order this collection by. */
|
|
45
|
+
function isSortableField(collection, sort) {
|
|
46
|
+
if (sort === 'indexed_at')
|
|
47
|
+
return true;
|
|
48
|
+
const schema = getSchema(collection);
|
|
49
|
+
return !!schema?.columns.some((c) => c.originalName === sort || c.name === sort);
|
|
50
|
+
}
|
|
51
|
+
/** Narrow a client-supplied `order` to the two values that may reach ORDER BY. */
|
|
52
|
+
function parseOrder(raw) {
|
|
53
|
+
if (!raw)
|
|
54
|
+
return undefined;
|
|
55
|
+
const lowered = raw.toLowerCase();
|
|
56
|
+
return lowered === 'asc' || lowered === 'desc' ? lowered : null;
|
|
57
|
+
}
|
|
23
58
|
function scopeMissingResponse(acceptEncoding, handle) {
|
|
24
59
|
const res = withCors(json({ error: 'ScopeMissingError', ...(handle ? { handle } : {}) }, 401, acceptEncoding));
|
|
25
60
|
res.headers.append('Set-Cookie', clearSessionCookieHeader());
|
|
@@ -45,7 +80,11 @@ export function registerCoreHandlers(collections, oauth) {
|
|
|
45
80
|
if (!getSchema(collection))
|
|
46
81
|
throw new NotFoundError(`Unknown collection: ${collection}`);
|
|
47
82
|
const sort = params.sort || undefined;
|
|
48
|
-
|
|
83
|
+
if (sort && !isSortableField(collection, sort))
|
|
84
|
+
throw new InvalidRequestError(`Invalid sort field: ${sort}`);
|
|
85
|
+
const order = parseOrder(params.order);
|
|
86
|
+
if (order === null)
|
|
87
|
+
throw new InvalidRequestError(`Invalid order: ${params.order}`);
|
|
49
88
|
const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
|
|
50
89
|
const filters = {};
|
|
51
90
|
for (const [key, value] of Object.entries(params)) {
|
|
@@ -219,7 +258,11 @@ export function registerCoreHandlers(collections, oauth) {
|
|
|
219
258
|
subjectDid = match[1];
|
|
220
259
|
}
|
|
221
260
|
else if (body.subject.did) {
|
|
222
|
-
|
|
261
|
+
// The bare DID, not `at://<did>`: resolving this report labels
|
|
262
|
+
// `subjectUri`, and an account label is keyed by DID alone. Ozone
|
|
263
|
+
// writes it that way and every appview reads it that way, so the
|
|
264
|
+
// `at://` form would apply a label nothing ever looks up.
|
|
265
|
+
subjectUri = body.subject.did;
|
|
223
266
|
subjectDid = body.subject.did;
|
|
224
267
|
}
|
|
225
268
|
else {
|
|
@@ -348,6 +391,19 @@ export function createHandler(config) {
|
|
|
348
391
|
}
|
|
349
392
|
catch { }
|
|
350
393
|
}
|
|
394
|
+
// Which permissioned spaces this request may be shown, established once and
|
|
395
|
+
// read by every query below. A signed-out visitor gets none — there is no
|
|
396
|
+
// anonymous read path into a space — and so does an instance that indexes
|
|
397
|
+
// none, which is every deployment that has not configured `spaces`.
|
|
398
|
+
try {
|
|
399
|
+
enterReadableSpaces(await readableSpacesFor(oauth, viewer));
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
// Failing to establish the scope must not widen it: the default is to
|
|
403
|
+
// serve no space row, and that is what an unresolved viewer gets.
|
|
404
|
+
enterReadableSpaces([]);
|
|
405
|
+
emit('spaces', 'viewer_resolve_error', { error: err.message });
|
|
406
|
+
}
|
|
351
407
|
try {
|
|
352
408
|
// GET /xrpc/dev.hatk.getRecords?collection=<nsid>&limit=N&cursor=C&<field>=<value>
|
|
353
409
|
if (url.pathname === coreXrpc('getRecords')) {
|
|
@@ -358,11 +414,23 @@ export function createHandler(config) {
|
|
|
358
414
|
return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
|
|
359
415
|
if (!getSchema(collection))
|
|
360
416
|
return withCors(jsonError(404, `Unknown collection: ${collection}`, acceptEncoding));
|
|
361
|
-
|
|
417
|
+
// Every one of these is attacker-controlled, and queryRecords trusts
|
|
418
|
+
// what it is handed: `sort` and `order` reach the ORDER BY clause and a
|
|
419
|
+
// non-numeric `limit` reaches the driver. Rejecting them here keeps a
|
|
420
|
+
// bad query string a 400 instead of a 500 (or worse).
|
|
421
|
+
const limit = parseLimit(url.searchParams.get('limit'), 20);
|
|
422
|
+
if (limit === null)
|
|
423
|
+
return withCors(jsonError(400, 'Invalid limit parameter', acceptEncoding));
|
|
362
424
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
363
425
|
const sort = url.searchParams.get('sort') || undefined;
|
|
364
|
-
const order = (url.searchParams.get('order')
|
|
365
|
-
|
|
426
|
+
const order = parseOrder(url.searchParams.get('order'));
|
|
427
|
+
if (order === null)
|
|
428
|
+
return withCors(jsonError(400, `Invalid order: ${url.searchParams.get('order')}`, acceptEncoding));
|
|
429
|
+
if (sort && !isSortableField(collection, sort))
|
|
430
|
+
return withCors(jsonError(400, `Invalid sort field: ${sort}`, acceptEncoding));
|
|
431
|
+
// Collect field filters (everything except reserved params). An
|
|
432
|
+
// unknown field is ignored rather than rejected — queryRecords does
|
|
433
|
+
// the same, and clients rely on passing through extra params.
|
|
366
434
|
const reserved = new Set(['collection', 'limit', 'cursor', 'sort', 'order']);
|
|
367
435
|
const filters = {};
|
|
368
436
|
for (const [key, value] of url.searchParams) {
|
|
@@ -403,10 +471,7 @@ export function createHandler(config) {
|
|
|
403
471
|
return withCors(jsonError(400, 'Missing feed parameter', acceptEncoding));
|
|
404
472
|
const limit = parseInt(url.searchParams.get('limit') || '30');
|
|
405
473
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
406
|
-
const params =
|
|
407
|
-
for (const [key, value] of url.searchParams) {
|
|
408
|
-
params[key] = value;
|
|
409
|
-
}
|
|
474
|
+
const params = paramsFromSearch(url.searchParams);
|
|
410
475
|
const result = await executeFeed(feedName, params, cursor, limit, viewer);
|
|
411
476
|
if (!result)
|
|
412
477
|
return withCors(jsonError(404, `Unknown feed: ${feedName}`, acceptEncoding));
|
|
@@ -816,6 +881,12 @@ export function createHandler(config) {
|
|
|
816
881
|
}, 200, acceptEncoding));
|
|
817
882
|
}
|
|
818
883
|
// --- OAuth Endpoints ---
|
|
884
|
+
// GET /.well-known/did.json — this instance's own DID document, so a space
|
|
885
|
+
// authority can resolve where to deliver notices. Published only when a
|
|
886
|
+
// service DID is configured; it carries a service entry and no key.
|
|
887
|
+
if (url.pathname === '/.well-known/did.json' && config.spaces?.serviceDid) {
|
|
888
|
+
return withCors(json(spaceDidDocument(config.spaces.serviceDid, config.spaces.publicUrl ?? requestOrigin, config.spaces.serviceFragment ?? 'atproto_space_syncer'), 200, acceptEncoding));
|
|
889
|
+
}
|
|
819
890
|
// OAuth well-known endpoints
|
|
820
891
|
if (url.pathname === '/.well-known/oauth-authorization-server' && oauth) {
|
|
821
892
|
return withCors(json(getAuthServerMetadata(oauth.issuer, oauth), 200, acceptEncoding));
|
|
@@ -920,7 +991,24 @@ export function createHandler(config) {
|
|
|
920
991
|
}
|
|
921
992
|
if (!code)
|
|
922
993
|
return withCors(jsonError(400, 'Missing code', acceptEncoding));
|
|
923
|
-
|
|
994
|
+
let result;
|
|
995
|
+
try {
|
|
996
|
+
result = await handleCallback(oauth, code, state, iss);
|
|
997
|
+
}
|
|
998
|
+
catch (err) {
|
|
999
|
+
// Same convention as the token endpoint below: an error that names
|
|
1000
|
+
// its own status keeps it, anything else is a genuine failure and
|
|
1001
|
+
// falls through to the 500.
|
|
1002
|
+
if (err instanceof OAuthError)
|
|
1003
|
+
return withCors(json({ error: err.code, error_description: err.description }, err.status, acceptEncoding));
|
|
1004
|
+
// A callback matching no pending authorization request is a stale
|
|
1005
|
+
// redirect, not a server fault — the request expired while a login
|
|
1006
|
+
// tab sat open, or the user came back to a login they already
|
|
1007
|
+
// finished. Answer like the other client errors on this route.
|
|
1008
|
+
if (err instanceof Error && err.message === 'No matching authorization request found')
|
|
1009
|
+
return withCors(jsonError(400, err.message, acceptEncoding));
|
|
1010
|
+
throw err;
|
|
1011
|
+
}
|
|
924
1012
|
const isSecure = requestOrigin.startsWith('https');
|
|
925
1013
|
const handle = (await getRepoHandle(result.did)) ?? result.did;
|
|
926
1014
|
const session = { did: result.did, handle };
|
|
@@ -1151,14 +1239,68 @@ export function createHandler(config) {
|
|
|
1151
1239
|
return withCors(file(png, 'image/png', 'public, max-age=300'));
|
|
1152
1240
|
}
|
|
1153
1241
|
// GET/POST /xrpc/{nsid} — custom XRPC handlers (matched by full NSID from folder structure)
|
|
1242
|
+
// POST /xrpc/com.atproto.space.notifyWrite — a space authority telling us
|
|
1243
|
+
// one of its repos moved. Carries no records: it says look again, and the
|
|
1244
|
+
// read that follows uses our own credential like any other.
|
|
1245
|
+
//
|
|
1246
|
+
// Best-effort by protocol, so this answers before syncing and never lets
|
|
1247
|
+
// a sync failure become the authority's problem. A missed notice is
|
|
1248
|
+
// recovered by the reconcile sweep.
|
|
1249
|
+
if (url.pathname === '/xrpc/com.atproto.space.notifyWrite' && request.method === 'POST') {
|
|
1250
|
+
const serviceId = spaceServiceId();
|
|
1251
|
+
if (!serviceId)
|
|
1252
|
+
return withCors(jsonError(404, 'This instance receives no space notices', acceptEncoding));
|
|
1253
|
+
const notice = parseWriteNotice(await request.json().catch(() => null));
|
|
1254
|
+
if (!notice)
|
|
1255
|
+
return withCors(jsonError(400, 'Expected space, repo and rev', acceptEncoding));
|
|
1256
|
+
try {
|
|
1257
|
+
await verifyNotice(request.headers.get('authorization'), {
|
|
1258
|
+
iss: parseSpaceRef(notice.space).authority,
|
|
1259
|
+
aud: serviceId,
|
|
1260
|
+
lxm: 'com.atproto.space.notifyWrite',
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
catch (err) {
|
|
1264
|
+
if (err instanceof NoticeError)
|
|
1265
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
1266
|
+
throw err;
|
|
1267
|
+
}
|
|
1268
|
+
// Debounced per repo: one member writing a gallery sends a notice per
|
|
1269
|
+
// record, and each is an invitation to read the same repo again.
|
|
1270
|
+
scheduleNoticeSync(`${notice.space}|${notice.repo}`, () => handleWriteNotice(notice).then(() => { }));
|
|
1271
|
+
return withCors(json({}, 200, acceptEncoding));
|
|
1272
|
+
}
|
|
1273
|
+
// POST /xrpc/com.atproto.space.notifySpaceDeleted — the authority saying a
|
|
1274
|
+
// space is gone and every copy of it should go too.
|
|
1275
|
+
if (url.pathname === '/xrpc/com.atproto.space.notifySpaceDeleted' && request.method === 'POST') {
|
|
1276
|
+
const serviceId = spaceServiceId();
|
|
1277
|
+
if (!serviceId)
|
|
1278
|
+
return withCors(jsonError(404, 'This instance receives no space notices', acceptEncoding));
|
|
1279
|
+
const body = (await request.json().catch(() => null));
|
|
1280
|
+
const space = typeof body?.space === 'string' ? body.space : null;
|
|
1281
|
+
const ref = space ? parseSpaceRef(space) : null;
|
|
1282
|
+
if (!space || !ref)
|
|
1283
|
+
return withCors(jsonError(400, 'Expected a space', acceptEncoding));
|
|
1284
|
+
try {
|
|
1285
|
+
await verifyNotice(request.headers.get('authorization'), {
|
|
1286
|
+
iss: ref.authority,
|
|
1287
|
+
aud: serviceId,
|
|
1288
|
+
lxm: 'com.atproto.space.notifySpaceDeleted',
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
catch (err) {
|
|
1292
|
+
if (err instanceof NoticeError)
|
|
1293
|
+
return withCors(jsonError(err.status, err.message, acceptEncoding));
|
|
1294
|
+
throw err;
|
|
1295
|
+
}
|
|
1296
|
+
await unwatchSpace(space);
|
|
1297
|
+
return withCors(json({}, 200, acceptEncoding));
|
|
1298
|
+
}
|
|
1154
1299
|
if (url.pathname.startsWith('/xrpc/')) {
|
|
1155
1300
|
const method = url.pathname.slice('/xrpc/'.length);
|
|
1156
1301
|
const limit = parseInt(url.searchParams.get('limit') || '20');
|
|
1157
1302
|
const cursor = url.searchParams.get('cursor') || undefined;
|
|
1158
|
-
const params =
|
|
1159
|
-
for (const [key, value] of url.searchParams) {
|
|
1160
|
-
params[key] = value;
|
|
1161
|
-
}
|
|
1303
|
+
const params = paramsFromSearch(url.searchParams);
|
|
1162
1304
|
// Parse request body for POST (procedures)
|
|
1163
1305
|
let input;
|
|
1164
1306
|
if (request.method === 'POST') {
|
|
@@ -1189,6 +1331,16 @@ export function createHandler(config) {
|
|
|
1189
1331
|
throw err;
|
|
1190
1332
|
}
|
|
1191
1333
|
}
|
|
1334
|
+
// GET /space-blob?space=&repo=&cid= — a blob inside a permissioned space,
|
|
1335
|
+
// fetched with the viewer's own credential. Unlike /blob/ below it is not
|
|
1336
|
+
// dev-only and not cacheable: a space blob has no public URL by design,
|
|
1337
|
+
// and this response belongs to one viewer.
|
|
1338
|
+
if (url.pathname === '/space-blob') {
|
|
1339
|
+
const parsed = parseSpaceBlobRequest(url.searchParams);
|
|
1340
|
+
if (!parsed)
|
|
1341
|
+
return withCors(jsonError(400, 'Expected space, repo and cid', acceptEncoding));
|
|
1342
|
+
return withCors(await serveSpaceBlob(oauth, viewer, parsed));
|
|
1343
|
+
}
|
|
1192
1344
|
// GET /blob/:did/:cid — dev-only image proxy (see blobUrl in xrpc.ts).
|
|
1193
1345
|
// A PDS serves getBlob as an attachment with nosniff, which a browser
|
|
1194
1346
|
// refuses to render in <img>; re-serving it from here drops that and
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving a blob that lives in a permissioned space.
|
|
3
|
+
*
|
|
4
|
+
* Every other image hatk hands out has a public URL — a CDN path, or the
|
|
5
|
+
* repo's own `com.atproto.sync.getBlob`. A space's blob has neither, by
|
|
6
|
+
* design: `com.atproto.space.getBlob` serves it only to a credential holder,
|
|
7
|
+
* and only for a space the blob is actually referenced from. Giving one a
|
|
8
|
+
* public URL would turn that URL into the capability the credential exists to
|
|
9
|
+
* replace.
|
|
10
|
+
*
|
|
11
|
+
* So the bytes come through here, fetched on the viewer's own behalf with the
|
|
12
|
+
* viewer's own credential, and go no further: `private, no-store` keeps them
|
|
13
|
+
* out of every shared cache between this server and that browser.
|
|
14
|
+
*/
|
|
15
|
+
import type { OAuthConfig } from '../config.ts';
|
|
16
|
+
/**
|
|
17
|
+
* The URL this appview serves a space blob at, for whoever is asking.
|
|
18
|
+
*
|
|
19
|
+
* Built here rather than by the app, because the route is hatk's: a blob in a
|
|
20
|
+
* space has no public address by design, and this is the one address it does
|
|
21
|
+
* have — answered per viewer, with that viewer's credential, uncacheable.
|
|
22
|
+
*/
|
|
23
|
+
export declare function spaceBlobUrl(space: string, repo: string, cid: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* The CID a blob reference names, in whichever spelling the read used.
|
|
26
|
+
*
|
|
27
|
+
* A repo read gives the lexicon's `{ ref: { $link } }`; a space read comes
|
|
28
|
+
* back through a lex client that decodes the same field into a CID object
|
|
29
|
+
* whose `/` is the string. One blob, two spellings, and no caller should have
|
|
30
|
+
* to know which it got.
|
|
31
|
+
*/
|
|
32
|
+
export declare function blobCid(blob: unknown): string | undefined;
|
|
33
|
+
export interface SpaceBlobRequest {
|
|
34
|
+
space: string;
|
|
35
|
+
/** The repo holding the blob — the writer's, not the authority's. */
|
|
36
|
+
repo: string;
|
|
37
|
+
cid: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function parseSpaceBlobRequest(params: URLSearchParams): SpaceBlobRequest | null;
|
|
40
|
+
/**
|
|
41
|
+
* Fetch a space blob as this viewer, or answer why not.
|
|
42
|
+
*
|
|
43
|
+
* A viewer who cannot read the space gets the same 404 as a blob that is not
|
|
44
|
+
* there. The distinction between "you may not" and "it does not exist" is
|
|
45
|
+
* itself information about a private space, and the space host draws the line
|
|
46
|
+
* the same way when it reports a non-member's repo as simply absent.
|
|
47
|
+
*/
|
|
48
|
+
export declare function serveSpaceBlob(oauth: OAuthConfig | null, viewer: {
|
|
49
|
+
did: string;
|
|
50
|
+
} | null, request: SpaceBlobRequest): Promise<Response>;
|
|
51
|
+
//# sourceMappingURL=blob.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blob.d.ts","sourceRoot":"","sources":["../../src/spaces/blob.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAe/C;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAG7E;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAMzD;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,eAAe,GAAG,gBAAgB,GAAG,IAAI,CAOtF;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,WAAW,GAAG,IAAI,EACzB,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC9B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,QAAQ,CAAC,CAsCnB"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving a blob that lives in a permissioned space.
|
|
3
|
+
*
|
|
4
|
+
* Every other image hatk hands out has a public URL — a CDN path, or the
|
|
5
|
+
* repo's own `com.atproto.sync.getBlob`. A space's blob has neither, by
|
|
6
|
+
* design: `com.atproto.space.getBlob` serves it only to a credential holder,
|
|
7
|
+
* and only for a space the blob is actually referenced from. Giving one a
|
|
8
|
+
* public URL would turn that URL into the capability the credential exists to
|
|
9
|
+
* replace.
|
|
10
|
+
*
|
|
11
|
+
* So the bytes come through here, fetched on the viewer's own behalf with the
|
|
12
|
+
* viewer's own credential, and go no further: `private, no-store` keeps them
|
|
13
|
+
* out of every shared cache between this server and that browser.
|
|
14
|
+
*/
|
|
15
|
+
import { emit } from "../logger.js";
|
|
16
|
+
import { repoEndpoint } from "./identity.js";
|
|
17
|
+
import { parseSpaceRef } from "./uri.js";
|
|
18
|
+
import { viewerCredential } from "./viewer.js";
|
|
19
|
+
/**
|
|
20
|
+
* What we are willing to hand back, whatever the repo claims it is.
|
|
21
|
+
*
|
|
22
|
+
* The record naming this blob was written by an account hatk does not control,
|
|
23
|
+
* and so was its mime type. Echoing that unchecked would let a writer choose
|
|
24
|
+
* what a browser executes in this origin.
|
|
25
|
+
*/
|
|
26
|
+
const SERVABLE = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']);
|
|
27
|
+
/**
|
|
28
|
+
* The URL this appview serves a space blob at, for whoever is asking.
|
|
29
|
+
*
|
|
30
|
+
* Built here rather than by the app, because the route is hatk's: a blob in a
|
|
31
|
+
* space has no public address by design, and this is the one address it does
|
|
32
|
+
* have — answered per viewer, with that viewer's credential, uncacheable.
|
|
33
|
+
*/
|
|
34
|
+
export function spaceBlobUrl(space, repo, cid) {
|
|
35
|
+
const params = new URLSearchParams({ space, repo, cid });
|
|
36
|
+
return `/space-blob?${params}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The CID a blob reference names, in whichever spelling the read used.
|
|
40
|
+
*
|
|
41
|
+
* A repo read gives the lexicon's `{ ref: { $link } }`; a space read comes
|
|
42
|
+
* back through a lex client that decodes the same field into a CID object
|
|
43
|
+
* whose `/` is the string. One blob, two spellings, and no caller should have
|
|
44
|
+
* to know which it got.
|
|
45
|
+
*/
|
|
46
|
+
export function blobCid(blob) {
|
|
47
|
+
const ref = blob?.ref;
|
|
48
|
+
if (!ref || typeof ref !== 'object')
|
|
49
|
+
return undefined;
|
|
50
|
+
if (typeof ref.$link === 'string')
|
|
51
|
+
return ref.$link;
|
|
52
|
+
if (typeof ref['/'] === 'string')
|
|
53
|
+
return ref['/'];
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
export function parseSpaceBlobRequest(params) {
|
|
57
|
+
const space = params.get('space');
|
|
58
|
+
const repo = params.get('repo') ?? params.get('did');
|
|
59
|
+
const cid = params.get('cid');
|
|
60
|
+
if (!space || !repo || !cid)
|
|
61
|
+
return null;
|
|
62
|
+
if (!parseSpaceRef(space) || !repo.startsWith('did:'))
|
|
63
|
+
return null;
|
|
64
|
+
return { space, repo, cid };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fetch a space blob as this viewer, or answer why not.
|
|
68
|
+
*
|
|
69
|
+
* A viewer who cannot read the space gets the same 404 as a blob that is not
|
|
70
|
+
* there. The distinction between "you may not" and "it does not exist" is
|
|
71
|
+
* itself information about a private space, and the space host draws the line
|
|
72
|
+
* the same way when it reports a non-member's repo as simply absent.
|
|
73
|
+
*/
|
|
74
|
+
export async function serveSpaceBlob(oauth, viewer, request) {
|
|
75
|
+
if (!viewer)
|
|
76
|
+
return new Response('Unauthorized', { status: 401 });
|
|
77
|
+
const credential = await viewerCredential(oauth, viewer, request.space);
|
|
78
|
+
if (!credential)
|
|
79
|
+
return new Response('Not found', { status: 404 });
|
|
80
|
+
let upstream;
|
|
81
|
+
try {
|
|
82
|
+
const url = new URL(`${await repoEndpoint(request.repo)}/xrpc/com.atproto.space.getBlob`);
|
|
83
|
+
url.searchParams.set('space', request.space);
|
|
84
|
+
// `repo`, not `did`: a space read names the repo holding the record the
|
|
85
|
+
// same way everywhere. A server hosting one account falls back to it,
|
|
86
|
+
// which is what makes `did` ever appear to work.
|
|
87
|
+
url.searchParams.set('repo', request.repo);
|
|
88
|
+
url.searchParams.set('cid', request.cid);
|
|
89
|
+
upstream = await credential.fetch(url);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
emit('spaces', 'blob_error', { space: request.space, repo: request.repo, error: err.message });
|
|
93
|
+
return new Response('Blob unavailable', { status: 502 });
|
|
94
|
+
}
|
|
95
|
+
if (!upstream.ok)
|
|
96
|
+
return new Response('Not found', { status: upstream.status === 401 ? 404 : upstream.status });
|
|
97
|
+
const claimed = (upstream.headers.get('content-type') ?? '').split(';')[0].trim();
|
|
98
|
+
const contentType = SERVABLE.has(claimed) ? claimed : 'application/octet-stream';
|
|
99
|
+
return new Response(upstream.body, {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: {
|
|
102
|
+
'content-type': contentType,
|
|
103
|
+
// Not a public cache: this response is one viewer's, and the next
|
|
104
|
+
// request for the same URL may be somebody with no right to it.
|
|
105
|
+
'cache-control': 'private, no-store',
|
|
106
|
+
'content-disposition': 'inline',
|
|
107
|
+
'content-security-policy': "default-src 'none'; sandbox",
|
|
108
|
+
'x-content-type-options': 'nosniff',
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting into a space to read it.
|
|
3
|
+
*
|
|
4
|
+
* hatk is not a member of anything. It has no DID that any community has
|
|
5
|
+
* admitted, and asking every authority to admit an indexer would make indexing
|
|
6
|
+
* a per-community act of trust before a single record could be read.
|
|
7
|
+
*
|
|
8
|
+
* So it reads as somebody who is already inside: a viewer whose OAuth session
|
|
9
|
+
* it already holds, whose PDS will mint a delegation token for the space, and
|
|
10
|
+
* whom the authority already lets in. The exchange is the same one the
|
|
11
|
+
* community's own web app makes from the browser —
|
|
12
|
+
*
|
|
13
|
+
* the reader's PDS signs a delegation token (60s, single-use, addressed to
|
|
14
|
+
* the authority's space host)
|
|
15
|
+
* the authority checks it, checks the reader against the space's policy, and
|
|
16
|
+
* returns a credential bound to the key that signed our DPoP proof (2h)
|
|
17
|
+
*
|
|
18
|
+
* — and the credential is then presented to every writer's host in the space.
|
|
19
|
+
* It carries no audience, which is why it must be bound to a key rather than
|
|
20
|
+
* held as a bearer token: a host handed a bearer credential to serve its own
|
|
21
|
+
* repo could replay it against every other host in the space.
|
|
22
|
+
*
|
|
23
|
+
* The consequence to be honest about: a space is only readable while somebody
|
|
24
|
+
* who can read it has a live session here. That is the same bound the data has
|
|
25
|
+
* anyway — nobody outside the space's audience was ever entitled to this — but
|
|
26
|
+
* it does mean indexing lapses when the last member logs out.
|
|
27
|
+
*/
|
|
28
|
+
import type { OAuthConfig } from '../config.ts';
|
|
29
|
+
export declare class SpaceCredentialError extends Error {
|
|
30
|
+
readonly status: number;
|
|
31
|
+
readonly code: string;
|
|
32
|
+
constructor(status: number, code: string, message: string);
|
|
33
|
+
}
|
|
34
|
+
/** A refusal about who is asking, rather than about the space or the network. */
|
|
35
|
+
export declare function isNotAuthorized(err: unknown): boolean;
|
|
36
|
+
export declare function isSpaceGone(err: unknown): boolean;
|
|
37
|
+
export interface SpaceCredential {
|
|
38
|
+
/** The space this credential reads, and the only one it is accepted for. */
|
|
39
|
+
space: string;
|
|
40
|
+
/** The account whose delegation bought it. */
|
|
41
|
+
readerDid: string;
|
|
42
|
+
expiresAt: number;
|
|
43
|
+
/** A fetch that presents the credential with a fresh DPoP proof per request. */
|
|
44
|
+
fetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
45
|
+
}
|
|
46
|
+
/** Mint a credential for `space` using `readerDid`'s stored session. */
|
|
47
|
+
export declare function mintSpaceCredential(oauthConfig: OAuthConfig, space: string, readerDid: string): Promise<SpaceCredential>;
|
|
48
|
+
/**
|
|
49
|
+
* A credential for `space`, minted with whichever candidate session works.
|
|
50
|
+
*
|
|
51
|
+
* Candidates are tried in order and the first that succeeds is cached. A
|
|
52
|
+
* refusal about the reader — they were ejected, or never belonged — moves on to
|
|
53
|
+
* the next candidate; a refusal about the space itself stops the whole attempt,
|
|
54
|
+
* since no other reader would fare better.
|
|
55
|
+
*/
|
|
56
|
+
export declare function getSpaceCredential(oauthConfig: OAuthConfig, space: string, candidates: string[], opts?: {
|
|
57
|
+
refresh?: boolean;
|
|
58
|
+
}): Promise<SpaceCredential>;
|
|
59
|
+
export declare function forgetSpaceCredential(space: string): void;
|
|
60
|
+
/** For tests: drop every cached credential and the process binding key. */
|
|
61
|
+
export declare function resetSpaceCredentials(): void;
|
|
62
|
+
//# sourceMappingURL=credential.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential.d.ts","sourceRoot":"","sources":["../../src/spaces/credential.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAsB/C,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;gBADZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM;CAIlB;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAErD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAEjD;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAA;IACb,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CACtE;AAiDD,wEAAwE;AACxE,wBAAsB,mBAAmB,CACvC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAiC1B;AAUD;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAAE,EACpB,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAO,GAC/B,OAAO,CAAC,eAAe,CAAC,CAwB1B;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEzD;AAED,2EAA2E;AAC3E,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C"}
|