@interop/was-client 0.35.0 → 0.36.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 (48) hide show
  1. package/README.md +22 -5
  2. package/dist/Collection.d.ts +5 -0
  3. package/dist/Collection.d.ts.map +1 -1
  4. package/dist/Collection.js +37 -4
  5. package/dist/Collection.js.map +1 -1
  6. package/dist/Resource.d.ts +4 -3
  7. package/dist/Resource.d.ts.map +1 -1
  8. package/dist/Resource.js +31 -20
  9. package/dist/Resource.js.map +1 -1
  10. package/dist/codec.d.ts +91 -4
  11. package/dist/codec.d.ts.map +1 -1
  12. package/dist/codec.js +10 -1
  13. package/dist/codec.js.map +1 -1
  14. package/dist/edv/EdvCodec.d.ts +33 -9
  15. package/dist/edv/EdvCodec.d.ts.map +1 -1
  16. package/dist/edv/EdvCodec.js +471 -52
  17. package/dist/edv/EdvCodec.js.map +1 -1
  18. package/dist/edv/WasTransport.d.ts +39 -2
  19. package/dist/edv/WasTransport.d.ts.map +1 -1
  20. package/dist/edv/WasTransport.js +82 -26
  21. package/dist/edv/WasTransport.js.map +1 -1
  22. package/dist/edv/docCipher.d.ts +59 -7
  23. package/dist/edv/docCipher.d.ts.map +1 -1
  24. package/dist/edv/docCipher.js +63 -13
  25. package/dist/edv/docCipher.js.map +1 -1
  26. package/dist/edv/index.d.ts +1 -1
  27. package/dist/edv/index.d.ts.map +1 -1
  28. package/dist/errors.d.ts +31 -10
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +35 -14
  31. package/dist/errors.js.map +1 -1
  32. package/dist/index.d.ts +4 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/features.d.ts +44 -1
  37. package/dist/internal/features.d.ts.map +1 -1
  38. package/dist/internal/features.js +39 -0
  39. package/dist/internal/features.js.map +1 -1
  40. package/dist/internal/write.d.ts +65 -12
  41. package/dist/internal/write.d.ts.map +1 -1
  42. package/dist/internal/write.js +71 -9
  43. package/dist/internal/write.js.map +1 -1
  44. package/dist/sync/index.d.ts +9 -5
  45. package/dist/sync/index.d.ts.map +1 -1
  46. package/dist/sync/index.js +9 -5
  47. package/dist/sync/index.js.map +1 -1
  48. package/package.json +1 -1
@@ -36,9 +36,13 @@
36
36
  * - **Inline non-JSON as a single JWE.** A `Blob`/`Uint8Array` under the size cap
37
37
  * is encrypted as one document -- stored as a legible UTF-8 string for a
38
38
  * text-family type (else base64) -- with the plaintext content type and the
39
- * encoding carried in the document `meta`. An oversized one is
40
- * rejected (large chunked encrypted blobs need the server's `chunked-streams`
41
- * affordance).
39
+ * encoding carried in the document `meta`. A blob over `maxBlobBytes` is
40
+ * auto-routed by `add()` to the chunked-stream path instead: `encode` returns
41
+ * a multi-request plan the write path executes, storing one document plus its
42
+ * chunk resources over a `WasTransport` of the codec's own. That needs the
43
+ * backend's `chunked-streams` affordance, checked before the first write.
44
+ * Reads reassemble transparently, so `get()` returns the same `Blob` either
45
+ * way.
42
46
  * - **Enforced sequence (conditional writes).** The codec sets
43
47
  * `conditionalWrites`, so the write path pre-reads the current envelope and
44
48
  * hands it to `encode`: an update advances `sequence` from its prior value and
@@ -64,8 +68,9 @@
64
68
  import { base64, base64urlnopad } from '@scure/base';
65
69
  import { EdvClientCore, assertDocId } from '@interop/edv-client';
66
70
  import { EMPTY_INDEX_SCHEMA, assertQueryAttributes } from '../internal/indexSchema.js';
67
- import { EncryptionError, IntegrityError, KeyUnwrapError, UnknownEpochError, ValidationError } from '../errors.js';
68
- import { readEtag } from '../internal/conditional.js';
71
+ import { EncryptionError, IntegrityError, KeyUnwrapError, NotSupportedError, UnknownEpochError, ValidationError } from '../errors.js';
72
+ import { readEtag, writeHeaders } from '../internal/conditional.js';
73
+ import { WasTransport } from './WasTransport.js';
69
74
  import { isEncryptedEnvelope } from '../sync/envelope.js';
70
75
  import { resolveEpochKeys } from './epochKeys.js';
71
76
  import { didKeyResolver } from './epochCrypto.js';
@@ -73,16 +78,26 @@ import { resolveHmacKey } from './hmacKey.js';
73
78
  import { isBlob, isTextContentType, readJsonData, resolvePayload } from '../internal/content.js';
74
79
  import { DEFAULT_CONTENT_TYPE, EDV_SCHEME_VERSION, envelopeBytes } from './constants.js';
75
80
  /**
76
- * Default ceiling for a single-document (unchunked) encrypted binary or text
77
- * write, measured in raw (pre-base64) bytes. Past this a `Blob`/`Uint8Array`
78
- * is rejected with guidance toward the chunked-stream path. 512 KiB: the
79
- * envelope is stored as a JSON-family content type routed through the server's
80
- * in-memory JSON body parser (a ~1 MiB cap), and a binary payload inflates
81
- * ~33% inside the document (base64) and again ~33% in the JWE ciphertext
82
- * (base64url) -- ~1.78x total, so 512 KiB raw stays safely under the cap.
83
- * Raise `maxBlobBytes` against a server with a larger JSON body limit.
81
+ * Default threshold above which an encrypted binary write is routed to the
82
+ * chunked-stream path instead of being sealed into one document, measured in
83
+ * raw (pre-base64) bytes. It is a routing threshold, not a hard cap: `add()`
84
+ * carries a larger blob as a document plus chunk resources, which needs the
85
+ * backend's `chunked-streams` feature. 512 KiB: a single-document envelope is
86
+ * stored as a JSON-family content type routed through the server's in-memory
87
+ * JSON body parser (a ~1 MiB cap), and a binary payload inflates ~33% inside
88
+ * the document (base64) and again ~33% in the JWE ciphertext (base64url) --
89
+ * ~1.78x total, so 512 KiB raw stays safely under the cap. Raise
90
+ * `maxBlobBytes` against a server with a larger JSON body limit.
84
91
  */
85
92
  const DEFAULT_MAX_BLOB_BYTES = 512 * 1024;
93
+ /**
94
+ * The `meta.encoding` discriminator a chunked binary document carries: its
95
+ * bytes live in the document's chunk resources, not in `content`. It is sealed
96
+ * inside the JWE payload (the cipher encrypts `meta` alongside `content`), so
97
+ * it is the AEAD-authenticated signal the read side routes on, and it keeps the
98
+ * decrypted document self-describing alongside `'utf-8'` and `'base64'`.
99
+ */
100
+ const CHUNKED_ENCODING = 'chunked';
86
101
  /**
87
102
  * A shared strict UTF-8 decoder used to test whether a non-JSON payload is
88
103
  * valid UTF-8 (so it can be stored legibly as text rather than base64).
@@ -203,7 +218,20 @@ export class EdvCodec {
203
218
  */
204
219
  #writeEpoch;
205
220
  #contentType;
221
+ /**
222
+ * The size, in raw bytes, above which a binary write is routed to the
223
+ * chunked-stream path instead of being sealed into one document. A routing
224
+ * threshold, not a hard cap.
225
+ */
206
226
  #maxBlobBytes;
227
+ /**
228
+ * The size of each encrypted chunk a routed write emits, or `undefined` to
229
+ * take the EDV core's default (1 MiB). One chunk is one upload, so it has to
230
+ * stay under the backend's `maxUploadBytes`; nothing here can check that (the
231
+ * feature probe answers affordance tokens, not the backend's constraints), so
232
+ * a chunk over the limit surfaces as the server's 413.
233
+ */
234
+ #chunkSize;
207
235
  #idDerivation;
208
236
  /**
209
237
  * The EDV-over-WAS scheme version this codec binds into every envelope's
@@ -212,6 +240,12 @@ export class EdvCodec {
212
240
  * stamped with a greater version.
213
241
  */
214
242
  #version;
243
+ /**
244
+ * The id of the Space holding this codec's Collection. Needed only by the
245
+ * chunked-stream path, which addresses the document and its chunks through a
246
+ * `WasTransport` of its own.
247
+ */
248
+ #spaceId;
215
249
  /**
216
250
  * The id of the Collection this codec was built for. It is bound into the
217
251
  * Collection metadata envelope's `was.collection` on write and required to
@@ -219,6 +253,16 @@ export class EdvCodec {
219
253
  * ({@link UnknownEpochError}).
220
254
  */
221
255
  #collectionId;
256
+ /**
257
+ * The id of every epoch the descriptor lists -- held by this reader or not.
258
+ * Decrypt routing uses it to tell the two unroutable-envelope cases apart:
259
+ * an envelope kid whose epoch is listed here but matches no candidate key
260
+ * means this reader is not a recipient of that epoch
261
+ * ({@link KeyUnwrapError}); a kid whose epoch is not listed at all means the
262
+ * descriptor this codec was built from has never seen the epoch
263
+ * ({@link UnknownEpochError} -- the stale-descriptor signal).
264
+ */
265
+ #epochIds;
222
266
  /**
223
267
  * The collection's blinded-index key, or `null` where the collection
224
268
  * declares none. It blinds attribute names and values both on write (the
@@ -252,19 +296,30 @@ export class EdvCodec {
252
296
  * into every envelope's `was.epoch`, which the decode side checks against
253
297
  * the decrypting key's epoch unconditionally.
254
298
  * @param options.contentType {string} stored envelope content type
255
- * @param options.maxBlobBytes {number} single-document binary cap
299
+ * @param options.maxBlobBytes {number} the size above which a binary write
300
+ * is routed to the chunked-stream path instead of one document
301
+ * @param [options.chunkSize] {number} the size of each encrypted chunk a
302
+ * routed write emits (defaults to the EDV core's 1 MiB). One chunk is one
303
+ * upload, so it must stay under the backend's `maxUploadBytes`; that
304
+ * constraint is not advertised through the feature probe, so it is the
305
+ * caller's to respect (see `createEdvEncryption`)
256
306
  * @param options.idDerivation {string} how `add()` mints a document
257
307
  * id: `'random'` (classic `generateId()`) or `'content'` (derived from the
258
308
  * JWE ciphertext, content-addressed)
259
309
  * @param [options.version] {number} the EDV-over-WAS scheme version to bind
260
310
  * into each envelope's `was.v` (defaults to {@link EDV_SCHEME_VERSION})
311
+ * @param options.spaceId {string} the Space holding the Collection, for the
312
+ * chunked-stream path's own transport
261
313
  * @param options.collectionId {string} the Collection this codec reads and
262
314
  * writes: bound into the Collection metadata envelope's `was.collection`
263
315
  * (and checked on read), and it labels decrypt-routing errors
316
+ * @param options.epochIds {string[]} the id of every epoch the descriptor
317
+ * lists (held by this reader or not); decrypt routing checks it to tell a
318
+ * not-a-recipient envelope apart from a stale-descriptor one
264
319
  * @param [options.hmac] {BlindingKey} the collection's blinded-index key,
265
320
  * where it declares one
266
321
  */
267
- constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, collectionId, hmac }) {
322
+ constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, chunkSize, idDerivation, version, spaceId, collectionId, epochIds, hmac }) {
268
323
  this.#edv = edv;
269
324
  this.#recipients =
270
325
  edv.documentCipher.createDefaultRecipients(keyAgreementKey);
@@ -272,9 +327,12 @@ export class EdvCodec {
272
327
  this.#writeEpoch = writeEpoch;
273
328
  this.#contentType = contentType;
274
329
  this.#maxBlobBytes = maxBlobBytes;
330
+ this.#chunkSize = chunkSize;
275
331
  this.#idDerivation = idDerivation;
276
332
  this.#version = version ?? EDV_SCHEME_VERSION;
333
+ this.#spaceId = spaceId;
277
334
  this.#collectionId = collectionId;
335
+ this.#epochIds = new Set(epochIds);
278
336
  this.#blindingKey = hmac ?? null;
279
337
  if (this.#blindingKey !== null) {
280
338
  this.#indexing = {
@@ -379,7 +437,25 @@ export class EdvCodec {
379
437
  (this.#idDerivation === 'content'
380
438
  ? undefined
381
439
  : (await this.#edv.generateId()));
382
- const { content, meta } = await this.#toDocument(data, contentType, docId);
440
+ const parts = await this.#toDocument(data, contentType, docId);
441
+ if (parts.kind === 'chunked') {
442
+ if (docId === undefined) {
443
+ throw new ValidationError(`Encrypted binary write of ${parts.size} bytes exceeds the ` +
444
+ `single-document threshold of ${this.#maxBlobBytes} bytes, so it ` +
445
+ 'must be stored as a document plus chunk resources -- which a ' +
446
+ "content-addressed collection (idDerivation: 'content') cannot " +
447
+ 'do: the document is written twice (once to reserve it, once to ' +
448
+ 'record the chunk count), so no single ciphertext derives its id. ' +
449
+ 'Store large blobs in a random-id collection, or keep the payload ' +
450
+ 'under the threshold.');
451
+ }
452
+ return this.#chunkedWrite({
453
+ id: docId,
454
+ stream: parts.stream,
455
+ meta: parts.meta
456
+ });
457
+ }
458
+ const { content, meta } = parts;
383
459
  // When the write path pre-read a current envelope, advance `sequence` from
384
460
  // its prior value (`encrypt({ update: true })` increments it) and pin the
385
461
  // write to the server's current ETag with `If-Match`. With no prior envelope
@@ -458,6 +534,171 @@ export class EdvCodec {
458
534
  epoch: this.#writeEpoch
459
535
  };
460
536
  }
537
+ /**
538
+ * Builds the `WasTransport` the chunked-stream paths drive, over the signed
539
+ * requester core supplied and this codec's own Space/Collection. The
540
+ * handle's memoized feature probe is passed straight through, so the
541
+ * transport's own affordance gates cost no extra descriptor read.
542
+ *
543
+ * @param context {CodecRequestContext}
544
+ * @param [documentHeaders] {Record<string, string>} extra headers for
545
+ * document writes (the `Key-Epoch` stamp the codec seam applies)
546
+ * @returns {WasTransport}
547
+ */
548
+ #transportFor(context, documentHeaders) {
549
+ return new WasTransport({
550
+ was: { request: input => context.request(input) },
551
+ spaceId: this.#spaceId,
552
+ collectionId: this.#collectionId,
553
+ contentType: this.#contentType,
554
+ features: context.features,
555
+ ...(documentHeaders !== undefined && { documentHeaders })
556
+ });
557
+ }
558
+ /**
559
+ * Refuses the operation unless the collection's backend advertises the
560
+ * `chunked-streams` affordance. Checked before the first write, so an
561
+ * unsupported server never ends up holding a document stub with no chunks.
562
+ *
563
+ * @param context {CodecRequestContext}
564
+ * @param what {string} the operation, for the message
565
+ * @returns {Promise<void>}
566
+ */
567
+ async #assertChunkedStreams(context, what) {
568
+ if (await context.features.has('chunked-streams')) {
569
+ return;
570
+ }
571
+ // "No features" has two causes, and only one of them is about the server's
572
+ // capabilities: a descriptor that was read and lists no `chunked-streams`,
573
+ // versus a descriptor that could not be read at all (no backend descriptor
574
+ // endpoint, a deleted collection, or a capability that cannot read it --
575
+ // WAS masks unauthorized reads as 404). Name the one that applies, so a
576
+ // capable server whose collection is gone does not look incapable.
577
+ if (await context.features.descriptorAbsent()) {
578
+ throw new NotSupportedError(`${what} needs the collection's backend to advertise the ` +
579
+ `'chunked-streams' feature, but the backend descriptor could not be ` +
580
+ 'read at all: the collection may not exist, or this capability may ' +
581
+ 'not be able to read its descriptor. Confirm the collection and the ' +
582
+ 'capability, then retry.');
583
+ }
584
+ throw new NotSupportedError(`${what} needs the collection's backend to advertise the ` +
585
+ `'chunked-streams' feature, which it does not. Store the blob in a ` +
586
+ 'collection on a backend that supports chunked streams, or keep the ' +
587
+ `payload under the ${this.#maxBlobBytes}-byte single-document ` +
588
+ "threshold (raise it with the provider's `maxBlobBytes` where the " +
589
+ 'server accepts a larger body).');
590
+ }
591
+ /**
592
+ * The plan for a binary payload over the single-document threshold: one EDV
593
+ * document plus its chunk resources, written by `EdvClientCore.insert({ doc,
594
+ * stream, transport })` over a transport built from the write context. The
595
+ * document id is minted before the plan is returned, so the caller can report
596
+ * it without waiting for the write.
597
+ *
598
+ * The `was` binding, the recipients and the write epoch are exactly the
599
+ * single-document path's, and `additionalProtectedParams` carries the binding
600
+ * into both the document envelope and every chunk's AAD. `content` stays
601
+ * empty: the bytes are the chunks, and `meta` records the plaintext content
602
+ * type plus the chunked encoding discriminator so a read reconstructs the
603
+ * same `Blob` a small binary read returns. `meta` is sealed inside the JWE
604
+ * payload, so that discriminator is what the read side routes on: a server
605
+ * cannot mint it, and cannot suppress it to hide the chunks either.
606
+ *
607
+ * The write is two-phase (`EdvClientCore.insert` writes the document, then
608
+ * streams the chunks), so a failure partway leaves a document stub whose
609
+ * sealed stream state is still `{ pending: true }` -- undecryptable, listed,
610
+ * and never re-used, since a retry mints a fresh id. The plan therefore
611
+ * compensates: if the document was written and the write then failed, it
612
+ * best-effort deletes the stub before rethrowing.
613
+ *
614
+ * @param options {object}
615
+ * @param options.id {string} the minted document id
616
+ * @param options.stream {ReadableStream<Uint8Array>} the payload, as the
617
+ * stream the EDV core re-chunks (never buffered whole by this codec)
618
+ * @param options.meta {Record<string, unknown>} the document meta to seal
619
+ * @returns {ChunkedWrite}
620
+ */
621
+ #chunkedWrite({ id, stream, meta }) {
622
+ const was = wasParam({
623
+ version: this.#version,
624
+ resource: id,
625
+ epoch: this.#writeEpoch
626
+ });
627
+ return {
628
+ chunked: true,
629
+ id,
630
+ resourceContentType: meta.contentType,
631
+ // What the scheme-agnostic write path appends when it refuses this plan
632
+ // for a write by id: only this codec knows why the payload needs several
633
+ // requests, and which low-level API writes one directly.
634
+ guidance: 'This payload is too large for a single encrypted document, so it is ' +
635
+ 'stored as a document plus chunk resources. Drive the write yourself ' +
636
+ 'with `EdvClientCore.update({ doc, stream, transport })` over a ' +
637
+ '`WasTransport`, against a server whose backend advertises the ' +
638
+ "'chunked-streams' feature.",
639
+ execute: async (context) => {
640
+ await this.#assertChunkedStreams(context, 'Writing a large blob');
641
+ // The EDV core owns the write and swallows the responses, so the
642
+ // transport reports the document write it made: whether one landed at
643
+ // all (the cleanup decision below) and the validator the server acked
644
+ // it with.
645
+ const transport = this.#transportFor(context, writeHeaders({ epoch: this.#writeEpoch }));
646
+ try {
647
+ await this.#edv.insert({
648
+ doc: { id, content: {}, meta },
649
+ stream,
650
+ ...(this.#chunkSize !== undefined && {
651
+ chunkSize: this.#chunkSize
652
+ }),
653
+ recipients: this.#recipients,
654
+ keyResolver: this.#edv.keyResolver,
655
+ hmac: this.#writeBlindingKey(),
656
+ additionalProtectedParams: { was },
657
+ transport
658
+ });
659
+ }
660
+ catch (err) {
661
+ throw await this.#chunkedWriteFailed({ err, id, transport });
662
+ }
663
+ const etag = transport.lastDocumentWrite?.etag;
664
+ return { id, ...(etag !== undefined && { etag }) };
665
+ }
666
+ };
667
+ }
668
+ /**
669
+ * Compensates a failed chunked write and builds the error to rethrow. The
670
+ * document stub is deleted only when the transport reports it actually wrote
671
+ * one: a write that failed before that (the id is freshly minted, so this is
672
+ * a server or network failure, not a collision) must not delete a resource
673
+ * this write never created. The delete is best effort -- it is a cleanup, and
674
+ * its own failure must not mask the failure that caused it -- so its outcome
675
+ * only shapes the message.
676
+ *
677
+ * @param options {object}
678
+ * @param options.err {unknown} the failure from the chunked write
679
+ * @param options.id {string} the document id the write minted
680
+ * @param options.transport {WasTransport} the transport the write ran on
681
+ * @returns {Promise<Error>} the error to throw, carrying `err` as its cause
682
+ */
683
+ async #chunkedWriteFailed({ err, id, transport }) {
684
+ if (transport.lastDocumentWrite === undefined) {
685
+ return err instanceof Error ? err : new Error(String(err));
686
+ }
687
+ let removed = true;
688
+ try {
689
+ await transport.deleteDocument({ id });
690
+ }
691
+ catch {
692
+ removed = false;
693
+ }
694
+ return new EncryptionError(`The chunked encrypted write of resource "${id}" failed partway: its ` +
695
+ 'document was written but its chunks were not, so the stored ' +
696
+ 'document cannot be read. ' +
697
+ (removed
698
+ ? 'The incomplete document was deleted; retry the write.'
699
+ : 'The incomplete document could NOT be deleted and is still ' +
700
+ 'stored; delete it and retry the write.'), { cause: err });
701
+ }
461
702
  /**
462
703
  * The blinding key a content write should index with: the collection's key
463
704
  * once the applied schema declares at least one attribute, else `undefined`
@@ -475,11 +716,89 @@ export class EdvCodec {
475
716
  /**
476
717
  * @inheritdoc
477
718
  */
478
- async decode(response, expectedId) {
719
+ async decode(response, expectedId, context) {
479
720
  const stored = await readJsonData(response);
480
721
  const decrypted = await this.#openEnvelope({ doc: stored, expectedId });
722
+ // A chunked document's bytes live in its chunk resources. Both routing
723
+ // inputs are AEAD-authenticated, never the cleartext copies on the
724
+ // envelope: the `meta.encoding` discriminator sealed in the JWE payload
725
+ // decides that this IS a chunked document (a server cannot bolt a
726
+ // cleartext `stream` onto an ordinary document to mask its sealed
727
+ // content), and the sealed `stream.chunks` count then says how many chunks
728
+ // to fetch (a server cannot lower it to truncate the read). A sealed
729
+ // discriminator with no sealed count is an interrupted write, whose state
730
+ // is still `{ pending: true }`: it fails loudly rather than decoding to an
731
+ // empty document.
732
+ if (decrypted.meta?.encoding === CHUNKED_ENCODING) {
733
+ return this.#readChunked({
734
+ // Address the chunk resources by the AEAD-bound `was.resource` id, not
735
+ // by the envelope's cleartext `id`: a server that serves document A's
736
+ // authentic envelope with the cleartext id swapped to B would
737
+ // otherwise have the read fetch (and cleanly decrypt) B's chunks,
738
+ // exactly the envelope swap the `was.resource` binding exists to
739
+ // detect.
740
+ id: decrypted.resourceId,
741
+ chunks: decrypted.stream?.chunks,
742
+ meta: decrypted.meta,
743
+ keyId: decrypted.keyId,
744
+ context
745
+ });
746
+ }
481
747
  return this.#fromDocument(decrypted.content, decrypted.meta);
482
748
  }
749
+ /**
750
+ * Reassembles a chunked binary document: drives `EdvClientCore.getStream`
751
+ * over a transport built from the read context, buffers the decrypt stream,
752
+ * and returns the same `Blob` a small binary read returns.
753
+ *
754
+ * Only AEAD-authenticated inputs are trusted -- the sealed chunk count and
755
+ * the `was.resource` id the envelope is bound to, never the envelope's
756
+ * cleartext `id` -- and the decrypt uses the very key that opened the
757
+ * document envelope, so a chunk sealed to some other epoch fails to
758
+ * authenticate rather than being accepted.
759
+ *
760
+ * @param options {object}
761
+ * @param [options.id] {string} the AEAD-bound resource id (= WAS resource
762
+ * id, the parent of the chunk resources)
763
+ * @param options.chunks {unknown} the sealed chunk count
764
+ * @param [options.meta] {Record<string, unknown>} the decrypted meta
765
+ * @param options.keyId {string} the id of the key that decrypted the
766
+ * document envelope
767
+ * @param [options.context] {CodecRequestContext} the signed-request context
768
+ * @returns {Promise<Blob>}
769
+ */
770
+ async #readChunked({ id, chunks, meta, keyId, context }) {
771
+ if (typeof chunks !== 'number') {
772
+ throw new EncryptionError('Cannot read this resource: it is a chunked encrypted blob whose ' +
773
+ 'sealed stream state records no chunk count, so the write that ' +
774
+ 'created it never completed. Re-upload the blob.');
775
+ }
776
+ if (context === undefined) {
777
+ throw new EncryptionError('Cannot read this resource: it is a chunked encrypted blob, whose ' +
778
+ 'bytes live in separate chunk resources, and this caller supplied no ' +
779
+ 'request context to fetch them with. Read it through a Resource or ' +
780
+ 'Collection handle (`resource.get()`), which supplies one.');
781
+ }
782
+ if (id === undefined) {
783
+ throw new EncryptionError('Cannot read this resource: the stored chunked document binds no ' +
784
+ '`was.resource` id, so its chunk resources cannot be addressed. ' +
785
+ "Only the envelope's AEAD-bound id may address them -- the cleartext " +
786
+ 'id on the envelope is server-controlled and could point the read at ' +
787
+ "another document's chunks.");
788
+ }
789
+ await this.#assertChunkedStreams(context, 'Reading a large blob');
790
+ const keyAgreementKey = this.#readKeys.find(key => key.id === keyId);
791
+ const stream = (await this.#edv.getStream({
792
+ doc: { id, stream: { chunks } },
793
+ keyAgreementKey,
794
+ transport: this.#transportFor(context)
795
+ }));
796
+ const contentType = typeof meta?.contentType === 'string' ? meta.contentType : undefined;
797
+ return streamToBlob({
798
+ stream,
799
+ ...(contentType !== undefined && { type: contentType })
800
+ });
801
+ }
483
802
  /**
484
803
  * Opens a stored envelope: asserts it IS an EDV envelope, decrypts it with
485
804
  * whichever read key its JWE recipient names, and only then verifies the
@@ -495,19 +814,20 @@ export class EdvCodec {
495
814
  * to a resource id is refused there, and one bound to this Collection's id
496
815
  * is required (see {@link _verifyBinding}). Set only by the
497
816
  * Collection-level metadata read
498
- * @returns {Promise<{ content?: unknown; meta?: Record<string, unknown>;
499
- * keyId: string }>} the decrypted document
817
+ * @returns {Promise<object>} the decrypted document (`content`, `meta`, the
818
+ * AEAD-authenticated `stream` state where one was sealed, `keyId`, and the
819
+ * AEAD-bound `resourceId` the envelope declares, where it binds one)
500
820
  */
501
821
  async #openEnvelope({ doc, expectedId, collectionSlot }) {
502
822
  this.#assertEnvelope(doc, 'read');
503
823
  const decrypted = await this.#decrypt(doc);
504
- await this.#verifyBinding({
824
+ const resourceId = await this.#verifyBinding({
505
825
  jwe: doc.jwe,
506
826
  expectedId,
507
827
  collectionSlot,
508
828
  keyId: decrypted.keyId
509
829
  });
510
- return decrypted;
830
+ return { ...decrypted, ...(resourceId !== undefined && { resourceId }) };
511
831
  }
512
832
  /**
513
833
  * Decrypts a stored EDV envelope, selecting which read key to use by matching
@@ -516,34 +836,53 @@ export class EdvCodec {
516
836
  * selects that epoch's key, so history stays readable.
517
837
  *
518
838
  * A stored envelope naming only recipients this reader holds no candidate
519
- * key for fails fast with {@link UnknownEpochError} -- the signal that the
520
- * cached Collection Description may be stale (an epoch rotation emits no
839
+ * key for fails fast, and which error it raises depends on whether the
840
+ * descriptor lists the named epoch. An epoch the descriptor lists but wraps
841
+ * only to other recipients raises {@link KeyUnwrapError}: this reader is
842
+ * not a recipient of that epoch (it never was, or it was removed and the
843
+ * epoch rotated), so re-reading the descriptor cannot help. That is the
844
+ * read axis only; it says nothing about whether the server will still
845
+ * serve (pull) the ciphertext. An epoch the descriptor does not list at
846
+ * all raises {@link UnknownEpochError} -- the signal that the cached
847
+ * Collection Description may be stale (an epoch rotation emits no
521
848
  * change-feed entry) and the codec must be rebuilt from a re-read
522
- * descriptor. A candidate whose entry then fails to unwrap surfaces
523
- * {@link KeyUnwrapError} -- the **read** axis only; it says nothing about
524
- * whether the server will still serve (pull) the ciphertext.
849
+ * descriptor. A candidate whose entry then fails to unwrap also surfaces
850
+ * {@link KeyUnwrapError}.
525
851
  *
526
852
  * Also returns the `id` of the key that actually decrypted the envelope (its
527
853
  * JWE recipient `kid`), so {@link _verifyBinding} can check a `was.epoch`
528
854
  * binding against the epoch of the decrypting key.
529
855
  *
530
856
  * @param encryptedDoc {IEncryptedDocument}
531
- * @returns {Promise<{ content?: unknown; meta?: Record<string, unknown>;
532
- * keyId: string }>}
857
+ * @returns {Promise<object>} the decrypted document plus `keyId`
533
858
  */
534
859
  async #decrypt(encryptedDoc) {
535
860
  const kids = envelopeRecipientKids(encryptedDoc);
536
861
  const kidSet = new Set(kids);
537
862
  // Prefer the read key whose id names a recipient of this envelope; for a
538
863
  // well-formed envelope the exact match always hits. A non-empty recipient
539
- // set that matches NO candidate is unroutable: the envelope was encrypted
540
- // under an epoch (or key) this codec's descriptor knows nothing about, so
541
- // fail fast with the stale-descriptor signal rather than burning ECDH
542
- // attempts that cannot succeed. The `rest` fallback below is then reached
543
- // only for a malformed envelope naming no recipient kid at all, letting a
544
- // candidate surface the cipher's own typed decrypt error.
864
+ // set that matches NO candidate is unroutable, so fail fast rather than
865
+ // burning ECDH attempts that cannot succeed. Which failure it is depends
866
+ // on whether the descriptor lists the named epoch: an epoch key's kid is
867
+ // `<epoch did:key>#<fingerprint>`, so the portion before the fragment
868
+ // names the epoch. Listed but wrapped only to others: this reader is not
869
+ // a recipient of that epoch (the membership signal, KeyUnwrapError). Not
870
+ // listed at all: the descriptor has never seen the epoch (the
871
+ // stale-descriptor signal, UnknownEpochError). The `rest` fallback below
872
+ // is then reached only for a malformed envelope naming no recipient kid
873
+ // at all, letting a candidate surface the cipher's own typed decrypt
874
+ // error.
545
875
  const preferred = this.#readKeys.filter(key => kidSet.has(key.id));
546
876
  if (preferred.length === 0 && kids.length > 0) {
877
+ const listed = kids.some(kid => this.#epochIds.has(kid.split('#')[0] ?? kid));
878
+ if (listed) {
879
+ throw new KeyUnwrapError('Cannot decrypt this resource: it was encrypted under a key epoch ' +
880
+ 'this reader holds no key for. The epoch is on the Collection ' +
881
+ 'Description, but none of its recipient entries name this reader ' +
882
+ '(it was never a recipient of that epoch, or it was removed and ' +
883
+ 'the epoch rotated). This is the read axis only -- the server ' +
884
+ 'may still serve the ciphertext (a separate zcap decision).');
885
+ }
547
886
  throw new UnknownEpochError({ collectionId: this.#collectionId, kids });
548
887
  }
549
888
  const rest = this.#readKeys.filter(key => !kidSet.has(key.id));
@@ -635,7 +974,13 @@ export class EdvCodec {
635
974
  * Collection metadata slot
636
975
  * @param options.keyId {string} the id of the key that decrypted, for the
637
976
  * epoch check
638
- * @returns {Promise<void>}
977
+ * @returns {Promise<string | undefined>} the verified `was.resource` id the
978
+ * envelope binds, or `undefined` where it binds none (a content-derived
979
+ * content envelope, or the Collection metadata slot). It is the only
980
+ * trustworthy resource id on a stored document -- the envelope's top-level
981
+ * `id` is cleartext and server-controlled -- so a read that addresses
982
+ * anything under the document's path (the chunked-stream path) must use
983
+ * this one.
639
984
  */
640
985
  async #verifyBinding({ jwe, expectedId, collectionSlot, keyId }) {
641
986
  const was = parseWasHeader(jwe);
@@ -720,6 +1065,7 @@ export class EdvCodec {
720
1065
  `"${decryptedEpoch}". The server replayed it under a different ` +
721
1066
  'epoch.');
722
1067
  }
1068
+ return typeof was.resource === 'string' ? was.resource : undefined;
723
1069
  }
724
1070
  /**
725
1071
  * @inheritdoc
@@ -850,6 +1196,13 @@ export class EdvCodec {
850
1196
  * 3. Binary (any other `Blob`/`Uint8Array`) to `content = { bytes: base64 }`,
851
1197
  * `meta = { contentType, encoding: 'base64' }`.
852
1198
  *
1199
+ * A binary payload over {@link #maxBlobBytes} is not an inline document at
1200
+ * all: it answers `kind: 'chunked'`, carrying a byte stream (plus its size,
1201
+ * for messages) and the `meta` the chunked-stream path seals, and the caller
1202
+ * routes the write there. The routing decision is made on the payload's size
1203
+ * alone, so a `Blob` over the threshold is never buffered here: it is handed
1204
+ * on as `blob.stream()`, and the EDV core re-chunks it as it reads.
1205
+ *
853
1206
  * A bare primitive is rejected (mirroring the plaintext `prepareBody`
854
1207
  * contract). The binary/text detection and content-type precedence are the
855
1208
  * shared `resolvePayload` rules, so the plaintext and encrypted write paths
@@ -858,25 +1211,36 @@ export class EdvCodec {
858
1211
  * @param data {ResourceData}
859
1212
  * @param [contentType] {string} caller-supplied content type
860
1213
  * @param [id] {string} resource id, for the extension guess
861
- * @returns {Promise<{ content: Record<string, unknown>; meta:
862
- * Record<string, unknown> }>}
1214
+ * @returns {Promise<object>} the inline document `{ content, meta }`, or
1215
+ * the `{ stream, size, meta }` of a payload to route to the chunked-stream
1216
+ * path
863
1217
  */
864
1218
  async #toDocument(data, contentType, id) {
865
1219
  const payload = resolvePayload({ data, contentType, id });
866
1220
  if (payload.kind === 'binary') {
1221
+ const resolvedType = payload.contentType;
1222
+ // Route on the size alone (`Blob.size` is synchronous), so an
1223
+ // over-threshold blob is never read into memory here just to measure it.
1224
+ const size = isBlob(payload.data)
1225
+ ? payload.data.size
1226
+ : payload.data.length;
1227
+ if (size > this.#maxBlobBytes) {
1228
+ // Too large for one document: route it to the chunked-stream path,
1229
+ // where the bytes live in the document's own chunk resources. Hand it
1230
+ // over as a stream -- a `Blob` streams itself, and bytes already in
1231
+ // hand become a one-value stream the same way -- so the payload is not
1232
+ // held twice while the EDV core re-chunks it.
1233
+ return {
1234
+ kind: 'chunked',
1235
+ stream: bytesToStream(payload.data),
1236
+ size,
1237
+ meta: { contentType: resolvedType, encoding: CHUNKED_ENCODING }
1238
+ };
1239
+ }
1240
+ // Under the threshold the bytes are sealed inline, so buffer them now.
867
1241
  const bytes = isBlob(payload.data)
868
1242
  ? new Uint8Array(await payload.data.arrayBuffer())
869
1243
  : payload.data;
870
- const resolvedType = payload.contentType;
871
- if (bytes.length > this.#maxBlobBytes) {
872
- throw new ValidationError(`Encrypted binary write of ${bytes.length} bytes exceeds the ` +
873
- `single-document limit of ${this.#maxBlobBytes} bytes. The codec ` +
874
- 'seam is a single-request transform and cannot chunk. To store a ' +
875
- 'blob this large, write it through the chunked-stream path -- ' +
876
- '`EdvClientCore.insert({ doc, stream, transport })` with a ' +
877
- '`WasTransport`, read back with `getStream` -- against a server ' +
878
- "whose backend advertises the 'chunked-streams' feature.");
879
- }
880
1244
  // Text-family AND valid UTF-8 to store as a legible string. The UTF-8 gate
881
1245
  // guarantees the bytes survive the string round-trip exactly; anything
882
1246
  // else falls through to base64, which is always byte-safe.
@@ -884,12 +1248,14 @@ export class EdvCodec {
884
1248
  const text = decodeUtf8(bytes);
885
1249
  if (text !== null) {
886
1250
  return {
1251
+ kind: 'inline',
887
1252
  content: { text },
888
1253
  meta: { contentType: resolvedType, encoding: 'utf-8' }
889
1254
  };
890
1255
  }
891
1256
  }
892
1257
  return {
1258
+ kind: 'inline',
893
1259
  content: { bytes: base64.encode(bytes) },
894
1260
  meta: { contentType: resolvedType, encoding: 'base64' }
895
1261
  };
@@ -899,6 +1265,7 @@ export class EdvCodec {
899
1265
  // an absent `meta.encoding` as JSON). EDV models `content` as an object
900
1266
  // record; a JSON array is also a valid encrypted value here, so widen it.
901
1267
  return {
1268
+ kind: 'inline',
902
1269
  content: data,
903
1270
  meta: { contentType: contentType ?? 'application/json' }
904
1271
  };
@@ -948,6 +1315,43 @@ export class EdvCodec {
948
1315
  return content;
949
1316
  }
950
1317
  }
1318
+ /**
1319
+ * Presents a binary payload as the `ReadableStream`
1320
+ * `EdvClientCore.insert({ stream })` consumes. A `Blob` streams itself; bytes
1321
+ * already in hand are wrapped in a `Blob` and stream the same way. The encrypt
1322
+ * stream re-chunks whatever it is fed at its own `chunkSize`, so the shape of
1323
+ * the source stream does not affect the stored chunks.
1324
+ *
1325
+ * @param data {Blob | Uint8Array}
1326
+ * @returns {ReadableStream<Uint8Array>}
1327
+ */
1328
+ function bytesToStream(data) {
1329
+ const blob = isBlob(data) ? data : new Blob([data]);
1330
+ return blob.stream();
1331
+ }
1332
+ /**
1333
+ * Drains a byte stream into one `Blob` of the given type. `Blob` does the
1334
+ * concatenation: its parts are the chunks the stream yielded, in order.
1335
+ *
1336
+ * @param options {object}
1337
+ * @param options.stream {ReadableStream<Uint8Array>}
1338
+ * @param [options.type] {string} the blob's content type
1339
+ * @returns {Promise<Blob>}
1340
+ */
1341
+ async function streamToBlob({ stream, type }) {
1342
+ const reader = stream.getReader();
1343
+ const parts = [];
1344
+ for (;;) {
1345
+ const { value, done } = await reader.read();
1346
+ if (done) {
1347
+ break;
1348
+ }
1349
+ if (value !== undefined) {
1350
+ parts.push(value);
1351
+ }
1352
+ }
1353
+ return new Blob(parts, type !== undefined ? { type } : undefined);
1354
+ }
951
1355
  /**
952
1356
  * Decodes bytes as strict UTF-8, returning `null` when they are not valid UTF-8
953
1357
  * (so the caller can fall back to base64). Uses the shared fatal decoder.
@@ -1017,10 +1421,19 @@ const EDV_SCHEME = 'edv';
1017
1421
  * defaults to `application/json`. Pass `JOSE_CONTENT_TYPE`
1018
1422
  * (`application/jose+json`) against a server that registers an
1019
1423
  * `application/*+json` parser.
1020
- * @param [options.maxBlobBytes] {number} single-document binary cap in raw
1021
- * bytes (default 512 KiB, sized so the encrypted envelope stays under a
1424
+ * @param [options.maxBlobBytes] {number} the size in raw bytes above which a
1425
+ * binary `add()` is routed to the chunked-stream path instead of one document
1426
+ * (default 512 KiB, sized so a single-document envelope stays under a
1022
1427
  * server's ~1 MiB JSON body cap; raise it against a server with a larger
1023
- * limit)
1428
+ * limit). A routing threshold, not a hard cap.
1429
+ * @param [options.chunkSize] {number} the size of each encrypted chunk a
1430
+ * routed write emits, in bytes (default 1 MiB). Each chunk is one upload, so
1431
+ * it must stay under the backend's `maxUploadBytes` constraint (the
1432
+ * encrypted chunk is somewhat larger than `chunkSize`, so leave headroom).
1433
+ * This is not checked client-side: the shared backend probe reads the
1434
+ * descriptor's affordance tokens, not its `constraints`, so a chunk over the
1435
+ * limit is rejected by the server with a `PayloadTooLargeError` (413) and
1436
+ * the failed write's document stub is then cleaned up.
1024
1437
  * @param [options.idDerivation] {string} how `add()` mints a document id.
1025
1438
  * `'random'` (default) is the classic mutable-document model: a random
1026
1439
  * `generateId()` id, updated in place via `sequence`. `'content'` derives the
@@ -1032,7 +1445,7 @@ const EDV_SCHEME = 'edv';
1032
1445
  * `put(id, ...)` path is unaffected either way.
1033
1446
  * @returns {EncryptionProvider}
1034
1447
  */
1035
- export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT_TYPE, maxBlobBytes = DEFAULT_MAX_BLOB_BYTES, idDerivation = 'random' }) {
1448
+ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT_TYPE, maxBlobBytes = DEFAULT_MAX_BLOB_BYTES, chunkSize, idDerivation = 'random' }) {
1036
1449
  return {
1037
1450
  async codecFor({ spaceId, collectionId, scheme, encryption, keys }) {
1038
1451
  if (scheme !== EDV_SCHEME) {
@@ -1102,9 +1515,15 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
1102
1515
  writeEpoch: epochKeys.writeEpoch,
1103
1516
  contentType,
1104
1517
  maxBlobBytes,
1518
+ ...(chunkSize !== undefined && { chunkSize }),
1105
1519
  idDerivation,
1106
1520
  version: descriptorVersion ?? EDV_SCHEME_VERSION,
1107
- collectionId
1521
+ spaceId,
1522
+ collectionId,
1523
+ // Every epoch the descriptor lists, recipient of it or not, so
1524
+ // decrypt routing can tell "not a recipient of this epoch" apart
1525
+ // from "descriptor has never seen this epoch".
1526
+ epochIds: encryption.epochs.map(epoch => epoch.id)
1108
1527
  });
1109
1528
  }
1110
1529
  };