@interop/was-client 0.41.0 → 0.43.0

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 (104) hide show
  1. package/dist/Collection.d.ts.map +1 -1
  2. package/dist/Collection.js +27 -83
  3. package/dist/Collection.js.map +1 -1
  4. package/dist/Resource.d.ts +10 -7
  5. package/dist/Resource.d.ts.map +1 -1
  6. package/dist/Resource.js +32 -99
  7. package/dist/Resource.js.map +1 -1
  8. package/dist/codec.d.ts +18 -4
  9. package/dist/codec.d.ts.map +1 -1
  10. package/dist/codec.js.map +1 -1
  11. package/dist/edv/EdvCodec.d.ts +77 -8
  12. package/dist/edv/EdvCodec.d.ts.map +1 -1
  13. package/dist/edv/EdvCodec.js +361 -224
  14. package/dist/edv/EdvCodec.js.map +1 -1
  15. package/dist/edv/WasTransport.js +2 -2
  16. package/dist/edv/WasTransport.js.map +1 -1
  17. package/dist/edv/constants.d.ts +0 -8
  18. package/dist/edv/constants.d.ts.map +1 -1
  19. package/dist/edv/constants.js +0 -8
  20. package/dist/edv/constants.js.map +1 -1
  21. package/dist/edv/descriptorStore.d.ts +10 -6
  22. package/dist/edv/descriptorStore.d.ts.map +1 -1
  23. package/dist/edv/descriptorStore.js +5 -3
  24. package/dist/edv/descriptorStore.js.map +1 -1
  25. package/dist/edv/didKeyRecipient.d.ts.map +1 -1
  26. package/dist/edv/didKeyRecipient.js +1 -1
  27. package/dist/edv/didKeyRecipient.js.map +1 -1
  28. package/dist/edv/docCipher.d.ts.map +1 -1
  29. package/dist/edv/docCipher.js +22 -38
  30. package/dist/edv/docCipher.js.map +1 -1
  31. package/dist/edv/epochCrypto.d.ts +7 -0
  32. package/dist/edv/epochCrypto.d.ts.map +1 -1
  33. package/dist/edv/epochCrypto.js +12 -5
  34. package/dist/edv/epochCrypto.js.map +1 -1
  35. package/dist/edv/epochKeys.d.ts.map +1 -1
  36. package/dist/edv/epochKeys.js +21 -37
  37. package/dist/edv/epochKeys.js.map +1 -1
  38. package/dist/edv/epochRoster.d.ts +24 -0
  39. package/dist/edv/epochRoster.d.ts.map +1 -1
  40. package/dist/edv/epochRoster.js +26 -0
  41. package/dist/edv/epochRoster.js.map +1 -1
  42. package/dist/edv/index.d.ts +2 -2
  43. package/dist/edv/index.d.ts.map +1 -1
  44. package/dist/edv/index.js +1 -1
  45. package/dist/edv/index.js.map +1 -1
  46. package/dist/edv/recipients.d.ts +2 -2
  47. package/dist/edv/recipients.d.ts.map +1 -1
  48. package/dist/edv/recipients.js +238 -202
  49. package/dist/edv/recipients.js.map +1 -1
  50. package/dist/internal/codec.d.ts.map +1 -1
  51. package/dist/internal/codec.js +5 -18
  52. package/dist/internal/codec.js.map +1 -1
  53. package/dist/internal/conditional.d.ts +33 -2
  54. package/dist/internal/conditional.d.ts.map +1 -1
  55. package/dist/internal/conditional.js +52 -0
  56. package/dist/internal/conditional.js.map +1 -1
  57. package/dist/internal/content.d.ts +7 -0
  58. package/dist/internal/content.d.ts.map +1 -1
  59. package/dist/internal/content.js +7 -0
  60. package/dist/internal/content.js.map +1 -1
  61. package/dist/internal/features.d.ts.map +1 -1
  62. package/dist/internal/features.js +9 -9
  63. package/dist/internal/features.js.map +1 -1
  64. package/dist/internal/indexSchema.d.ts +0 -10
  65. package/dist/internal/indexSchema.d.ts.map +1 -1
  66. package/dist/internal/indexSchema.js +1 -1
  67. package/dist/internal/indexSchema.js.map +1 -1
  68. package/dist/internal/memo.d.ts +39 -0
  69. package/dist/internal/memo.d.ts.map +1 -0
  70. package/dist/internal/memo.js +59 -0
  71. package/dist/internal/memo.js.map +1 -0
  72. package/dist/internal/meta.d.ts +92 -0
  73. package/dist/internal/meta.d.ts.map +1 -0
  74. package/dist/internal/meta.js +126 -0
  75. package/dist/internal/meta.js.map +1 -0
  76. package/dist/internal/pagination.js +1 -1
  77. package/dist/internal/pagination.js.map +1 -1
  78. package/dist/internal/withCodec.d.ts +26 -0
  79. package/dist/internal/withCodec.d.ts.map +1 -0
  80. package/dist/internal/withCodec.js +15 -0
  81. package/dist/internal/withCodec.js.map +1 -0
  82. package/dist/internal/write.d.ts +5 -8
  83. package/dist/internal/write.d.ts.map +1 -1
  84. package/dist/internal/write.js +25 -11
  85. package/dist/internal/write.js.map +1 -1
  86. package/dist/log/logStore.d.ts +6 -3
  87. package/dist/log/logStore.d.ts.map +1 -1
  88. package/dist/log/logStore.js +6 -3
  89. package/dist/log/logStore.js.map +1 -1
  90. package/dist/sync/cid.d.ts.map +1 -1
  91. package/dist/sync/cid.js +1 -6
  92. package/dist/sync/cid.js.map +1 -1
  93. package/dist/sync/index.d.ts +2 -1
  94. package/dist/sync/index.d.ts.map +1 -1
  95. package/dist/sync/index.js +5 -1
  96. package/dist/sync/index.js.map +1 -1
  97. package/dist/sync/port.d.ts +0 -14
  98. package/dist/sync/port.d.ts.map +1 -1
  99. package/dist/sync/port.js +6 -19
  100. package/dist/sync/port.js.map +1 -1
  101. package/dist/sync/provisioning.d.ts.map +1 -1
  102. package/dist/sync/provisioning.js +23 -2
  103. package/dist/sync/provisioning.js.map +1 -1
  104. package/package.json +1 -1
@@ -47,9 +47,13 @@
47
47
  * `conditionalWrites`, so the write path pre-reads the current envelope and
48
48
  * hands it to `encode`: an update advances `sequence` from its prior value and
49
49
  * pins the write to the server's current ETag via `If-Match`, while a fresh
50
- * insert (`sequence: 0`) is guarded by `If-None-Match: *`. A stale write
51
- * surfaces as a `PreconditionFailedError` (412) -- the lost-update guard --
52
- * rather than the old advisory last-writer-wins. Against a backend that does
50
+ * insert (`sequence: 0`) is guarded by `If-None-Match: *`. A caller that
51
+ * named its own baseline (`put({ ifMatch })`) keeps it: the write is pinned
52
+ * to the revision the caller last saw rather than to the codec's pre-read, so
53
+ * a compare-and-swap loop works the same on an encrypted collection as on a
54
+ * plaintext one. A stale write surfaces as a `PreconditionFailedError` (412)
55
+ * -- the lost-update guard -- rather than the old advisory
56
+ * last-writer-wins. Against a backend that does
53
57
  * not advertise `conditional-writes` (no ETag) an update degrades to
54
58
  * advisory, and a create-by-put is refused by the write path (the masked-404
55
59
  * pre-read could otherwise silently clobber; see `upsertResource`).
@@ -76,8 +80,8 @@ import { isEncryptedEnvelope } from '../sync/envelope.js';
76
80
  import { resolveEpochKeys } from './epochKeys.js';
77
81
  import { didKeyResolver, epochKeyIdFor } from './epochCrypto.js';
78
82
  import { resolveHmacKey } from './hmacKey.js';
79
- import { isBlob, isTextContentType, readJsonData, resolvePayload } from '../internal/content.js';
80
- import { DEFAULT_CONTENT_TYPE, EDV_SCHEME_VERSION, envelopeBytes, LOCAL_SPACE_ID } from './constants.js';
83
+ import { DECODER, isBlob, isTextContentType, readJsonData, resolvePayload } from '../internal/content.js';
84
+ import { DEFAULT_CONTENT_TYPE, EDV_SCHEME_VERSION, envelopeBytes } from './constants.js';
81
85
  /**
82
86
  * Default threshold above which an encrypted binary write is routed to the
83
87
  * chunked-stream path instead of being sealed into one document, measured in
@@ -99,6 +103,26 @@ const DEFAULT_MAX_BLOB_BYTES = 512 * 1024;
99
103
  * decrypted document self-describing alongside `'utf-8'` and `'base64'`.
100
104
  */
101
105
  const CHUNKED_ENCODING = 'chunked';
106
+ /**
107
+ * Builds the transport factory for a Collection reachable over WAS: the
108
+ * codec's route to its own document and chunk resources on the server.
109
+ *
110
+ * @param options {object}
111
+ * @param options.spaceId {string} the Space holding the Collection
112
+ * @param options.collectionId {string} the Collection
113
+ * @param options.contentType {string} stored envelope content type
114
+ * @returns {CodecTransportFactory}
115
+ */
116
+ export function wasTransportFactory({ spaceId, collectionId, contentType }) {
117
+ return ({ context, documentHeaders }) => new WasTransport({
118
+ was: { request: input => context.request(input) },
119
+ spaceId,
120
+ collectionId,
121
+ contentType,
122
+ features: context.features,
123
+ ...(documentHeaders !== undefined && { documentHeaders })
124
+ });
125
+ }
102
126
  /**
103
127
  * A shared strict UTF-8 decoder used to test whether a non-JSON payload is
104
128
  * valid UTF-8 (so it can be stored legibly as text rather than base64).
@@ -109,13 +133,6 @@ const CHUNKED_ENCODING = 'chunked';
109
133
  * instance is reused.
110
134
  */
111
135
  const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
112
- /**
113
- * A shared lenient UTF-8 decoder for a JWE protected header (already proven
114
- * authentic by a successful decrypt). Deliberately NOT {@link UTF8_DECODER},
115
- * whose `fatal: true` would turn malformed bytes into a throw rather than the
116
- * documented `undefined` return.
117
- */
118
- const HEADER_DECODER = new TextDecoder();
119
136
  /**
120
137
  * Builds the AEAD-bound `was` protected-header parameter: the scheme version
121
138
  * and key epoch always, and then exactly one slot marker -- the resource id
@@ -242,11 +259,13 @@ export class EdvCodec {
242
259
  */
243
260
  #version;
244
261
  /**
245
- * The id of the Space holding this codec's Collection. Needed only by the
246
- * chunked-stream path, which addresses the document and its chunks through a
247
- * `WasTransport` of its own.
262
+ * Builds the `WasTransport` the chunked-stream paths drive, or `undefined`
263
+ * on a codec with no server behind it (the local-replica cipher builds).
264
+ * The factory is the codec's only way to address a resource by path, so a
265
+ * codec built without one cannot reach the network at all -- there is no id
266
+ * it could fabricate a route from.
248
267
  */
249
- #spaceId;
268
+ #transportFactory;
250
269
  /**
251
270
  * The id of the Collection this codec was built for. It is bound into the
252
271
  * Collection metadata envelope's `was.collection` on write and required to
@@ -309,8 +328,9 @@ export class EdvCodec {
309
328
  * JWE ciphertext, content-addressed)
310
329
  * @param [options.version] {number} the EDV-over-WAS scheme version to bind
311
330
  * into each envelope's `was.v` (defaults to {@link EDV_SCHEME_VERSION})
312
- * @param options.spaceId {string} the Space holding the Collection, for the
313
- * chunked-stream path's own transport
331
+ * @param [options.transportFactory] {CodecTransportFactory} builds the
332
+ * transport the chunked-stream path drives; omitted by a build with no
333
+ * server behind it, which then refuses that path
314
334
  * @param options.collectionId {string} the Collection this codec reads and
315
335
  * writes: bound into the Collection metadata envelope's `was.collection`
316
336
  * (and checked on read), and it labels decrypt-routing errors
@@ -320,7 +340,7 @@ export class EdvCodec {
320
340
  * @param [options.hmac] {BlindingKey} the collection's blinded-index key,
321
341
  * where it declares one
322
342
  */
323
- constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, chunkSize, idDerivation, version, spaceId, collectionId, epochIds, hmac }) {
343
+ constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, chunkSize, idDerivation, version, transportFactory, collectionId, epochIds, hmac }) {
324
344
  this.#edv = edv;
325
345
  this.#recipients =
326
346
  edv.documentCipher.createDefaultRecipients(keyAgreementKey);
@@ -331,7 +351,7 @@ export class EdvCodec {
331
351
  this.#chunkSize = chunkSize;
332
352
  this.#idDerivation = idDerivation;
333
353
  this.#version = version ?? EDV_SCHEME_VERSION;
334
- this.#spaceId = spaceId;
354
+ this.#transportFactory = transportFactory;
335
355
  this.#collectionId = collectionId;
336
356
  this.#epochIds = new Set(epochIds);
337
357
  this.#blindingKey = hmac ?? null;
@@ -382,6 +402,9 @@ export class EdvCodec {
382
402
  * all -- the residual footgun the name check cannot see, e.g. a compound
383
403
  * index queried by something other than a leading prefix of its members.
384
404
  *
405
+ * Reached only through the `indexing` adapter, which exists only on a codec
406
+ * whose (readonly) blinding key is non-null, so the key is non-null here.
407
+ *
385
408
  * @param input {object}
386
409
  * @param [input.equals] {object | object[]}
387
410
  * @param [input.has] {string | string[]}
@@ -389,10 +412,6 @@ export class EdvCodec {
389
412
  */
390
413
  async #buildQuery({ equals, has }) {
391
414
  const hmac = this.#blindingKey;
392
- if (hmac === null) {
393
- throw new EncryptionError(`Collection ${this.#collectionId} declares no blinded-index key, so ` +
394
- 'its contents cannot be searched.');
395
- }
396
415
  assertQueryAttributes({ schema: this.#schema, equals, has });
397
416
  const query = (await this.#edv.indexHelper.buildQuery({
398
417
  hmac,
@@ -412,7 +431,7 @@ export class EdvCodec {
412
431
  /**
413
432
  * @inheritdoc
414
433
  */
415
- async encode({ id, data, contentType, current }) {
434
+ async encode({ id, data, contentType, current, precondition }) {
416
435
  if (id !== undefined && !current) {
417
436
  try {
418
437
  // A full multibase decode + multihash length check (the same assertion
@@ -469,16 +488,6 @@ export class EdvCodec {
469
488
  priorDoc = read;
470
489
  }
471
490
  const { documentCipher } = this.#edv;
472
- // Bind an AEAD-authenticated `was` parameter into the JWE protected header:
473
- // the scheme version, the resource id when known at encrypt time (omitted
474
- // for a content-derived id, which does not exist until after encryption),
475
- // and the write epoch. A server that swaps two envelopes between ids (or
476
- // replays one under a rolled-back epoch) is then detected on decrypt.
477
- const was = wasParam({
478
- version: this.#version,
479
- resource: docId,
480
- epoch: this.#writeEpoch
481
- });
482
491
  const encrypted = await documentCipher.encrypt({
483
492
  doc: {
484
493
  ...(docId !== undefined && { id: docId }),
@@ -496,15 +505,22 @@ export class EdvCodec {
496
505
  // state that recomputation updates.
497
506
  ...(priorDoc?.indexed !== undefined && { indexed: priorDoc.indexed })
498
507
  },
499
- recipients: this.#recipients,
500
- keyResolver: this.#edv.keyResolver,
508
+ // Bind an AEAD-authenticated `was` parameter into the JWE protected
509
+ // header: the scheme version, the resource id when known at encrypt time
510
+ // (omitted for a content-derived id, which does not exist until after
511
+ // encryption), and the write epoch. A server that swaps two envelopes
512
+ // between ids (or replays one under a rolled-back epoch) is then detected
513
+ // on decrypt.
514
+ //
501
515
  // Blind the declared attributes into the envelope's cleartext `indexed`
502
516
  // entries, so the server can match a search without decrypting anything.
503
517
  // Absent a blinding key or a declared attribute there is nothing to
504
518
  // index, and the prior entries passed above are stored verbatim.
505
- hmac: this.#writeBlindingKey(),
506
- update: priorDoc !== null,
507
- additionalProtectedParams: { was }
519
+ ...this.#sealParams({
520
+ ...(docId !== undefined && { resource: docId }),
521
+ hmac: this.#writeBlindingKey()
522
+ }),
523
+ update: priorDoc !== null
508
524
  });
509
525
  if (docId === undefined) {
510
526
  // Encrypt-then-stamp: the id lives in the cleartext envelope, outside the
@@ -513,30 +529,49 @@ export class EdvCodec {
513
529
  docId = await documentCipher.deriveId({ jwe: encrypted.jwe });
514
530
  encrypted.id = docId;
515
531
  }
532
+ // Serialized on first read of `body` and kept for any later read, so the
533
+ // wire bytes are built once per write and only when someone asks for them.
534
+ let bytes;
516
535
  return {
517
536
  id: docId,
518
- body: envelopeBytes(encrypted),
537
+ // Lazy: the HTTP write path reads `body` and pays the serialization, and
538
+ // a local-replica consumer reads `envelope` instead and pays nothing.
539
+ // Enumerable and forced by a spread or a structured clone, so a consumer
540
+ // that copies this object still sees the same bytes under the same key.
541
+ get body() {
542
+ bytes ??= envelopeBytes(encrypted);
543
+ return bytes;
544
+ },
519
545
  // The same envelope in object form, so a consumer holding a local replica
520
- // (the sync `DocCipher`) need not decode and re-parse the bytes it was
521
- // just serialized from. `body` remains the wire truth.
546
+ // (the sync `DocCipher`) need not force `body` and re-parse the bytes it
547
+ // was just serialized from. `body` remains the wire truth.
522
548
  envelope: encrypted,
523
549
  contentType: this.#contentType,
524
550
  // Surface the plaintext content type (the server-opaque envelope type
525
551
  // stays `contentType`) so `add()` reports the real resource type.
526
552
  resourceContentType: meta.contentType,
527
- // Pin an update to the server's current ETag; guard a fresh insert with
528
- // create-if-absent. An update's `If-Match` carries a server-provided ETag,
529
- // so it degrades to an advisory write against a backend without the
530
- // conditional-writes feature (the ETag is absent). A fresh insert's
531
- // `If-None-Match: *` needs no server-provided validator and so is emitted
532
- // unconditionally by design -- it expresses the insert's intent
533
- // (create-only-if-absent). A backend that does not honor it would ignore
534
- // it, so the write path refuses the insert-after-null-pre-read up front
535
- // on such a backend (see `upsertResource`) -- a masked-404 pre-read must
536
- // not silently overwrite an existing document there.
537
- ...(priorDoc
538
- ? { ifMatch: readEtag(current ?? null) }
539
- : { ifNoneMatch: true }),
553
+ // The caller's own compare-and-swap baseline wins when they named one:
554
+ // an `If-Match` derived from this codec's pre-read would pin the write to
555
+ // current server state rather than to the revision the caller last saw,
556
+ // which silently turns a lost-update guard into last-write-wins. The
557
+ // write path has already checked the caller's baseline against the
558
+ // pre-read, so the two agree by the time this runs.
559
+ //
560
+ // Otherwise pin an update to the server's current ETag and guard a fresh
561
+ // insert with create-if-absent. An update's `If-Match` carries a
562
+ // server-provided ETag, so it degrades to an advisory write against a
563
+ // backend without the conditional-writes feature (the ETag is absent). A
564
+ // fresh insert's `If-None-Match: *` needs no server-provided validator
565
+ // and so is emitted unconditionally by design -- it expresses the
566
+ // insert's intent (create-only-if-absent). A backend that does not honor
567
+ // it would ignore it, so the write path refuses the
568
+ // insert-after-null-pre-read up front on such a backend (see
569
+ // `upsertResource`) -- a masked-404 pre-read must not silently overwrite
570
+ // an existing document there.
571
+ ...(precondition ??
572
+ (priorDoc
573
+ ? { ifMatch: readEtag(current ?? null) }
574
+ : { ifNoneMatch: true })),
540
575
  // Stamp the key epoch this write encrypted under (the `currentEpoch`), so
541
576
  // the server records it and a reader can pick the epoch key.
542
577
  epoch: this.#writeEpoch
@@ -544,9 +579,9 @@ export class EdvCodec {
544
579
  }
545
580
  /**
546
581
  * Builds the `WasTransport` the chunked-stream paths drive, over the signed
547
- * requester core supplied and this codec's own Space/Collection. The
548
- * handle's memoized feature probe is passed straight through, so the
549
- * transport's own affordance gates cost no extra descriptor read.
582
+ * requester core supplied and this codec's injected factory. The handle's
583
+ * memoized feature probe is passed straight through, so the transport's own
584
+ * affordance gates cost no extra descriptor read.
550
585
  *
551
586
  * @param context {CodecRequestContext}
552
587
  * @param [documentHeaders] {Record<string, string>} extra headers for
@@ -554,14 +589,14 @@ export class EdvCodec {
554
589
  * @returns {WasTransport}
555
590
  */
556
591
  #transportFor(context, documentHeaders) {
557
- return new WasTransport({
558
- was: { request: input => context.request(input) },
559
- spaceId: this.#spaceId,
560
- collectionId: this.#collectionId,
561
- contentType: this.#contentType,
562
- features: context.features,
563
- ...(documentHeaders !== undefined && { documentHeaders })
564
- });
592
+ if (this.#transportFactory === undefined) {
593
+ throw new NotSupportedError(`Collection "${this.#collectionId}" has no server behind it: this ` +
594
+ 'codec was built for a local replica and holds no route to address ' +
595
+ 'a document and its chunk resources with. Chunked encrypted blobs ' +
596
+ 'can only be read and written through a Collection handle bound to ' +
597
+ 'a Space.');
598
+ }
599
+ return this.#transportFactory({ context, documentHeaders });
565
600
  }
566
601
  /**
567
602
  * Refuses the operation unless the collection's backend advertises the
@@ -627,11 +662,6 @@ export class EdvCodec {
627
662
  * @returns {ChunkedWrite}
628
663
  */
629
664
  #chunkedWrite({ id, stream, meta }) {
630
- const was = wasParam({
631
- version: this.#version,
632
- resource: id,
633
- epoch: this.#writeEpoch
634
- });
635
665
  return {
636
666
  chunked: true,
637
667
  id,
@@ -658,10 +688,10 @@ export class EdvCodec {
658
688
  ...(this.#chunkSize !== undefined && {
659
689
  chunkSize: this.#chunkSize
660
690
  }),
661
- recipients: this.#recipients,
662
- keyResolver: this.#edv.keyResolver,
663
- hmac: this.#writeBlindingKey(),
664
- additionalProtectedParams: { was },
691
+ ...this.#sealParams({
692
+ resource: id,
693
+ hmac: this.#writeBlindingKey()
694
+ }),
665
695
  transport
666
696
  });
667
697
  }
@@ -721,6 +751,39 @@ export class EdvCodec {
721
751
  }
722
752
  return this.#blindingKey;
723
753
  }
754
+ /**
755
+ * The recipient/AAD wiring every write seals with: the epoch recipients, the
756
+ * key resolver that resolves them, the blinding key the caller decided on,
757
+ * and the AEAD-bound `was` protected-header parameter carrying this codec's
758
+ * scheme version, the write epoch, and the caller's slot marker. Spread into
759
+ * each `encrypt`/`insert` call so the single-document, chunked and metadata
760
+ * write paths cannot drift apart.
761
+ *
762
+ * @param options {object}
763
+ * @param [options.resource] {string} the resource id the envelope binds
764
+ * (absent for a content-derived id, which does not exist until after
765
+ * encryption)
766
+ * @param [options.collection] {string} the collection id the envelope binds
767
+ * (the Collection metadata slot only, never alongside `resource`)
768
+ * @param options.hmac {BlindingKey | undefined} the blinding key to index
769
+ * with, or `undefined` to emit no blinded entries
770
+ * @returns {{ recipients: IRecipientTemplate[], keyResolver: IKeyResolver,
771
+ * hmac: BlindingKey, additionalProtectedParams: { was: object } }}
772
+ */
773
+ #sealParams({ resource, collection, hmac }) {
774
+ const was = wasParam({
775
+ version: this.#version,
776
+ ...(resource !== undefined && { resource }),
777
+ ...(collection !== undefined && { collection }),
778
+ epoch: this.#writeEpoch
779
+ });
780
+ return {
781
+ recipients: this.#recipients,
782
+ keyResolver: this.#edv.keyResolver,
783
+ hmac,
784
+ additionalProtectedParams: { was }
785
+ };
786
+ }
724
787
  /**
725
788
  * @inheritdoc
726
789
  */
@@ -940,39 +1003,18 @@ export class EdvCodec {
940
1003
  * envelope stamps its scheme version) -- {@link EncryptionError}. Greater
941
1004
  * than this codec's scheme version: a future-scheme envelope this client
942
1005
  * does not implement -- {@link EncryptionError}.
943
- * Then the slot markers, which declare positively which of the profile's
944
- * slots the envelope was written for -- `was.resource` a resource slot,
945
- * `was.collection` the Collection metadata slot, neither a content-derived
946
- * content envelope. A read of the Collection metadata slot
947
- * (`collectionSlot`) enforces:
948
- *
949
- * - `was.resource` present: refused outright -- a resource's envelope was
950
- * served in the Collection's metadata slot, which belongs to no resource --
951
- * {@link IntegrityError}.
952
- * - `was.collection` missing or not a string: refused -- the envelope belongs
953
- * to some other slot, notably a content-derived content envelope, whose
954
- * member set is otherwise identical -- {@link IntegrityError}.
955
- * - `was.collection` not this codec's collection id: one Collection's
956
- * metadata served as another's -- {@link IntegrityError}.
957
- *
958
- * A read of a resource slot (content or resource metadata) enforces:
959
- *
960
- * - `was.collection` present: refused before any id comparison -- the
961
- * Collection's metadata envelope was served in a resource slot --
962
- * {@link IntegrityError}.
963
- * - `was.resource` present and the expected id known: a mismatch is a server-side
964
- * swap of two resources' envelopes -- {@link IntegrityError}.
965
- * - `resource` absent (a content-derived write) and the expected
966
- * id known: the envelope's ciphertext must re-derive to the expected id
967
- * ({@link EdvDocumentCipher.deriveId}); a mismatch means the envelope was
968
- * copied under a different id -- {@link IntegrityError}.
969
- *
970
- * Finally, unconditionally:
971
- * - `was.epoch` missing or not a string: refused like a missing `was` --
972
- * {@link EncryptionError}. Present, it must equal the epoch (the `did:key`
973
- * before the `#`) of the key that actually decrypted -- a mismatch is a
974
- * replay under a different epoch's key -- {@link IntegrityError}. The check
975
- * is unconditional: there is no epoch-less envelope to carve out.
1006
+ * - Then the slot markers, which declare positively which of the profile's
1007
+ * slots the envelope was written for -- `was.resource` a resource slot,
1008
+ * `was.collection` the Collection metadata slot, neither a content-derived
1009
+ * content envelope. A read of the Collection metadata slot
1010
+ * (`collectionSlot`) is checked by `#verifyCollectionSlot`, a read of a
1011
+ * resource slot by `#verifyResourceSlot`.
1012
+ * - Finally, unconditionally: `was.epoch` missing or not a string: refused
1013
+ * like a missing `was` -- {@link EncryptionError}. Present, it must equal
1014
+ * the epoch (the `did:key` before the `#`) of the key that actually
1015
+ * decrypted -- a mismatch is a replay under a different epoch's key --
1016
+ * {@link IntegrityError}. The check is unconditional: there is no
1017
+ * epoch-less envelope to carve out.
976
1018
  *
977
1019
  * @param options {object}
978
1020
  * @param options.jwe {unknown} the envelope's JWE (its `protected` header is
@@ -1010,55 +1052,10 @@ export class EdvCodec {
1010
1052
  `${this.#version}) does not implement. Upgrade the client.`);
1011
1053
  }
1012
1054
  if (collectionSlot) {
1013
- if (typeof was.resource === 'string') {
1014
- throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
1015
- `bound to resource "${was.resource}", but the Collection metadata ` +
1016
- "slot belongs to no resource. The server swapped a resource's " +
1017
- "metadata envelope into the Collection's metadata slot.");
1018
- }
1019
- if (typeof was.collection !== 'string') {
1020
- throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope ` +
1021
- 'binds no `was.collection`, so it was written for some other slot ' +
1022
- '(a content envelope, whose binding is otherwise identical). The ' +
1023
- "server served a foreign envelope in the Collection's metadata slot.");
1024
- }
1025
- if (was.collection !== this.#collectionId) {
1026
- throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
1027
- `bound to collection "${was.collection}", not to the requested ` +
1028
- `collection ("${this.#collectionId}"). The server served one ` +
1029
- "Collection's metadata as another's.");
1030
- }
1055
+ this.#verifyCollectionSlot(was);
1031
1056
  }
1032
1057
  else {
1033
- // A resource slot (content or resource metadata). The Collection's own
1034
- // metadata envelope has no business here, whatever id it names, so it is
1035
- // refused before any resource-id comparison.
1036
- if (was.collection !== undefined) {
1037
- throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to ` +
1038
- `collection "${String(was.collection)}", but a resource's slot ` +
1039
- 'belongs to no collection binding. The server served the ' +
1040
- "Collection's own metadata envelope in a resource's slot.");
1041
- }
1042
- if (typeof was.resource === 'string') {
1043
- if (expectedId !== undefined && was.resource !== expectedId) {
1044
- throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to a ` +
1045
- `different resource id ("${was.resource}") than the one requested ` +
1046
- `("${expectedId}"). The server swapped two resources' envelopes.`);
1047
- }
1048
- }
1049
- else if (expectedId !== undefined) {
1050
- // Content-derived write (no `resource`): the id is a function of the
1051
- // ciphertext, so re-derive and compare.
1052
- const { documentCipher } = this.#edv;
1053
- const derived = await documentCipher.deriveId({
1054
- jwe: jwe
1055
- });
1056
- if (derived !== expectedId) {
1057
- throw new IntegrityError(`Cannot decrypt this resource: its content-derived id ("${derived}") ` +
1058
- `does not match the requested id ("${expectedId}"). The server ` +
1059
- 'served this envelope under an id it was not written for.');
1060
- }
1061
- }
1058
+ await this.#verifyResourceSlot({ was, expectedId, jwe });
1062
1059
  }
1063
1060
  if (typeof was.epoch !== 'string') {
1064
1061
  throw new EncryptionError('Cannot decrypt this resource: its envelope binds no `was.epoch`. ' +
@@ -1075,6 +1072,96 @@ export class EdvCodec {
1075
1072
  }
1076
1073
  return typeof was.resource === 'string' ? was.resource : undefined;
1077
1074
  }
1075
+ /**
1076
+ * The slot-marker half of `#verifyBinding` for a read of the Collection
1077
+ * metadata slot. Enforces:
1078
+ *
1079
+ * - `was.resource` present: refused outright -- a resource's envelope was
1080
+ * served in the Collection's metadata slot, which belongs to no resource --
1081
+ * {@link IntegrityError}.
1082
+ * - `was.collection` missing or not a string: refused -- the envelope belongs
1083
+ * to some other slot, notably a content-derived content envelope, whose
1084
+ * member set is otherwise identical -- {@link IntegrityError}.
1085
+ * - `was.collection` not this codec's collection id: one Collection's
1086
+ * metadata served as another's -- {@link IntegrityError}.
1087
+ *
1088
+ * @param was {Record<string, unknown>} the parsed `was` protected-header
1089
+ * parameter
1090
+ * @returns {void}
1091
+ */
1092
+ #verifyCollectionSlot(was) {
1093
+ if (typeof was.resource === 'string') {
1094
+ throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
1095
+ `bound to resource "${was.resource}", but the Collection metadata ` +
1096
+ "slot belongs to no resource. The server swapped a resource's " +
1097
+ "metadata envelope into the Collection's metadata slot.");
1098
+ }
1099
+ if (typeof was.collection !== 'string') {
1100
+ throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope ` +
1101
+ 'binds no `was.collection`, so it was written for some other slot ' +
1102
+ '(a content envelope, whose binding is otherwise identical). The ' +
1103
+ "server served a foreign envelope in the Collection's metadata slot.");
1104
+ }
1105
+ if (was.collection !== this.#collectionId) {
1106
+ throw new IntegrityError(`Cannot decrypt this Collection's metadata: the stored envelope is ` +
1107
+ `bound to collection "${was.collection}", not to the requested ` +
1108
+ `collection ("${this.#collectionId}"). The server served one ` +
1109
+ "Collection's metadata as another's.");
1110
+ }
1111
+ }
1112
+ /**
1113
+ * The slot-marker half of `#verifyBinding` for a read of a resource slot
1114
+ * (content or resource metadata). Enforces:
1115
+ *
1116
+ * - `was.collection` present: refused before any id comparison -- the
1117
+ * Collection's metadata envelope was served in a resource slot --
1118
+ * {@link IntegrityError}.
1119
+ * - `was.resource` present and the expected id known: a mismatch is a
1120
+ * server-side swap of two resources' envelopes -- {@link IntegrityError}.
1121
+ * - `resource` absent (a content-derived write) and the expected
1122
+ * id known: the envelope's ciphertext must re-derive to the expected id
1123
+ * ({@link EdvDocumentCipher.deriveId}); a mismatch means the envelope was
1124
+ * copied under a different id -- {@link IntegrityError}.
1125
+ *
1126
+ * @param options {object}
1127
+ * @param options.was {Record<string, unknown>} the parsed `was`
1128
+ * protected-header parameter
1129
+ * @param [options.expectedId] {string} the resource id the read targeted
1130
+ * @param options.jwe {unknown} the envelope's JWE, whose ciphertext the
1131
+ * content-derived branch re-derives the id from
1132
+ * @returns {Promise<void>}
1133
+ */
1134
+ async #verifyResourceSlot({ was, expectedId, jwe }) {
1135
+ // A resource slot (content or resource metadata). The Collection's own
1136
+ // metadata envelope has no business here, whatever id it names, so it is
1137
+ // refused before any resource-id comparison.
1138
+ if (was.collection !== undefined) {
1139
+ throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to ` +
1140
+ `collection "${String(was.collection)}", but a resource's slot ` +
1141
+ 'belongs to no collection binding. The server served the ' +
1142
+ "Collection's own metadata envelope in a resource's slot.");
1143
+ }
1144
+ if (typeof was.resource === 'string') {
1145
+ if (expectedId !== undefined && was.resource !== expectedId) {
1146
+ throw new IntegrityError(`Cannot decrypt this resource: the stored envelope is bound to a ` +
1147
+ `different resource id ("${was.resource}") than the one requested ` +
1148
+ `("${expectedId}"). The server swapped two resources' envelopes.`);
1149
+ }
1150
+ }
1151
+ else if (expectedId !== undefined) {
1152
+ // Content-derived write (no `resource`): the id is a function of the
1153
+ // ciphertext, so re-derive and compare.
1154
+ const { documentCipher } = this.#edv;
1155
+ const derived = await documentCipher.deriveId({
1156
+ jwe: jwe
1157
+ });
1158
+ if (derived !== expectedId) {
1159
+ throw new IntegrityError(`Cannot decrypt this resource: its content-derived id ("${derived}") ` +
1160
+ `does not match the requested id ("${expectedId}"). The server ` +
1161
+ 'served this envelope under an id it was not written for.');
1162
+ }
1163
+ }
1164
+ }
1078
1165
  /**
1079
1166
  * @inheritdoc
1080
1167
  *
@@ -1100,27 +1187,23 @@ export class EdvCodec {
1100
1187
  // marker) served in the Collection metadata slot is then detected too. It
1101
1188
  // seals to the current epoch key like every write, so it binds `was.epoch`
1102
1189
  // like every write.
1103
- const was = wasParam({
1104
- version: this.#version,
1105
- ...(resourceId === undefined
1106
- ? { collection: this.#collectionId }
1107
- : { resource: resourceId }),
1108
- epoch: this.#writeEpoch
1109
- });
1110
1190
  const encrypted = await documentCipher.encrypt({
1111
1191
  doc: { id, content: custom },
1112
- recipients: this.#recipients,
1113
- keyResolver: this.#edv.keyResolver,
1114
- // Deliberately un-blinded, even on a searchable collection: this envelope
1115
- // is the WAS `/meta` value, not part of the resource's content document,
1116
- // and it is stored in a different slot the search endpoint never reads.
1117
- // The `meta.*` attribute paths a declared index may name address the
1118
- // *content document's* own `meta` (the content type and inline-encoding
1119
- // discriminator), which is a different object entirely -- so blinding
1120
- // here would emit entries that can never match a query and would leak the
1121
- // shape of the metadata into a slot with no index at all.
1122
- hmac: undefined,
1123
- additionalProtectedParams: { was }
1192
+ ...this.#sealParams({
1193
+ ...(resourceId === undefined
1194
+ ? { collection: this.#collectionId }
1195
+ : { resource: resourceId }),
1196
+ // Deliberately un-blinded, even on a searchable collection: this
1197
+ // envelope is the WAS `/meta` value, not part of the resource's content
1198
+ // document, and it is stored in a different slot the search endpoint
1199
+ // never reads. The `meta.*` attribute paths a declared index may name
1200
+ // address the *content document's* own `meta` (the content type and
1201
+ // inline-encoding discriminator), which is a different object entirely
1202
+ // -- so blinding here would emit entries that can never match a query
1203
+ // and would leak the shape of the metadata into a slot with no index at
1204
+ // all.
1205
+ hmac: undefined
1206
+ })
1124
1207
  });
1125
1208
  // Surface the epoch this envelope sealed under: a Collection-level `/meta`
1126
1209
  // PUT carries it as the body's top-level `epoch` stamp (the server clears
@@ -1392,7 +1475,12 @@ function parseWasHeader(jwe) {
1392
1475
  }
1393
1476
  let parsed;
1394
1477
  try {
1395
- parsed = JSON.parse(HEADER_DECODER.decode(base64urlnopad.decode(protectedHeader)));
1478
+ parsed = JSON.parse(
1479
+ // The shared lenient decoder, deliberately NOT `UTF8_DECODER`: its
1480
+ // `fatal: true` would turn malformed bytes into a throw rather than the
1481
+ // documented `undefined` return. The header is already proven authentic
1482
+ // by a successful decrypt.
1483
+ DECODER.decode(base64urlnopad.decode(protectedHeader)));
1396
1484
  }
1397
1485
  catch {
1398
1486
  return undefined;
@@ -1459,66 +1547,117 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
1459
1547
  if (scheme !== EDV_SCHEME) {
1460
1548
  return null;
1461
1549
  }
1462
- const descriptor = guardEncryptionDescriptor({
1550
+ // Guard the descriptor before consulting the keystore, so a collection
1551
+ // whose descriptor cannot be opened reports THAT rather than the vaguer
1552
+ // "holds no keys" the null return below would produce. `buildEdvCodec`
1553
+ // guards again for callers that reach it directly; the guard is pure, so
1554
+ // running it twice costs nothing.
1555
+ guardEncryptionDescriptor({
1463
1556
  label: `${spaceId}/${collectionId}`,
1464
1557
  encryption
1465
1558
  });
1466
- const descriptorVersion = descriptor.version;
1467
1559
  // Prefer override-supplied keys; otherwise consult the keystore.
1468
1560
  const resolved = keys ??
1469
1561
  (await resolveKeys({ spaceId, collectionId }));
1470
1562
  if (!resolved) {
1471
1563
  return null;
1472
1564
  }
1473
- // Resolve the reader's per-epoch keys from the descriptor -- the
1474
- // `currentEpoch` key pair for writes, every epoch key it can unwrap for
1475
- // reads -- and drive the cipher with those. The reader's own
1476
- // key-agreement key never encrypts or decrypts resources itself; it
1477
- // only unwraps epoch keys. Non-null: the epochs guard above already
1478
- // refused a descriptor without epochs, the one case resolveEpochKeys
1479
- // resolves null for.
1480
- const epochKeys = (await resolveEpochKeys({
1481
- encryption: descriptor,
1482
- keyAgreementKey: resolved.keyAgreementKey
1483
- }));
1484
- // Epoch keys are self-describing did:key key-agreement keys, so a
1485
- // resource's recipient (the epoch public key) resolves through the
1486
- // standard did:key resolver, independent of the reader's own keystore.
1487
- const keyAgreementKey = epochKeys.writeKey;
1488
- // The collection's blinding key: an explicitly supplied one (a keystore
1489
- // custodying the HMAC key itself) wins over unwrapping the descriptor's
1490
- // `hmac` member; `null` means the collection declares no blinded index.
1491
- const hmac = resolved.hmac ??
1492
- (await resolveHmacKey({
1493
- encryption: descriptor,
1494
- keyAgreementKey: resolved.keyAgreementKey
1495
- }));
1496
- const edv = new EdvClientCore({
1497
- keyAgreementKey,
1498
- keyResolver: didKeyResolver,
1499
- ...(hmac !== null && { hmac })
1500
- });
1501
- return new EdvCodec({
1502
- edv,
1503
- keyAgreementKey,
1504
- hmac,
1505
- readKeys: epochKeys.readKeys,
1506
- writeEpoch: epochKeys.writeEpoch,
1565
+ return buildEdvCodec({
1566
+ label: `${spaceId}/${collectionId}`,
1567
+ transportFactory: wasTransportFactory({
1568
+ spaceId,
1569
+ collectionId,
1570
+ contentType
1571
+ }),
1572
+ collectionId,
1573
+ encryption,
1574
+ keys: resolved,
1507
1575
  contentType,
1508
1576
  maxBlobBytes,
1509
1577
  ...(chunkSize !== undefined && { chunkSize }),
1510
- idDerivation,
1511
- version: descriptorVersion ?? EDV_SCHEME_VERSION,
1512
- spaceId,
1513
- collectionId,
1514
- // Every epoch the descriptor lists, recipient of it or not, so
1515
- // decrypt routing can tell "not a recipient of this epoch" apart
1516
- // from "descriptor has never seen this epoch".
1517
- epochIds: descriptor.epochs.map(epoch => epoch.id)
1578
+ idDerivation
1518
1579
  });
1519
1580
  }
1520
1581
  };
1521
1582
  }
1583
+ /**
1584
+ * Builds the {@link EdvCodec} for one encrypted collection from a reader's
1585
+ * keys and the collection's encryption descriptor. The whole of the codec
1586
+ * build that follows key resolution: the fail-closed descriptor guard, the
1587
+ * reader's per-epoch keys, the collection's blinding key, and the EDV core the
1588
+ * codec drives. Shared by the keystore provider (`createEdvEncryption`'s
1589
+ * `codecFor`, which resolves the keys first) and by the local-replica cipher,
1590
+ * which holds its keys already.
1591
+ *
1592
+ * @param options {object}
1593
+ * @param options.collectionId {string} the collection's WAS id
1594
+ * @param [options.label] {string} how the collection is named in errors;
1595
+ * defaults to its id alone, which is all a build with no Space knows
1596
+ * @param [options.transportFactory] {CodecTransportFactory} builds the
1597
+ * transport the chunked-stream path drives; omitted by the local-replica
1598
+ * build, which has no server to address
1599
+ * @param [options.encryption] {CollectionEncryption} the collection's
1600
+ * encryption descriptor; must carry the key-epoch roster
1601
+ * @param options.keys {EdvKeys} the reader's key material
1602
+ * @param options.idDerivation {'random' | 'content'} how `add()` mints ids
1603
+ * @param [options.contentType] {string} stored envelope content type
1604
+ * @param [options.maxBlobBytes] {number} the single-document threshold
1605
+ * @param [options.chunkSize] {number} the size of each encrypted chunk
1606
+ * @returns {Promise<EdvCodec>}
1607
+ */
1608
+ export async function buildEdvCodec({ collectionId, label = `"${collectionId}"`, transportFactory, encryption, keys, idDerivation, contentType = DEFAULT_CONTENT_TYPE, maxBlobBytes = DEFAULT_MAX_BLOB_BYTES, chunkSize }) {
1609
+ const descriptor = guardEncryptionDescriptor({
1610
+ label,
1611
+ encryption
1612
+ });
1613
+ const descriptorVersion = descriptor.version;
1614
+ // Resolve the reader's per-epoch keys from the descriptor -- the
1615
+ // `currentEpoch` key pair for writes, every epoch key it can unwrap for
1616
+ // reads -- and drive the cipher with those. The reader's own
1617
+ // key-agreement key never encrypts or decrypts resources itself; it
1618
+ // only unwraps epoch keys. Non-null: the epochs guard above already
1619
+ // refused a descriptor without epochs, the one case resolveEpochKeys
1620
+ // resolves null for.
1621
+ const epochKeys = (await resolveEpochKeys({
1622
+ encryption: descriptor,
1623
+ keyAgreementKey: keys.keyAgreementKey
1624
+ }));
1625
+ // Epoch keys are self-describing did:key key-agreement keys, so a
1626
+ // resource's recipient (the epoch public key) resolves through the
1627
+ // standard did:key resolver, independent of the reader's own keystore.
1628
+ const keyAgreementKey = epochKeys.writeKey;
1629
+ // The collection's blinding key: an explicitly supplied one (a keystore
1630
+ // custodying the HMAC key itself) wins over unwrapping the descriptor's
1631
+ // `hmac` member; `null` means the collection declares no blinded index.
1632
+ const hmac = keys.hmac ??
1633
+ (await resolveHmacKey({
1634
+ encryption: descriptor,
1635
+ keyAgreementKey: keys.keyAgreementKey
1636
+ }));
1637
+ const edv = new EdvClientCore({
1638
+ keyAgreementKey,
1639
+ keyResolver: didKeyResolver,
1640
+ ...(hmac !== null && { hmac })
1641
+ });
1642
+ return new EdvCodec({
1643
+ edv,
1644
+ keyAgreementKey,
1645
+ hmac,
1646
+ readKeys: epochKeys.readKeys,
1647
+ writeEpoch: epochKeys.writeEpoch,
1648
+ contentType,
1649
+ maxBlobBytes,
1650
+ ...(chunkSize !== undefined && { chunkSize }),
1651
+ idDerivation,
1652
+ version: descriptorVersion ?? EDV_SCHEME_VERSION,
1653
+ ...(transportFactory !== undefined && { transportFactory }),
1654
+ collectionId,
1655
+ // Every epoch the descriptor lists, recipient of it or not, so
1656
+ // decrypt routing can tell "not a recipient of this epoch" apart
1657
+ // from "descriptor has never seen this epoch".
1658
+ epochIds: descriptor.epochs.map(epoch => epoch.id)
1659
+ });
1660
+ }
1522
1661
  /**
1523
1662
  * Refuses an encryption descriptor this client cannot operate on, fail-closed:
1524
1663
  * a descriptor from a future scheme version (this client does not implement
@@ -1578,10 +1717,9 @@ function guardEncryptionDescriptor({ label, encryption }) {
1578
1717
  * @param options.idDerivation {'content' | 'random'} how ids are minted
1579
1718
  * @param options.encryption {CollectionEncryption} the collection's
1580
1719
  * descriptor; must carry the key-epoch roster
1581
- * @param [options.contentType] {string} stored envelope content type
1582
1720
  * @returns {Promise<EdvCodec>}
1583
1721
  */
1584
- export async function encryptOnlyEdvCodec({ collectionId, idDerivation, encryption, contentType = DEFAULT_CONTENT_TYPE }) {
1722
+ export async function encryptOnlyEdvCodec({ collectionId, idDerivation, encryption }) {
1585
1723
  const descriptor = guardEncryptionDescriptor({
1586
1724
  label: `"${collectionId}"`,
1587
1725
  encryption
@@ -1630,11 +1768,10 @@ export async function encryptOnlyEdvCodec({ collectionId, idDerivation, encrypti
1630
1768
  keyAgreementKey: writeKey,
1631
1769
  readKeys: [],
1632
1770
  writeEpoch,
1633
- contentType,
1771
+ contentType: DEFAULT_CONTENT_TYPE,
1634
1772
  maxBlobBytes: DEFAULT_MAX_BLOB_BYTES,
1635
1773
  idDerivation,
1636
1774
  version: descriptor.version ?? EDV_SCHEME_VERSION,
1637
- spaceId: LOCAL_SPACE_ID,
1638
1775
  collectionId,
1639
1776
  epochIds: epochs.map(epoch => epoch.id)
1640
1777
  });