@interop/was-client 0.35.1 → 0.37.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 (50) 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 +29 -9
  15. package/dist/edv/EdvCodec.d.ts.map +1 -1
  16. package/dist/edv/EdvCodec.js +421 -40
  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 +55 -4
  23. package/dist/edv/docCipher.d.ts.map +1 -1
  24. package/dist/edv/docCipher.js +51 -5
  25. package/dist/edv/docCipher.js.map +1 -1
  26. package/dist/edv/epochRoster.d.ts +53 -0
  27. package/dist/edv/epochRoster.d.ts.map +1 -0
  28. package/dist/edv/epochRoster.js +49 -0
  29. package/dist/edv/epochRoster.js.map +1 -0
  30. package/dist/edv/index.d.ts +6 -1
  31. package/dist/edv/index.d.ts.map +1 -1
  32. package/dist/edv/index.js +5 -0
  33. package/dist/edv/index.js.map +1 -1
  34. package/dist/errors.d.ts +11 -0
  35. package/dist/errors.d.ts.map +1 -1
  36. package/dist/errors.js +11 -0
  37. package/dist/errors.js.map +1 -1
  38. package/dist/index.d.ts +4 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/internal/features.d.ts +44 -1
  43. package/dist/internal/features.d.ts.map +1 -1
  44. package/dist/internal/features.js +39 -0
  45. package/dist/internal/features.js.map +1 -1
  46. package/dist/internal/write.d.ts +65 -12
  47. package/dist/internal/write.d.ts.map +1 -1
  48. package/dist/internal/write.js +71 -9
  49. package/dist/internal/write.js.map +1 -1
  50. 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
@@ -262,12 +296,20 @@ export class EdvCodec {
262
296
  * into every envelope's `was.epoch`, which the decode side checks against
263
297
  * the decrypting key's epoch unconditionally.
264
298
  * @param options.contentType {string} stored envelope content type
265
- * @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`)
266
306
  * @param options.idDerivation {string} how `add()` mints a document
267
307
  * id: `'random'` (classic `generateId()`) or `'content'` (derived from the
268
308
  * JWE ciphertext, content-addressed)
269
309
  * @param [options.version] {number} the EDV-over-WAS scheme version to bind
270
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
271
313
  * @param options.collectionId {string} the Collection this codec reads and
272
314
  * writes: bound into the Collection metadata envelope's `was.collection`
273
315
  * (and checked on read), and it labels decrypt-routing errors
@@ -277,7 +319,7 @@ export class EdvCodec {
277
319
  * @param [options.hmac] {BlindingKey} the collection's blinded-index key,
278
320
  * where it declares one
279
321
  */
280
- constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, idDerivation, version, collectionId, epochIds, hmac }) {
322
+ constructor({ edv, keyAgreementKey, readKeys, writeEpoch, contentType, maxBlobBytes, chunkSize, idDerivation, version, spaceId, collectionId, epochIds, hmac }) {
281
323
  this.#edv = edv;
282
324
  this.#recipients =
283
325
  edv.documentCipher.createDefaultRecipients(keyAgreementKey);
@@ -285,8 +327,10 @@ export class EdvCodec {
285
327
  this.#writeEpoch = writeEpoch;
286
328
  this.#contentType = contentType;
287
329
  this.#maxBlobBytes = maxBlobBytes;
330
+ this.#chunkSize = chunkSize;
288
331
  this.#idDerivation = idDerivation;
289
332
  this.#version = version ?? EDV_SCHEME_VERSION;
333
+ this.#spaceId = spaceId;
290
334
  this.#collectionId = collectionId;
291
335
  this.#epochIds = new Set(epochIds);
292
336
  this.#blindingKey = hmac ?? null;
@@ -393,7 +437,25 @@ export class EdvCodec {
393
437
  (this.#idDerivation === 'content'
394
438
  ? undefined
395
439
  : (await this.#edv.generateId()));
396
- 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;
397
459
  // When the write path pre-read a current envelope, advance `sequence` from
398
460
  // its prior value (`encrypt({ update: true })` increments it) and pin the
399
461
  // write to the server's current ETag with `If-Match`. With no prior envelope
@@ -472,6 +534,171 @@ export class EdvCodec {
472
534
  epoch: this.#writeEpoch
473
535
  };
474
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
+ }
475
702
  /**
476
703
  * The blinding key a content write should index with: the collection's key
477
704
  * once the applied schema declares at least one attribute, else `undefined`
@@ -489,11 +716,89 @@ export class EdvCodec {
489
716
  /**
490
717
  * @inheritdoc
491
718
  */
492
- async decode(response, expectedId) {
719
+ async decode(response, expectedId, context) {
493
720
  const stored = await readJsonData(response);
494
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
+ }
495
747
  return this.#fromDocument(decrypted.content, decrypted.meta);
496
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
+ }
497
802
  /**
498
803
  * Opens a stored envelope: asserts it IS an EDV envelope, decrypts it with
499
804
  * whichever read key its JWE recipient names, and only then verifies the
@@ -509,19 +814,20 @@ export class EdvCodec {
509
814
  * to a resource id is refused there, and one bound to this Collection's id
510
815
  * is required (see {@link _verifyBinding}). Set only by the
511
816
  * Collection-level metadata read
512
- * @returns {Promise<{ content?: unknown; meta?: Record<string, unknown>;
513
- * 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)
514
820
  */
515
821
  async #openEnvelope({ doc, expectedId, collectionSlot }) {
516
822
  this.#assertEnvelope(doc, 'read');
517
823
  const decrypted = await this.#decrypt(doc);
518
- await this.#verifyBinding({
824
+ const resourceId = await this.#verifyBinding({
519
825
  jwe: doc.jwe,
520
826
  expectedId,
521
827
  collectionSlot,
522
828
  keyId: decrypted.keyId
523
829
  });
524
- return decrypted;
830
+ return { ...decrypted, ...(resourceId !== undefined && { resourceId }) };
525
831
  }
526
832
  /**
527
833
  * Decrypts a stored EDV envelope, selecting which read key to use by matching
@@ -548,8 +854,7 @@ export class EdvCodec {
548
854
  * binding against the epoch of the decrypting key.
549
855
  *
550
856
  * @param encryptedDoc {IEncryptedDocument}
551
- * @returns {Promise<{ content?: unknown; meta?: Record<string, unknown>;
552
- * keyId: string }>}
857
+ * @returns {Promise<object>} the decrypted document plus `keyId`
553
858
  */
554
859
  async #decrypt(encryptedDoc) {
555
860
  const kids = envelopeRecipientKids(encryptedDoc);
@@ -669,7 +974,13 @@ export class EdvCodec {
669
974
  * Collection metadata slot
670
975
  * @param options.keyId {string} the id of the key that decrypted, for the
671
976
  * epoch check
672
- * @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.
673
984
  */
674
985
  async #verifyBinding({ jwe, expectedId, collectionSlot, keyId }) {
675
986
  const was = parseWasHeader(jwe);
@@ -754,6 +1065,7 @@ export class EdvCodec {
754
1065
  `"${decryptedEpoch}". The server replayed it under a different ` +
755
1066
  'epoch.');
756
1067
  }
1068
+ return typeof was.resource === 'string' ? was.resource : undefined;
757
1069
  }
758
1070
  /**
759
1071
  * @inheritdoc
@@ -884,6 +1196,13 @@ export class EdvCodec {
884
1196
  * 3. Binary (any other `Blob`/`Uint8Array`) to `content = { bytes: base64 }`,
885
1197
  * `meta = { contentType, encoding: 'base64' }`.
886
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
+ *
887
1206
  * A bare primitive is rejected (mirroring the plaintext `prepareBody`
888
1207
  * contract). The binary/text detection and content-type precedence are the
889
1208
  * shared `resolvePayload` rules, so the plaintext and encrypted write paths
@@ -892,25 +1211,36 @@ export class EdvCodec {
892
1211
  * @param data {ResourceData}
893
1212
  * @param [contentType] {string} caller-supplied content type
894
1213
  * @param [id] {string} resource id, for the extension guess
895
- * @returns {Promise<{ content: Record<string, unknown>; meta:
896
- * 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
897
1217
  */
898
1218
  async #toDocument(data, contentType, id) {
899
1219
  const payload = resolvePayload({ data, contentType, id });
900
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.
901
1241
  const bytes = isBlob(payload.data)
902
1242
  ? new Uint8Array(await payload.data.arrayBuffer())
903
1243
  : payload.data;
904
- const resolvedType = payload.contentType;
905
- if (bytes.length > this.#maxBlobBytes) {
906
- throw new ValidationError(`Encrypted binary write of ${bytes.length} bytes exceeds the ` +
907
- `single-document limit of ${this.#maxBlobBytes} bytes. The codec ` +
908
- 'seam is a single-request transform and cannot chunk. To store a ' +
909
- 'blob this large, write it through the chunked-stream path -- ' +
910
- '`EdvClientCore.insert({ doc, stream, transport })` with a ' +
911
- '`WasTransport`, read back with `getStream` -- against a server ' +
912
- "whose backend advertises the 'chunked-streams' feature.");
913
- }
914
1244
  // Text-family AND valid UTF-8 to store as a legible string. The UTF-8 gate
915
1245
  // guarantees the bytes survive the string round-trip exactly; anything
916
1246
  // else falls through to base64, which is always byte-safe.
@@ -918,12 +1248,14 @@ export class EdvCodec {
918
1248
  const text = decodeUtf8(bytes);
919
1249
  if (text !== null) {
920
1250
  return {
1251
+ kind: 'inline',
921
1252
  content: { text },
922
1253
  meta: { contentType: resolvedType, encoding: 'utf-8' }
923
1254
  };
924
1255
  }
925
1256
  }
926
1257
  return {
1258
+ kind: 'inline',
927
1259
  content: { bytes: base64.encode(bytes) },
928
1260
  meta: { contentType: resolvedType, encoding: 'base64' }
929
1261
  };
@@ -933,6 +1265,7 @@ export class EdvCodec {
933
1265
  // an absent `meta.encoding` as JSON). EDV models `content` as an object
934
1266
  // record; a JSON array is also a valid encrypted value here, so widen it.
935
1267
  return {
1268
+ kind: 'inline',
936
1269
  content: data,
937
1270
  meta: { contentType: contentType ?? 'application/json' }
938
1271
  };
@@ -982,6 +1315,43 @@ export class EdvCodec {
982
1315
  return content;
983
1316
  }
984
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
+ }
985
1355
  /**
986
1356
  * Decodes bytes as strict UTF-8, returning `null` when they are not valid UTF-8
987
1357
  * (so the caller can fall back to base64). Uses the shared fatal decoder.
@@ -1051,10 +1421,19 @@ const EDV_SCHEME = 'edv';
1051
1421
  * defaults to `application/json`. Pass `JOSE_CONTENT_TYPE`
1052
1422
  * (`application/jose+json`) against a server that registers an
1053
1423
  * `application/*+json` parser.
1054
- * @param [options.maxBlobBytes] {number} single-document binary cap in raw
1055
- * 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
1056
1427
  * server's ~1 MiB JSON body cap; raise it against a server with a larger
1057
- * 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.
1058
1437
  * @param [options.idDerivation] {string} how `add()` mints a document id.
1059
1438
  * `'random'` (default) is the classic mutable-document model: a random
1060
1439
  * `generateId()` id, updated in place via `sequence`. `'content'` derives the
@@ -1066,7 +1445,7 @@ const EDV_SCHEME = 'edv';
1066
1445
  * `put(id, ...)` path is unaffected either way.
1067
1446
  * @returns {EncryptionProvider}
1068
1447
  */
1069
- 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' }) {
1070
1449
  return {
1071
1450
  async codecFor({ spaceId, collectionId, scheme, encryption, keys }) {
1072
1451
  if (scheme !== EDV_SCHEME) {
@@ -1136,8 +1515,10 @@ export function createEdvEncryption({ resolveKeys, contentType = DEFAULT_CONTENT
1136
1515
  writeEpoch: epochKeys.writeEpoch,
1137
1516
  contentType,
1138
1517
  maxBlobBytes,
1518
+ ...(chunkSize !== undefined && { chunkSize }),
1139
1519
  idDerivation,
1140
1520
  version: descriptorVersion ?? EDV_SCHEME_VERSION,
1521
+ spaceId,
1141
1522
  collectionId,
1142
1523
  // Every epoch the descriptor lists, recipient of it or not, so
1143
1524
  // decrypt routing can tell "not a recipient of this epoch" apart