@hatk/hatk 0.0.1-alpha.85 → 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 (86) 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 +13 -0
  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 +157 -22
  10. package/dist/database/index.d.ts +1 -1
  11. package/dist/database/index.d.ts.map +1 -1
  12. package/dist/database/index.js +1 -1
  13. package/dist/database/schema.d.ts +2 -0
  14. package/dist/database/schema.d.ts.map +1 -1
  15. package/dist/database/schema.js +40 -3
  16. package/dist/dev-entry.d.ts.map +1 -1
  17. package/dist/dev-entry.js +2 -2
  18. package/dist/feeds.d.ts +14 -0
  19. package/dist/feeds.d.ts.map +1 -1
  20. package/dist/feeds.js +24 -13
  21. package/dist/hooks.d.ts +9 -0
  22. package/dist/hooks.d.ts.map +1 -1
  23. package/dist/hooks.js +2 -2
  24. package/dist/hydrate.d.ts +42 -0
  25. package/dist/hydrate.d.ts.map +1 -1
  26. package/dist/hydrate.js +18 -5
  27. package/dist/lex-types.d.ts +2 -0
  28. package/dist/lex-types.d.ts.map +1 -1
  29. package/dist/main.js +26 -2
  30. package/dist/oauth/db.d.ts +9 -0
  31. package/dist/oauth/db.d.ts.map +1 -1
  32. package/dist/oauth/db.js +12 -0
  33. package/dist/private-collections.d.ts +7 -2
  34. package/dist/private-collections.d.ts.map +1 -1
  35. package/dist/private-collections.js +9 -4
  36. package/dist/server.d.ts +8 -0
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +99 -10
  39. package/dist/spaces/blob.d.ts +51 -0
  40. package/dist/spaces/blob.d.ts.map +1 -0
  41. package/dist/spaces/blob.js +111 -0
  42. package/dist/spaces/credential.d.ts +62 -0
  43. package/dist/spaces/credential.d.ts.map +1 -0
  44. package/dist/spaces/credential.js +184 -0
  45. package/dist/spaces/engine.d.ts +97 -0
  46. package/dist/spaces/engine.d.ts.map +1 -0
  47. package/dist/spaces/engine.js +552 -0
  48. package/dist/spaces/guard.d.ts +41 -0
  49. package/dist/spaces/guard.d.ts.map +1 -0
  50. package/dist/spaces/guard.js +79 -0
  51. package/dist/spaces/identity.d.ts +37 -0
  52. package/dist/spaces/identity.d.ts.map +1 -0
  53. package/dist/spaces/identity.js +88 -0
  54. package/dist/spaces/index.d.ts +55 -0
  55. package/dist/spaces/index.d.ts.map +1 -0
  56. package/dist/spaces/index.js +133 -0
  57. package/dist/spaces/notify.d.ts +51 -0
  58. package/dist/spaces/notify.d.ts.map +1 -0
  59. package/dist/spaces/notify.js +121 -0
  60. package/dist/spaces/records.d.ts +37 -0
  61. package/dist/spaces/records.d.ts.map +1 -0
  62. package/dist/spaces/records.js +63 -0
  63. package/dist/spaces/store.d.ts +51 -0
  64. package/dist/spaces/store.d.ts.map +1 -0
  65. package/dist/spaces/store.js +99 -0
  66. package/dist/spaces/uri.d.ts +60 -0
  67. package/dist/spaces/uri.d.ts.map +1 -0
  68. package/dist/spaces/uri.js +97 -0
  69. package/dist/spaces/verify.d.ts +39 -0
  70. package/dist/spaces/verify.d.ts.map +1 -0
  71. package/dist/spaces/verify.js +87 -0
  72. package/dist/spaces/viewer.d.ts +51 -0
  73. package/dist/spaces/viewer.d.ts.map +1 -0
  74. package/dist/spaces/viewer.js +167 -0
  75. package/dist/spaces/visibility.d.ts +57 -0
  76. package/dist/spaces/visibility.d.ts.map +1 -0
  77. package/dist/spaces/visibility.js +81 -0
  78. package/dist/templates/feed.tpl +4 -0
  79. package/dist/templates/xrpc.tpl +13 -1
  80. package/dist/test.d.ts +2 -0
  81. package/dist/test.d.ts.map +1 -1
  82. package/dist/test.js +8 -5
  83. package/dist/xrpc.d.ts +19 -3
  84. package/dist/xrpc.d.ts.map +1 -1
  85. package/dist/xrpc.js +61 -31
  86. package/package.json +5 -3
package/dist/oauth/db.js CHANGED
@@ -146,6 +146,18 @@ export async function getSession(did) {
146
146
  const rows = await querySQL('SELECT * FROM _oauth_sessions WHERE did = $1', [did]);
147
147
  return rows.length > 0 ? rows[0] : null;
148
148
  }
149
+ /**
150
+ * Every DID this instance holds a PDS session for, most recently refreshed
151
+ * first.
152
+ *
153
+ * Space indexing reads as a member rather than as itself, so this is the set of
154
+ * people it could borrow a delegation from. Ordered by recency because a
155
+ * session refreshed lately is the one most likely to still work.
156
+ */
157
+ export async function listSessionDids() {
158
+ const rows = (await querySQL('SELECT did FROM _oauth_sessions ORDER BY updated_at DESC, created_at DESC'));
159
+ return rows.map((r) => r.did);
160
+ }
149
161
  export async function deleteSession(did) {
150
162
  await runSQL('DELETE FROM _oauth_sessions WHERE did = $1', [did]);
151
163
  }
@@ -1,8 +1,13 @@
1
1
  export declare function setPrivateCollections(list: string[]): void;
2
2
  export declare function isPrivateCollection(nsid: string | null | undefined): boolean;
3
3
  /**
4
- * The collection segment of an AT-URI: at://{did}/{collection}/{rkey}.
5
- * Splitting yields ['at:', '', did, collection, rkey].
4
+ * The collection segment of a record AT-URI, in either shape.
5
+ *
6
+ * A repo record is `at://{did}/{collection}/{rkey}`; a space record is
7
+ * `at://{authority}/space/{type}/{skey}/{writer}/{collection}/{rkey}`, where
8
+ * the position a collection used to occupy holds the literal 'space'. Reading
9
+ * it positionally without that distinction reports every space record as
10
+ * belonging to a collection named 'space', which no guard would match.
6
11
  */
7
12
  export declare function collectionFromUri(uri: string): string | undefined;
8
13
  //# sourceMappingURL=private-collections.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"private-collections.d.ts","sourceRoot":"","sources":["../src/private-collections.ts"],"names":[],"mappings":"AASA,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAE1D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5E;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGjE"}
1
+ {"version":3,"file":"private-collections.d.ts","sourceRoot":"","sources":["../src/private-collections.ts"],"names":[],"mappings":"AAWA,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAE1D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEjE"}
@@ -1,3 +1,4 @@
1
+ import { collectionFromRecordUri } from "./spaces/uri.js";
1
2
  /**
2
3
  * Collections that are indexed and queryable in-process but must never be
3
4
  * served by the built-in dev.hatk.* record endpoints.
@@ -13,10 +14,14 @@ export function isPrivateCollection(nsid) {
13
14
  return nsid != null && privateCollections.has(nsid);
14
15
  }
15
16
  /**
16
- * The collection segment of an AT-URI: at://{did}/{collection}/{rkey}.
17
- * Splitting yields ['at:', '', did, collection, rkey].
17
+ * The collection segment of a record AT-URI, in either shape.
18
+ *
19
+ * A repo record is `at://{did}/{collection}/{rkey}`; a space record is
20
+ * `at://{authority}/space/{type}/{skey}/{writer}/{collection}/{rkey}`, where
21
+ * the position a collection used to occupy holds the literal 'space'. Reading
22
+ * it positionally without that distinction reports every space record as
23
+ * belonging to a collection named 'space', which no guard would match.
18
24
  */
19
25
  export function collectionFromUri(uri) {
20
- const parts = uri.split('/');
21
- return parts.length > 4 ? parts[3] : undefined;
26
+ return collectionFromRecordUri(uri);
22
27
  }
package/dist/server.d.ts CHANGED
@@ -4,6 +4,12 @@ import type { OAuthConfig } from './config.ts';
4
4
  * This makes them available to callXrpc() for use in SSR and server code.
5
5
  */
6
6
  export declare function registerCoreHandlers(collections: string[], oauth: OAuthConfig | null): void;
7
+ export interface SpaceHandlerConfig {
8
+ serviceDid?: string;
9
+ serviceFragment?: string;
10
+ /** The origin an authority should deliver to, when it is not the request's own. */
11
+ publicUrl?: string;
12
+ }
7
13
  export interface HandlerConfig {
8
14
  collections: string[];
9
15
  publicDir: string | null;
@@ -17,6 +23,8 @@ export interface HandlerConfig {
17
23
  did: string;
18
24
  } | null;
19
25
  onResync?: () => void;
26
+ /** Permissioned-space notice delivery. Absent on an instance that indexes none. */
27
+ spaces?: SpaceHandlerConfig;
20
28
  }
21
29
  /**
22
30
  * Create a Web Standard request handler for all hatk routes.
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAsEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAyD9C;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAuM3F;AAgDD,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,WAAW,GAAG,IAAI,CAAA;IACzB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxF,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAwkC5F;AAGD,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EAAE,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,KAAK,EAAE,WAAW,GAAG,IAAI,EACzB,MAAM,GAAE,MAAM,EAAO,EACrB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,GACpB,OAAO,WAAW,EAAE,MAAM,CAG5B"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAsEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAuE9C;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CA2M3F;AAgDD,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,mFAAmF;IACnF,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,WAAW,GAAG,IAAI,CAAA;IACzB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxF,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,mFAAmF;IACnF,MAAM,CAAC,EAAE,kBAAkB,CAAA;CAC5B;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6pC5F;AAGD,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EAAE,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,KAAK,EAAE,WAAW,GAAG,IAAI,EACzB,MAAM,GAAE,MAAM,EAAO,EACrB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC5D,QAAQ,CAAC,EAAE,MAAM,IAAI,GACpB,OAAO,WAAW,EAAE,MAAM,CAG5B"}
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,6 +18,11 @@ 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";
23
28
  /**
@@ -253,7 +258,11 @@ export function registerCoreHandlers(collections, oauth) {
253
258
  subjectDid = match[1];
254
259
  }
255
260
  else if (body.subject.did) {
256
- 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;
257
266
  subjectDid = body.subject.did;
258
267
  }
259
268
  else {
@@ -382,6 +391,19 @@ export function createHandler(config) {
382
391
  }
383
392
  catch { }
384
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
+ }
385
407
  try {
386
408
  // GET /xrpc/dev.hatk.getRecords?collection=<nsid>&limit=N&cursor=C&<field>=<value>
387
409
  if (url.pathname === coreXrpc('getRecords')) {
@@ -449,10 +471,7 @@ export function createHandler(config) {
449
471
  return withCors(jsonError(400, 'Missing feed parameter', acceptEncoding));
450
472
  const limit = parseInt(url.searchParams.get('limit') || '30');
451
473
  const cursor = url.searchParams.get('cursor') || undefined;
452
- const params = {};
453
- for (const [key, value] of url.searchParams) {
454
- params[key] = value;
455
- }
474
+ const params = paramsFromSearch(url.searchParams);
456
475
  const result = await executeFeed(feedName, params, cursor, limit, viewer);
457
476
  if (!result)
458
477
  return withCors(jsonError(404, `Unknown feed: ${feedName}`, acceptEncoding));
@@ -862,6 +881,12 @@ export function createHandler(config) {
862
881
  }, 200, acceptEncoding));
863
882
  }
864
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
+ }
865
890
  // OAuth well-known endpoints
866
891
  if (url.pathname === '/.well-known/oauth-authorization-server' && oauth) {
867
892
  return withCors(json(getAuthServerMetadata(oauth.issuer, oauth), 200, acceptEncoding));
@@ -1214,14 +1239,68 @@ export function createHandler(config) {
1214
1239
  return withCors(file(png, 'image/png', 'public, max-age=300'));
1215
1240
  }
1216
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
+ }
1217
1299
  if (url.pathname.startsWith('/xrpc/')) {
1218
1300
  const method = url.pathname.slice('/xrpc/'.length);
1219
1301
  const limit = parseInt(url.searchParams.get('limit') || '20');
1220
1302
  const cursor = url.searchParams.get('cursor') || undefined;
1221
- const params = {};
1222
- for (const [key, value] of url.searchParams) {
1223
- params[key] = value;
1224
- }
1303
+ const params = paramsFromSearch(url.searchParams);
1225
1304
  // Parse request body for POST (procedures)
1226
1305
  let input;
1227
1306
  if (request.method === 'POST') {
@@ -1252,6 +1331,16 @@ export function createHandler(config) {
1252
1331
  throw err;
1253
1332
  }
1254
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
+ }
1255
1344
  // GET /blob/:did/:cid — dev-only image proxy (see blobUrl in xrpc.ts).
1256
1345
  // A PDS serves getBlob as an attachment with nosniff, which a browser
1257
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"}