@interop/was-client 0.14.5 → 0.16.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 (84) hide show
  1. package/dist/Collection.d.ts +17 -26
  2. package/dist/Collection.d.ts.map +1 -1
  3. package/dist/Collection.js +60 -59
  4. package/dist/Collection.js.map +1 -1
  5. package/dist/Resource.d.ts +27 -3
  6. package/dist/Resource.d.ts.map +1 -1
  7. package/dist/Resource.js +60 -22
  8. package/dist/Resource.js.map +1 -1
  9. package/dist/Space.d.ts.map +1 -1
  10. package/dist/Space.js +19 -11
  11. package/dist/Space.js.map +1 -1
  12. package/dist/WasClient.d.ts.map +1 -1
  13. package/dist/WasClient.js +4 -13
  14. package/dist/WasClient.js.map +1 -1
  15. package/dist/edv/EdvCodec.d.ts +11 -2
  16. package/dist/edv/EdvCodec.d.ts.map +1 -1
  17. package/dist/edv/EdvCodec.js +85 -70
  18. package/dist/edv/EdvCodec.js.map +1 -1
  19. package/dist/edv/WasTransport.d.ts +61 -53
  20. package/dist/edv/WasTransport.d.ts.map +1 -1
  21. package/dist/edv/WasTransport.js +189 -157
  22. package/dist/edv/WasTransport.js.map +1 -1
  23. package/dist/edv/constants.d.ts +0 -14
  24. package/dist/edv/constants.d.ts.map +1 -1
  25. package/dist/edv/constants.js +4 -8
  26. package/dist/edv/constants.js.map +1 -1
  27. package/dist/edv/epochCrypto.d.ts +1 -1
  28. package/dist/edv/epochCrypto.d.ts.map +1 -1
  29. package/dist/edv/epochCrypto.js +33 -4
  30. package/dist/edv/epochCrypto.js.map +1 -1
  31. package/dist/edv/epochKeys.js +11 -1
  32. package/dist/edv/epochKeys.js.map +1 -1
  33. package/dist/edv/recipients.d.ts +5 -1
  34. package/dist/edv/recipients.d.ts.map +1 -1
  35. package/dist/edv/recipients.js +38 -16
  36. package/dist/edv/recipients.js.map +1 -1
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/internal/codec.d.ts +19 -0
  40. package/dist/internal/codec.d.ts.map +1 -1
  41. package/dist/internal/codec.js +26 -8
  42. package/dist/internal/codec.js.map +1 -1
  43. package/dist/internal/conditional.d.ts +11 -0
  44. package/dist/internal/conditional.d.ts.map +1 -1
  45. package/dist/internal/conditional.js +12 -0
  46. package/dist/internal/conditional.js.map +1 -1
  47. package/dist/internal/content.d.ts +7 -0
  48. package/dist/internal/content.d.ts.map +1 -1
  49. package/dist/internal/content.js +5 -3
  50. package/dist/internal/content.js.map +1 -1
  51. package/dist/internal/describe.d.ts +49 -1
  52. package/dist/internal/describe.d.ts.map +1 -1
  53. package/dist/internal/describe.js +47 -6
  54. package/dist/internal/describe.js.map +1 -1
  55. package/dist/internal/features.d.ts +73 -0
  56. package/dist/internal/features.d.ts.map +1 -0
  57. package/dist/internal/features.js +129 -0
  58. package/dist/internal/features.js.map +1 -0
  59. package/dist/internal/grant.d.ts.map +1 -1
  60. package/dist/internal/grant.js +2 -1
  61. package/dist/internal/grant.js.map +1 -1
  62. package/dist/internal/pagination.d.ts +30 -1
  63. package/dist/internal/pagination.d.ts.map +1 -1
  64. package/dist/internal/pagination.js +44 -1
  65. package/dist/internal/pagination.js.map +1 -1
  66. package/dist/internal/paths.d.ts +25 -0
  67. package/dist/internal/paths.d.ts.map +1 -1
  68. package/dist/internal/paths.js +67 -10
  69. package/dist/internal/paths.js.map +1 -1
  70. package/dist/internal/policy.d.ts +28 -0
  71. package/dist/internal/policy.d.ts.map +1 -1
  72. package/dist/internal/policy.js +31 -0
  73. package/dist/internal/policy.js.map +1 -1
  74. package/dist/internal/revoke.d.ts +11 -0
  75. package/dist/internal/revoke.d.ts.map +1 -1
  76. package/dist/internal/revoke.js +14 -1
  77. package/dist/internal/revoke.js.map +1 -1
  78. package/dist/internal/write.d.ts +10 -1
  79. package/dist/internal/write.d.ts.map +1 -1
  80. package/dist/internal/write.js +29 -4
  81. package/dist/internal/write.js.map +1 -1
  82. package/dist/types.d.ts +12 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/package.json +9 -9
@@ -32,16 +32,20 @@
32
32
  * feature). `updateIndex` throws: in this profile the `indexed` array rides
33
33
  * inside the stored document envelope, so `update()` IS the re-index
34
34
  * operation and no separate index endpoint exists. Chunked streams
35
- * (`storeChunk` / `getChunk`) still require the server's `chunked-streams`
36
- * affordance (the reserved `/{id}/chunks/{n}` sub-segment), which neither
37
- * reference backend provides, so they throw.
35
+ * (`storeChunk` / `getChunk`) map each EDV chunk onto the reserved
36
+ * `/{id}/chunks/{n}` sub-segment (the server's `chunked-streams` affordance),
37
+ * storing the chunk object as an opaque JSON body -- so `EdvClientCore.insert({
38
+ * stream })` / `getStream` drive chunked encrypted blobs over a WAS server
39
+ * unchanged. Both chunk methods are gated on the backend advertising
40
+ * `chunked-streams` (throwing `NotSupportedError` when it is absent), like
41
+ * `find` is on `blinded-index-query`.
38
42
  * `insert` uses an atomic `If-None-Match: *` create when the backend
39
43
  * advertises the optional `conditional-writes` feature; otherwise (and for
40
44
  * `update`) writes are advisory -- the EDV `sequence` is not enforced
41
45
  * (last-writer-wins on `update`).
42
46
  */
43
47
  import { Transport } from '@interop/edv-client';
44
- import type { IEDVQuery, IEncryptedDocument } from '@interop/data-integrity-core';
48
+ import type { IEDVChunk, IEDVQuery, IEncryptedDocument } from '@interop/data-integrity-core';
45
49
  import type { WasClient } from '../WasClient.js';
46
50
  import { JOSE_CONTENT_TYPE } from './constants.js';
47
51
  export { JOSE_CONTENT_TYPE };
@@ -55,7 +59,7 @@ export declare class WasTransport extends Transport {
55
59
  readonly collectionId: string;
56
60
  readonly contentType: string;
57
61
  private readonly _was;
58
- private _backendFeaturesPromise?;
62
+ private readonly _features;
59
63
  /**
60
64
  * @param options {object}
61
65
  * @param options.was {WasClient} a WAS client holding the signer
@@ -94,42 +98,6 @@ export declare class WasTransport extends Transport {
94
98
  * @returns {Promise<HttpResponse>}
95
99
  */
96
100
  private _put;
97
- /**
98
- * The feature tokens the collection's backend advertises in its "Collection
99
- * Backend Selected" descriptor (e.g. `conditional-writes`,
100
- * `blinded-index-query`). Memoized once it produces a definitive answer: a
101
- * successful read (including one that lists no features) and a definitive
102
- * "endpoint absent" (`404` / `405` / `501`) both resolve to a cached feature
103
- * list, so every affordance gate falls closed against a server that has no
104
- * backend descriptors.
105
- *
106
- * A transient/ambiguous failure (network error, timeout, `401`, `429`, other
107
- * `5xx`) is NOT cached: the memo is cleared so the next call re-probes, and
108
- * the error is rethrown so the caller fails loud rather than silently
109
- * degrading atomicity against a server that may well be capable. (A single
110
- * transient failure must not poison the transport for its lifetime.)
111
- *
112
- * @returns {Promise<string[]>}
113
- */
114
- private _backendFeatures;
115
- /**
116
- * Reads and parses the backend descriptor once. On a definitive answer
117
- * (success, or a `404` / `405` / `501` that means the endpoint is legitimately
118
- * absent) resolves the feature list, which `_backendFeatures` then caches. On
119
- * a transient failure, clears the memo (so the next call re-probes) and
120
- * rethrows.
121
- *
122
- * @returns {Promise<string[]>}
123
- */
124
- private _probeBackendFeatures;
125
- /**
126
- * Whether the collection's backend advertises the optional
127
- * `conditional-writes` feature, so `insert` can use an atomic
128
- * `If-None-Match: *` create instead of the advisory existence-check path.
129
- *
130
- * @returns {Promise<boolean>}
131
- */
132
- private _conditionalWrites;
133
101
  /**
134
102
  * @inheritdoc
135
103
  *
@@ -185,6 +153,16 @@ export declare class WasTransport extends Transport {
185
153
  get({ id }?: {
186
154
  id?: string;
187
155
  }): Promise<IEncryptedDocument>;
156
+ /**
157
+ * Throws a `NotSupportedError` (the name `EdvClientCore` dispatches on)
158
+ * unless the collection's backend advertises the given affordance token --
159
+ * the shared gate in front of every optional-feature operation.
160
+ *
161
+ * @param feature {string} the affordance token (e.g. `chunked-streams`)
162
+ * @param what {string} the operation name, for the message
163
+ * @returns {Promise<void>}
164
+ */
165
+ private _requireFeature;
188
166
  /**
189
167
  * Resolves to `true` if a resource exists at the document's path, via a
190
168
  * bodiless `HEAD` (the stored envelope is not needed, only its existence). A
@@ -230,26 +208,56 @@ export declare class WasTransport extends Transport {
230
208
  */
231
209
  updateIndex(): Promise<never>;
232
210
  /**
233
- * @inheritdoc
211
+ * The WAS path of one chunk of a document, delegating to the internal
212
+ * `resourceChunkPath` builder (member form, no trailing slash --
213
+ * put/get/delete one chunk by index).
234
214
  *
235
- * Chunked streams need the reserved `/{id}/chunks/{n}` sub-segment.
215
+ * @param docId {string} the EDV document id (= WAS resource id)
216
+ * @param chunkIndex {number} the chunk's non-negative ordinal index
217
+ * @returns {string}
236
218
  */
237
- storeChunk(): Promise<never>;
219
+ private _chunkPath;
238
220
  /**
239
221
  * @inheritdoc
240
222
  *
241
- * Chunked streams need the reserved `/{id}/chunks/{n}` sub-segment.
223
+ * Stores one encrypted chunk of a document's data stream. The EDV chunk
224
+ * object (`{ sequence, index, jwe, offset }`) is serialized to JSON and
225
+ * `PUT` as an opaque binary body ({@link CHUNK_CONTENT_TYPE}) to the chunk's
226
+ * own URL (`.../chunks/{index}`), signed like every other write. The server
227
+ * stores the bytes verbatim -- it never parses the chunk -- so any
228
+ * client-side crypto framing is transparent to it. Requires the backend's
229
+ * `chunked-streams` affordance (throws `NotSupportedError` when it is
230
+ * absent). The parent Resource must already exist
231
+ * (`EdvClientCore.insert`/`update` writes the document envelope before
232
+ * draining the stream), so a 404 here surfaces as a `NotFoundError`.
233
+ *
234
+ * @param options {object}
235
+ * @param options.docId {string} the owning document id (= WAS resource id)
236
+ * @param options.chunk {IEDVChunk} the encrypted chunk to store
237
+ * @returns {Promise<void>}
242
238
  */
243
- getChunk(): Promise<never>;
239
+ storeChunk({ docId, chunk }?: {
240
+ docId?: string;
241
+ chunk?: IEDVChunk;
242
+ }): Promise<void>;
244
243
  /**
245
- * Throws a uniform "not supported in this profile" error for the chunked-
246
- * stream operations, which depend on a server-side affordance (the reserved
247
- * `/{id}/chunks/{n}` sub-segment, the `chunked-streams` backend feature)
248
- * that neither reference backend provides yet.
244
+ * @inheritdoc
249
245
  *
250
- * @param operation {string}
251
- * @returns {never}
246
+ * Reads one encrypted chunk back by index, `GET`ting the chunk's own URL and
247
+ * parsing the opaque body (stored as raw bytes, so parsed client-side) back
248
+ * into the EDV chunk object the decrypt stream consumes. Requires the
249
+ * backend's `chunked-streams` affordance (throws `NotSupportedError` when it
250
+ * is absent). A missing chunk (404) surfaces as a `NotFoundError` (the name
251
+ * `EdvClientCore` expects), so a reassembling reader can distinguish it.
252
+ *
253
+ * @param options {object}
254
+ * @param options.docId {string} the owning document id
255
+ * @param options.chunkIndex {number} the chunk's ordinal index
256
+ * @returns {Promise<IEDVChunk>}
252
257
  */
253
- private _unsupported;
258
+ getChunk({ docId, chunkIndex }?: {
259
+ docId?: string;
260
+ chunkIndex?: number;
261
+ }): Promise<IEDVChunk>;
254
262
  }
255
263
  //# sourceMappingURL=WasTransport.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"WasTransport.d.ts","sourceRoot":"","sources":["../../src/edv/WasTransport.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAE/C,OAAO,KAAK,EACV,SAAS,EACT,kBAAkB,EACnB,MAAM,8BAA8B,CAAA;AACrC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAQhD,OAAO,EAGL,iBAAiB,EAClB,MAAM,gBAAgB,CAAA;AAEvB,OAAO,EAAE,iBAAiB,EAAE,CAAA;AAc5B;;;GAGG;AACH,KAAK,YAAY,GAAG,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;AA8B9C,qBAAa,YAAa,SAAQ,SAAS;IACzC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAE5B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAc;IACnC,OAAO,CAAC,uBAAuB,CAAC,CAAmB;IAEnD;;;;;;;;;OASG;gBACS,EACV,GAAG,EACH,OAAO,EACP,YAAY,EACZ,WAAkC,EACnC,EAAE;QACD,GAAG,EAAE,YAAY,CAAA;QACjB,OAAO,EAAE,MAAM,CAAA;QACf,YAAY,EAAE,MAAM,CAAA;QACpB,WAAW,CAAC,EAAE,MAAM,CAAA;KACrB;IAQD;;;;;;;;OAQG;IACH,OAAO,CAAC,aAAa;IAIrB;;;;;;;;;;OAUG;YACW,IAAI;IAclB;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,gBAAgB;IAKxB;;;;;;;;OAQG;YACW,qBAAqB;IA0BnC;;;;;;OAMG;YACW,kBAAkB;IAIhC;;;;;;;;;;;;;;;;OAgBG;IACY,MAAM,CAAC,EACpB,SAAS,EACV,GAAE;QAAE,SAAS,CAAC,EAAE,kBAAkB,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAkD1D;;;;;;;;;;;;;;;;;;OAkBG;IACY,MAAM,CAAC,EACpB,SAAS,EACV,GAAE;QAAE,SAAS,CAAC,EAAE,kBAAkB,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA6B1D;;;;;;;;;OASG;IACY,GAAG,CAAC,EACjB,EAAE,EACH,GAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAuBrD;;;;;;;OAOG;YACW,OAAO;IAYrB;;;;;;;;;;;;;;;;;;;;OAoBG;IACY,IAAI,CAAC,EAAE,KAAK,EAAE,GAAE;QAAE,KAAK,CAAC,EAAE,SAAS,CAAA;KAAO,GAAG,OAAO,CAAC,MAAM,CAAC;IAyC3E;;;;;;;;;OASG;IACY,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;IAU5C;;;;OAIG;IACY,UAAU,IAAI,OAAO,CAAC,KAAK,CAAC;IAI3C;;;;OAIG;IACY,QAAQ,IAAI,OAAO,CAAC,KAAK,CAAC;IAIzC;;;;;;;;OAQG;IACH,OAAO,CAAC,YAAY;CAQrB"}
1
+ {"version":3,"file":"WasTransport.d.ts","sourceRoot":"","sources":["../../src/edv/WasTransport.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAE/C,OAAO,KAAK,EACV,SAAS,EACT,SAAS,EACT,kBAAkB,EACnB,MAAM,8BAA8B,CAAA;AACrC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EAGL,iBAAiB,EAClB,MAAM,gBAAgB,CAAA;AAEvB,OAAO,EAAE,iBAAiB,EAAE,CAAA;AAsB5B;;;GAGG;AACH,KAAK,YAAY,GAAG,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;AAqD9C,qBAAa,YAAa,SAAQ,SAAS;IACzC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAE5B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAc;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiB;IAE3C;;;;;;;;;OASG;gBACS,EACV,GAAG,EACH,OAAO,EACP,YAAY,EACZ,WAAkC,EACnC,EAAE;QACD,GAAG,EAAE,YAAY,CAAA;QACjB,OAAO,EAAE,MAAM,CAAA;QACf,YAAY,EAAE,MAAM,CAAA;QACpB,WAAW,CAAC,EAAE,MAAM,CAAA;KACrB;IAkBD;;;;;;;;OAQG;IACH,OAAO,CAAC,aAAa;IAIrB;;;;;;;;;;OAUG;YACW,IAAI;IAclB;;;;;;;;;;;;;;;;OAgBG;IACY,MAAM,CAAC,EACpB,SAAS,EACV,GAAE;QAAE,SAAS,CAAC,EAAE,kBAAkB,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAoC1D;;;;;;;;;;;;;;;;;;OAkBG;IACY,MAAM,CAAC,EACpB,SAAS,EACV,GAAE;QAAE,SAAS,CAAC,EAAE,kBAAkB,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAmB1D;;;;;;;;;OASG;IACY,GAAG,CAAC,EACjB,EAAE,EACH,GAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAkBrD;;;;;;;;OAQG;YACW,eAAe;IAW7B;;;;;;;OAOG;YACW,OAAO;IAYrB;;;;;;;;;;;;;;;;;;;;OAoBG;IACY,IAAI,CAAC,EAAE,KAAK,EAAE,GAAE;QAAE,KAAK,CAAC,EAAE,SAAS,CAAA;KAAO,GAAG,OAAO,CAAC,MAAM,CAAC;IA6B3E;;;;;;;;;OASG;IACY,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;IAU5C;;;;;;;;OAQG;IACH,OAAO,CAAC,UAAU;IAIlB;;;;;;;;;;;;;;;;;;OAkBG;IACY,UAAU,CAAC,EACxB,KAAK,EACL,KAAK,EACN,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,SAAS,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA8B7D;;;;;;;;;;;;;;OAcG;IACY,QAAQ,CAAC,EACtB,KAAK,EACL,UAAU,EACX,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC,SAAS,CAAC;CA6BrE"}
@@ -32,9 +32,13 @@
32
32
  * feature). `updateIndex` throws: in this profile the `indexed` array rides
33
33
  * inside the stored document envelope, so `update()` IS the re-index
34
34
  * operation and no separate index endpoint exists. Chunked streams
35
- * (`storeChunk` / `getChunk`) still require the server's `chunked-streams`
36
- * affordance (the reserved `/{id}/chunks/{n}` sub-segment), which neither
37
- * reference backend provides, so they throw.
35
+ * (`storeChunk` / `getChunk`) map each EDV chunk onto the reserved
36
+ * `/{id}/chunks/{n}` sub-segment (the server's `chunked-streams` affordance),
37
+ * storing the chunk object as an opaque JSON body -- so `EdvClientCore.insert({
38
+ * stream })` / `getStream` drive chunked encrypted blobs over a WAS server
39
+ * unchanged. Both chunk methods are gated on the backend advertising
40
+ * `chunked-streams` (throwing `NotSupportedError` when it is absent), like
41
+ * `find` is on `blinded-index-query`.
38
42
  * `insert` uses an atomic `If-None-Match: *` create when the backend
39
43
  * advertises the optional `conditional-writes` feature; otherwise (and for
40
44
  * `update`) writes are advisory -- the EDV `sequence` is not enforced
@@ -42,21 +46,28 @@
42
46
  */
43
47
  import { Transport } from '@interop/edv-client';
44
48
  import { httpStatus } from '../errors.js';
49
+ import { BackendFeatures } from '../internal/features.js';
45
50
  import { readJsonData } from '../internal/content.js';
46
- import { collectionBackend, collectionQuery, resourcePath } from '../internal/paths.js';
51
+ import { collectionBackend, collectionQuery, resourceChunkPath, resourcePath } from '../internal/paths.js';
47
52
  import { DEFAULT_CONTENT_TYPE, envelopeBytes, JOSE_CONTENT_TYPE } from './constants.js';
48
53
  export { JOSE_CONTENT_TYPE };
49
54
  /**
50
- * HTTP statuses that mean the backend-descriptor endpoint is legitimately
51
- * absent (or explicitly unimplemented), as opposed to transiently failing:
52
- * `404` (no such endpoint), `405` (endpoint does not answer `GET`), `501`
53
- * (not implemented). These are a definitive "this server advertises no
54
- * backend features" answer and are safe to cache -- every affordance gate
55
- * then falls closed against a server that has no backend descriptors. Any
56
- * other failure (network error, timeout, `401`, `429`, other `5xx`) is
57
- * transient/ambiguous and is re-probed instead of cached.
55
+ * The content type a serialized EDV chunk is stored under. Deliberately an
56
+ * opaque binary type (not `application/json`): the chunk is `PUT` as raw bytes
57
+ * so the server routes it through its streaming binary write path (bounded by
58
+ * the backend's `maxUploadBytes`, tens of MiB) rather than the in-memory JSON
59
+ * body parser (a ~1 MiB cap that a full encrypted chunk would exceed). The
60
+ * body is still JSON text -- the server stores it verbatim and never parses it,
61
+ * so `getChunk` decodes and parses it back client-side.
58
62
  */
59
- const DESCRIPTOR_ABSENT_STATUSES = new Set([404, 405, 501]);
63
+ const CHUNK_CONTENT_TYPE = 'application/octet-stream';
64
+ /**
65
+ * The message for the EDV unique-attribute collision (HTTP 409): a
66
+ * `unique: true` blinded attribute value already held by another document.
67
+ * Shared by every write method that can trip it.
68
+ */
69
+ const DUPLICATE_ATTRIBUTE_MESSAGE = 'A unique indexed attribute value is already held by another document in ' +
70
+ 'this collection.';
60
71
  /**
61
72
  * Builds an `Error` carrying the `name` that `EdvClientCore` (and the reference
62
73
  * `HttpsTransport`) dispatch on -- `DuplicateError`, `InvalidStateError`,
@@ -76,12 +87,31 @@ function namedError({ name, message, cause }) {
76
87
  }
77
88
  return err;
78
89
  }
90
+ /**
91
+ * Rethrows a caught transport error as the named error `EdvClientCore`
92
+ * dispatches on, selected by the error's HTTP status from `mapping`; an
93
+ * unmapped status (or a non-HTTP error) is rethrown verbatim. The single
94
+ * status-to-named-error funnel every transport method's catch goes through.
95
+ *
96
+ * @param err {unknown} the caught error
97
+ * @param mapping {object} HTTP status to `{ name, message }` of the named
98
+ * error to throw in its place (the original error becomes its `cause`)
99
+ * @returns {never}
100
+ */
101
+ function mapTransportError(err, mapping) {
102
+ const status = httpStatus(err);
103
+ const entry = status === undefined ? undefined : mapping[status];
104
+ if (entry) {
105
+ throw namedError({ ...entry, cause: err });
106
+ }
107
+ throw err;
108
+ }
79
109
  export class WasTransport extends Transport {
80
110
  spaceId;
81
111
  collectionId;
82
112
  contentType;
83
113
  _was;
84
- _backendFeaturesPromise;
114
+ _features;
85
115
  /**
86
116
  * @param options {object}
87
117
  * @param options.was {WasClient} a WAS client holding the signer
@@ -98,6 +128,16 @@ export class WasTransport extends Transport {
98
128
  this.spaceId = spaceId;
99
129
  this.collectionId = collectionId;
100
130
  this.contentType = contentType;
131
+ // The shared memoizing feature probe (see `BackendFeatures` for the
132
+ // definitive-vs-transient caching rules), reading this collection's
133
+ // "Collection Backend Selected" descriptor with a signed GET.
134
+ this._features = new BackendFeatures(async () => {
135
+ const response = await this._was.request({
136
+ path: collectionBackend(this.spaceId, this.collectionId),
137
+ method: 'GET'
138
+ });
139
+ return readJsonData(response);
140
+ });
101
141
  }
102
142
  /**
103
143
  * The WAS resource path for a document id, delegating to was-client's
@@ -131,68 +171,6 @@ export class WasTransport extends Transport {
131
171
  headers: { 'content-type': this.contentType, ...headers }
132
172
  });
133
173
  }
134
- /**
135
- * The feature tokens the collection's backend advertises in its "Collection
136
- * Backend Selected" descriptor (e.g. `conditional-writes`,
137
- * `blinded-index-query`). Memoized once it produces a definitive answer: a
138
- * successful read (including one that lists no features) and a definitive
139
- * "endpoint absent" (`404` / `405` / `501`) both resolve to a cached feature
140
- * list, so every affordance gate falls closed against a server that has no
141
- * backend descriptors.
142
- *
143
- * A transient/ambiguous failure (network error, timeout, `401`, `429`, other
144
- * `5xx`) is NOT cached: the memo is cleared so the next call re-probes, and
145
- * the error is rethrown so the caller fails loud rather than silently
146
- * degrading atomicity against a server that may well be capable. (A single
147
- * transient failure must not poison the transport for its lifetime.)
148
- *
149
- * @returns {Promise<string[]>}
150
- */
151
- _backendFeatures() {
152
- this._backendFeaturesPromise ??= this._probeBackendFeatures();
153
- return this._backendFeaturesPromise;
154
- }
155
- /**
156
- * Reads and parses the backend descriptor once. On a definitive answer
157
- * (success, or a `404` / `405` / `501` that means the endpoint is legitimately
158
- * absent) resolves the feature list, which `_backendFeatures` then caches. On
159
- * a transient failure, clears the memo (so the next call re-probes) and
160
- * rethrows.
161
- *
162
- * @returns {Promise<string[]>}
163
- */
164
- async _probeBackendFeatures() {
165
- try {
166
- const response = await this._was.request({
167
- path: collectionBackend(this.spaceId, this.collectionId),
168
- method: 'GET'
169
- });
170
- const descriptor = (await readJsonData(response));
171
- return Array.isArray(descriptor?.features)
172
- ? descriptor.features.filter((feature) => typeof feature === 'string')
173
- : [];
174
- }
175
- catch (err) {
176
- const status = httpStatus(err);
177
- if (status !== undefined && DESCRIPTOR_ABSENT_STATUSES.has(status)) {
178
- return [];
179
- }
180
- // Transient/ambiguous: do not cache this failure -- drop the memo so the
181
- // next call re-probes -- and rethrow.
182
- this._backendFeaturesPromise = undefined;
183
- throw err;
184
- }
185
- }
186
- /**
187
- * Whether the collection's backend advertises the optional
188
- * `conditional-writes` feature, so `insert` can use an atomic
189
- * `If-None-Match: *` create instead of the advisory existence-check path.
190
- *
191
- * @returns {Promise<boolean>}
192
- */
193
- async _conditionalWrites() {
194
- return (await this._backendFeatures()).includes('conditional-writes');
195
- }
196
174
  /**
197
175
  * @inheritdoc
198
176
  *
@@ -214,27 +192,21 @@ export class WasTransport extends Transport {
214
192
  if (!encrypted) {
215
193
  throw new TypeError('"encrypted" is required.');
216
194
  }
217
- if (await this._conditionalWrites()) {
195
+ if (await this._features.has('conditional-writes')) {
218
196
  try {
219
197
  await this._put(encrypted.id, encrypted, { 'if-none-match': '*' });
220
198
  }
221
199
  catch (err) {
222
- if (httpStatus(err) === 412) {
223
- throw namedError({
200
+ mapTransportError(err, {
201
+ 412: {
224
202
  name: 'DuplicateError',
225
- message: `A document with id "${encrypted.id}" already exists.`,
226
- cause: err
227
- });
228
- }
229
- if (httpStatus(err) === 409) {
230
- throw namedError({
203
+ message: `A document with id "${encrypted.id}" already exists.`
204
+ },
205
+ 409: {
231
206
  name: 'DuplicateError',
232
- message: 'A unique indexed attribute value is already held by another ' +
233
- 'document in this collection.',
234
- cause: err
235
- });
236
- }
237
- throw err;
207
+ message: DUPLICATE_ATTRIBUTE_MESSAGE
208
+ }
209
+ });
238
210
  }
239
211
  return;
240
212
  }
@@ -248,15 +220,9 @@ export class WasTransport extends Transport {
248
220
  await this._put(encrypted.id, encrypted);
249
221
  }
250
222
  catch (err) {
251
- if (httpStatus(err) === 409) {
252
- throw namedError({
253
- name: 'DuplicateError',
254
- message: 'A unique indexed attribute value is already held by another ' +
255
- 'document in this collection.',
256
- cause: err
257
- });
258
- }
259
- throw err;
223
+ mapTransportError(err, {
224
+ 409: { name: 'DuplicateError', message: DUPLICATE_ATTRIBUTE_MESSAGE }
225
+ });
260
226
  }
261
227
  }
262
228
  /**
@@ -286,23 +252,14 @@ export class WasTransport extends Transport {
286
252
  await this._put(encrypted.id, encrypted);
287
253
  }
288
254
  catch (err) {
289
- if (httpStatus(err) === 412) {
290
- throw namedError({
255
+ mapTransportError(err, {
256
+ 412: {
291
257
  name: 'InvalidStateError',
292
258
  message: 'Document update conflict: the stored document changed since it ' +
293
- 'was read. Re-fetch the current document and retry.',
294
- cause: err
295
- });
296
- }
297
- if (httpStatus(err) === 409) {
298
- throw namedError({
299
- name: 'DuplicateError',
300
- message: 'A unique indexed attribute value is already held by another ' +
301
- 'document in this collection.',
302
- cause: err
303
- });
304
- }
305
- throw err;
259
+ 'was read. Re-fetch the current document and retry.'
260
+ },
261
+ 409: { name: 'DuplicateError', message: DUPLICATE_ATTRIBUTE_MESSAGE }
262
+ });
306
263
  }
307
264
  }
308
265
  /**
@@ -327,17 +284,30 @@ export class WasTransport extends Transport {
327
284
  });
328
285
  }
329
286
  catch (err) {
330
- if (httpStatus(err) === 404) {
331
- throw namedError({
332
- name: 'NotFoundError',
333
- message: 'Document not found.',
334
- cause: err
335
- });
336
- }
337
- throw err;
287
+ mapTransportError(err, {
288
+ 404: { name: 'NotFoundError', message: 'Document not found.' }
289
+ });
338
290
  }
339
291
  return (await readJsonData(response));
340
292
  }
293
+ /**
294
+ * Throws a `NotSupportedError` (the name `EdvClientCore` dispatches on)
295
+ * unless the collection's backend advertises the given affordance token --
296
+ * the shared gate in front of every optional-feature operation.
297
+ *
298
+ * @param feature {string} the affordance token (e.g. `chunked-streams`)
299
+ * @param what {string} the operation name, for the message
300
+ * @returns {Promise<void>}
301
+ */
302
+ async _requireFeature(feature, what) {
303
+ if (!(await this._features.has(feature))) {
304
+ throw namedError({
305
+ name: 'NotSupportedError',
306
+ message: `${what} is not supported: the collection's backend ` +
307
+ `does not advertise the "${feature}" affordance.`
308
+ });
309
+ }
310
+ }
341
311
  /**
342
312
  * Resolves to `true` if a resource exists at the document's path, via a
343
313
  * bodiless `HEAD` (the stored envelope is not needed, only its existence). A
@@ -383,13 +353,7 @@ export class WasTransport extends Transport {
383
353
  if (!query) {
384
354
  throw new TypeError('"query" is required.');
385
355
  }
386
- if (!(await this._backendFeatures()).includes('blinded-index-query')) {
387
- throw namedError({
388
- name: 'NotSupportedError',
389
- message: "Blinded-index query is not supported: the collection's backend " +
390
- 'does not advertise the "blinded-index-query" affordance.'
391
- });
392
- }
356
+ await this._requireFeature('blinded-index-query', 'Blinded-index query');
393
357
  // `returnDocuments` is a first-class `IEDVQuery` field, but the WAS profile
394
358
  // has no ids-only mode, so it is dropped (whatever its value) and full
395
359
  // documents come back -- the best-effort degradation `EdvClientCore.find`
@@ -404,14 +368,9 @@ export class WasTransport extends Transport {
404
368
  });
405
369
  }
406
370
  catch (err) {
407
- if (httpStatus(err) === 404) {
408
- throw namedError({
409
- name: 'NotFoundError',
410
- message: 'Collection not found.',
411
- cause: err
412
- });
413
- }
414
- throw err;
371
+ mapTransportError(err, {
372
+ 404: { name: 'NotFoundError', message: 'Collection not found.' }
373
+ });
415
374
  }
416
375
  const result = await readJsonData(response);
417
376
  if (result === null || typeof result !== 'object') {
@@ -438,36 +397,109 @@ export class WasTransport extends Transport {
438
397
  });
439
398
  }
440
399
  /**
441
- * @inheritdoc
400
+ * The WAS path of one chunk of a document, delegating to the internal
401
+ * `resourceChunkPath` builder (member form, no trailing slash --
402
+ * put/get/delete one chunk by index).
442
403
  *
443
- * Chunked streams need the reserved `/{id}/chunks/{n}` sub-segment.
404
+ * @param docId {string} the EDV document id (= WAS resource id)
405
+ * @param chunkIndex {number} the chunk's non-negative ordinal index
406
+ * @returns {string}
444
407
  */
445
- async storeChunk() {
446
- return this._unsupported('storeChunk (chunked streams)');
408
+ _chunkPath(docId, chunkIndex) {
409
+ return resourceChunkPath(this.spaceId, this.collectionId, docId, chunkIndex);
447
410
  }
448
411
  /**
449
412
  * @inheritdoc
450
413
  *
451
- * Chunked streams need the reserved `/{id}/chunks/{n}` sub-segment.
414
+ * Stores one encrypted chunk of a document's data stream. The EDV chunk
415
+ * object (`{ sequence, index, jwe, offset }`) is serialized to JSON and
416
+ * `PUT` as an opaque binary body ({@link CHUNK_CONTENT_TYPE}) to the chunk's
417
+ * own URL (`.../chunks/{index}`), signed like every other write. The server
418
+ * stores the bytes verbatim -- it never parses the chunk -- so any
419
+ * client-side crypto framing is transparent to it. Requires the backend's
420
+ * `chunked-streams` affordance (throws `NotSupportedError` when it is
421
+ * absent). The parent Resource must already exist
422
+ * (`EdvClientCore.insert`/`update` writes the document envelope before
423
+ * draining the stream), so a 404 here surfaces as a `NotFoundError`.
424
+ *
425
+ * @param options {object}
426
+ * @param options.docId {string} the owning document id (= WAS resource id)
427
+ * @param options.chunk {IEDVChunk} the encrypted chunk to store
428
+ * @returns {Promise<void>}
452
429
  */
453
- async getChunk() {
454
- return this._unsupported('getChunk (chunked streams)');
430
+ async storeChunk({ docId, chunk } = {}) {
431
+ if (!docId) {
432
+ throw new TypeError('"docId" is required.');
433
+ }
434
+ if (!chunk) {
435
+ throw new TypeError('"chunk" is required.');
436
+ }
437
+ // Gate on the affordance before diagnosing a 404: against a server with no
438
+ // `/chunks/{n}` route at all, the 404 would otherwise be misreported as a
439
+ // missing parent document.
440
+ await this._requireFeature('chunked-streams', 'Chunked encrypted storage');
441
+ try {
442
+ await this._was.request({
443
+ path: this._chunkPath(docId, chunk.index),
444
+ method: 'PUT',
445
+ body: envelopeBytes(chunk),
446
+ headers: { 'content-type': CHUNK_CONTENT_TYPE }
447
+ });
448
+ }
449
+ catch (err) {
450
+ mapTransportError(err, {
451
+ 404: {
452
+ name: 'NotFoundError',
453
+ message: `Cannot store chunk ${chunk.index}: the parent document ` +
454
+ `"${docId}" does not exist. Write the document before its chunks.`
455
+ }
456
+ });
457
+ }
455
458
  }
456
459
  /**
457
- * Throws a uniform "not supported in this profile" error for the chunked-
458
- * stream operations, which depend on a server-side affordance (the reserved
459
- * `/{id}/chunks/{n}` sub-segment, the `chunked-streams` backend feature)
460
- * that neither reference backend provides yet.
460
+ * @inheritdoc
461
461
  *
462
- * @param operation {string}
463
- * @returns {never}
462
+ * Reads one encrypted chunk back by index, `GET`ting the chunk's own URL and
463
+ * parsing the opaque body (stored as raw bytes, so parsed client-side) back
464
+ * into the EDV chunk object the decrypt stream consumes. Requires the
465
+ * backend's `chunked-streams` affordance (throws `NotSupportedError` when it
466
+ * is absent). A missing chunk (404) surfaces as a `NotFoundError` (the name
467
+ * `EdvClientCore` expects), so a reassembling reader can distinguish it.
468
+ *
469
+ * @param options {object}
470
+ * @param options.docId {string} the owning document id
471
+ * @param options.chunkIndex {number} the chunk's ordinal index
472
+ * @returns {Promise<IEDVChunk>}
464
473
  */
465
- _unsupported(operation) {
466
- throw namedError({
467
- name: 'NotSupportedError',
468
- message: `"${operation}" is not supported by the EDV-over-WAS profile ` +
469
- '(requires the server\'s "chunked-streams" affordance).'
470
- });
474
+ async getChunk({ docId, chunkIndex } = {}) {
475
+ if (!docId) {
476
+ throw new TypeError('"docId" is required.');
477
+ }
478
+ if (chunkIndex === undefined) {
479
+ throw new TypeError('"chunkIndex" is required.');
480
+ }
481
+ // Gate on the affordance before diagnosing a 404: against a server with no
482
+ // `/chunks/{n}` route at all, the 404 would otherwise surface as a spurious
483
+ // missing-chunk (data corruption) report.
484
+ await this._requireFeature('chunked-streams', 'Chunked encrypted storage');
485
+ let response;
486
+ try {
487
+ response = await this._was.request({
488
+ path: this._chunkPath(docId, chunkIndex),
489
+ method: 'GET'
490
+ });
491
+ }
492
+ catch (err) {
493
+ mapTransportError(err, {
494
+ 404: {
495
+ name: 'NotFoundError',
496
+ message: `Chunk ${chunkIndex} of document "${docId}" not found.`
497
+ }
498
+ });
499
+ }
500
+ // The chunk was stored as opaque bytes, so the http-client did not
501
+ // pre-parse it: decode the body text and parse the EDV chunk object back.
502
+ return JSON.parse(await response.text());
471
503
  }
472
504
  }
473
505
  //# sourceMappingURL=WasTransport.js.map