@pdsjs/spaces 2.0.1 → 2.0.2

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.
@@ -8,12 +8,11 @@
8
8
  // does not own — it is signed by the authority, so it verifies without
9
9
  // contacting them.
10
10
 
11
- import { cborDecode } from '@pdsjs/core/repo';
11
+ import { cborDecode, findBlobRefs } from '@pdsjs/core/repo';
12
12
  import { serializeRepo } from '../car.js';
13
13
  import { RepoCommit } from '../commit.js';
14
- import { SpaceTokenError } from '../token.js';
15
14
  import { parseSpaceUri } from '../uri.js';
16
- import { verifySpaceCredential } from './auth.js';
15
+ import { createReadAuthorizer } from './auth.js';
17
16
 
18
17
  /**
19
18
  * @param {string} error
@@ -106,9 +105,41 @@ async function buildSignedCommit({ spaceStorage, space, author, signer }) {
106
105
  );
107
106
  }
108
107
 
108
+ /**
109
+ * Every blob CID a space's records reference, paired with the rev of the commit
110
+ * that wrote the referencing record.
111
+ *
112
+ * Read from the records themselves rather than from a link table. A space repo
113
+ * on a personal server is small, and a table that can drift is the one thing a
114
+ * blob's reachability must not rest on.
115
+ *
116
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} spaceStorage
117
+ * @param {string} space
118
+ * @returns {Promise<Map<string, string>>} blob CID -> earliest referencing rev
119
+ */
120
+ async function spaceBlobRefs(spaceStorage, space) {
121
+ /** @type {Map<string, string>} */
122
+ const refs = new Map();
123
+ const paths = await spaceStorage.listAllSpaceRecords(space);
124
+ for (const path of paths) {
125
+ const row = await spaceStorage.getSpaceRecord(
126
+ space,
127
+ path.collection,
128
+ path.rkey,
129
+ );
130
+ if (!row) continue;
131
+ for (const cid of findBlobRefs(cborDecode(row.value))) {
132
+ const seen = refs.get(cid);
133
+ if (seen === undefined || row.repoRev < seen) refs.set(cid, row.repoRev);
134
+ }
135
+ }
136
+ return refs;
137
+ }
138
+
109
139
  /**
110
140
  * @param {Object} ctx
111
141
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
142
+ * @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs
112
143
  * @param {() => Promise<string|null>} ctx.getDid
113
144
  * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
114
145
  * @param {(did: string) => Promise<any>} ctx.resolveDid
@@ -116,45 +147,9 @@ async function buildSignedCommit({ spaceStorage, space, author, signer }) {
116
147
  * @returns {import('@pdsjs/core/pds').Routes}
117
148
  */
118
149
  export function createReadRoutes(ctx) {
119
- const { spaceStorage, getDid, getSigner, resolveDid, verifier } = ctx;
150
+ const { spaceStorage, blobs, getDid, getSigner, resolveDid, verifier } = ctx;
120
151
 
121
- /**
122
- * Authorize a read: the hosted account itself, or a valid space credential for
123
- * the space being read.
124
- *
125
- * @param {Request} request
126
- * @param {string} space
127
- * @param {{did: string}|null} auth
128
- * @returns {Promise<Response|null>} a Response to return, or null to proceed
129
- */
130
- async function authorizeRead(request, space, auth) {
131
- const hosted = await getDid();
132
- if (auth && auth.did === hosted) return null;
133
-
134
- const header = request.headers.get('authorization') ?? '';
135
- const match = header.match(/^Bearer\s+(.+)$/i);
136
- if (!match) {
137
- return errorResponse(
138
- 'AuthenticationRequired',
139
- 'A session or space credential is required',
140
- 401,
141
- );
142
- }
143
- try {
144
- await verifySpaceCredential({
145
- credential: match[1],
146
- space,
147
- resolveDid,
148
- verifier,
149
- });
150
- return null;
151
- } catch (err) {
152
- if (err instanceof SpaceTokenError) {
153
- return errorResponse(err.code, err.message, 401);
154
- }
155
- throw err;
156
- }
157
- }
152
+ const authorizeRead = createReadAuthorizer({ getDid, resolveDid, verifier });
158
153
 
159
154
  /**
160
155
  * The repo a request targets. `repo` is optional on the wire; this PDS hosts
@@ -171,6 +166,27 @@ export function createReadRoutes(ctx) {
171
166
  return did;
172
167
  }
173
168
 
169
+ /** @type {import('@pdsjs/core/pds').Route} */
170
+ const latestCommitRoute = {
171
+ auth: 'optional',
172
+ handler: async (request, url, auth) => {
173
+ const target = readTarget(url);
174
+ if (target instanceof Response) return target;
175
+ const denied = await authorizeRead(request, target.space, auth);
176
+ if (denied) return denied;
177
+ const did = await resolveRepo(target.repo);
178
+ if (did instanceof Response) return did;
179
+
180
+ const commit = await buildSignedCommit({
181
+ spaceStorage,
182
+ space: target.space,
183
+ author: did,
184
+ signer: await getSigner(),
185
+ });
186
+ return Response.json(commit ? { commit: toJsonCommit(commit) } : {});
187
+ },
188
+ };
189
+
174
190
  return {
175
191
  '/xrpc/com.atproto.space.getRecord': {
176
192
  auth: 'optional',
@@ -221,29 +237,26 @@ export function createReadRoutes(ctx) {
221
237
  const did = await resolveRepo(target.repo);
222
238
  if (did instanceof Response) return did;
223
239
 
224
- const limit = readLimit(url, 'limit', 50, 100);
240
+ const limit = readLimit(url, 'limit', 50, 1000);
225
241
  if (limit instanceof Response) return limit;
226
242
 
227
- const collection = url.searchParams.get('collection');
228
- if (!collection) {
229
- return errorResponse(
230
- 'InvalidRequest',
231
- 'collection is required by this implementation',
232
- );
233
- }
234
-
235
243
  const { records, cursor } = await spaceStorage.listSpaceRecords(
236
244
  target.space,
237
- collection,
238
- url.searchParams.get('cursor'),
239
- limit,
245
+ {
246
+ collection: url.searchParams.get('collection'),
247
+ cursor: url.searchParams.get('cursor'),
248
+ limit,
249
+ reverse: url.searchParams.get('reverse') === 'true',
250
+ excludeValues: url.searchParams.get('excludeValues') === 'true',
251
+ },
240
252
  );
241
253
  return Response.json({
242
254
  ...(cursor ? { cursor } : {}),
243
255
  records: records.map((r) => ({
244
- uri: `${target.space}/${did}/${r.collection}/${r.rkey}`,
256
+ collection: r.collection,
257
+ rkey: r.rkey,
245
258
  cid: r.cid,
246
- value: cborDecode(r.value),
259
+ ...(r.value === undefined ? {} : { value: cborDecode(r.value) }),
247
260
  })),
248
261
  });
249
262
  },
@@ -316,7 +329,17 @@ export function createReadRoutes(ctx) {
316
329
  },
317
330
  },
318
331
 
319
- '/xrpc/com.atproto.space.getLatestCommit': {
332
+ '/xrpc/com.atproto.space.getLatestCommit': latestCommitRoute,
333
+
334
+ // The same handler under the name this method shipped as before the draft
335
+ // renamed it. Both are in use, and a client that knows only one reads the
336
+ // whole feature as missing.
337
+ '/xrpc/com.atproto.space.getRepoState': latestCommitRoute,
338
+
339
+ // Blobs are uploaded through com.atproto.repo.uploadBlob and referenced
340
+ // from a space record, so a client writing blob-bearing records needs a
341
+ // blob permission alongside its space permission.
342
+ '/xrpc/com.atproto.space.listBlobs': {
320
343
  auth: 'optional',
321
344
  handler: async (request, url, auth) => {
322
345
  const target = readTarget(url);
@@ -326,13 +349,65 @@ export function createReadRoutes(ctx) {
326
349
  const did = await resolveRepo(target.repo);
327
350
  if (did instanceof Response) return did;
328
351
 
329
- const commit = await buildSignedCommit({
330
- spaceStorage,
331
- space: target.space,
332
- author: did,
333
- signer: await getSigner(),
352
+ const limit = readLimit(url, 'limit', 500, 1000);
353
+ if (limit instanceof Response) return limit;
354
+ const since = url.searchParams.get('since');
355
+ const cursor = url.searchParams.get('cursor');
356
+
357
+ const refs = await spaceBlobRefs(spaceStorage, target.space);
358
+ const cids = [...refs]
359
+ .filter(([, rev]) => (since ? rev > since : true))
360
+ .map(([cid]) => cid)
361
+ .filter((cid) => (cursor ? cid > cursor : true))
362
+ .sort();
363
+ const page = cids.slice(0, limit);
364
+
365
+ return Response.json({
366
+ cids: page,
367
+ ...(page.length < limit ? {} : { cursor: page[page.length - 1] }),
368
+ });
369
+ },
370
+ },
371
+
372
+ // Scoped to the space: a credential admits its holder to that space, not to
373
+ // the account's whole blob store. The reference serves any blob the account
374
+ // holds once the space read passes.
375
+ '/xrpc/com.atproto.space.getBlob': {
376
+ auth: 'optional',
377
+ handler: async (request, url, auth) => {
378
+ const target = readTarget(url);
379
+ if (target instanceof Response) return target;
380
+ const denied = await authorizeRead(request, target.space, auth);
381
+ if (denied) return denied;
382
+ const did = await resolveRepo(target.repo);
383
+ if (did instanceof Response) return did;
384
+
385
+ const cid = url.searchParams.get('cid');
386
+ if (!cid) return errorResponse('InvalidRequest', 'cid is required');
387
+
388
+ const refs = await spaceBlobRefs(spaceStorage, target.space);
389
+ if (!refs.has(cid)) {
390
+ return errorResponse(
391
+ 'BlobNotFound',
392
+ `No record in ${target.space} references ${cid}`,
393
+ 404,
394
+ );
395
+ }
396
+
397
+ const blob = await blobs.get(did, cid);
398
+ if (!blob) {
399
+ return errorResponse('BlobNotFound', 'Blob not found', 404);
400
+ }
401
+ return new Response(/** @type {BodyInit} */ (blob.data), {
402
+ headers: {
403
+ 'Content-Type': blob.mimeType,
404
+ 'X-Content-Type-Options': 'nosniff',
405
+ // Force download and block execution so a hostile blob cannot run
406
+ // in the PDS origin.
407
+ 'Content-Disposition': `attachment; filename="${cid}"`,
408
+ 'Content-Security-Policy': "default-src 'none'; sandbox",
409
+ },
334
410
  });
335
- return Response.json(commit ? { commit: toJsonCommit(commit) } : {});
336
411
  },
337
412
  },
338
413
 
@@ -360,9 +435,19 @@ export function createReadRoutes(ctx) {
360
435
  );
361
436
  }
362
437
 
438
+ const excludeValues = url.searchParams.get('excludeValues') === 'true';
363
439
  const paths = await spaceStorage.listAllSpaceRecords(target.space);
440
+ /** @type {import('../car.js').SerializedRecord[]} */
364
441
  const records = [];
365
442
  for (const p of paths) {
443
+ if (excludeValues) {
444
+ records.push({
445
+ collection: p.collection,
446
+ rkey: p.rkey,
447
+ cid: p.cid,
448
+ });
449
+ continue;
450
+ }
366
451
  const row = await spaceStorage.getSpaceRecord(
367
452
  target.space,
368
453
  p.collection,
@@ -378,7 +463,7 @@ export function createReadRoutes(ctx) {
378
463
  }
379
464
  }
380
465
 
381
- const car = await serializeRepo(commit, records);
466
+ const car = await serializeRepo(commit, records, { excludeValues });
382
467
  // Copy into a plain ArrayBuffer: a Uint8Array view is not a BodyInit
383
468
  // per the DOM lib types, and `.buffer` widens to ArrayBufferLike, which
384
469
  // includes SharedArrayBuffer. The copy keeps this honest rather than
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * @param {Object} ctx
3
3
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
4
+ * @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
5
+ * @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
4
6
  * @param {() => Promise<string|null>} ctx.getDid
5
7
  * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
6
8
  * @param {(did: string) => Promise<any>} ctx.resolveDid
@@ -9,6 +11,8 @@
9
11
  */
10
12
  export declare function createWriteRoutes(ctx: {
11
13
  spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
14
+ linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
15
+ unlinkBlobs: (recordUri: string) => Promise<void>;
12
16
  getDid: () => Promise<string | null>;
13
17
  getSigner: () => Promise<{
14
18
  sign: (bytes: Uint8Array) => Promise<Uint8Array>;
@@ -7,10 +7,11 @@
7
7
  // Authorization is two checks: the caller may only write its own repo, and the
8
8
  // token's `space:` scope must cover the target space, action and collection.
9
9
 
10
- import { createTid } from '@pdsjs/core/repo';
10
+ import { createTid, findBlobRefs, recordTimeOf } from '@pdsjs/core/repo';
11
11
  import { ScopePermissions } from '@pdsjs/core/scope';
12
12
  import { spaceHostEndpoint } from '../authority.js';
13
13
  import { LtHash } from '../lthash.js';
14
+ import { forwardToSyncers } from '../notify.js';
14
15
  import { createServiceAuth } from '../service-auth.js';
15
16
  import { parseSpaceUri } from '../uri.js';
16
17
  import { applyWrites, SpaceWriteError } from '../writer.js';
@@ -142,9 +143,32 @@ function recordUri(space, did, collection, rkey) {
142
143
  return `${space}/${did}/${collection}/${rkey}`;
143
144
  }
144
145
 
146
+ const WRITE_ACTIONS = ['create', 'update', 'delete'];
147
+
148
+ /**
149
+ * Which of the three operations a write in an applyWrites batch is.
150
+ *
151
+ * The proposal made these a closed union discriminated by `$type` after clients
152
+ * had shipped against an `action` string. Both say the same thing, so both are
153
+ * read; anything else is a write this server cannot place.
154
+ *
155
+ * @param {any} write
156
+ * @returns {'create'|'update'|'delete'|null}
157
+ */
158
+ function writeAction(write) {
159
+ const fromType = String(write?.$type ?? '').match(
160
+ /^com\.atproto\.space\.applyWrites#(create|update|delete)$/,
161
+ );
162
+ if (fromType) return /** @type {any} */ (fromType[1]);
163
+ if (WRITE_ACTIONS.includes(write?.action)) return write.action;
164
+ return null;
165
+ }
166
+
145
167
  /**
146
168
  * @param {Object} ctx
147
169
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
170
+ * @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
171
+ * @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
148
172
  * @param {() => Promise<string|null>} ctx.getDid
149
173
  * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
150
174
  * @param {(did: string) => Promise<any>} ctx.resolveDid
@@ -152,7 +176,33 @@ function recordUri(space, did, collection, rkey) {
152
176
  * @returns {import('@pdsjs/core/pds').Routes}
153
177
  */
154
178
  export function createWriteRoutes(ctx) {
155
- const { spaceStorage, getDid, getSigner, resolveDid } = ctx;
179
+ const { spaceStorage, linkBlob, unlinkBlobs, getDid, getSigner, resolveDid } =
180
+ ctx;
181
+
182
+ /**
183
+ * Record which blobs a space write leaves referenced.
184
+ *
185
+ * The links go in the account's one blob link table, beside those of public
186
+ * records. Orphan reaping asks that table whether anything still points at a
187
+ * blob, so a space record protects its blobs the same way a public record
188
+ * does, with no second table for the reaper to be taught about.
189
+ *
190
+ * @param {string} space
191
+ * @param {string} did
192
+ * @param {import('../writer.js').SpaceWriteInput[]} writes
193
+ * @returns {Promise<void>}
194
+ */
195
+ async function indexBlobs(space, did, writes) {
196
+ for (const write of writes) {
197
+ const uri = recordUri(space, did, write.collection, write.rkey);
198
+ // An update replaces the record's references rather than adding to them.
199
+ await unlinkBlobs(uri);
200
+ if (write.action === 'delete') continue;
201
+ for (const cid of findBlobRefs(write.record)) {
202
+ await linkBlob(cid, uri, recordTimeOf(write.record));
203
+ }
204
+ }
205
+ }
156
206
 
157
207
  /**
158
208
  * Tell the space authority this repo advanced, so it can maintain the writer
@@ -170,13 +220,21 @@ export function createWriteRoutes(ctx) {
170
220
  const { spaceDid } = parseSpaceUri(space);
171
221
  const did = await getDid();
172
222
  if (!did) return;
173
- // Writing into our own space: record the writer directly instead of
174
- // notifying ourselves over HTTP. Skipping entirely would leave the
175
- // authority out of its own writer set, and every syncer walking
176
- // listRepos would silently miss this repo.
223
+ // Writing into our own space: this server is both the repo host and the
224
+ // authority, so it does what the authority would have done on receiving
225
+ // the notification. Both halves of that, not just the first a syncer
226
+ // registered for this space is owed the notice however the write reached
227
+ // it, and a Durable Object cannot post to itself to be told.
177
228
  if (spaceDid === did) {
178
229
  const hash = await new LtHash(commit.setHash).digest();
179
230
  await spaceStorage.putSpaceWriter(space, did, commit.rev, hash);
231
+ await forwardToSyncers(ctx, {
232
+ authorityDid: did,
233
+ space,
234
+ repo: did,
235
+ rev: commit.rev,
236
+ hash,
237
+ });
180
238
  return;
181
239
  }
182
240
  const didDoc = await resolveDid(spaceDid);
@@ -264,6 +322,11 @@ export function createWriteRoutes(ctx) {
264
322
  space: body.space,
265
323
  writes: [write],
266
324
  });
325
+ // After the commit: the write is what makes the references real, and a
326
+ // link to a record that failed to write would keep a blob alive forever.
327
+ await indexBlobs(body.space, did, [
328
+ { ...write, rkey: commit.results[0].rkey },
329
+ ]);
267
330
  await fireNotifyWrite(body.space, commit);
268
331
  return Response.json(toBody(body, commit.results[0], did));
269
332
  } catch (err) {
@@ -366,7 +429,6 @@ export function createWriteRoutes(ctx) {
366
429
  /** @type {import('../writer.js').SpaceWriteInput[]} */
367
430
  const writes = [];
368
431
  for (const w of body.writes) {
369
- const type = w?.$type;
370
432
  const collection = w?.collection;
371
433
  if (typeof collection !== 'string') {
372
434
  return errorResponse(
@@ -374,27 +436,24 @@ export function createWriteRoutes(ctx) {
374
436
  'each write needs a collection',
375
437
  );
376
438
  }
377
- if (type === 'com.atproto.space.applyWrites#create') {
378
- writes.push({
379
- action: 'create',
380
- collection,
381
- rkey: w.rkey ?? createTid(),
382
- record: w.value,
383
- });
384
- } else if (type === 'com.atproto.space.applyWrites#update') {
439
+ const action = writeAction(w);
440
+ if (!action) {
441
+ return errorResponse(
442
+ 'InvalidRequest',
443
+ `Unsupported write type: ${w?.$type ?? w?.action ?? '(none)'}`,
444
+ );
445
+ }
446
+ if (action === 'delete') {
447
+ writes.push({ action, collection, rkey: w.rkey });
448
+ } else {
385
449
  writes.push({
386
- action: 'update',
450
+ action,
387
451
  collection,
388
- rkey: w.rkey,
452
+ // A create may leave the server to mint the key. An empty string
453
+ // is how the earlier clients say so.
454
+ rkey: action === 'create' ? w.rkey || createTid() : w.rkey,
389
455
  record: w.value,
390
456
  });
391
- } else if (type === 'com.atproto.space.applyWrites#delete') {
392
- writes.push({ action: 'delete', collection, rkey: w.rkey });
393
- } else {
394
- return errorResponse(
395
- 'InvalidRequest',
396
- `Unsupported write type: ${type}`,
397
- );
398
457
  }
399
458
  }
400
459
 
@@ -411,6 +470,11 @@ export function createWriteRoutes(ctx) {
411
470
  space: body.space,
412
471
  writes,
413
472
  });
473
+ await indexBlobs(
474
+ body.space,
475
+ did,
476
+ writes.map((w, i) => ({ ...w, rkey: commit.results[i].rkey })),
477
+ );
414
478
  await fireNotifyWrite(body.space, commit);
415
479
  return Response.json({
416
480
  results: commit.results.map((r) =>
@@ -1,3 +1,17 @@
1
+ /**
2
+ * The blob store and blob link table createSpaceRoutes wants, in memory. A real
3
+ * deployment passes the account's own store and link table; here they are two
4
+ * Maps that answer the same way.
5
+ *
6
+ * @returns {{blobs: import('@pdsjs/core/ports').BlobPort, linkBlob: (blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>, unlinkBlobs: (recordUri: string) => Promise<void>, links: Map<string, Set<string>>, put: (cid: string, data: Uint8Array, mimeType?: string) => void}}
7
+ */
8
+ export declare function createMemoryBlobContext(): {
9
+ blobs: import('@pdsjs/core/ports').BlobPort;
10
+ linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
11
+ unlinkBlobs: (recordUri: string) => Promise<void>;
12
+ links: Map<string, Set<string>>;
13
+ put: (cid: string, data: Uint8Array, mimeType?: string) => void;
14
+ };
1
15
  /**
2
16
  * @returns {import('@pdsjs/core/ports').SpaceStoragePort}
3
17
  */
@@ -1,9 +1,54 @@
1
- // @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort.
1
+ // @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort, and the blob side
2
+ // of the route context.
2
3
  //
3
4
  // Backs unit tests and gives handler tests a store with no database. Keyed
4
5
  // exactly like the SQL adapters so the shared conformance suite exercises the
5
6
  // same behaviour everywhere.
6
7
 
8
+ /**
9
+ * The blob store and blob link table createSpaceRoutes wants, in memory. A real
10
+ * deployment passes the account's own store and link table; here they are two
11
+ * Maps that answer the same way.
12
+ *
13
+ * @returns {{blobs: import('@pdsjs/core/ports').BlobPort, linkBlob: (blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>, unlinkBlobs: (recordUri: string) => Promise<void>, links: Map<string, Set<string>>, put: (cid: string, data: Uint8Array, mimeType?: string) => void}}
14
+ */
15
+ export function createMemoryBlobContext() {
16
+ /** @type {Map<string, {data: Uint8Array, mimeType: string}>} */
17
+ const stored = new Map();
18
+ /** @type {Map<string, Set<string>>} record uri -> blob cids */
19
+ const links = new Map();
20
+
21
+ return {
22
+ blobs: {
23
+ async get(_did, cid) {
24
+ const blob = stored.get(cid);
25
+ return blob ? { ...blob, data: new Uint8Array(blob.data) } : null;
26
+ },
27
+ async put(_did, cid, data, mimeType) {
28
+ stored.set(cid, { data: new Uint8Array(data), mimeType });
29
+ },
30
+ async delete(_did, cid) {
31
+ stored.delete(cid);
32
+ },
33
+ },
34
+ async linkBlob(blobCid, recordUri) {
35
+ let forRecord = links.get(recordUri);
36
+ if (!forRecord) {
37
+ forRecord = new Set();
38
+ links.set(recordUri, forRecord);
39
+ }
40
+ forRecord.add(blobCid);
41
+ },
42
+ async unlinkBlobs(recordUri) {
43
+ links.delete(recordUri);
44
+ },
45
+ links,
46
+ put(cid, data, mimeType = 'application/octet-stream') {
47
+ stored.set(cid, { data, mimeType });
48
+ },
49
+ };
50
+ }
51
+
7
52
  /**
8
53
  * Keyset pagination over a sorted key list.
9
54
  * @template T
@@ -117,17 +162,31 @@ export function createMemorySpaceStorage() {
117
162
  return records.get(space)?.get(`${collection}/${rkey}`)?.cid ?? null;
118
163
  },
119
164
 
120
- async listSpaceRecords(space, collection, cursor, limit) {
165
+ async listSpaceRecords(
166
+ space,
167
+ { collection, cursor, limit, reverse, excludeValues },
168
+ ) {
121
169
  const inSpace = records.get(space) ?? new Map();
122
- const keys = [...inSpace.values()]
123
- .filter((r) => r.collection === collection)
124
- .map((r) => r.rkey)
170
+ let keys = [...inSpace.values()]
171
+ .filter((r) => !collection || r.collection === collection)
172
+ .map((r) => `${r.collection}/${r.rkey}`)
125
173
  .sort();
126
- const { items, cursor: next } = page(keys, cursor, limit, (rkey) => {
127
- const row = inSpace.get(`${collection}/${rkey}`);
128
- return { ...row, value: new Uint8Array(row.value) };
174
+ // Descending unless the caller asks otherwise, matching the reference.
175
+ if (!reverse) keys.reverse();
176
+ if (cursor) {
177
+ keys = keys.filter((k) => (reverse ? k > cursor : k < cursor));
178
+ }
179
+ const items = keys.slice(0, limit).map((key) => {
180
+ const row = inSpace.get(key);
181
+ const { value, ...rest } = row;
182
+ return excludeValues
183
+ ? { ...rest }
184
+ : { ...rest, value: new Uint8Array(value) };
129
185
  });
130
- return { records: items, cursor: next };
186
+ return {
187
+ records: items,
188
+ cursor: items.length === limit ? keys[limit - 1] : null,
189
+ };
131
190
  },
132
191
 
133
192
  async listAllSpaceRecords(space) {
@@ -226,19 +285,27 @@ export function createMemorySpaceStorage() {
226
285
  space,
227
286
  serviceDid,
228
287
  serviceEndpoint,
229
- lastIssuedAt,
288
+ expiresAt,
230
289
  ) {
231
290
  let inSpace = recipients.get(space);
232
291
  if (!inSpace) {
233
292
  inSpace = new Map();
234
293
  recipients.set(space, inSpace);
235
294
  }
236
- inSpace.set(serviceDid, { serviceDid, serviceEndpoint, lastIssuedAt });
295
+ inSpace.set(serviceDid, { serviceDid, serviceEndpoint, expiresAt });
296
+ },
297
+
298
+ async deleteCredentialRecipient(space, serviceDid) {
299
+ recipients.get(space)?.delete(serviceDid);
237
300
  },
238
301
 
239
302
  async listCredentialRecipients(space) {
240
303
  const inSpace = recipients.get(space) ?? new Map();
241
- return [...inSpace.keys()].sort().map((k) => ({ ...inSpace.get(k) }));
304
+ const now = new Date().toISOString();
305
+ return [...inSpace.keys()]
306
+ .sort()
307
+ .map((k) => ({ ...inSpace.get(k) }))
308
+ .filter((r) => r.expiresAt > now);
242
309
  },
243
310
  };
244
311
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Forward a write notice to every service registered for this space.
3
+ *
4
+ * Best effort, and deliberately so: sync correctness rests on comparing set
5
+ * hashes, so a syncer that misses a notice finds out on the next one or on its
6
+ * own next walk. One unreachable recipient must not fail the others, and none of
7
+ * them may fail the write that produced the notice.
8
+ *
9
+ * The caller awaits this, which puts one round trip per recipient in front of
10
+ * the response. A Worker cancels a promise still running when it answers, and
11
+ * this package holds no execution context to hand the work to, so backgrounding
12
+ * it here would mean dropping notifications on Cloudflare.
13
+ *
14
+ * @param {Object} ctx
15
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
16
+ * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
17
+ * @param {typeof fetch} [ctx.fetch]
18
+ * @param {Object} notice
19
+ * @param {string} notice.authorityDid - the space's authority, which signs
20
+ * @param {string} notice.space
21
+ * @param {string} notice.repo - the account whose repo advanced
22
+ * @param {string} notice.rev
23
+ * @param {Uint8Array} notice.hash - the repo's commit hash after the write
24
+ * @returns {Promise<void>}
25
+ */
26
+ export declare function forwardToSyncers(ctx: {
27
+ spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
28
+ getSigner: () => Promise<{
29
+ sign: (bytes: Uint8Array) => Promise<Uint8Array>;
30
+ }>;
31
+ fetch?: typeof fetch;
32
+ }, { authorityDid, space, repo, rev, hash }: {
33
+ authorityDid: string;
34
+ space: string;
35
+ repo: string;
36
+ rev: string;
37
+ hash: Uint8Array;
38
+ }): Promise<void>;