@btravstack/storage 0.38.0 → 0.39.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 CHANGED
@@ -67,7 +67,7 @@ export const invoices = InvoiceProvider(Invoices)({
67
67
  // is swallowed because an observer already recorded it and
68
68
  // counted it one layer down.
69
69
  .recoverErrCases((matcher) =>
70
- matcher.with(P.tag("StorageUnavailable"), () => undefined),
70
+ matcher.with(P.tag("StorageUnavailable"), P.tag("InvalidStorageOptions"), P.tag("ChecksumMismatch"), () => undefined),
71
71
  ),
72
72
  // Serving bytes through your own process is the anti-pattern this arm
73
73
  // exists to avoid: the caller fetches the object directly, for a minute.
@@ -106,13 +106,16 @@ export const InvoicesApp = Module("InvoicesApp")({
106
106
 
107
107
  ## Options
108
108
 
109
- | Option | Where | What it is |
110
- | ----------------------------------------------------------- | ---------------------------------- | -------------------------------------------------------- |
111
- | `adapter` | `storage({ adapter })` | the adapter module providing `StorageBackend` — required |
112
- | `STORAGE_S3_ENDPOINT` | environment, read by `s3Storage()` | the store's endpoint — required |
113
- | `STORAGE_S3_BUCKET` | environment | the bucket — required |
114
- | `STORAGE_S3_ACCESS_KEY_ID` / `STORAGE_S3_SECRET_ACCESS_KEY` | environment | the credentials — required |
115
- | `STORAGE_S3_REGION` | environment | default `us-east-1` |
109
+ | Option | Where | What it is |
110
+ | ----------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------- |
111
+ | `sha256` / `metadata` | `put` / `putIfAbsent` | verify the write digest and atomically describe the object |
112
+ | `checksum` / `metadata` | `copy` | compute SHA-256 server side and replace the description |
113
+ | `kmsKeyId` | writes, copy and `presignedUpload` | select per-object SSE-KMS; upload clients send both signed encryption headers |
114
+ | `adapter` | `storage({ adapter })` | the adapter module providing `StorageBackend` — required |
115
+ | `STORAGE_S3_ENDPOINT` | environment, read by `s3Storage()` | the store's endpoint — required |
116
+ | `STORAGE_S3_BUCKET` | environment | the bucket — required |
117
+ | `STORAGE_S3_ACCESS_KEY_ID` / `STORAGE_S3_SECRET_ACCESS_KEY` | environment | the credentials — required |
118
+ | `STORAGE_S3_REGION` | environment | default `us-east-1` |
116
119
 
117
120
  The full table — defaults, semantics and the reasoning — lives on
118
121
  [the reference page](https://btravstack.github.io/btravstack/reference/storage),
@@ -121,7 +124,7 @@ which is this list's one detailed home.
121
124
  ## What it decides, and what it does not
122
125
 
123
126
  **It decides** that a missing object is an ordinary answer rather than a fault
124
- (counted `not_found`, logged at `info`), that a held key under `putIfAbsent` is an ordinary answer (`ObjectExists`, counted `exists`), that `delete` is idempotent, that `copy` and `head` never move the bytes through the process, that
127
+ (counted `not_found`, without a fault line), that a held key under `putIfAbsent` is an ordinary answer (`ObjectExists`, counted `exists`), that `delete` is idempotent, that `copy` and `head` never move the bytes through the process, that writes can verify SHA-256 and publish user metadata atomically, that copies replace metadata and can compute SHA-256 server side, that presigned uploads can require a per-object KMS key, that
125
128
  presigning asks the store nothing — and that an adapter which cannot presign
126
129
  says so instead of minting a URL that would fail only in production.
127
130
 
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_storage = require("./storage-EA671z1R.cjs");
2
+ const require_options = require("./options-BhML_i5p.cjs");
3
3
  let node_crypto = require("node:crypto");
4
4
  let _btravstack_di = require("@btravstack/di");
5
5
  let unthrown = require("unthrown");
@@ -19,34 +19,38 @@ let _btravstack_core = require("@btravstack/core");
19
19
  */
20
20
  const memoryStorageBackend = () => {
21
21
  const objects = /* @__PURE__ */ new Map();
22
+ const verify = (key, bytes, options) => require_options.validateOptions(key, options).flatMap(() => options.sha256 !== void 0 && (0, node_crypto.createHash)("sha256").update(bytes).digest("hex") !== options.sha256 ? (0, unthrown.Err)(new require_options.ChecksumMismatch({ key })) : (0, unthrown.Ok)());
22
23
  return {
23
- put: (key, bytes, options) => {
24
+ put: (key, bytes, options) => verify(key, bytes, options).map(() => {
24
25
  objects.set(key, {
25
- bytes: bytes.slice(),
26
- contentType: options.contentType
26
+ bytes: Uint8Array.from(bytes),
27
+ contentType: options.contentType,
28
+ metadata: { ...options.metadata }
27
29
  });
28
- return (0, unthrown.OkAsync)();
29
- },
30
- putIfAbsent: (key, bytes, options) => {
31
- if (objects.has(key)) return (0, unthrown.ErrAsync)(new require_storage.ObjectExists({ key }));
30
+ }).toAsync(),
31
+ putIfAbsent: (key, bytes, options) => verify(key, bytes, options).flatMap(() => {
32
+ if (objects.has(key)) return (0, unthrown.Err)(new require_options.ObjectExists({ key }));
32
33
  objects.set(key, {
33
- bytes: bytes.slice(),
34
- contentType: options.contentType
34
+ bytes: Uint8Array.from(bytes),
35
+ contentType: options.contentType,
36
+ metadata: { ...options.metadata }
35
37
  });
36
- return (0, unthrown.OkAsync)();
37
- },
38
- copy: (from, to, options) => {
38
+ return (0, unthrown.Ok)();
39
+ }).toAsync(),
40
+ copy: (from, to, options) => require_options.validateOptions(to, options).flatMap(() => {
39
41
  const object = objects.get(from);
40
- if (object === void 0) return (0, unthrown.ErrAsync)(new require_storage.ObjectNotFound({ key: from }));
42
+ if (object === void 0) return (0, unthrown.Err)(new require_options.ObjectNotFound({ key: from }));
41
43
  objects.set(to, {
42
44
  bytes: object.bytes.slice(),
43
- contentType: options.contentType
45
+ contentType: options.contentType,
46
+ metadata: { ...options.metadata }
44
47
  });
45
- return (0, unthrown.OkAsync)();
46
- },
48
+ return (0, unthrown.Ok)();
49
+ }).toAsync(),
47
50
  head: (key) => {
48
51
  const object = objects.get(key);
49
- return object === void 0 ? (0, unthrown.ErrAsync)(new require_storage.ObjectNotFound({ key })) : (0, unthrown.OkAsync)({
52
+ return object === void 0 ? (0, unthrown.ErrAsync)(new require_options.ObjectNotFound({ key })) : (0, unthrown.OkAsync)({
53
+ metadata: { ...object.metadata },
50
54
  size: object.bytes.byteLength,
51
55
  contentType: object.contentType,
52
56
  sha256: (0, node_crypto.createHash)("sha256").update(object.bytes).digest("hex")
@@ -54,8 +58,8 @@ const memoryStorageBackend = () => {
54
58
  },
55
59
  get: (key) => {
56
60
  const object = objects.get(key);
57
- return object === void 0 ? (0, unthrown.ErrAsync)(new require_storage.ObjectNotFound({ key })) : (0, unthrown.OkAsync)({
58
- ...object,
61
+ return object === void 0 ? (0, unthrown.ErrAsync)(new require_options.ObjectNotFound({ key })) : (0, unthrown.OkAsync)({
62
+ contentType: object.contentType,
59
63
  bytes: object.bytes.slice()
60
64
  });
61
65
  },
@@ -63,19 +67,19 @@ const memoryStorageBackend = () => {
63
67
  objects.delete(key);
64
68
  return (0, unthrown.OkAsync)();
65
69
  },
66
- presignedUrl: (key) => (0, unthrown.ErrAsync)(new require_storage.PresignNotSupported({ key })),
67
- presignedUpload: (key) => (0, unthrown.ErrAsync)(new require_storage.PresignNotSupported({ key }))
70
+ presignedUrl: (key) => (0, unthrown.ErrAsync)(new require_options.PresignNotSupported({ key })),
71
+ presignedUpload: (key) => (0, unthrown.ErrAsync)(new require_options.PresignNotSupported({ key }))
68
72
  };
69
73
  };
70
74
  /** The adapter as a provider, which is the shape `@btravstack/testing`'s `overridden` takes. */
71
- const memoryStorageProvider = () => (0, _btravstack_di.Provider)(require_storage.StorageBackend)({
75
+ const memoryStorageProvider = () => (0, _btravstack_di.Provider)(require_options.StorageBackend)({
72
76
  inject: {},
73
77
  sync: () => memoryStorageBackend()
74
78
  });
75
79
  /** The adapter as a module, which is the shape `storage({ adapter })` takes. */
76
80
  const memoryStorage = () => (0, _btravstack_di.Module)("MemoryStorage")({
77
81
  provides: [memoryStorageProvider()],
78
- exports: [require_storage.StorageBackend]
82
+ exports: [require_options.StorageBackend]
79
83
  });
80
84
  //#endregion
81
85
  //#region src/instrument.ts
@@ -83,8 +87,8 @@ const memoryStorage = () => (0, _btravstack_di.Module)("MemoryStorage")({
83
87
  * **A missing object settles `ok` with `result: "not_found"`, not `error`.**
84
88
  * Asking for something that is not there is an ordinary answer, and a dashboard
85
89
  * that treats it as a fault teaches its readers to ignore the fault line.
86
- * `StorageUnavailable` is what pages somebody, and it is the only one that
87
- * settles as an error. The two ordinary answers keep their own words in
90
+ * `StorageUnavailable` is what pages somebody; invalid options and checksum
91
+ * mismatches also settle as errors. The two ordinary answers keep their own words in
88
92
  * `reason`, so an operator is not left hunting an object sitting where they put
89
93
  * it.
90
94
  */
@@ -126,10 +130,28 @@ const through = (observers) => (operation, key, call) => (0, _btravstack_core.ob
126
130
  const instrument = (backend, observers) => {
127
131
  const observe = through(observers);
128
132
  return {
129
- put: (key, bytes, options) => observe("put", key, () => backend.put(key, bytes, options)),
130
- putIfAbsent: (key, bytes, options) => observe("put_if_absent", key, () => backend.putIfAbsent(key, bytes, options)),
133
+ put: (key, bytes, options) => {
134
+ const snapshot = {
135
+ ...options,
136
+ metadata: { ...options.metadata }
137
+ };
138
+ return observe("put", key, () => backend.put(key, bytes, snapshot));
139
+ },
140
+ putIfAbsent: (key, bytes, options) => {
141
+ const snapshot = {
142
+ ...options,
143
+ metadata: { ...options.metadata }
144
+ };
145
+ return observe("put_if_absent", key, () => backend.putIfAbsent(key, bytes, snapshot));
146
+ },
131
147
  get: (key) => observe("get", key, () => backend.get(key)),
132
- copy: (from, to, options) => observe("copy", from, () => backend.copy(from, to, options)),
148
+ copy: (from, to, options) => {
149
+ const snapshot = {
150
+ ...options,
151
+ metadata: { ...options.metadata }
152
+ };
153
+ return observe("copy", from, () => backend.copy(from, to, snapshot));
154
+ },
133
155
  head: (key) => observe("head", key, () => backend.head(key)),
134
156
  delete: (key) => observe("delete", key, () => backend.delete(key)),
135
157
  presignedUrl: (key, options) => observe("presigned_url", key, () => backend.presignedUrl(key, options)),
@@ -156,7 +178,7 @@ const instrument = (backend, observers) => {
156
178
  */
157
179
  const storage = ({ adapter }) => {
158
180
  const healthCheck = _btravstack_di.Provider.member(_btravstack_core.HealthChecks)({
159
- inject: { backend: require_storage.StorageBackend },
181
+ inject: { backend: require_options.StorageBackend },
160
182
  sync: ({ backend }) => ({
161
183
  name: "storage",
162
184
  check: () => backend.get("btravstack:health").map(() => void 0).flatMapErrCases((matcher) => matcher.with(unthrown.P.tag("ObjectNotFound"), () => (0, unthrown.Ok)(void 0)).with(unthrown.P.tag("StorageUnavailable"), ({ reason }) => (0, unthrown.Err)(new _btravstack_core.HealthCheckFailed({ reason: `storage unavailable (${reason})` }))))
@@ -166,25 +188,27 @@ const storage = ({ adapter }) => {
166
188
  imports: [adapter],
167
189
  provides: [
168
190
  _btravstack_core.noObserverMember,
169
- (0, _btravstack_di.Provider)(require_storage.Storage)({
191
+ (0, _btravstack_di.Provider)(require_options.Storage)({
170
192
  inject: {
171
- backend: require_storage.StorageBackend,
193
+ backend: require_options.StorageBackend,
172
194
  observers: _btravstack_core.Observers
173
195
  },
174
196
  sync: ({ backend, observers }) => instrument(backend, observers)
175
197
  }),
176
198
  healthCheck
177
199
  ],
178
- exports: [require_storage.Storage, _btravstack_core.HealthChecks]
200
+ exports: [require_options.Storage, _btravstack_core.HealthChecks]
179
201
  });
180
202
  };
181
203
  //#endregion
182
- exports.ObjectExists = require_storage.ObjectExists;
183
- exports.ObjectNotFound = require_storage.ObjectNotFound;
184
- exports.PresignNotSupported = require_storage.PresignNotSupported;
185
- exports.Storage = require_storage.Storage;
186
- exports.StorageBackend = require_storage.StorageBackend;
187
- exports.StorageUnavailable = require_storage.StorageUnavailable;
204
+ exports.ChecksumMismatch = require_options.ChecksumMismatch;
205
+ exports.InvalidStorageOptions = require_options.InvalidStorageOptions;
206
+ exports.ObjectExists = require_options.ObjectExists;
207
+ exports.ObjectNotFound = require_options.ObjectNotFound;
208
+ exports.PresignNotSupported = require_options.PresignNotSupported;
209
+ exports.Storage = require_options.Storage;
210
+ exports.StorageBackend = require_options.StorageBackend;
211
+ exports.StorageUnavailable = require_options.StorageUnavailable;
188
212
  exports.memoryStorage = memoryStorage;
189
213
  exports.memoryStorageBackend = memoryStorageBackend;
190
214
  exports.memoryStorageProvider = memoryStorageProvider;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as Storage, c as StorageUnavailable, i as PresignNotSupported, l as StoredObject, n as ObjectHead, o as StorageBackend, r as ObjectNotFound, s as StorageService, t as ObjectExists, u as WriteOptions } from "./storage-BavPXZpA.cjs";
1
+ import { a as ObjectHead, c as Storage, d as StorageUnavailable, f as StoredObject, i as ObjectExists, l as StorageBackend, n as CopyOptions, o as ObjectNotFound, p as WriteOptions, r as InvalidStorageOptions, s as PresignNotSupported, t as ChecksumMismatch, u as StorageService } from "./storage-CfN3KqWm.cjs";
2
2
  import { Module, Provider } from "@btravstack/di";
3
3
  import { HealthChecks } from "@btravstack/core";
4
4
  //#region src/memory.d.ts
@@ -49,4 +49,4 @@ type StorageOptions<E, N> = {
49
49
  */
50
50
  export declare const storage: <E, N>({ adapter }: StorageOptions<E, N>) => Module<Storage | HealthChecks, E, N>;
51
51
  //#endregion
52
- export { ObjectExists, type ObjectHead, ObjectNotFound, PresignNotSupported, Storage, StorageBackend, type StorageOptions, type StorageService, StorageUnavailable, type StoredObject, type WriteOptions };
52
+ export { ChecksumMismatch, type CopyOptions, InvalidStorageOptions, ObjectExists, type ObjectHead, ObjectNotFound, PresignNotSupported, Storage, StorageBackend, type StorageOptions, type StorageService, StorageUnavailable, type StoredObject, type WriteOptions };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as Storage, c as StorageUnavailable, i as PresignNotSupported, l as StoredObject, n as ObjectHead, o as StorageBackend, r as ObjectNotFound, s as StorageService, t as ObjectExists, u as WriteOptions } from "./storage-BavPXZpA.mjs";
1
+ import { a as ObjectHead, c as Storage, d as StorageUnavailable, f as StoredObject, i as ObjectExists, l as StorageBackend, n as CopyOptions, o as ObjectNotFound, p as WriteOptions, r as InvalidStorageOptions, s as PresignNotSupported, t as ChecksumMismatch, u as StorageService } from "./storage-CfN3KqWm.mjs";
2
2
  import { Module, Provider } from "@btravstack/di";
3
3
  import { HealthChecks } from "@btravstack/core";
4
4
  //#region src/memory.d.ts
@@ -49,4 +49,4 @@ type StorageOptions<E, N> = {
49
49
  */
50
50
  export declare const storage: <E, N>({ adapter }: StorageOptions<E, N>) => Module<Storage | HealthChecks, E, N>;
51
51
  //#endregion
52
- export { ObjectExists, type ObjectHead, ObjectNotFound, PresignNotSupported, Storage, StorageBackend, type StorageOptions, type StorageService, StorageUnavailable, type StoredObject, type WriteOptions };
52
+ export { ChecksumMismatch, type CopyOptions, InvalidStorageOptions, ObjectExists, type ObjectHead, ObjectNotFound, PresignNotSupported, Storage, StorageBackend, type StorageOptions, type StorageService, StorageUnavailable, type StoredObject, type WriteOptions };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as StorageBackend, i as Storage, n as ObjectNotFound, o as StorageUnavailable, r as PresignNotSupported, t as ObjectExists } from "./storage-BIKstQ72.mjs";
1
+ import { a as ObjectNotFound, c as StorageBackend, i as ObjectExists, l as StorageUnavailable, n as ChecksumMismatch, o as PresignNotSupported, r as InvalidStorageOptions, s as Storage, t as validateOptions } from "./options-DYPXkWb9.mjs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { Module, Provider } from "@btravstack/di";
4
4
  import { Err, ErrAsync, Ok, OkAsync, P } from "unthrown";
@@ -18,34 +18,38 @@ import { HealthCheckFailed, HealthChecks, Observers, noObserverMember, observed
18
18
  */
19
19
  const memoryStorageBackend = () => {
20
20
  const objects = /* @__PURE__ */ new Map();
21
+ const verify = (key, bytes, options) => validateOptions(key, options).flatMap(() => options.sha256 !== void 0 && createHash("sha256").update(bytes).digest("hex") !== options.sha256 ? Err(new ChecksumMismatch({ key })) : Ok());
21
22
  return {
22
- put: (key, bytes, options) => {
23
+ put: (key, bytes, options) => verify(key, bytes, options).map(() => {
23
24
  objects.set(key, {
24
- bytes: bytes.slice(),
25
- contentType: options.contentType
25
+ bytes: Uint8Array.from(bytes),
26
+ contentType: options.contentType,
27
+ metadata: { ...options.metadata }
26
28
  });
27
- return OkAsync();
28
- },
29
- putIfAbsent: (key, bytes, options) => {
30
- if (objects.has(key)) return ErrAsync(new ObjectExists({ key }));
29
+ }).toAsync(),
30
+ putIfAbsent: (key, bytes, options) => verify(key, bytes, options).flatMap(() => {
31
+ if (objects.has(key)) return Err(new ObjectExists({ key }));
31
32
  objects.set(key, {
32
- bytes: bytes.slice(),
33
- contentType: options.contentType
33
+ bytes: Uint8Array.from(bytes),
34
+ contentType: options.contentType,
35
+ metadata: { ...options.metadata }
34
36
  });
35
- return OkAsync();
36
- },
37
- copy: (from, to, options) => {
37
+ return Ok();
38
+ }).toAsync(),
39
+ copy: (from, to, options) => validateOptions(to, options).flatMap(() => {
38
40
  const object = objects.get(from);
39
- if (object === void 0) return ErrAsync(new ObjectNotFound({ key: from }));
41
+ if (object === void 0) return Err(new ObjectNotFound({ key: from }));
40
42
  objects.set(to, {
41
43
  bytes: object.bytes.slice(),
42
- contentType: options.contentType
44
+ contentType: options.contentType,
45
+ metadata: { ...options.metadata }
43
46
  });
44
- return OkAsync();
45
- },
47
+ return Ok();
48
+ }).toAsync(),
46
49
  head: (key) => {
47
50
  const object = objects.get(key);
48
51
  return object === void 0 ? ErrAsync(new ObjectNotFound({ key })) : OkAsync({
52
+ metadata: { ...object.metadata },
49
53
  size: object.bytes.byteLength,
50
54
  contentType: object.contentType,
51
55
  sha256: createHash("sha256").update(object.bytes).digest("hex")
@@ -54,7 +58,7 @@ const memoryStorageBackend = () => {
54
58
  get: (key) => {
55
59
  const object = objects.get(key);
56
60
  return object === void 0 ? ErrAsync(new ObjectNotFound({ key })) : OkAsync({
57
- ...object,
61
+ contentType: object.contentType,
58
62
  bytes: object.bytes.slice()
59
63
  });
60
64
  },
@@ -82,8 +86,8 @@ const memoryStorage = () => Module("MemoryStorage")({
82
86
  * **A missing object settles `ok` with `result: "not_found"`, not `error`.**
83
87
  * Asking for something that is not there is an ordinary answer, and a dashboard
84
88
  * that treats it as a fault teaches its readers to ignore the fault line.
85
- * `StorageUnavailable` is what pages somebody, and it is the only one that
86
- * settles as an error. The two ordinary answers keep their own words in
89
+ * `StorageUnavailable` is what pages somebody; invalid options and checksum
90
+ * mismatches also settle as errors. The two ordinary answers keep their own words in
87
91
  * `reason`, so an operator is not left hunting an object sitting where they put
88
92
  * it.
89
93
  */
@@ -125,10 +129,28 @@ const through = (observers) => (operation, key, call) => observed(observers, {
125
129
  const instrument = (backend, observers) => {
126
130
  const observe = through(observers);
127
131
  return {
128
- put: (key, bytes, options) => observe("put", key, () => backend.put(key, bytes, options)),
129
- putIfAbsent: (key, bytes, options) => observe("put_if_absent", key, () => backend.putIfAbsent(key, bytes, options)),
132
+ put: (key, bytes, options) => {
133
+ const snapshot = {
134
+ ...options,
135
+ metadata: { ...options.metadata }
136
+ };
137
+ return observe("put", key, () => backend.put(key, bytes, snapshot));
138
+ },
139
+ putIfAbsent: (key, bytes, options) => {
140
+ const snapshot = {
141
+ ...options,
142
+ metadata: { ...options.metadata }
143
+ };
144
+ return observe("put_if_absent", key, () => backend.putIfAbsent(key, bytes, snapshot));
145
+ },
130
146
  get: (key) => observe("get", key, () => backend.get(key)),
131
- copy: (from, to, options) => observe("copy", from, () => backend.copy(from, to, options)),
147
+ copy: (from, to, options) => {
148
+ const snapshot = {
149
+ ...options,
150
+ metadata: { ...options.metadata }
151
+ };
152
+ return observe("copy", from, () => backend.copy(from, to, snapshot));
153
+ },
132
154
  head: (key) => observe("head", key, () => backend.head(key)),
133
155
  delete: (key) => observe("delete", key, () => backend.delete(key)),
134
156
  presignedUrl: (key, options) => observe("presigned_url", key, () => backend.presignedUrl(key, options)),
@@ -178,4 +200,4 @@ const storage = ({ adapter }) => {
178
200
  });
179
201
  };
180
202
  //#endregion
181
- export { ObjectExists, ObjectNotFound, PresignNotSupported, Storage, StorageBackend, StorageUnavailable, memoryStorage, memoryStorageBackend, memoryStorageProvider, storage };
203
+ export { ChecksumMismatch, InvalidStorageOptions, ObjectExists, ObjectNotFound, PresignNotSupported, Storage, StorageBackend, StorageUnavailable, memoryStorage, memoryStorageBackend, memoryStorageProvider, storage };
@@ -5,6 +5,10 @@ let unthrown = require("unthrown");
5
5
  var ObjectNotFound = class extends (0, unthrown.TaggedError)("ObjectNotFound") {};
6
6
  /** An object already holds the key. Only `putIfAbsent` answers it. */
7
7
  var ObjectExists = class extends (0, unthrown.TaggedError)("ObjectExists") {};
8
+ /** Invalid options are refused before any write or signature is attempted. */
9
+ var InvalidStorageOptions = class extends (0, unthrown.TaggedError)("InvalidStorageOptions") {};
10
+ /** The supplied SHA-256 did not match the body; no object was written. */
11
+ var ChecksumMismatch = class extends (0, unthrown.TaggedError)("ChecksumMismatch") {};
8
12
  /** The store could not answer — the endpoint is down, the credentials were refused, the bucket is gone. */
9
13
  var StorageUnavailable = class extends (0, unthrown.TaggedError)("StorageUnavailable") {};
10
14
  /** The adapter cannot mint a time-limited URL, and says so rather than pretending. */
@@ -24,6 +28,31 @@ var Storage = class extends (0, _btravstack_di.Port)("Storage") {};
24
28
  /** The port every adapter provides, and the one an application never depends on. */
25
29
  var StorageBackend = class extends (0, _btravstack_di.Port)("StorageBackend") {};
26
30
  //#endregion
31
+ //#region src/options.ts
32
+ const validateOptions = (key, options) => {
33
+ if (options.sha256 !== void 0 && !/^[a-f0-9]{64}$/.test(options.sha256)) return (0, unthrown.Err)(new InvalidStorageOptions({
34
+ key,
35
+ reason: "invalid_sha256"
36
+ }));
37
+ if (Object.keys(options.metadata ?? {}).some((name) => !/^[!#$%&'*+\-.^_`|~0-9a-z]+$/.test(name))) return (0, unthrown.Err)(new InvalidStorageOptions({
38
+ key,
39
+ reason: "invalid_metadata_key"
40
+ }));
41
+ return (0, unthrown.Ok)();
42
+ };
43
+ //#endregion
44
+ Object.defineProperty(exports, "ChecksumMismatch", {
45
+ enumerable: true,
46
+ get: function() {
47
+ return ChecksumMismatch;
48
+ }
49
+ });
50
+ Object.defineProperty(exports, "InvalidStorageOptions", {
51
+ enumerable: true,
52
+ get: function() {
53
+ return InvalidStorageOptions;
54
+ }
55
+ });
27
56
  Object.defineProperty(exports, "ObjectExists", {
28
57
  enumerable: true,
29
58
  get: function() {
@@ -60,3 +89,9 @@ Object.defineProperty(exports, "StorageUnavailable", {
60
89
  return StorageUnavailable;
61
90
  }
62
91
  });
92
+ Object.defineProperty(exports, "validateOptions", {
93
+ enumerable: true,
94
+ get: function() {
95
+ return validateOptions;
96
+ }
97
+ });
@@ -1,10 +1,14 @@
1
1
  import { Port } from "@btravstack/di";
2
- import { TaggedError } from "unthrown";
2
+ import { Err, Ok, TaggedError } from "unthrown";
3
3
  //#region src/storage.ts
4
4
  /** The key is not there. Only `get`, `head` and `copy` (its source) answer it — see `delete` and `presignedUrl` below. */
5
5
  var ObjectNotFound = class extends TaggedError("ObjectNotFound") {};
6
6
  /** An object already holds the key. Only `putIfAbsent` answers it. */
7
7
  var ObjectExists = class extends TaggedError("ObjectExists") {};
8
+ /** Invalid options are refused before any write or signature is attempted. */
9
+ var InvalidStorageOptions = class extends TaggedError("InvalidStorageOptions") {};
10
+ /** The supplied SHA-256 did not match the body; no object was written. */
11
+ var ChecksumMismatch = class extends TaggedError("ChecksumMismatch") {};
8
12
  /** The store could not answer — the endpoint is down, the credentials were refused, the bucket is gone. */
9
13
  var StorageUnavailable = class extends TaggedError("StorageUnavailable") {};
10
14
  /** The adapter cannot mint a time-limited URL, and says so rather than pretending. */
@@ -24,4 +28,17 @@ var Storage = class extends Port("Storage") {};
24
28
  /** The port every adapter provides, and the one an application never depends on. */
25
29
  var StorageBackend = class extends Port("StorageBackend") {};
26
30
  //#endregion
27
- export { StorageBackend as a, Storage as i, ObjectNotFound as n, StorageUnavailable as o, PresignNotSupported as r, ObjectExists as t };
31
+ //#region src/options.ts
32
+ const validateOptions = (key, options) => {
33
+ if (options.sha256 !== void 0 && !/^[a-f0-9]{64}$/.test(options.sha256)) return Err(new InvalidStorageOptions({
34
+ key,
35
+ reason: "invalid_sha256"
36
+ }));
37
+ if (Object.keys(options.metadata ?? {}).some((name) => !/^[!#$%&'*+\-.^_`|~0-9a-z]+$/.test(name))) return Err(new InvalidStorageOptions({
38
+ key,
39
+ reason: "invalid_metadata_key"
40
+ }));
41
+ return Ok();
42
+ };
43
+ //#endregion
44
+ export { ObjectNotFound as a, StorageBackend as c, ObjectExists as i, StorageUnavailable as l, ChecksumMismatch as n, PresignNotSupported as o, InvalidStorageOptions as r, Storage as s, validateOptions as t };
package/dist/s3.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_storage = require("./storage-EA671z1R.cjs");
2
+ const require_options = require("./options-BhML_i5p.cjs");
3
3
  let _btravstack_di = require("@btravstack/di");
4
4
  let unthrown = require("unthrown");
5
5
  let _aws_sdk_client_s3 = require("@aws-sdk/client-s3");
@@ -30,13 +30,14 @@ const s3Schema = _btravstack_config.Config.object({
30
30
  * close — so the client rides the graph and the scope closing destroys it.
31
31
  */
32
32
  var S3Connection = class extends (0, _btravstack_di.Port)("S3Connection") {};
33
- const unavailable = (operation, key, cause) => new require_storage.StorageUnavailable({
33
+ const unavailable = (operation, key, cause) => new require_options.StorageUnavailable({
34
34
  operation,
35
35
  key,
36
36
  reason: cause instanceof Error ? cause.message : String(cause)
37
37
  });
38
38
  /** S3's answer to `If-None-Match: *` on a key that is held: HTTP 412. */
39
39
  const isPreconditionFailed = (cause) => cause?.$metadata?.httpStatusCode === 412;
40
+ const isChecksumMismatch = (cause) => cause instanceof Error && (cause.name === "BadDigest" || cause.name === "InvalidDigest");
40
41
  /** A missing key is S3's `NoSuchKey`, which is a different fact from "the store is down". */
41
42
  const isMissing = (cause) => cause instanceof Error && (cause.name === "NoSuchKey" || cause.name === "NotFound");
42
43
  /** SSE-KMS parameters when a key is named; none otherwise, so the bucket's default applies. */
@@ -53,47 +54,66 @@ const base64ToHex = (base64) => Buffer.from(base64, "base64").toString("hex");
53
54
  * fiction layered over it.
54
55
  */
55
56
  const s3StorageBackend = (client, bucket) => ({
56
- put: (key, bytes, options) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.PutObjectCommand({
57
- Bucket: bucket,
58
- Key: key,
59
- Body: bytes,
60
- ContentType: options.contentType,
61
- ...encryption(options)
62
- })), (cause) => unavailable("put", key, cause)).map(() => void 0),
63
- putIfAbsent: (key, bytes, options) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.PutObjectCommand({
64
- Bucket: bucket,
65
- Key: key,
66
- Body: bytes,
67
- ContentType: options.contentType,
68
- IfNoneMatch: "*",
69
- ...encryption(options)
70
- })), (cause) => isPreconditionFailed(cause) ? new require_storage.ObjectExists({ key }) : unavailable("putIfAbsent", key, cause)).map(() => void 0),
57
+ put: (key, bytes, options) => {
58
+ const validated = require_options.validateOptions(key, options);
59
+ if (!validated.isOk()) return validated.toAsync();
60
+ return (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.PutObjectCommand({
61
+ Bucket: bucket,
62
+ Key: key,
63
+ Body: bytes,
64
+ ContentType: options.contentType,
65
+ Metadata: { ...options.metadata },
66
+ ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) },
67
+ ...encryption(options)
68
+ })), (cause) => isChecksumMismatch(cause) ? new require_options.ChecksumMismatch({ key }) : unavailable("put", key, cause)).map(() => void 0);
69
+ },
70
+ putIfAbsent: (key, bytes, options) => {
71
+ const validated = require_options.validateOptions(key, options);
72
+ if (!validated.isOk()) return validated.toAsync();
73
+ return (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.PutObjectCommand({
74
+ Bucket: bucket,
75
+ Key: key,
76
+ Body: bytes,
77
+ ContentType: options.contentType,
78
+ IfNoneMatch: "*",
79
+ Metadata: { ...options.metadata },
80
+ ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) },
81
+ ...encryption(options)
82
+ })), (cause) => isChecksumMismatch(cause) ? new require_options.ChecksumMismatch({ key }) : isPreconditionFailed(cause) ? new require_options.ObjectExists({ key }) : unavailable("putIfAbsent", key, cause)).map(() => void 0);
83
+ },
71
84
  get: (key) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.GetObjectCommand({
72
85
  Bucket: bucket,
73
86
  Key: key
74
87
  })).then(async (response) => ({
75
88
  bytes: await response.Body.transformToByteArray(),
76
89
  contentType: response.ContentType ?? "application/octet-stream"
77
- })), (cause) => isMissing(cause) ? new require_storage.ObjectNotFound({ key }) : unavailable("get", key, cause)),
78
- copy: (from, to, options) => (0, unthrown.fromPromise)((async () => client.send(new _aws_sdk_client_s3.CopyObjectCommand({
79
- Bucket: bucket,
80
- Key: to,
81
- CopySource: `${bucket}/${from.split("/").map(encodeURIComponent).join("/")}`,
82
- MetadataDirective: "REPLACE",
83
- TaggingDirective: "REPLACE",
84
- AnnotationDirective: "EXCLUDE",
85
- ContentType: options.contentType,
86
- ...encryption(options)
87
- })))(), (cause) => isMissing(cause) ? new require_storage.ObjectNotFound({ key: from }) : unavailable("copy", from, cause)).map(() => void 0),
90
+ })), (cause) => isMissing(cause) ? new require_options.ObjectNotFound({ key }) : unavailable("get", key, cause)),
91
+ copy: (from, to, options) => {
92
+ const validated = require_options.validateOptions(to, options);
93
+ if (!validated.isOk()) return validated.toAsync();
94
+ return (0, unthrown.fromPromise)((async () => client.send(new _aws_sdk_client_s3.CopyObjectCommand({
95
+ Bucket: bucket,
96
+ Key: to,
97
+ CopySource: `${bucket}/${from.split("/").map(encodeURIComponent).join("/")}`,
98
+ MetadataDirective: "REPLACE",
99
+ TaggingDirective: "REPLACE",
100
+ AnnotationDirective: "EXCLUDE",
101
+ ContentType: options.contentType,
102
+ Metadata: { ...options.metadata },
103
+ ...options.checksum === void 0 ? {} : { ChecksumAlgorithm: "SHA256" },
104
+ ...encryption(options)
105
+ })))(), (cause) => isMissing(cause) ? new require_options.ObjectNotFound({ key: from }) : unavailable("copy", from, cause)).map(() => void 0);
106
+ },
88
107
  head: (key) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.HeadObjectCommand({
89
108
  Bucket: bucket,
90
109
  Key: key,
91
110
  ChecksumMode: "ENABLED"
92
111
  })).then((response) => ({
112
+ metadata: { ...response.Metadata },
93
113
  size: response.ContentLength ?? 0,
94
114
  contentType: response.ContentType ?? "application/octet-stream",
95
- ...response.ChecksumSHA256 === void 0 || response.ChecksumType === "COMPOSITE" ? {} : { sha256: base64ToHex(response.ChecksumSHA256) }
96
- })), (cause) => isMissing(cause) ? new require_storage.ObjectNotFound({ key }) : unavailable("head", key, cause)),
115
+ ...response.ChecksumSHA256 === void 0 || response.ChecksumType === "COMPOSITE" || !/^[A-Za-z0-9+/]{43}=$/.test(response.ChecksumSHA256) ? {} : { sha256: base64ToHex(response.ChecksumSHA256) }
116
+ })), (cause) => isMissing(cause) ? new require_options.ObjectNotFound({ key }) : unavailable("head", key, cause)),
97
117
  delete: (key) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.DeleteObjectCommand({
98
118
  Bucket: bucket,
99
119
  Key: key
@@ -102,17 +122,26 @@ const s3StorageBackend = (client, bucket) => ({
102
122
  Bucket: bucket,
103
123
  Key: key
104
124
  }), { expiresIn: Math.ceil(options.ttlMs / 1e3) }), (cause) => unavailable("presignedUrl", key, cause)),
105
- presignedUpload: (key, options) => (0, unthrown.fromPromise)((0, _aws_sdk_s3_request_presigner.getSignedUrl)(client, new _aws_sdk_client_s3.PutObjectCommand({
106
- Bucket: bucket,
107
- Key: key,
108
- ContentType: options.contentType,
109
- ContentLength: options.contentLength,
110
- ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) }
111
- }), {
112
- expiresIn: Math.ceil(options.ttlMs / 1e3),
113
- signableHeaders: /* @__PURE__ */ new Set(["content-type"]),
114
- ...options.sha256 === void 0 ? {} : { unhoistableHeaders: /* @__PURE__ */ new Set(["x-amz-checksum-sha256"]) }
115
- }), (cause) => unavailable("presignedUpload", key, cause))
125
+ presignedUpload: (key, options) => {
126
+ const command = new _aws_sdk_client_s3.PutObjectCommand({
127
+ Bucket: bucket,
128
+ Key: key,
129
+ ContentType: options.contentType,
130
+ ContentLength: options.contentLength,
131
+ ...encryption(options),
132
+ ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) }
133
+ });
134
+ const expiresIn = Math.ceil(options.ttlMs / 1e3);
135
+ return require_options.validateOptions(key, options).toAsync().flatMap(() => (0, unthrown.fromPromise)((0, _aws_sdk_s3_request_presigner.getSignedUrl)(client, command, {
136
+ expiresIn,
137
+ signableHeaders: /* @__PURE__ */ new Set(["content-type"]),
138
+ unhoistableHeaders: /* @__PURE__ */ new Set([
139
+ "x-amz-checksum-sha256",
140
+ "x-amz-server-side-encryption",
141
+ "x-amz-server-side-encryption-aws-kms-key-id"
142
+ ])
143
+ }), (cause) => unavailable("presignedUpload", key, cause)));
144
+ }
116
145
  });
117
146
  /**
118
147
  * The S3 adapter: one client, built with the scope and destroyed with it, and
@@ -142,12 +171,12 @@ const s3Storage = () => (0, _btravstack_di.Module)("S3Storage")({
142
171
  client.destroy();
143
172
  }
144
173
  }),
145
- (0, _btravstack_di.Provider)(require_storage.StorageBackend)({
174
+ (0, _btravstack_di.Provider)(require_options.StorageBackend)({
146
175
  inject: { connection: S3Connection },
147
176
  sync: ({ connection }) => s3StorageBackend(connection.client, connection.bucket)
148
177
  })
149
178
  ],
150
- exports: [require_storage.StorageBackend]
179
+ exports: [require_options.StorageBackend]
151
180
  });
152
181
  //#endregion
153
182
  exports.s3Storage = s3Storage;
package/dist/s3.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { o as StorageBackend, s as StorageService } from "./storage-BavPXZpA.cjs";
1
+ import { l as StorageBackend, u as StorageService } from "./storage-CfN3KqWm.cjs";
2
2
  import { Module, Scope } from "@btravstack/di";
3
3
  import { S3Client } from "@aws-sdk/client-s3";
4
4
  import { ConfigInvalid, EnvReading } from "@btravstack/config";
package/dist/s3.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { o as StorageBackend, s as StorageService } from "./storage-BavPXZpA.mjs";
1
+ import { l as StorageBackend, u as StorageService } from "./storage-CfN3KqWm.mjs";
2
2
  import { Module, Scope } from "@btravstack/di";
3
3
  import { S3Client } from "@aws-sdk/client-s3";
4
4
  import { ConfigInvalid, EnvReading } from "@btravstack/config";
package/dist/s3.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as StorageBackend, n as ObjectNotFound, o as StorageUnavailable, t as ObjectExists } from "./storage-BIKstQ72.mjs";
1
+ import { a as ObjectNotFound, c as StorageBackend, i as ObjectExists, l as StorageUnavailable, n as ChecksumMismatch, t as validateOptions } from "./options-DYPXkWb9.mjs";
2
2
  import { Module, Port, Provider } from "@btravstack/di";
3
3
  import { Ok, fromPromise } from "unthrown";
4
4
  import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
@@ -36,6 +36,7 @@ const unavailable = (operation, key, cause) => new StorageUnavailable({
36
36
  });
37
37
  /** S3's answer to `If-None-Match: *` on a key that is held: HTTP 412. */
38
38
  const isPreconditionFailed = (cause) => cause?.$metadata?.httpStatusCode === 412;
39
+ const isChecksumMismatch = (cause) => cause instanceof Error && (cause.name === "BadDigest" || cause.name === "InvalidDigest");
39
40
  /** A missing key is S3's `NoSuchKey`, which is a different fact from "the store is down". */
40
41
  const isMissing = (cause) => cause instanceof Error && (cause.name === "NoSuchKey" || cause.name === "NotFound");
41
42
  /** SSE-KMS parameters when a key is named; none otherwise, so the bucket's default applies. */
@@ -52,21 +53,33 @@ const base64ToHex = (base64) => Buffer.from(base64, "base64").toString("hex");
52
53
  * fiction layered over it.
53
54
  */
54
55
  const s3StorageBackend = (client, bucket) => ({
55
- put: (key, bytes, options) => fromPromise(client.send(new PutObjectCommand({
56
- Bucket: bucket,
57
- Key: key,
58
- Body: bytes,
59
- ContentType: options.contentType,
60
- ...encryption(options)
61
- })), (cause) => unavailable("put", key, cause)).map(() => void 0),
62
- putIfAbsent: (key, bytes, options) => fromPromise(client.send(new PutObjectCommand({
63
- Bucket: bucket,
64
- Key: key,
65
- Body: bytes,
66
- ContentType: options.contentType,
67
- IfNoneMatch: "*",
68
- ...encryption(options)
69
- })), (cause) => isPreconditionFailed(cause) ? new ObjectExists({ key }) : unavailable("putIfAbsent", key, cause)).map(() => void 0),
56
+ put: (key, bytes, options) => {
57
+ const validated = validateOptions(key, options);
58
+ if (!validated.isOk()) return validated.toAsync();
59
+ return fromPromise(client.send(new PutObjectCommand({
60
+ Bucket: bucket,
61
+ Key: key,
62
+ Body: bytes,
63
+ ContentType: options.contentType,
64
+ Metadata: { ...options.metadata },
65
+ ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) },
66
+ ...encryption(options)
67
+ })), (cause) => isChecksumMismatch(cause) ? new ChecksumMismatch({ key }) : unavailable("put", key, cause)).map(() => void 0);
68
+ },
69
+ putIfAbsent: (key, bytes, options) => {
70
+ const validated = validateOptions(key, options);
71
+ if (!validated.isOk()) return validated.toAsync();
72
+ return fromPromise(client.send(new PutObjectCommand({
73
+ Bucket: bucket,
74
+ Key: key,
75
+ Body: bytes,
76
+ ContentType: options.contentType,
77
+ IfNoneMatch: "*",
78
+ Metadata: { ...options.metadata },
79
+ ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) },
80
+ ...encryption(options)
81
+ })), (cause) => isChecksumMismatch(cause) ? new ChecksumMismatch({ key }) : isPreconditionFailed(cause) ? new ObjectExists({ key }) : unavailable("putIfAbsent", key, cause)).map(() => void 0);
82
+ },
70
83
  get: (key) => fromPromise(client.send(new GetObjectCommand({
71
84
  Bucket: bucket,
72
85
  Key: key
@@ -74,24 +87,31 @@ const s3StorageBackend = (client, bucket) => ({
74
87
  bytes: await response.Body.transformToByteArray(),
75
88
  contentType: response.ContentType ?? "application/octet-stream"
76
89
  })), (cause) => isMissing(cause) ? new ObjectNotFound({ key }) : unavailable("get", key, cause)),
77
- copy: (from, to, options) => fromPromise((async () => client.send(new CopyObjectCommand({
78
- Bucket: bucket,
79
- Key: to,
80
- CopySource: `${bucket}/${from.split("/").map(encodeURIComponent).join("/")}`,
81
- MetadataDirective: "REPLACE",
82
- TaggingDirective: "REPLACE",
83
- AnnotationDirective: "EXCLUDE",
84
- ContentType: options.contentType,
85
- ...encryption(options)
86
- })))(), (cause) => isMissing(cause) ? new ObjectNotFound({ key: from }) : unavailable("copy", from, cause)).map(() => void 0),
90
+ copy: (from, to, options) => {
91
+ const validated = validateOptions(to, options);
92
+ if (!validated.isOk()) return validated.toAsync();
93
+ return fromPromise((async () => client.send(new CopyObjectCommand({
94
+ Bucket: bucket,
95
+ Key: to,
96
+ CopySource: `${bucket}/${from.split("/").map(encodeURIComponent).join("/")}`,
97
+ MetadataDirective: "REPLACE",
98
+ TaggingDirective: "REPLACE",
99
+ AnnotationDirective: "EXCLUDE",
100
+ ContentType: options.contentType,
101
+ Metadata: { ...options.metadata },
102
+ ...options.checksum === void 0 ? {} : { ChecksumAlgorithm: "SHA256" },
103
+ ...encryption(options)
104
+ })))(), (cause) => isMissing(cause) ? new ObjectNotFound({ key: from }) : unavailable("copy", from, cause)).map(() => void 0);
105
+ },
87
106
  head: (key) => fromPromise(client.send(new HeadObjectCommand({
88
107
  Bucket: bucket,
89
108
  Key: key,
90
109
  ChecksumMode: "ENABLED"
91
110
  })).then((response) => ({
111
+ metadata: { ...response.Metadata },
92
112
  size: response.ContentLength ?? 0,
93
113
  contentType: response.ContentType ?? "application/octet-stream",
94
- ...response.ChecksumSHA256 === void 0 || response.ChecksumType === "COMPOSITE" ? {} : { sha256: base64ToHex(response.ChecksumSHA256) }
114
+ ...response.ChecksumSHA256 === void 0 || response.ChecksumType === "COMPOSITE" || !/^[A-Za-z0-9+/]{43}=$/.test(response.ChecksumSHA256) ? {} : { sha256: base64ToHex(response.ChecksumSHA256) }
95
115
  })), (cause) => isMissing(cause) ? new ObjectNotFound({ key }) : unavailable("head", key, cause)),
96
116
  delete: (key) => fromPromise(client.send(new DeleteObjectCommand({
97
117
  Bucket: bucket,
@@ -101,17 +121,26 @@ const s3StorageBackend = (client, bucket) => ({
101
121
  Bucket: bucket,
102
122
  Key: key
103
123
  }), { expiresIn: Math.ceil(options.ttlMs / 1e3) }), (cause) => unavailable("presignedUrl", key, cause)),
104
- presignedUpload: (key, options) => fromPromise(getSignedUrl(client, new PutObjectCommand({
105
- Bucket: bucket,
106
- Key: key,
107
- ContentType: options.contentType,
108
- ContentLength: options.contentLength,
109
- ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) }
110
- }), {
111
- expiresIn: Math.ceil(options.ttlMs / 1e3),
112
- signableHeaders: /* @__PURE__ */ new Set(["content-type"]),
113
- ...options.sha256 === void 0 ? {} : { unhoistableHeaders: /* @__PURE__ */ new Set(["x-amz-checksum-sha256"]) }
114
- }), (cause) => unavailable("presignedUpload", key, cause))
124
+ presignedUpload: (key, options) => {
125
+ const command = new PutObjectCommand({
126
+ Bucket: bucket,
127
+ Key: key,
128
+ ContentType: options.contentType,
129
+ ContentLength: options.contentLength,
130
+ ...encryption(options),
131
+ ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) }
132
+ });
133
+ const expiresIn = Math.ceil(options.ttlMs / 1e3);
134
+ return validateOptions(key, options).toAsync().flatMap(() => fromPromise(getSignedUrl(client, command, {
135
+ expiresIn,
136
+ signableHeaders: /* @__PURE__ */ new Set(["content-type"]),
137
+ unhoistableHeaders: /* @__PURE__ */ new Set([
138
+ "x-amz-checksum-sha256",
139
+ "x-amz-server-side-encryption",
140
+ "x-amz-server-side-encryption-aws-kms-key-id"
141
+ ])
142
+ }), (cause) => unavailable("presignedUpload", key, cause)));
143
+ }
115
144
  });
116
145
  /**
117
146
  * The S3 adapter: one client, built with the scope and destroyed with it, and
@@ -15,6 +15,17 @@ declare const ObjectExists_base: import("unthrown").TaggedErrorConstructor<"Obje
15
15
  declare class ObjectExists extends ObjectExists_base<{
16
16
  readonly key: string;
17
17
  }> {}
18
+ declare const InvalidStorageOptions_base: import("unthrown").TaggedErrorConstructor<"InvalidStorageOptions">;
19
+ /** Invalid options are refused before any write or signature is attempted. */
20
+ declare class InvalidStorageOptions extends InvalidStorageOptions_base<{
21
+ readonly key: string;
22
+ readonly reason: "invalid_sha256" | "invalid_metadata_key";
23
+ }> {}
24
+ declare const ChecksumMismatch_base: import("unthrown").TaggedErrorConstructor<"ChecksumMismatch">;
25
+ /** The supplied SHA-256 did not match the body; no object was written. */
26
+ declare class ChecksumMismatch extends ChecksumMismatch_base<{
27
+ readonly key: string;
28
+ }> {}
18
29
  declare const StorageUnavailable_base: import("unthrown").TaggedErrorConstructor<"StorageUnavailable">;
19
30
  /** The store could not answer — the endpoint is down, the credentials were refused, the bucket is gone. */
20
31
  declare class StorageUnavailable extends StorageUnavailable_base<{
@@ -30,8 +41,10 @@ declare class PresignNotSupported extends PresignNotSupported_base<{
30
41
  /** What the store knows about an object without sending its bytes. */
31
42
  type ObjectHead = {
32
43
  readonly size: number;
44
+ /** An owned snapshot of user metadata; empty when none was written. */
45
+ readonly metadata: Readonly<Record<string, string>>;
33
46
  readonly contentType: string;
34
- /** Lowercase hex of the SHA-256 the store holds for the object; absent when none was supplied at write time. */
47
+ /** Lowercase hex of a full-object SHA-256; absent when the store holds none. Composite multipart checksums are excluded. */
35
48
  readonly sha256?: string;
36
49
  };
37
50
  /**
@@ -41,22 +54,35 @@ type ObjectHead = {
41
54
  type WriteOptions = {
42
55
  readonly contentType: string;
43
56
  readonly kmsKeyId?: string;
57
+ /** Lowercase, 64-character hex. The store verifies the body before publishing it. */
58
+ readonly sha256?: string;
59
+ /** Lowercase ASCII HTTP-token keys, without `x-amz-meta-`. No normalization; uppercase keys (and thus case collisions) are refused. The caller respects S3's 2 KB UTF-8 key/value limit. */
60
+ readonly metadata?: Readonly<Record<string, string>>;
61
+ readonly checksum?: never;
62
+ };
63
+ /** A destination description and optional server-computed checksum; no write digest is accepted. */
64
+ type CopyOptions = {
65
+ readonly contentType: string;
66
+ readonly kmsKeyId?: string;
67
+ readonly metadata?: Readonly<Record<string, string>>;
68
+ readonly checksum?: "sha256";
69
+ readonly sha256?: never;
44
70
  };
45
71
  type StorageService = {
46
- readonly put: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, StorageUnavailable>;
72
+ readonly put: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, InvalidStorageOptions | ChecksumMismatch | StorageUnavailable>;
47
73
  /** Writes the object only if no object holds the key: the first writer wins, atomically, in the store. */
48
- readonly putIfAbsent: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, ObjectExists | StorageUnavailable>;
74
+ readonly putIfAbsent: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, ObjectExists | InvalidStorageOptions | ChecksumMismatch | StorageUnavailable>;
49
75
  readonly get: (key: string) => AsyncResult<StoredObject, ObjectNotFound | StorageUnavailable>;
50
76
  /**
51
77
  * Copies `from` to `to` inside the store: no bytes pass through this process.
52
- * The copy takes `contentType` (the source's is replaced) and, with
78
+ * The copy replaces metadata (empty when omitted) and `contentType`, and, with
53
79
  * `kmsKeyId`, is encrypted with that key. An existing `to` is overwritten.
54
80
  */
55
- readonly copy: (from: string, to: string, options: WriteOptions) => AsyncResult<void, ObjectNotFound | StorageUnavailable>;
81
+ readonly copy: (from: string, to: string, options: CopyOptions) => AsyncResult<void, ObjectNotFound | InvalidStorageOptions | StorageUnavailable>;
56
82
  /**
57
83
  * Size, type and checksum of an object, without reading it. `sha256` is
58
84
  * present only if the store holds one: S3 keeps it for an object written with
59
- * a SHA-256 checksum (such as through `presignedUpload` with `sha256`), and
85
+ * a SHA-256 checksum (write/presign `sha256`, or copy `checksum`), and
60
86
  * reports it base64-encoded, which the adapter converts to hex.
61
87
  */
62
88
  readonly head: (key: string) => AsyncResult<ObjectHead, ObjectNotFound | StorageUnavailable>;
@@ -91,6 +117,11 @@ type StorageService = {
91
117
  * hash to it, and keeps it for `head`. The client must send the same value as
92
118
  * `x-amz-checksum-sha256` (base64 of the digest).
93
119
  *
120
+ * With `kmsKeyId`, both `x-amz-server-side-encryption: aws:kms` and
121
+ * `x-amz-server-side-encryption-aws-kms-key-id` are mandatory signed headers.
122
+ * The client sends the exact key id used here. Omitting or changing either
123
+ * breaks the signature. Malformed `sha256` returns `InvalidStorageOptions`.
124
+ *
94
125
  * The application still decides every input: whether this caller may write
95
126
  * this key, how long the URL lives, and what size it is willing to accept.
96
127
  * The adapter computes a signature over that decision and nothing more.
@@ -100,7 +131,8 @@ type StorageService = {
100
131
  readonly contentType: string;
101
132
  readonly contentLength: number;
102
133
  readonly sha256?: string;
103
- }) => AsyncResult<string, PresignNotSupported | StorageUnavailable>;
134
+ readonly kmsKeyId?: string;
135
+ }) => AsyncResult<string, PresignNotSupported | InvalidStorageOptions | StorageUnavailable>;
104
136
  };
105
137
  declare const Storage_base: import("@btravstack/di").PortClass<"Storage">;
106
138
  /**
@@ -119,4 +151,4 @@ declare const StorageBackend_base: import("@btravstack/di").PortClass<"StorageBa
119
151
  /** The port every adapter provides, and the one an application never depends on. */
120
152
  declare class StorageBackend extends StorageBackend_base<StorageService> {}
121
153
  //#endregion
122
- export { Storage as a, StorageUnavailable as c, PresignNotSupported as i, StoredObject as l, ObjectHead as n, StorageBackend as o, ObjectNotFound as r, StorageService as s, ObjectExists as t, WriteOptions as u };
154
+ export { ObjectHead as a, Storage as c, StorageUnavailable as d, StoredObject as f, ObjectExists as i, StorageBackend as l, CopyOptions as n, ObjectNotFound as o, WriteOptions as p, InvalidStorageOptions as r, PresignNotSupported as s, ChecksumMismatch as t, StorageService as u };
@@ -15,6 +15,17 @@ declare const ObjectExists_base: import("unthrown").TaggedErrorConstructor<"Obje
15
15
  declare class ObjectExists extends ObjectExists_base<{
16
16
  readonly key: string;
17
17
  }> {}
18
+ declare const InvalidStorageOptions_base: import("unthrown").TaggedErrorConstructor<"InvalidStorageOptions">;
19
+ /** Invalid options are refused before any write or signature is attempted. */
20
+ declare class InvalidStorageOptions extends InvalidStorageOptions_base<{
21
+ readonly key: string;
22
+ readonly reason: "invalid_sha256" | "invalid_metadata_key";
23
+ }> {}
24
+ declare const ChecksumMismatch_base: import("unthrown").TaggedErrorConstructor<"ChecksumMismatch">;
25
+ /** The supplied SHA-256 did not match the body; no object was written. */
26
+ declare class ChecksumMismatch extends ChecksumMismatch_base<{
27
+ readonly key: string;
28
+ }> {}
18
29
  declare const StorageUnavailable_base: import("unthrown").TaggedErrorConstructor<"StorageUnavailable">;
19
30
  /** The store could not answer — the endpoint is down, the credentials were refused, the bucket is gone. */
20
31
  declare class StorageUnavailable extends StorageUnavailable_base<{
@@ -30,8 +41,10 @@ declare class PresignNotSupported extends PresignNotSupported_base<{
30
41
  /** What the store knows about an object without sending its bytes. */
31
42
  type ObjectHead = {
32
43
  readonly size: number;
44
+ /** An owned snapshot of user metadata; empty when none was written. */
45
+ readonly metadata: Readonly<Record<string, string>>;
33
46
  readonly contentType: string;
34
- /** Lowercase hex of the SHA-256 the store holds for the object; absent when none was supplied at write time. */
47
+ /** Lowercase hex of a full-object SHA-256; absent when the store holds none. Composite multipart checksums are excluded. */
35
48
  readonly sha256?: string;
36
49
  };
37
50
  /**
@@ -41,22 +54,35 @@ type ObjectHead = {
41
54
  type WriteOptions = {
42
55
  readonly contentType: string;
43
56
  readonly kmsKeyId?: string;
57
+ /** Lowercase, 64-character hex. The store verifies the body before publishing it. */
58
+ readonly sha256?: string;
59
+ /** Lowercase ASCII HTTP-token keys, without `x-amz-meta-`. No normalization; uppercase keys (and thus case collisions) are refused. The caller respects S3's 2 KB UTF-8 key/value limit. */
60
+ readonly metadata?: Readonly<Record<string, string>>;
61
+ readonly checksum?: never;
62
+ };
63
+ /** A destination description and optional server-computed checksum; no write digest is accepted. */
64
+ type CopyOptions = {
65
+ readonly contentType: string;
66
+ readonly kmsKeyId?: string;
67
+ readonly metadata?: Readonly<Record<string, string>>;
68
+ readonly checksum?: "sha256";
69
+ readonly sha256?: never;
44
70
  };
45
71
  type StorageService = {
46
- readonly put: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, StorageUnavailable>;
72
+ readonly put: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, InvalidStorageOptions | ChecksumMismatch | StorageUnavailable>;
47
73
  /** Writes the object only if no object holds the key: the first writer wins, atomically, in the store. */
48
- readonly putIfAbsent: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, ObjectExists | StorageUnavailable>;
74
+ readonly putIfAbsent: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, ObjectExists | InvalidStorageOptions | ChecksumMismatch | StorageUnavailable>;
49
75
  readonly get: (key: string) => AsyncResult<StoredObject, ObjectNotFound | StorageUnavailable>;
50
76
  /**
51
77
  * Copies `from` to `to` inside the store: no bytes pass through this process.
52
- * The copy takes `contentType` (the source's is replaced) and, with
78
+ * The copy replaces metadata (empty when omitted) and `contentType`, and, with
53
79
  * `kmsKeyId`, is encrypted with that key. An existing `to` is overwritten.
54
80
  */
55
- readonly copy: (from: string, to: string, options: WriteOptions) => AsyncResult<void, ObjectNotFound | StorageUnavailable>;
81
+ readonly copy: (from: string, to: string, options: CopyOptions) => AsyncResult<void, ObjectNotFound | InvalidStorageOptions | StorageUnavailable>;
56
82
  /**
57
83
  * Size, type and checksum of an object, without reading it. `sha256` is
58
84
  * present only if the store holds one: S3 keeps it for an object written with
59
- * a SHA-256 checksum (such as through `presignedUpload` with `sha256`), and
85
+ * a SHA-256 checksum (write/presign `sha256`, or copy `checksum`), and
60
86
  * reports it base64-encoded, which the adapter converts to hex.
61
87
  */
62
88
  readonly head: (key: string) => AsyncResult<ObjectHead, ObjectNotFound | StorageUnavailable>;
@@ -91,6 +117,11 @@ type StorageService = {
91
117
  * hash to it, and keeps it for `head`. The client must send the same value as
92
118
  * `x-amz-checksum-sha256` (base64 of the digest).
93
119
  *
120
+ * With `kmsKeyId`, both `x-amz-server-side-encryption: aws:kms` and
121
+ * `x-amz-server-side-encryption-aws-kms-key-id` are mandatory signed headers.
122
+ * The client sends the exact key id used here. Omitting or changing either
123
+ * breaks the signature. Malformed `sha256` returns `InvalidStorageOptions`.
124
+ *
94
125
  * The application still decides every input: whether this caller may write
95
126
  * this key, how long the URL lives, and what size it is willing to accept.
96
127
  * The adapter computes a signature over that decision and nothing more.
@@ -100,7 +131,8 @@ type StorageService = {
100
131
  readonly contentType: string;
101
132
  readonly contentLength: number;
102
133
  readonly sha256?: string;
103
- }) => AsyncResult<string, PresignNotSupported | StorageUnavailable>;
134
+ readonly kmsKeyId?: string;
135
+ }) => AsyncResult<string, PresignNotSupported | InvalidStorageOptions | StorageUnavailable>;
104
136
  };
105
137
  declare const Storage_base: import("@btravstack/di").PortClass<"Storage">;
106
138
  /**
@@ -119,4 +151,4 @@ declare const StorageBackend_base: import("@btravstack/di").PortClass<"StorageBa
119
151
  /** The port every adapter provides, and the one an application never depends on. */
120
152
  declare class StorageBackend extends StorageBackend_base<StorageService> {}
121
153
  //#endregion
122
- export { Storage as a, StorageUnavailable as c, PresignNotSupported as i, StoredObject as l, ObjectHead as n, StorageBackend as o, ObjectNotFound as r, StorageService as s, ObjectExists as t, WriteOptions as u };
154
+ export { ObjectHead as a, Storage as c, StorageUnavailable as d, StoredObject as f, ObjectExists as i, StorageBackend as l, CopyOptions as n, ObjectNotFound as o, WriteOptions as p, InvalidStorageOptions as r, PresignNotSupported as s, ChecksumMismatch as t, StorageService as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/storage",
3
- "version": "0.38.0",
3
+ "version": "0.39.1",
4
4
  "description": "A Storage port for @btravstack/core: an in-memory adapter, an S3-compatible adapter with presigned reads, and a composition that spans, counts and logs every operation",
5
5
  "keywords": [
6
6
  "dependency-injection",
@@ -55,11 +55,11 @@
55
55
  "devDependencies": {
56
56
  "@aws-sdk/client-s3": "3.1149.0",
57
57
  "@aws-sdk/s3-request-presigner": "3.1149.0",
58
- "@btravstack/config": "0.38.0",
59
- "@btravstack/core": "0.38.0",
60
- "@btravstack/di": "0.38.0",
58
+ "@btravstack/config": "0.39.1",
59
+ "@btravstack/core": "0.39.1",
60
+ "@btravstack/di": "0.39.1",
61
61
  "@btravstack/internal-test-infra": "0.0.0",
62
- "@btravstack/observability": "0.38.0",
62
+ "@btravstack/observability": "0.39.1",
63
63
  "@btravstack/tsconfig": "0.4.0",
64
64
  "@opentelemetry/api": "1.9.1",
65
65
  "@opentelemetry/sdk-metrics": "2.12.0",
@@ -76,9 +76,9 @@
76
76
  "peerDependencies": {
77
77
  "@aws-sdk/client-s3": "^3.1000.0",
78
78
  "@aws-sdk/s3-request-presigner": "^3.1000.0",
79
- "@btravstack/config": "^0.38.0",
80
- "@btravstack/core": "^0.38.0",
81
- "@btravstack/di": "^0.38.0",
79
+ "@btravstack/config": "^0.39.1",
80
+ "@btravstack/core": "^0.39.1",
81
+ "@btravstack/di": "^0.39.1",
82
82
  "unthrown": "^5.0.0"
83
83
  },
84
84
  "peerDependenciesMeta": {