@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.
Files changed (94) hide show
  1. package/dist/adapter.d.ts.map +1 -1
  2. package/dist/adapter.js +2 -0
  3. package/dist/cli.js +5 -1
  4. package/dist/config.d.ts +47 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +18 -1
  7. package/dist/database/db.d.ts +11 -0
  8. package/dist/database/db.d.ts.map +1 -1
  9. package/dist/database/db.js +356 -160
  10. package/dist/database/dialect.js +2 -2
  11. package/dist/database/fts.d.ts.map +1 -1
  12. package/dist/database/fts.js +6 -2
  13. package/dist/database/index.d.ts +1 -1
  14. package/dist/database/index.d.ts.map +1 -1
  15. package/dist/database/index.js +1 -1
  16. package/dist/database/schema.d.ts +2 -0
  17. package/dist/database/schema.d.ts.map +1 -1
  18. package/dist/database/schema.js +46 -4
  19. package/dist/dev-entry.d.ts.map +1 -1
  20. package/dist/dev-entry.js +2 -2
  21. package/dist/feeds.d.ts +14 -0
  22. package/dist/feeds.d.ts.map +1 -1
  23. package/dist/feeds.js +24 -13
  24. package/dist/hooks.d.ts +17 -0
  25. package/dist/hooks.d.ts.map +1 -1
  26. package/dist/hooks.js +26 -3
  27. package/dist/hydrate.d.ts +42 -0
  28. package/dist/hydrate.d.ts.map +1 -1
  29. package/dist/hydrate.js +18 -5
  30. package/dist/indexer.d.ts +4 -3
  31. package/dist/indexer.d.ts.map +1 -1
  32. package/dist/indexer.js +81 -64
  33. package/dist/lex-types.d.ts +2 -0
  34. package/dist/lex-types.d.ts.map +1 -1
  35. package/dist/main.js +26 -2
  36. package/dist/oauth/db.d.ts +9 -0
  37. package/dist/oauth/db.d.ts.map +1 -1
  38. package/dist/oauth/db.js +12 -0
  39. package/dist/opengraph.d.ts.map +1 -1
  40. package/dist/opengraph.js +16 -4
  41. package/dist/private-collections.d.ts +7 -2
  42. package/dist/private-collections.d.ts.map +1 -1
  43. package/dist/private-collections.js +9 -4
  44. package/dist/server.d.ts +8 -0
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +167 -15
  47. package/dist/spaces/blob.d.ts +51 -0
  48. package/dist/spaces/blob.d.ts.map +1 -0
  49. package/dist/spaces/blob.js +111 -0
  50. package/dist/spaces/credential.d.ts +62 -0
  51. package/dist/spaces/credential.d.ts.map +1 -0
  52. package/dist/spaces/credential.js +184 -0
  53. package/dist/spaces/engine.d.ts +97 -0
  54. package/dist/spaces/engine.d.ts.map +1 -0
  55. package/dist/spaces/engine.js +552 -0
  56. package/dist/spaces/guard.d.ts +41 -0
  57. package/dist/spaces/guard.d.ts.map +1 -0
  58. package/dist/spaces/guard.js +79 -0
  59. package/dist/spaces/identity.d.ts +37 -0
  60. package/dist/spaces/identity.d.ts.map +1 -0
  61. package/dist/spaces/identity.js +88 -0
  62. package/dist/spaces/index.d.ts +55 -0
  63. package/dist/spaces/index.d.ts.map +1 -0
  64. package/dist/spaces/index.js +133 -0
  65. package/dist/spaces/notify.d.ts +51 -0
  66. package/dist/spaces/notify.d.ts.map +1 -0
  67. package/dist/spaces/notify.js +121 -0
  68. package/dist/spaces/records.d.ts +37 -0
  69. package/dist/spaces/records.d.ts.map +1 -0
  70. package/dist/spaces/records.js +63 -0
  71. package/dist/spaces/store.d.ts +51 -0
  72. package/dist/spaces/store.d.ts.map +1 -0
  73. package/dist/spaces/store.js +99 -0
  74. package/dist/spaces/uri.d.ts +60 -0
  75. package/dist/spaces/uri.d.ts.map +1 -0
  76. package/dist/spaces/uri.js +97 -0
  77. package/dist/spaces/verify.d.ts +39 -0
  78. package/dist/spaces/verify.d.ts.map +1 -0
  79. package/dist/spaces/verify.js +87 -0
  80. package/dist/spaces/viewer.d.ts +51 -0
  81. package/dist/spaces/viewer.d.ts.map +1 -0
  82. package/dist/spaces/viewer.js +167 -0
  83. package/dist/spaces/visibility.d.ts +57 -0
  84. package/dist/spaces/visibility.d.ts.map +1 -0
  85. package/dist/spaces/visibility.js +81 -0
  86. package/dist/templates/feed.tpl +4 -0
  87. package/dist/templates/xrpc.tpl +13 -1
  88. package/dist/test.d.ts +2 -0
  89. package/dist/test.d.ts.map +1 -1
  90. package/dist/test.js +8 -5
  91. package/dist/xrpc.d.ts +19 -3
  92. package/dist/xrpc.d.ts.map +1 -1
  93. package/dist/xrpc.js +62 -32
  94. 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
- const order = (params.order || undefined);
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
- subjectUri = `at://${body.subject.did}`;
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
- const limit = parseInt(url.searchParams.get('limit') || '20');
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') || undefined);
365
- // Collect field filters (everything except reserved params)
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
- const result = await handleCallback(oauth, code, state, iss);
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"}