@interop/was-client 0.98.0 → 0.99.1
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.
- package/README.md +63 -42
- package/dist/Collection.d.ts +43 -15
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +127 -54
- package/dist/Collection.js.map +1 -1
- package/dist/Resource.d.ts +26 -11
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +26 -11
- package/dist/Resource.js.map +1 -1
- package/dist/WasClient.d.ts +23 -8
- package/dist/WasClient.d.ts.map +1 -1
- package/dist/WasClient.js +31 -15
- package/dist/WasClient.js.map +1 -1
- package/dist/codec.d.ts +6 -14
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js.map +1 -1
- package/dist/edv/EdvCodec.d.ts +2 -1
- package/dist/edv/EdvCodec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +348 -105
- package/dist/edv/EdvCodec.js.map +1 -1
- package/dist/edv/WasTransport.d.ts +76 -12
- package/dist/edv/WasTransport.d.ts.map +1 -1
- package/dist/edv/WasTransport.js +76 -11
- package/dist/edv/WasTransport.js.map +1 -1
- package/dist/edv/cipher.d.ts +6 -3
- package/dist/edv/cipher.d.ts.map +1 -1
- package/dist/edv/cipher.js +6 -3
- package/dist/edv/cipher.js.map +1 -1
- package/dist/edv/core.d.ts +1 -1
- package/dist/edv/core.d.ts.map +1 -1
- package/dist/edv/core.js +1 -1
- package/dist/edv/core.js.map +1 -1
- package/dist/edv/docCipher.d.ts +4 -2
- package/dist/edv/docCipher.d.ts.map +1 -1
- package/dist/edv/docCipher.js +17 -12
- package/dist/edv/docCipher.js.map +1 -1
- package/dist/edv/epochCrypto.d.ts +12 -4
- package/dist/edv/epochCrypto.d.ts.map +1 -1
- package/dist/edv/epochCrypto.js +37 -10
- package/dist/edv/epochCrypto.js.map +1 -1
- package/dist/edv/epochKeys.d.ts +6 -2
- package/dist/edv/epochKeys.d.ts.map +1 -1
- package/dist/edv/epochKeys.js +25 -14
- package/dist/edv/epochKeys.js.map +1 -1
- package/dist/edv/hmacKey.d.ts +7 -1
- package/dist/edv/hmacKey.d.ts.map +1 -1
- package/dist/edv/hmacKey.js +9 -3
- package/dist/edv/hmacKey.js.map +1 -1
- package/dist/edv/index.d.ts +2 -1
- package/dist/edv/index.d.ts.map +1 -1
- package/dist/edv/index.js +1 -1
- package/dist/edv/index.js.map +1 -1
- package/dist/edv/recipients.d.ts.map +1 -1
- package/dist/edv/recipients.js +2 -0
- package/dist/edv/recipients.js.map +1 -1
- package/dist/edv/transportFactory.d.ts.map +1 -1
- package/dist/edv/transportFactory.js +3 -2
- package/dist/edv/transportFactory.js.map +1 -1
- package/dist/errors.d.ts +19 -10
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +22 -11
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/codec.d.ts +51 -2
- package/dist/internal/codec.d.ts.map +1 -1
- package/dist/internal/codec.js +53 -21
- package/dist/internal/codec.js.map +1 -1
- package/dist/internal/indexSchema.d.ts +30 -0
- package/dist/internal/indexSchema.d.ts.map +1 -1
- package/dist/internal/indexSchema.js +40 -0
- package/dist/internal/indexSchema.js.map +1 -1
- package/dist/internal/memo.d.ts +13 -4
- package/dist/internal/memo.d.ts.map +1 -1
- package/dist/internal/memo.js +18 -7
- package/dist/internal/memo.js.map +1 -1
- package/dist/internal/meta.d.ts +9 -3
- package/dist/internal/meta.d.ts.map +1 -1
- package/dist/internal/meta.js +13 -7
- package/dist/internal/meta.js.map +1 -1
- package/dist/internal/pagination.d.ts +30 -15
- package/dist/internal/pagination.d.ts.map +1 -1
- package/dist/internal/pagination.js +31 -17
- package/dist/internal/pagination.js.map +1 -1
- package/dist/internal/paths.d.ts +11 -0
- package/dist/internal/paths.d.ts.map +1 -1
- package/dist/internal/paths.js +13 -0
- package/dist/internal/paths.js.map +1 -1
- package/dist/internal/request.d.ts +9 -0
- package/dist/internal/request.d.ts.map +1 -1
- package/dist/internal/request.js +31 -3
- package/dist/internal/request.js.map +1 -1
- package/dist/internal/write.d.ts +10 -10
- package/dist/internal/write.d.ts.map +1 -1
- package/dist/internal/write.js +23 -30
- package/dist/internal/write.js.map +1 -1
- package/dist/sync/index.d.ts +4 -2
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +4 -2
- package/dist/sync/index.js.map +1 -1
- package/package.json +7 -7
package/dist/edv/EdvCodec.js
CHANGED
|
@@ -38,13 +38,15 @@
|
|
|
38
38
|
* is encrypted as one document -- stored as a legible UTF-8 string for a
|
|
39
39
|
* text-family type (else base64) -- with the plaintext content type and the
|
|
40
40
|
* encoding carried in the document `meta`. A blob over `maxBlobBytes`, and
|
|
41
|
-
* any `ReadableStream` (whose size is unknown), is auto-routed by `add()`
|
|
42
|
-
* the chunked-stream path instead: `encode` returns a
|
|
43
|
-
* write path executes, storing one document plus its
|
|
44
|
-
* `WasTransport` supplied by the injected
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
41
|
+
* any `ReadableStream` (whose size is unknown), is auto-routed by `add()` and
|
|
42
|
+
* `put()` alike to the chunked-stream path instead: `encode` returns a
|
|
43
|
+
* multi-request plan the write path executes, storing one document plus its
|
|
44
|
+
* chunk resources over a `WasTransport` supplied by the injected
|
|
45
|
+
* `CodecTransportFactory`. A `put()` over a chunked document is a plan too,
|
|
46
|
+
* whatever its payload: the prior document's chunks that the new one does
|
|
47
|
+
* not cover are reaped after the write. Reads reassemble transparently, so
|
|
48
|
+
* `get()` returns the same `Blob` either way, and `decodeStream` hands a
|
|
49
|
+
* chunked document's decrypt stream back unbuffered.
|
|
48
50
|
* - **Enforced sequence (conditional writes).** The codec sets
|
|
49
51
|
* `conditionalWrites`, so the write path pre-reads the current envelope and
|
|
50
52
|
* hands it to `encode`: an update advances `sequence` from its prior value and
|
|
@@ -75,6 +77,7 @@ import { ConflictError, EncryptionError, IntegrityError, KeyUnwrapError, NotFoun
|
|
|
75
77
|
import { blobBytes, bytesToBlob, bytesToStream, streamToBlob } from '../internal/blob.js';
|
|
76
78
|
import { readEtag, writeHeaders } from '../internal/conditional.js';
|
|
77
79
|
import { isEncryptedEnvelope } from '../sync/envelope.js';
|
|
80
|
+
import { isIntegrityError, isKeyUnwrapError } from '../sync/predicates.js';
|
|
78
81
|
import { epochWriteStandIn, resolveEpochKeys } from './epochKeys.js';
|
|
79
82
|
import { didKeyResolver } from './epochCrypto.js';
|
|
80
83
|
import { currentEpochOf, hasKeyEpochs } from './epochRoster.js';
|
|
@@ -102,6 +105,13 @@ export const DEFAULT_MAX_BLOB_BYTES = 512 * 1024;
|
|
|
102
105
|
* decrypted document self-describing alongside `'utf-8'` and `'base64'`.
|
|
103
106
|
*/
|
|
104
107
|
const CHUNKED_ENCODING = 'chunked';
|
|
108
|
+
/**
|
|
109
|
+
* How many stale chunks a reap deletes at once. Each delete is one signed
|
|
110
|
+
* request that is independent of the others (pinned and 404-tolerant, in no
|
|
111
|
+
* required order), so a shrunk document's leftovers go a few at a time rather
|
|
112
|
+
* than one awaited round trip per chunk.
|
|
113
|
+
*/
|
|
114
|
+
const REAP_CONCURRENCY = 8;
|
|
105
115
|
/**
|
|
106
116
|
* A shared strict UTF-8 decoder used to test whether a non-JSON payload is
|
|
107
117
|
* valid UTF-8 (so it can be stored legibly as text rather than base64).
|
|
@@ -149,35 +159,43 @@ function wasParam({ version, resource, collection, epoch }) {
|
|
|
149
159
|
* was-client need not import the class at all.
|
|
150
160
|
*
|
|
151
161
|
* Also true for a {@link KeyUnwrapError} raised by a candidate itself -- a lazy
|
|
152
|
-
* epoch key whose recipient entry turned out
|
|
162
|
+
* epoch key whose recipient entry turned out malformed when its first decrypt
|
|
153
163
|
* forced the unwrap (see `lazyEpochKey`): that says nothing about the stored
|
|
154
164
|
* envelope, so the loop must move on to the next candidate rather than report
|
|
155
|
-
* tampering.
|
|
165
|
+
* tampering. Matched by name as well (the shared `isKeyUnwrapError`), since
|
|
166
|
+
* the read keys may be injected from another resolved copy of this package.
|
|
167
|
+
* Not true for the {@link IntegrityError} a lazy key raises when its
|
|
168
|
+
* kid-matched entry fails the key-wrap integrity check: that is a tamper
|
|
169
|
+
* signal in its own right and the loop surfaces it.
|
|
156
170
|
*
|
|
157
171
|
* @param err {unknown}
|
|
158
172
|
* @returns {boolean}
|
|
159
173
|
*/
|
|
160
174
|
function isKeyMiss(err) {
|
|
161
|
-
return (err
|
|
175
|
+
return (isKeyUnwrapError(err) ||
|
|
162
176
|
(err instanceof Error && err.name === 'KeyMissError'));
|
|
163
177
|
}
|
|
164
178
|
/**
|
|
165
|
-
* Whether a decrypt error
|
|
166
|
-
* content key, but the content's
|
|
167
|
-
* ciphertext is corrupt or has been
|
|
168
|
-
* raises its `IntegrityError` for
|
|
169
|
-
* matched by
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
179
|
+
* Whether a decrypt error is the cipher's integrity failure: the candidate
|
|
180
|
+
* key DID select a recipient and unwrap the content key, but the content's
|
|
181
|
+
* AEAD tag did not verify, so the stored ciphertext is corrupt or has been
|
|
182
|
+
* tampered with. `@interop/minimal-cipher` raises its `IntegrityError` for
|
|
183
|
+
* that, matched by name (the shared `isIntegrityError`) for the same reason
|
|
184
|
+
* {@link isKeyMiss} matches by name. This library's own {@link IntegrityError}
|
|
185
|
+
* carries the same name and can reach the same catch from a candidate key (a
|
|
186
|
+
* lazy epoch key whose kid-matched entry failed its key-wrap check), possibly
|
|
187
|
+
* from another resolved copy, so `instanceof` cannot tell the two apart. The
|
|
188
|
+
* cipher documents that its `IntegrityError` always carries the backend's raw
|
|
189
|
+
* error as `cause`; a key-wrap failure carries none, and is already typed and
|
|
190
|
+
* described, so it passes through untouched. Any other error is neither a key
|
|
191
|
+
* miss nor an integrity failure (a malformed envelope, a bug) and is not this
|
|
192
|
+
* library's to relabel.
|
|
175
193
|
*
|
|
176
194
|
* @param err {unknown}
|
|
177
195
|
* @returns {boolean}
|
|
178
196
|
*/
|
|
179
|
-
function
|
|
180
|
-
return err instanceof Error && err.
|
|
197
|
+
function isCipherIntegrityFailure(err) {
|
|
198
|
+
return (err instanceof Error && isIntegrityError(err) && err.cause !== undefined);
|
|
181
199
|
}
|
|
182
200
|
/**
|
|
183
201
|
* Extracts the JWE recipient key ids (`kid`) a stored envelope names (an epoch
|
|
@@ -452,6 +470,41 @@ export class EdvCodec {
|
|
|
452
470
|
? undefined
|
|
453
471
|
: (await this.#edv.generateId()));
|
|
454
472
|
const parts = await this.#toDocument(data, contentType, docId);
|
|
473
|
+
// When the write path pre-read a current envelope, this is an update:
|
|
474
|
+
// `sequence` advances from the prior value (`encrypt({ update: true })`
|
|
475
|
+
// increments it), the write is pinned to the server's current ETag with
|
|
476
|
+
// `If-Match`, and the write learns whether the prior document's bytes
|
|
477
|
+
// live in chunk resources, so they can be reaped once this write lands.
|
|
478
|
+
// Only the sealed `meta.encoding` says so for certain, and reading it
|
|
479
|
+
// means opening the envelope (decrypting it, with its binding verified
|
|
480
|
+
// against this id, as a read would): a key unwrap per update. The
|
|
481
|
+
// cleartext `stream` member a chunked write leaves on the envelope is the
|
|
482
|
+
// cue to pay for that; an envelope without one is taken as inline and is
|
|
483
|
+
// not opened. The cue is server-controlled, but nothing rests on it: a
|
|
484
|
+
// server that strips it can only leave the prior chunks stranded (stored
|
|
485
|
+
// and counted against quota, unreachable, removed with the resource), and
|
|
486
|
+
// one that adds it costs this write a decrypt that finds an inline
|
|
487
|
+
// encoding. With no prior envelope this is a fresh insert (`sequence: 0`)
|
|
488
|
+
// guarded by `If-None-Match: *` (create-if-absent), so a concurrent first
|
|
489
|
+
// writer cannot be clobbered.
|
|
490
|
+
let prior = null;
|
|
491
|
+
if (current) {
|
|
492
|
+
const read = await readJsonData(current);
|
|
493
|
+
this.#assertEnvelope(read, 'update');
|
|
494
|
+
const chunked = read.stream !== undefined &&
|
|
495
|
+
(await this.#openEnvelope({ doc: read, expectedId: id })).meta
|
|
496
|
+
?.encoding === CHUNKED_ENCODING;
|
|
497
|
+
// The caller's own compare-and-swap baseline wins when they named one:
|
|
498
|
+
// the write path has already checked it against the pre-read, so the
|
|
499
|
+
// two agree by the time this runs.
|
|
500
|
+
const etag = precondition?.ifMatch ?? readEtag(current);
|
|
501
|
+
prior = {
|
|
502
|
+
sequence: read.sequence,
|
|
503
|
+
...(read.indexed !== undefined && { indexed: read.indexed }),
|
|
504
|
+
...(etag !== undefined && { etag }),
|
|
505
|
+
chunked
|
|
506
|
+
};
|
|
507
|
+
}
|
|
455
508
|
if (parts.kind === 'chunked') {
|
|
456
509
|
if (docId === undefined) {
|
|
457
510
|
// A stream is chunked whatever its size, so only a sized payload can
|
|
@@ -473,21 +526,11 @@ export class EdvCodec {
|
|
|
473
526
|
return this.#chunkedWrite({
|
|
474
527
|
id: docId,
|
|
475
528
|
stream: parts.stream,
|
|
476
|
-
meta: parts.meta
|
|
529
|
+
meta: parts.meta,
|
|
530
|
+
prior
|
|
477
531
|
});
|
|
478
532
|
}
|
|
479
533
|
const { content, meta } = parts;
|
|
480
|
-
// When the write path pre-read a current envelope, advance `sequence` from
|
|
481
|
-
// its prior value (`encrypt({ update: true })` increments it) and pin the
|
|
482
|
-
// write to the server's current ETag with `If-Match`. With no prior envelope
|
|
483
|
-
// this is a fresh insert (`sequence: 0`) guarded by `If-None-Match: *`
|
|
484
|
-
// (create-if-absent), so a concurrent first writer cannot be clobbered.
|
|
485
|
-
let priorDoc = null;
|
|
486
|
-
if (current) {
|
|
487
|
-
const read = await readJsonData(current);
|
|
488
|
-
this.#assertEnvelope(read, 'update');
|
|
489
|
-
priorDoc = read;
|
|
490
|
-
}
|
|
491
534
|
const { documentCipher } = this.#edv;
|
|
492
535
|
const encrypted = await documentCipher.encrypt({
|
|
493
536
|
doc: {
|
|
@@ -497,14 +540,14 @@ export class EdvCodec {
|
|
|
497
540
|
// the plaintext content type and the inline-encoding discriminator,
|
|
498
541
|
// taken fresh from this write (the new type/encoding wins on update).
|
|
499
542
|
meta,
|
|
500
|
-
...(
|
|
543
|
+
...(prior && { sequence: prior.sequence }),
|
|
501
544
|
// Carry the prior envelope's blinded `indexed` entries into the
|
|
502
545
|
// update. With no blinding key edv-client stores `doc.indexed`
|
|
503
546
|
// verbatim; omitting it would replace the entries with `[]` and
|
|
504
547
|
// silently drop the record from `find()`. With a blinding key the
|
|
505
548
|
// entries are recomputed from the schema and this copy is the prior
|
|
506
549
|
// state that recomputation updates.
|
|
507
|
-
...(
|
|
550
|
+
...(prior?.indexed !== undefined && { indexed: prior.indexed })
|
|
508
551
|
},
|
|
509
552
|
// Bind an AEAD-authenticated `was` parameter into the JWE protected
|
|
510
553
|
// header: the scheme version, the resource id when known at encrypt time
|
|
@@ -521,7 +564,7 @@ export class EdvCodec {
|
|
|
521
564
|
...(docId !== undefined && { resource: docId }),
|
|
522
565
|
hmac: this.#writeBlindingKey()
|
|
523
566
|
}),
|
|
524
|
-
update:
|
|
567
|
+
update: prior !== null
|
|
525
568
|
});
|
|
526
569
|
if (docId === undefined) {
|
|
527
570
|
// Encrypt-then-stamp: the id lives in the cleartext envelope, outside the
|
|
@@ -530,6 +573,12 @@ export class EdvCodec {
|
|
|
530
573
|
docId = await documentCipher.deriveId({ jwe: encrypted.jwe });
|
|
531
574
|
encrypted.id = docId;
|
|
532
575
|
}
|
|
576
|
+
if (prior?.chunked) {
|
|
577
|
+
// The envelope replaces a document whose bytes live in chunk resources,
|
|
578
|
+
// which the inline document leaves behind: a plan writes it and reaps
|
|
579
|
+
// them, since one request cannot do both.
|
|
580
|
+
return this.#replacingWrite({ id: docId, encrypted, meta, prior });
|
|
581
|
+
}
|
|
533
582
|
// Serialized on first read of `body` and kept for any later read, so the
|
|
534
583
|
// wire bytes are built once per write and only when someone asks for them.
|
|
535
584
|
let bytes;
|
|
@@ -564,9 +613,7 @@ export class EdvCodec {
|
|
|
564
613
|
// (create-only-if-absent), and the server enforces it, so a masked-404
|
|
565
614
|
// pre-read cannot silently overwrite an existing document.
|
|
566
615
|
...(precondition ??
|
|
567
|
-
(
|
|
568
|
-
? { ifMatch: readEtag(current ?? null) }
|
|
569
|
-
: { ifNoneMatch: true })),
|
|
616
|
+
(prior ? { ifMatch: prior.etag } : { ifNoneMatch: true })),
|
|
570
617
|
// Stamp the key epoch this write encrypted under (the `currentEpoch`), so
|
|
571
618
|
// the server records it and a reader can pick the epoch key.
|
|
572
619
|
epoch: this.#writeEpoch
|
|
@@ -579,9 +626,11 @@ export class EdvCodec {
|
|
|
579
626
|
* @param context {CodecRequestContext}
|
|
580
627
|
* @param [documentHeaders] {Record<string, string>} extra headers for
|
|
581
628
|
* document writes (the `Key-Epoch` stamp the codec seam applies)
|
|
629
|
+
* @param [baseline] {StoredDocument} the stored document the transport's
|
|
630
|
+
* first update replaces, which it pins that update to
|
|
582
631
|
* @returns {WasTransport}
|
|
583
632
|
*/
|
|
584
|
-
#transportFor(context, documentHeaders) {
|
|
633
|
+
#transportFor(context, documentHeaders, baseline) {
|
|
585
634
|
if (this.#transportFactory === undefined) {
|
|
586
635
|
throw new NotSupportedError(`Collection "${this.#collectionId}" has no server behind it: this ` +
|
|
587
636
|
'codec was built for a local replica and holds no route to address ' +
|
|
@@ -591,14 +640,19 @@ export class EdvCodec {
|
|
|
591
640
|
'its `spaceId`, or by passing a `chunkSource` to the DocCipher ' +
|
|
592
641
|
'`decrypt` in place of a request context.');
|
|
593
642
|
}
|
|
594
|
-
return this.#transportFactory({
|
|
643
|
+
return this.#transportFactory({
|
|
644
|
+
context,
|
|
645
|
+
documentHeaders,
|
|
646
|
+
...(baseline !== undefined && { baseline })
|
|
647
|
+
});
|
|
595
648
|
}
|
|
596
649
|
/**
|
|
597
650
|
* The plan for a binary payload over the single-document threshold, or for
|
|
598
651
|
* a stream of any size: one EDV document plus its chunk resources, written
|
|
599
|
-
* by `EdvClientCore.insert({ doc, stream, transport })` over a
|
|
600
|
-
* built from the write context. The
|
|
601
|
-
* is returned, so the caller can
|
|
652
|
+
* by `EdvClientCore.insert({ doc, stream, transport })` (or, over a stored
|
|
653
|
+
* document, `update`) on a transport built from the write context. The
|
|
654
|
+
* document id is minted before the plan is returned, so the caller can
|
|
655
|
+
* report it without waiting for the write.
|
|
602
656
|
*
|
|
603
657
|
* The `was` binding, the recipients and the write epoch are exactly the
|
|
604
658
|
* single-document path's, and `additionalProtectedParams` carries the binding
|
|
@@ -609,69 +663,240 @@ export class EdvCodec {
|
|
|
609
663
|
* payload, so that discriminator is what the read side routes on: a server
|
|
610
664
|
* cannot mint it, and cannot suppress it to hide the chunks either.
|
|
611
665
|
*
|
|
612
|
-
*
|
|
613
|
-
* `If-None-Match: *`, so the plan
|
|
614
|
-
* id that holds no document
|
|
615
|
-
* `PreconditionFailedError` the request path raised.
|
|
666
|
+
* With no prior document the write is a create-if-absent: the first
|
|
667
|
+
* document write carries `If-None-Match: *`, so the plan serves `add()` and
|
|
668
|
+
* a write by id (`put()`) at an id that holds no document alike. A 412 there
|
|
669
|
+
* surfaces as the `PreconditionFailedError` the request path raised. Over a
|
|
670
|
+
* prior document the first write is pinned to the pre-read's `ETag`, so a
|
|
671
|
+
* 412 is the ordinary lost-update signal, and the new chunks overwrite the
|
|
672
|
+
* prior ones index by index. Once the write is stored, a prior document
|
|
673
|
+
* that was itself chunked has every chunk beyond the new count reaped
|
|
674
|
+
* ({@link #reapChunks}): the new document's sealed count already makes them
|
|
675
|
+
* unreachable, and the reaping frees them.
|
|
616
676
|
*
|
|
617
|
-
* The write is two-phase (
|
|
618
|
-
*
|
|
619
|
-
* document whose sealed stream state is still
|
|
620
|
-
* undecryptable and listed.
|
|
621
|
-
* was written and the write then failed,
|
|
622
|
-
* before rethrowing. A
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
677
|
+
* The write is two-phase (the EDV core writes the document, then streams
|
|
678
|
+
* the chunks, then writes the document again), so a failure partway leaves
|
|
679
|
+
* a pending stub: a document whose sealed stream state is still
|
|
680
|
+
* `{ pending: true }`, undecryptable and listed. A create compensates: if
|
|
681
|
+
* the document was written and the write then failed, the plan best-effort
|
|
682
|
+
* deletes the stub before rethrowing. A replace cannot restore the document
|
|
683
|
+
* it overwrote, so the stub is left in place for the next write at the id
|
|
684
|
+
* to replace, or for a `delete()`. A stub that survives is recognized with
|
|
685
|
+
* {@link isPendingStub}. The EDV core cancels the caller's source stream on
|
|
686
|
+
* such a failure, so the source behind it is released.
|
|
626
687
|
*
|
|
627
688
|
* @param options {object}
|
|
628
689
|
* @param options.id {string} the document id (minted, or the caller's)
|
|
629
690
|
* @param options.stream {ReadableStream<Uint8Array>} the payload, as the
|
|
630
691
|
* stream the EDV core re-chunks (never buffered whole by this codec)
|
|
631
692
|
* @param options.meta {Record<string, unknown>} the document meta to seal
|
|
693
|
+
* @param options.prior {PriorDocument | null} the stored document this
|
|
694
|
+
* write replaces, or `null` for a create
|
|
632
695
|
* @returns {ChunkedWrite}
|
|
633
696
|
*/
|
|
634
|
-
#chunkedWrite({ id, stream, meta }) {
|
|
697
|
+
#chunkedWrite({ id, stream, meta, prior }) {
|
|
698
|
+
return this.#writePlan({
|
|
699
|
+
id,
|
|
700
|
+
meta,
|
|
701
|
+
prior,
|
|
702
|
+
write: async (transport) => {
|
|
703
|
+
const write = {
|
|
704
|
+
stream,
|
|
705
|
+
...(this.#chunkSize !== undefined && {
|
|
706
|
+
chunkSize: this.#chunkSize
|
|
707
|
+
}),
|
|
708
|
+
...this.#sealParams({
|
|
709
|
+
resource: id,
|
|
710
|
+
hmac: this.#writeBlindingKey()
|
|
711
|
+
}),
|
|
712
|
+
transport
|
|
713
|
+
};
|
|
714
|
+
const stored = prior === null
|
|
715
|
+
? await this.#edv.insert({
|
|
716
|
+
doc: { id, content: {}, meta },
|
|
717
|
+
...write
|
|
718
|
+
})
|
|
719
|
+
: await this.#edv.update({
|
|
720
|
+
doc: {
|
|
721
|
+
id,
|
|
722
|
+
content: {},
|
|
723
|
+
meta,
|
|
724
|
+
sequence: prior.sequence,
|
|
725
|
+
...(prior.indexed !== undefined && {
|
|
726
|
+
indexed: prior.indexed
|
|
727
|
+
})
|
|
728
|
+
},
|
|
729
|
+
...write
|
|
730
|
+
});
|
|
731
|
+
// The EDV core reports the chunk count it sealed into the final
|
|
732
|
+
// document write; every stored chunk at or past it is the prior
|
|
733
|
+
// document's.
|
|
734
|
+
return stored.stream.chunks;
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The plan for an inline document that replaces a chunked one: the sealed
|
|
740
|
+
* envelope is written as the single-document path would write it, pinned to
|
|
741
|
+
* the pre-read's `ETag` and stamped with the write epoch, and the prior
|
|
742
|
+
* document's chunks are then reaped ({@link #reapChunks}). One request
|
|
743
|
+
* cannot do both, which is why the write is a plan rather than an
|
|
744
|
+
* `EncodedWrite`. The envelope's sealed `meta.encoding` is inline, so a
|
|
745
|
+
* reader of the new document never looks for chunks, and the reaping only
|
|
746
|
+
* frees what is already unreachable.
|
|
747
|
+
*
|
|
748
|
+
* @param options {object}
|
|
749
|
+
* @param options.id {string} the document id
|
|
750
|
+
* @param options.encrypted {IEncryptedDocument} the sealed envelope
|
|
751
|
+
* @param options.meta {Record<string, unknown>} the document meta sealed
|
|
752
|
+
* in it, for the plaintext content type
|
|
753
|
+
* @param options.prior {PriorDocument} the chunked document it replaces
|
|
754
|
+
* @returns {ChunkedWrite}
|
|
755
|
+
*/
|
|
756
|
+
#replacingWrite({ id, encrypted, meta, prior }) {
|
|
757
|
+
return this.#writePlan({
|
|
758
|
+
id,
|
|
759
|
+
meta,
|
|
760
|
+
prior,
|
|
761
|
+
write: async (transport) => {
|
|
762
|
+
await transport.update({ encrypted });
|
|
763
|
+
return 0;
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* The lifecycle shared by every plan ({@link #chunkedWrite},
|
|
769
|
+
* {@link #replacingWrite}): a transport is built from the write context,
|
|
770
|
+
* stamped with the write epoch and, over a prior document, given the
|
|
771
|
+
* pre-read as its baseline; `write` runs on it and reports the chunk count
|
|
772
|
+
* the stored document covers; a failure is compensated and rethrown by
|
|
773
|
+
* {@link #chunkedWriteFailed}; a prior document that was chunked has the
|
|
774
|
+
* chunks past that count reaped ({@link #reapChunks}); and the result
|
|
775
|
+
* carries the validator the server acked the last document write with. The
|
|
776
|
+
* EDV core owns a chunked write and swallows the responses, which is why the
|
|
777
|
+
* transport is what reports the document write it made.
|
|
778
|
+
*
|
|
779
|
+
* @param options {object}
|
|
780
|
+
* @param options.id {string} the document id
|
|
781
|
+
* @param options.meta {Record<string, unknown>} the document meta sealed
|
|
782
|
+
* in the write, for the plaintext content type
|
|
783
|
+
* @param options.prior {PriorDocument | null} the stored document the
|
|
784
|
+
* write replaces, or `null` for a create
|
|
785
|
+
* @param options.write {(transport: WasTransport) => Promise<number>} the
|
|
786
|
+
* write itself, resolving the chunk count the stored document covers
|
|
787
|
+
* @returns {ChunkedWrite}
|
|
788
|
+
*/
|
|
789
|
+
#writePlan({ id, meta, prior, write }) {
|
|
635
790
|
return {
|
|
636
791
|
chunked: true,
|
|
637
792
|
id,
|
|
638
793
|
resourceContentType: meta.contentType,
|
|
639
|
-
// What the scheme-agnostic write path appends when it refuses this plan
|
|
640
|
-
// for a write by id over an existing document: only this codec knows
|
|
641
|
-
// why the payload needs several requests, and which low-level API
|
|
642
|
-
// writes one directly.
|
|
643
|
-
guidance: 'This payload is too large for a single encrypted document, or is a ' +
|
|
644
|
-
'stream, so it is stored as a document plus chunk resources. Drive ' +
|
|
645
|
-
'the write yourself with `EdvClientCore.update({ doc, stream, ' +
|
|
646
|
-
'transport })` over a `WasTransport`.',
|
|
647
794
|
execute: async (context) => {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
// all (the cleanup decision below) and the validator the server acked
|
|
651
|
-
// it with.
|
|
652
|
-
const transport = this.#transportFor(context, writeHeaders({ epoch: this.#writeEpoch }));
|
|
795
|
+
const transport = this.#transportFor(context, writeHeaders({ epoch: this.#writeEpoch }), prior === null ? undefined : { id, etag: prior.etag });
|
|
796
|
+
let keep;
|
|
653
797
|
try {
|
|
654
|
-
await
|
|
655
|
-
doc: { id, content: {}, meta },
|
|
656
|
-
stream,
|
|
657
|
-
...(this.#chunkSize !== undefined && {
|
|
658
|
-
chunkSize: this.#chunkSize
|
|
659
|
-
}),
|
|
660
|
-
...this.#sealParams({
|
|
661
|
-
resource: id,
|
|
662
|
-
hmac: this.#writeBlindingKey()
|
|
663
|
-
}),
|
|
664
|
-
transport
|
|
665
|
-
});
|
|
798
|
+
keep = await write(transport);
|
|
666
799
|
}
|
|
667
800
|
catch (err) {
|
|
668
|
-
throw await this.#chunkedWriteFailed({
|
|
801
|
+
throw await this.#chunkedWriteFailed({
|
|
802
|
+
err,
|
|
803
|
+
id,
|
|
804
|
+
transport,
|
|
805
|
+
replace: prior !== null
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
if (prior?.chunked) {
|
|
809
|
+
await this.#reapChunks({ transport, id, keep });
|
|
669
810
|
}
|
|
670
811
|
const etag = transport.lastDocumentWrite?.etag;
|
|
671
812
|
return { id, ...(etag !== undefined && { etag }) };
|
|
672
813
|
}
|
|
673
814
|
};
|
|
674
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* Reaps the chunks a replaced document left behind: lists the chunks stored
|
|
818
|
+
* under the document and deletes every one at or past `keep`, the chunk
|
|
819
|
+
* count the new document covers (`0` for an inline document). The listing,
|
|
820
|
+
* not the prior envelope's sealed count, says what is stored, so a pending
|
|
821
|
+
* stub's chunks (whose count was never sealed) and a longer stale set are
|
|
822
|
+
* reaped the same way. Each delete is pinned to the chunk `etag` the listing
|
|
823
|
+
* reported, where it reported one, so a chunk a later writer has since
|
|
824
|
+
* stored at that index is left to it; such a 412, and a chunk already gone
|
|
825
|
+
* (404), are not failures. The deletes run {@link REAP_CONCURRENCY} at a
|
|
826
|
+
* time. A listing that finds no resource (404) means another client deleted
|
|
827
|
+
* it since the write landed, and its chunks with it: nothing is left to
|
|
828
|
+
* reap.
|
|
829
|
+
*
|
|
830
|
+
* Runs only after the new document is stored, and the new document's sealed
|
|
831
|
+
* state already makes these chunks unreachable, so a failure here loses no
|
|
832
|
+
* data: it is reported as {@link EncryptionError} naming what is still
|
|
833
|
+
* stored. A later write at the id reaps again only if the stored document
|
|
834
|
+
* is still chunked (a pending stub, say); under an inline document the
|
|
835
|
+
* stale chunks stay until the resource is deleted, which takes them.
|
|
836
|
+
*
|
|
837
|
+
* @param options {object}
|
|
838
|
+
* @param options.transport {WasTransport} the transport the write ran on
|
|
839
|
+
* @param options.id {string} the document id
|
|
840
|
+
* @param options.keep {number} the chunk count the stored document covers
|
|
841
|
+
* @returns {Promise<void>}
|
|
842
|
+
*/
|
|
843
|
+
async #reapChunks({ transport, id, keep }) {
|
|
844
|
+
let stale;
|
|
845
|
+
try {
|
|
846
|
+
stale = (await transport.listChunks({ id })).filter(chunk => chunk.index >= keep);
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
if (err instanceof NotFoundError) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
throw this.#reapFailed(id, err);
|
|
853
|
+
}
|
|
854
|
+
const reapOne = async (chunk) => {
|
|
855
|
+
try {
|
|
856
|
+
await transport.deleteChunk({
|
|
857
|
+
id,
|
|
858
|
+
chunkIndex: chunk.index,
|
|
859
|
+
...(chunk.etag !== undefined && { ifMatch: chunk.etag })
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
catch (err) {
|
|
863
|
+
if (err instanceof NotFoundError ||
|
|
864
|
+
err instanceof PreconditionFailedError) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
throw err;
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
// A fixed number of workers each take the next stale chunk until none is
|
|
871
|
+
// left, so at most `REAP_CONCURRENCY` deletes are in flight.
|
|
872
|
+
let next = 0;
|
|
873
|
+
const worker = async () => {
|
|
874
|
+
while (next < stale.length) {
|
|
875
|
+
await reapOne(stale[next++]);
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
try {
|
|
879
|
+
await Promise.all(Array.from({ length: Math.min(REAP_CONCURRENCY, stale.length) }, worker));
|
|
880
|
+
}
|
|
881
|
+
catch (err) {
|
|
882
|
+
throw this.#reapFailed(id, err);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* The error a failed {@link #reapChunks} throws: the write landed, and what
|
|
887
|
+
* it did not reap is still stored.
|
|
888
|
+
*
|
|
889
|
+
* @param id {string} the document id
|
|
890
|
+
* @param cause {unknown} the failure
|
|
891
|
+
* @returns {EncryptionError}
|
|
892
|
+
*/
|
|
893
|
+
#reapFailed(id, cause) {
|
|
894
|
+
return new EncryptionError(`The encrypted write of resource "${id}" is stored, but the chunk ` +
|
|
895
|
+
'resources of the document it replaced could not all be deleted. ' +
|
|
896
|
+
'They are unreachable through a read, which follows the chunk ' +
|
|
897
|
+
'count sealed in the stored document, but stay stored and counted ' +
|
|
898
|
+
'against quota; deleting the resource removes them.', { cause });
|
|
899
|
+
}
|
|
675
900
|
/**
|
|
676
901
|
* Compensates a failed chunked write and builds the error to rethrow. The
|
|
677
902
|
* pending stub is deleted only when the transport reports it actually wrote
|
|
@@ -683,6 +908,11 @@ export class EdvCodec {
|
|
|
683
908
|
* so its outcome only shapes the message. Deleting the document deletes the
|
|
684
909
|
* chunks already stored under it.
|
|
685
910
|
*
|
|
911
|
+
* A replace (`replace`) deletes nothing: the stub stands where the document
|
|
912
|
+
* it overwrote stood, and deleting it would turn a torn update into a
|
|
913
|
+
* missing resource. The message says the stub is there to be written over
|
|
914
|
+
* or deleted.
|
|
915
|
+
*
|
|
686
916
|
* Three failures keep their own type, so a caller can dispatch on them as it
|
|
687
917
|
* does for a single-request write. A 412 on the first document write (the
|
|
688
918
|
* id is taken) or on the final document update (another writer replaced the
|
|
@@ -697,10 +927,12 @@ export class EdvCodec {
|
|
|
697
927
|
* @param options.err {unknown} the failure from the chunked write
|
|
698
928
|
* @param options.id {string} the document id the write targeted
|
|
699
929
|
* @param options.transport {WasTransport} the transport the write ran on
|
|
930
|
+
* @param options.replace {boolean} whether the write replaced a stored
|
|
931
|
+
* document (no stub delete) rather than created one
|
|
700
932
|
* @returns {Promise<Error>} the error to throw, carrying `err` as its cause
|
|
701
933
|
* when it wraps it
|
|
702
934
|
*/
|
|
703
|
-
async #chunkedWriteFailed({ err, id, transport }) {
|
|
935
|
+
async #chunkedWriteFailed({ err, id, transport, replace }) {
|
|
704
936
|
// The transport rethrows a 412 or 409 as the named error the EDV core
|
|
705
937
|
// dispatches on, with the typed HTTP error as its cause.
|
|
706
938
|
const httpError = err instanceof Error &&
|
|
@@ -721,17 +953,26 @@ export class EdvCodec {
|
|
|
721
953
|
}
|
|
722
954
|
let outcome = 'The incomplete document and any chunks stored under it were deleted.';
|
|
723
955
|
let removed = true;
|
|
724
|
-
|
|
725
|
-
await transport.deleteDocument({ id, ifMatch: written.etag });
|
|
726
|
-
}
|
|
727
|
-
catch (deleteErr) {
|
|
956
|
+
if (replace) {
|
|
728
957
|
removed = false;
|
|
729
958
|
outcome =
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
959
|
+
'The document it replaced is gone, and the resource now holds this ' +
|
|
960
|
+
"write's pending stub, which no read can open: write the resource " +
|
|
961
|
+
'again, or delete it.';
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
try {
|
|
965
|
+
await transport.deleteDocument({ id, ifMatch: written.etag });
|
|
966
|
+
}
|
|
967
|
+
catch (deleteErr) {
|
|
968
|
+
removed = false;
|
|
969
|
+
outcome =
|
|
970
|
+
deleteErr instanceof PreconditionFailedError
|
|
971
|
+
? 'Another writer has since replaced the document, so it was ' +
|
|
972
|
+
'left in place.'
|
|
973
|
+
: 'The incomplete document could NOT be deleted and is still ' +
|
|
974
|
+
'stored; delete it before writing again.';
|
|
975
|
+
}
|
|
735
976
|
}
|
|
736
977
|
if (httpError instanceof QuotaExceededError ||
|
|
737
978
|
httpError instanceof ConflictError) {
|
|
@@ -1085,8 +1326,9 @@ export class EdvCodec {
|
|
|
1085
1326
|
* all raises {@link UnknownEpochError} -- the signal that the cached
|
|
1086
1327
|
* Collection Metadata object may be stale (an epoch rotation emits no
|
|
1087
1328
|
* change-feed entry) and the codec must be rebuilt from a re-read
|
|
1088
|
-
* descriptor. A candidate whose entry
|
|
1089
|
-
* {@link KeyUnwrapError}
|
|
1329
|
+
* descriptor. A candidate whose entry is malformed also surfaces
|
|
1330
|
+
* {@link KeyUnwrapError}; one whose entry names this reader but fails its
|
|
1331
|
+
* key-wrap integrity check surfaces the {@link IntegrityError} it raised.
|
|
1090
1332
|
*
|
|
1091
1333
|
* Also returns the `id` of the key that actually decrypted the envelope (its
|
|
1092
1334
|
* JWE recipient `kid`), so {@link _verifyBinding} can check a `was.epoch`
|
|
@@ -1139,8 +1381,9 @@ export class EdvCodec {
|
|
|
1139
1381
|
// unwrap its content-encryption key; try the next candidate.
|
|
1140
1382
|
continue;
|
|
1141
1383
|
}
|
|
1142
|
-
if (!
|
|
1143
|
-
// Neither a key miss nor an AEAD failure
|
|
1384
|
+
if (!isCipherIntegrityFailure(err)) {
|
|
1385
|
+
// Neither a key miss nor an AEAD failure (this library's own tamper
|
|
1386
|
+
// signal from a lazy epoch key included): rethrow as is.
|
|
1144
1387
|
throw err;
|
|
1145
1388
|
}
|
|
1146
1389
|
// Surface the AEAD failure immediately rather than masking it as a
|
|
@@ -1610,11 +1853,11 @@ function sealedContentType(meta) {
|
|
|
1610
1853
|
}
|
|
1611
1854
|
/**
|
|
1612
1855
|
* The chunked decrypt stream of resource `id`, with the cipher's integrity
|
|
1613
|
-
* failure (see {@link
|
|
1614
|
-
* replaced by an {@link EncryptionError} that carries the
|
|
1615
|
-
* Every other error passes through as it is, this
|
|
1616
|
-
* included (a chunk source or lazy epoch key may
|
|
1617
|
-
*
|
|
1856
|
+
* failure (see {@link isCipherIntegrityFailure}) or key miss (see
|
|
1857
|
+
* {@link isKeyMiss}) replaced by an {@link EncryptionError} that carries the
|
|
1858
|
+
* original as `cause`. Every other error passes through as it is, this
|
|
1859
|
+
* library's own typed errors included (a chunk source or lazy epoch key may
|
|
1860
|
+
* raise one that `isKeyMiss` would match by name). Cancellation is forwarded.
|
|
1618
1861
|
*
|
|
1619
1862
|
* @param options {object}
|
|
1620
1863
|
* @param options.id {string} the resource id, for the error message
|
|
@@ -1634,7 +1877,7 @@ function chunkDecryptErrors({ id, stream }) {
|
|
|
1634
1877
|
// Already one of this library's errors: not the cipher's.
|
|
1635
1878
|
throw err;
|
|
1636
1879
|
}
|
|
1637
|
-
if (!
|
|
1880
|
+
if (!isCipherIntegrityFailure(err) && !isKeyMiss(err)) {
|
|
1638
1881
|
// Neither tampering nor a key the reader lacks: rethrow as is.
|
|
1639
1882
|
throw err;
|
|
1640
1883
|
}
|