@ultimat3/storage 24.0.0 → 25.0.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.
package/src/driver-s3.ts CHANGED
@@ -5,19 +5,21 @@
5
5
  // credentials arrive as env var NAMES: a literal key in app.config.ts is a key in git.
6
6
 
7
7
  import {
8
- ConfigInvalidError,
9
- EnvMissingError,
8
+ type Clock,
10
9
  finiteCount,
10
+ isUltimateError,
11
11
  NotImplementedError,
12
+ type Page,
13
+ pageOf,
14
+ renderFixLiteral,
12
15
  renderFixShellArg,
13
- stringField,
16
+ systemClock,
14
17
  } from '@ultimat3/core';
15
18
  import {
16
19
  assertListOptions,
17
20
  assertPutOptions,
18
21
  DEFAULT_CONTENT_TYPE,
19
22
  type ListOptions,
20
- type ListPage,
21
23
  type PutOptions,
22
24
  resolveListLimit,
23
25
  type SignedUrlOptions,
@@ -29,7 +31,11 @@ import {
29
31
  sha256Base64,
30
32
  toBytes,
31
33
  } from './driver';
34
+ import { isAbsentObject } from './driver-s3-absent';
35
+ import { buildClient, type S3ClientLike, type S3ListResultLike } from './driver-s3-client';
36
+ import { needsSignedPut, readObjectLock, signedPut } from './driver-s3-lock';
32
37
  import { regionMismatch } from './driver-s3-region';
38
+ import type { S3FetchLike } from './driver-s3-signed';
33
39
  import {
34
40
  checksumMismatch,
35
41
  deleteFailed,
@@ -40,53 +46,13 @@ import {
40
46
  putFailed,
41
47
  readFailed,
42
48
  } from './errors';
49
+ import { assertObjectLockOptions, type ObjectLock } from './object-lock';
43
50
  import { assertSafeKey } from './path';
44
51
  import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
45
52
  import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
46
53
 
47
54
  const DRIVER_NAME = 's3';
48
55
 
49
- /** Structural view of `Bun.S3Client` — typing it here keeps `bun-types` out of the contract. */
50
- export interface S3FileLike {
51
- /** `S3FileLike` is in the union because Bun's own `write` takes an `S3File` — that is `copy`. */
52
- write(data: Uint8Array | Blob | S3FileLike, options?: { type?: string }): Promise<number>;
53
- arrayBuffer(): Promise<ArrayBuffer>;
54
- exists(): Promise<boolean>;
55
- delete(): Promise<void>;
56
- stream(): ReadableStream<Uint8Array>;
57
- stat(): Promise<S3StatLike>;
58
- presign(options: { method?: string; expiresIn?: number; type?: string }): string;
59
- }
60
-
61
- export interface S3StatLike {
62
- readonly size: number;
63
- readonly type?: string | undefined;
64
- readonly etag?: string | undefined;
65
- readonly lastModified?: string | Date | undefined;
66
- }
67
-
68
- export interface S3ListEntryLike {
69
- readonly key?: string | undefined;
70
- readonly size?: number | undefined;
71
- readonly eTag?: string | undefined;
72
- readonly lastModified?: string | Date | undefined;
73
- }
74
-
75
- export interface S3ListResultLike {
76
- readonly contents?: readonly S3ListEntryLike[] | undefined;
77
- readonly isTruncated?: boolean | undefined;
78
- readonly nextContinuationToken?: string | undefined;
79
- }
80
-
81
- export interface S3ClientLike {
82
- file(key: string): S3FileLike;
83
- list(input: {
84
- prefix?: string;
85
- maxKeys?: number;
86
- continuationToken?: string;
87
- }): Promise<S3ListResultLike>;
88
- }
89
-
90
56
  export interface S3DriverOptions {
91
57
  readonly bucket: string;
92
58
  /**
@@ -107,6 +73,15 @@ export interface S3DriverOptions {
107
73
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
108
74
  /** Injected in tests; production constructs `Bun.S3Client`. */
109
75
  readonly client?: S3ClientLike | undefined;
76
+ /**
77
+ * Injected in tests; production uses the global `fetch`. Carries the SIGNED requests the Bun
78
+ * client has no option for — a `put()` with `metadata`, `cacheControl`, `retention` or
79
+ * `legalHold`, and `retentionOf()` — signed by core's `signAwsRequest` against the same
80
+ * endpoint, region and credentials.
81
+ */
82
+ readonly fetch?: S3FetchLike | undefined;
83
+ /** Signs the signed requests and screens a `retainUntil`. Default `systemClock`. */
84
+ readonly clock?: Clock | undefined;
110
85
  /**
111
86
  * Ceiling on ONE server-side `put()`, because `put()` buffers the whole body. Defaults to the
112
87
  * upload policy's ceiling — the same number for the same fact. Raise it for a disk that really
@@ -122,59 +97,6 @@ export interface S3DriverOptions {
122
97
  readonly maxGetBytes?: number | undefined;
123
98
  }
124
99
 
125
- interface S3ClientConstructor {
126
- new (options: Record<string, unknown>): S3ClientLike;
127
- }
128
-
129
- function requireEnv(
130
- env: Readonly<Record<string, string | undefined>>,
131
- name: string,
132
- partner: string,
133
- ): string {
134
- const value = env[name];
135
- if (value === undefined || value === '') {
136
- throw new EnvMissingError({
137
- cause: `${name} is not set, so the s3 disk cannot authenticate`,
138
- fix: `set ${name} and ${partner} in .env (or the container's secret store), then re-run`,
139
- meta: { missing: name },
140
- });
141
- }
142
- return value;
143
- }
144
-
145
- function buildClient(options: S3DriverOptions): S3ClientLike {
146
- if (options.client !== undefined) return options.client;
147
- if (options.bucket === '') {
148
- throw new ConfigInvalidError({
149
- cause: 's3 disk was defined without a bucket',
150
- fix: 'set storage.disks.<name>.bucket in app.config.ts',
151
- });
152
- }
153
- const env = options.env ?? process.env;
154
- const idVar = options.accessKeyIdEnv ?? 'S3_ACCESS_KEY_ID';
155
- const secretVar = options.secretAccessKeyEnv ?? 'S3_SECRET_ACCESS_KEY';
156
- const Client = (Bun as unknown as { S3Client?: S3ClientConstructor }).S3Client;
157
- if (Client === undefined) {
158
- throw new ConfigInvalidError({
159
- cause: 'Bun.S3Client is unavailable in this runtime',
160
- fix: 'upgrade the runtime: bun upgrade # the s3 disk needs bun >= 1.3',
161
- });
162
- }
163
- const tokenVar = options.sessionTokenEnv;
164
- const sessionToken = tokenVar === undefined ? undefined : env[tokenVar];
165
- // Bun's flag is the inverse: path style means "not virtual hosted".
166
- const pathStyle = options.forcePathStyle;
167
- return new Client({
168
- bucket: options.bucket,
169
- accessKeyId: requireEnv(env, idVar, secretVar),
170
- secretAccessKey: requireEnv(env, secretVar, idVar),
171
- ...(options.region === undefined ? {} : { region: options.region }),
172
- ...(options.endpoint === undefined ? {} : { endpoint: options.endpoint }),
173
- ...(pathStyle === undefined ? {} : { virtualHostedStyle: !pathStyle }),
174
- ...(sessionToken === undefined ? {} : { sessionToken }),
175
- });
176
- }
177
-
178
100
  /**
179
101
  * A missing `LastModified` is ABSENT, never `new Date(0)`: epoch 0 is older than every window, so
180
102
  * `sweepOrphans` deleted an upload on the strength of a field the provider never sent.
@@ -182,59 +104,27 @@ function buildClient(options: S3DriverOptions): S3ClientLike {
182
104
  const dated = (value: string | Date | undefined): { readonly lastModified?: Date } =>
183
105
  value === undefined ? {} : { lastModified: value instanceof Date ? value : new Date(value) };
184
106
 
185
- /** One numeric field off a value that may fight being read — `stringField`'s missing twin. */
186
- function numberField(value: unknown, key: string): number | undefined {
187
- if (typeof value !== 'object' || value === null) return undefined;
188
- try {
189
- const held = (value as Record<string, unknown>)[key];
190
- return typeof held === 'number' ? held : undefined;
191
- } catch {
192
- return undefined;
193
- }
194
- }
195
-
196
- /** The provider codes that mean "there is nothing at that key", and nothing wider. */
197
- const ABSENT_OBJECT_CODES: ReadonlySet<string> = new Set(['NoSuchKey', 'NotFound', 'ENOENT']);
198
-
199
- /**
200
- * The ONE delete failure the contract calls success. `AccessDenied`, `SlowDown`, an expired
201
- * credential and a reset connection are none of them, and the previous `.catch(() => undefined)`
202
- * reported all four as deleted — which is how an erasure sweep certifies data it never removed.
203
- *
204
- * Read structurally: an `S3Error` is `name: 'S3Error'` with a `code` the service returned, and
205
- * every field of a value this process did not build is a getter that can throw.
206
- */
207
- function isAbsentObject(error: unknown): boolean {
208
- const code = stringField(error, 'code');
209
- if (code !== undefined && ABSENT_OBJECT_CODES.has(code)) return true;
210
- return numberField(error, 'statusCode') === 404 || numberField(error, 'status') === 404;
211
- }
212
-
213
107
  /**
214
108
  * Everything `put()` may be handed that this driver cannot honour, refused before a byte moves.
215
109
  * A typed refusal at the call site is the whole point: `serverSideEncryption` exists on
216
110
  * `PutOptions` so that "this disk cannot prove per-object encryption" is something an engineer
217
111
  * meets while writing the call, not while answering a security review.
218
112
  */
219
- function refuseUnsupportedPut(bucket: string, key: string, putOptions?: PutOptions): void {
220
- if (putOptions?.metadata !== undefined || putOptions?.cacheControl !== undefined) {
221
- const uri = renderFixShellArg(`s3://${bucket}/${key}`, '<s3-uri>');
222
- throw new NotImplementedError({
223
- cause:
224
- 'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet) is not implemented by this driver — drop metadata/cacheControl from put(), or set them out of band with the command in fix',
225
- fix: `aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
226
- });
227
- }
113
+ function refuseUnsupportedPut(bucket: string, putOptions?: PutOptions): void {
228
114
  const sse = putOptions?.serverSideEncryption;
229
115
  if (sse === undefined) return;
230
- const rule =
231
- sse.algorithm === 'aws:kms'
232
- ? `{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"${sse.kmsKeyId ?? '<key-arn>'}"}`
233
- : '{"SSEAlgorithm":"AES256"}';
116
+ // The key id is the caller's own option, spliced into single-quoted JSON: `renderFixLiteral`
117
+ // escapes the `"` and `\` JSON needs, and a `'` — the one byte live inside single quotes — makes
118
+ // it the placeholder rather than ending the argument (plan 101 row S12).
119
+ const keyId = sse.kmsKeyId?.includes("'") === false ? sse.kmsKeyId : undefined;
120
+ const bucketArg = renderFixShellArg(bucket, "'<bucket>'");
234
121
  throw new NotImplementedError({
235
122
  cause:
236
123
  'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption) is not implemented by this driver — set it bucket-wide with the command in fix, then drop serverSideEncryption from put()',
237
- fix: `aws s3api put-bucket-encryption --bucket ${renderFixShellArg(bucket, '<bucket>')} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":${rule},"BucketKeyEnabled":true}]}'`,
124
+ fix:
125
+ sse.algorithm === 'aws:kms'
126
+ ? `aws s3api put-bucket-encryption --bucket ${bucketArg} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":${renderFixLiteral(keyId, '"<key-arn>"')}},"BucketKeyEnabled":true}]}'`
127
+ : `aws s3api put-bucket-encryption --bucket ${bucketArg} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"},"BucketKeyEnabled":true}]}'`,
238
128
  });
239
129
  }
240
130
 
@@ -253,6 +143,10 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
253
143
  options.maxGetBytes === undefined ? maxPutBytes : options.maxGetBytes,
254
144
  1,
255
145
  );
146
+ // The key `defineStorage` registered this disk under: a not-found `fix:` is `disk('<it>')…`,
147
+ // and the driver kind there is `X_STORAGE_DISK_UNKNOWN` on any app that named its disk.
148
+ let diskName = DRIVER_NAME;
149
+ const clock = options.clock ?? systemClock;
256
150
  let client: S3ClientLike | undefined;
257
151
  const conn = (): S3ClientLike => {
258
152
  client ??= buildClient(options);
@@ -277,12 +171,15 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
277
171
  const refusedWrite =
278
172
  (key: string) =>
279
173
  (error: unknown): never => {
174
+ // A refusal this process already coded — a screened header, a missing credential — is its
175
+ // own answer, never re-read as the provider refusing the write.
176
+ if (isUltimateError(error)) throw error;
280
177
  throwIfMisconfigured(error);
281
178
  throw putFailed(
282
179
  DRIVER_NAME,
283
180
  key,
284
181
  error,
285
- `aws s3api get-bucket-policy --bucket ${renderFixShellArg(options.bucket, '<bucket>')}`,
182
+ `aws s3api get-bucket-policy --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")}`,
286
183
  );
287
184
  };
288
185
 
@@ -294,13 +191,13 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
294
191
  (key: string) =>
295
192
  (error: unknown): never => {
296
193
  if (isStorageError(error)) throw error;
297
- if (isAbsentObject(error)) throw objectNotFound(DRIVER_NAME, key);
194
+ if (isAbsentObject(error)) throw objectNotFound(diskName, key);
298
195
  throwIfMisconfigured(error);
299
196
  throw readFailed(
300
197
  DRIVER_NAME,
301
198
  key,
302
199
  error,
303
- `aws s3api head-object --bucket ${renderFixShellArg(options.bucket, '<bucket>')} --key ${renderFixShellArg(key, '<key>')}`,
200
+ `aws s3api head-object --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(key, "'<key>'")}`,
304
201
  );
305
202
  };
306
203
  const present = (key: string): Promise<boolean> =>
@@ -319,11 +216,15 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
319
216
 
320
217
  return {
321
218
  name: DRIVER_NAME,
219
+ registerAs(name: string): void {
220
+ diskName = name;
221
+ },
322
222
 
323
223
  async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
324
224
  const safe = assertSafeKey(key);
325
- refuseUnsupportedPut(options.bucket, safe, putOptions);
225
+ refuseUnsupportedPut(options.bucket, putOptions);
326
226
  assertPutOptions(DRIVER_NAME, putOptions);
227
+ assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
327
228
  // Buffered on purpose: size and checksum must be known before the object exists — so this
328
229
  // path is for objects that FIT IN MEMORY, and `maxPutBytes` is what makes that a contract
329
230
  // rather than a hope. User uploads never come through here: they go direct to the bucket
@@ -334,6 +235,20 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
334
235
  const actual = sha256Base64(bytes);
335
236
  if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
336
237
  }
238
+ if (putOptions !== undefined && needsSignedPut(putOptions)) {
239
+ // Off the Bun client only for what it cannot send: `S3File.write` takes a type, an ACL and
240
+ // a storage class and no header. One request either way — never a PUT then a second call.
241
+ const refusal = await signedPut(options, safe, bytes, putOptions).catch(refusedWrite(safe));
242
+ if (refusal !== undefined) refusedWrite(safe)(refusal);
243
+ // Stored — the PUT succeeded with them — so reported, as the local disk reports its own.
244
+ return {
245
+ ...(await statObject(safe)),
246
+ ...(putOptions.cacheControl === undefined
247
+ ? {}
248
+ : { cacheControl: putOptions.cacheControl }),
249
+ ...(putOptions.metadata === undefined ? {} : { metadata: { ...putOptions.metadata } }),
250
+ };
251
+ }
337
252
  await conn()
338
253
  .file(safe)
339
254
  .write(bytes, { type: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE })
@@ -349,7 +264,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
349
264
  async get(key: string): Promise<StorageRead> {
350
265
  const safe = assertSafeKey(key);
351
266
  const file = conn().file(safe);
352
- if (!(await present(safe))) throw objectNotFound(DRIVER_NAME, safe);
267
+ if (!(await present(safe))) throw objectNotFound(diskName, safe);
353
268
  // The HEAD comes FIRST, and it is the whole point: a client PUT straight into the bucket is
354
269
  // bounded by nothing on this disk, so `arrayBuffer()` on whatever is there was heap growth
355
270
  // the uploader chose. Refused on the provider's own size before a byte is read.
@@ -362,7 +277,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
362
277
  async stream(key: string): Promise<ReadableStream<Uint8Array>> {
363
278
  const safe = assertSafeKey(key);
364
279
  const file = conn().file(safe);
365
- if (!(await present(safe))) throw objectNotFound(DRIVER_NAME, safe);
280
+ if (!(await present(safe))) throw objectNotFound(diskName, safe);
366
281
  return file.stream();
367
282
  },
368
283
 
@@ -377,7 +292,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
377
292
  const source = assertSafeKey(from);
378
293
  const destination = assertSafeKey(to);
379
294
  const file = conn().file(source);
380
- if (!(await present(source))) throw objectNotFound(DRIVER_NAME, source);
295
+ if (!(await present(source))) throw objectNotFound(diskName, source);
381
296
  const stat = await file.stat().catch(refusedRead(source));
382
297
  await conn()
383
298
  .file(destination)
@@ -399,7 +314,8 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
399
314
  DRIVER_NAME,
400
315
  safe,
401
316
  error,
402
- `grant s3:DeleteObject on this prefix to the app's role, then reproduce with the provider's own words: aws s3api delete-object --bucket ${options.bucket} --key ${safe}`,
317
+ // `isSafeKey` admits `$(…)`, a backtick and `;` — legal key bytes, live shell syntax.
318
+ `grant s3:DeleteObject on this prefix to the app's role, then reproduce with the provider's own words: aws s3api delete-object --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(safe, "'<key>'")}`,
403
319
  );
404
320
  }
405
321
  },
@@ -408,7 +324,22 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
408
324
  return present(assertSafeKey(key));
409
325
  },
410
326
 
411
- async list(listOptions?: ListOptions): Promise<ListPage> {
327
+ /** Signed `GET ?retention` and `?legal-hold`, in parallel. A bucket with no lock reports none. */
328
+ async retentionOf(key: string): Promise<ObjectLock> {
329
+ const safe = assertSafeKey(key);
330
+ const read = await readObjectLock(options, safe).catch(refusedRead(safe));
331
+ if (read.kind === 'lock') return read.lock;
332
+ if (isAbsentObject(read.refusal)) throw objectNotFound(diskName, safe);
333
+ throwIfMisconfigured(read.refusal);
334
+ throw readFailed(
335
+ DRIVER_NAME,
336
+ safe,
337
+ read.refusal,
338
+ `aws s3api get-object-retention --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(safe, "'<key>'")} # reproduces the refusal; grant s3:GetObjectRetention and s3:GetObjectLegalHold to the app's role`,
339
+ );
340
+ },
341
+
342
+ async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
412
343
  const prefix = listOptions?.prefix ?? '';
413
344
  // Refused at the seam both drivers share, before the provider is asked: `maxKeys: 0` used to
414
345
  // go straight through, while the local disk answered a complete-looking empty page.
@@ -422,7 +353,9 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
422
353
  result = await client.list({
423
354
  maxKeys,
424
355
  ...(listOptions?.prefix === undefined ? {} : { prefix: listOptions.prefix }),
425
- ...(listOptions?.cursor === undefined ? {} : { continuationToken: listOptions.cursor }),
356
+ ...(typeof listOptions?.cursor === 'string'
357
+ ? { continuationToken: listOptions.cursor }
358
+ : {}),
426
359
  });
427
360
  } catch (error) {
428
361
  // A bare `S3Error` used to escape here, uncoded: no `X_*`, no `fix`, no `--json` shape, and
@@ -433,7 +366,11 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
433
366
  DRIVER_NAME,
434
367
  prefix,
435
368
  error,
436
- `grant s3:ListBucket on this bucket to the app's role, then reproduce with the provider's own words: aws s3api list-objects-v2 --bucket ${options.bucket}${prefix === '' ? '' : ` --prefix ${prefix}`}`,
369
+ // Two whole lines rather than a ternary INSIDE one: each substitution is then a screen
370
+ // the fix-shell-arg guard can read as one.
371
+ prefix === ''
372
+ ? `grant s3:ListBucket on this bucket to the app's role, then reproduce with the provider's own words: aws s3api list-objects-v2 --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")}`
373
+ : `grant s3:ListBucket on this bucket to the app's role, then reproduce with the provider's own words: aws s3api list-objects-v2 --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --prefix ${renderFixShellArg(prefix, "'<prefix>'")}`,
437
374
  );
438
375
  }
439
376
  const objects: StorageListEntry[] = [];
@@ -451,10 +388,13 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
451
388
  ...dated(entry.lastModified),
452
389
  });
453
390
  }
454
- const cursor = result.nextContinuationToken;
455
- return result.isTruncated === true && cursor !== undefined
456
- ? { objects, truncated: true, cursor }
457
- : { objects, truncated: false };
391
+ // `IsTruncated` decides and `NextContinuationToken` is the cursor: a token beside
392
+ // `IsTruncated: false` is not a next page, and a truncated answer with no token has none to
393
+ // offer — both are the last page, never a cursor that fetches an empty one.
394
+ return pageOf(
395
+ objects,
396
+ result.isTruncated === true ? (result.nextContinuationToken ?? null) : null,
397
+ );
458
398
  },
459
399
 
460
400
  /**
package/src/driver.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  // strings — a base64 round trip through JSON is how a "small" upload becomes a 33%-larger OOM —
4
4
  // and never unbounded, which is the same failure with the sender choosing the size.
5
5
 
6
- import type { Clock } from '@ultimat3/core';
6
+ import type { Clock, Page } from '@ultimat3/core';
7
7
  import { assert } from '@ultimat3/core';
8
8
  import { putTooLarge } from './errors';
9
+ import type { ObjectLock, ObjectLockOptions } from './object-lock';
9
10
  import type { SignedUrlVerification } from './signed-url';
10
11
 
11
12
  export type StorageBody = Uint8Array | ReadableStream<Uint8Array> | Blob;
@@ -53,7 +54,12 @@ export interface ServerSideEncryption {
53
54
  readonly kmsKeyId?: string | undefined;
54
55
  }
55
56
 
56
- export interface PutOptions {
57
+ /**
58
+ * `retention` and `legalHold` (from `ObjectLockOptions`) are S3 Object Lock, per object: sent as
59
+ * `x-amz-object-lock-*` on s3 — which needs a bucket created with Object Lock — and EMULATED by the
60
+ * local and memory disks, which refuse a delete or an overwrite of a locked object until it lapses.
61
+ */
62
+ export interface PutOptions extends ObjectLockOptions {
57
63
  readonly contentType?: string | undefined;
58
64
  readonly cacheControl?: string | undefined;
59
65
  readonly metadata?: Readonly<Record<string, string>> | undefined;
@@ -65,18 +71,15 @@ export interface PutOptions {
65
71
 
66
72
  export interface ListOptions {
67
73
  readonly prefix?: string | undefined;
68
- /** Opaque; pass back the `cursor` of the previous page. */
69
- readonly cursor?: string | undefined;
74
+ /**
75
+ * Opaque; pass back the `nextCursor` of the previous page. `null` and absent both mean the
76
+ * first page, so `cursor: page.nextCursor` threads a loop with no conversion — the loop stops
77
+ * on `hasMore: false` before a `null` is ever handed back as "the page after the last".
78
+ */
79
+ readonly cursor?: string | null | undefined;
70
80
  readonly limit?: number | undefined;
71
81
  }
72
82
 
73
- export interface ListPage {
74
- readonly objects: readonly StorageListEntry[];
75
- readonly truncated: boolean;
76
- /** Absent when `truncated` is false. */
77
- readonly cursor?: string | undefined;
78
- }
79
-
80
83
  export interface StorageRead {
81
84
  readonly object: StorageObject;
82
85
  readonly bytes: Uint8Array;
@@ -112,11 +115,13 @@ export interface StorageDriver {
112
115
  */
113
116
  readonly signedUrlBase?: string | undefined;
114
117
  /**
115
- * `defineStorage` tells the driver the key it was registered under, at boot, once. A driver that
116
- * mints its own URLs hangs them off THAT, never off `name`: the mounted `/_storage/:disk/*key`
117
- * route resolves the segment through the registry, so a disk registered as `uploads` whose URLs
118
- * say `local` 404s every signature it just wrote. Optional — a driver with no URLs of its own
119
- * (`s3`) needs none, and a third-party driver that ignores it keeps minting under `name`.
118
+ * `defineStorage` tells the driver the key it was registered under, at boot, once. Two readers:
119
+ * a driver that mints its own URLs hangs them off THAT, never off `name` — the mounted
120
+ * `/_storage/:disk/*key` route resolves the segment through the registry, so a disk registered
121
+ * as `uploads` whose URLs say `local` 404s every signature it just wrote — and every
122
+ * `X_STORAGE_NOT_FOUND`, whose `fix:` is `disk('<name>').list(…)`. `local`, `s3`
123
+ * and `memory` use it for both — their URLs and their not-found errors. Optional: a
124
+ * third-party driver that ignores it keeps minting and refusing under `name`.
120
125
  */
121
126
  registerAs?(diskName: string): void;
122
127
  put(key: string, body: StorageBody, options?: PutOptions): Promise<StorageObject>;
@@ -147,8 +152,20 @@ export interface StorageDriver {
147
152
  */
148
153
  delete(key: string): Promise<void>;
149
154
  exists(key: string): Promise<boolean>;
150
- list(options?: ListOptions): Promise<ListPage>;
155
+ /**
156
+ * One page of core's ONE `Page` shape: `nextCursor` exists exactly when another object does,
157
+ * so a full last page answers `hasMore: false` rather than a cursor to an empty page.
158
+ */
159
+ list(options?: ListOptions): Promise<Page<StorageListEntry>>;
151
160
  signedUrl(key: string, options?: SignedUrlOptions): Promise<string>;
161
+ /**
162
+ * What locks one object — its Object Lock retention and legal hold — read back from the disk.
163
+ * `{ legalHold: false }` with no `retention` means nothing does, including on a bucket created
164
+ * without Object Lock. `X_STORAGE_NOT_FOUND` when no object is at the key. Optional, like
165
+ * `registerAs`: every shipped disk answers it, and a third-party driver that does not is a
166
+ * driver with no lock to report.
167
+ */
168
+ retentionOf?(key: string): Promise<ObjectLock>;
152
169
  /**
153
170
  * Verify a URL THIS disk minted — without surrendering the key it minted with.
154
171
  *
@@ -180,7 +197,7 @@ export const DEFAULT_LIST_LIMIT = 1000;
180
197
  * The page size a `list()` honours — refused rather than clamped when it cannot be one.
181
198
  *
182
199
  * At the `ListOptions` seam and not inside a driver, because the two answered a non-positive
183
- * limit differently: the local disk sliced `[0, 0)` and then dropped its own `truncated` flag, so
200
+ * limit differently: the local disk sliced `[0, 0)` and then dropped its own "more" flag, so
184
201
  * `list({ limit: 0 })` over a full disk reported a COMPLETE empty listing, while the s3 disk
185
202
  * handed `maxKeys: 0` straight to the provider. `sweepOrphans` pages through `list()`, so a page
186
203
  * that is empty and claims to be complete is a false erasure report — the same lie a swallowed
@@ -229,7 +246,7 @@ export function assertListOptions(options: ListOptions | undefined): void {
229
246
  for (const name of ['prefix', 'cursor'] as const) {
230
247
  const value = options?.[name];
231
248
  assert(
232
- value === undefined || typeof value === 'string',
249
+ value === undefined || typeof value === 'string' || (name === 'cursor' && value === null),
233
250
  `list() was given a ${name} that is not a string`,
234
251
  `disk.list({ ${name}: 'a string' }) — or omit ${name}`,
235
252
  );
package/src/errors.ts CHANGED
@@ -29,6 +29,7 @@ export const STORAGE_OWNED_ERROR_CODES = [
29
29
  'X_STORAGE_KEY_CONFLICT',
30
30
  'X_STORAGE_PUT_FAILED',
31
31
  'X_STORAGE_READ_FAILED',
32
+ 'X_STORAGE_OBJECT_LOCKED',
32
33
  ] as const;
33
34
 
34
35
  /**
@@ -65,9 +66,10 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
65
66
  X_STORAGE_LIST_FAILED: 'the objects could not be listed',
66
67
  X_STORAGE_QUARANTINED: 'the object is still in quarantine',
67
68
  X_STORAGE_NOT_PENDING: 'the key is not a pending upload',
68
- X_STORAGE_KEY_CONFLICT: 'the key collides with another key on the local disk',
69
+ X_STORAGE_KEY_CONFLICT: 'the key collides with another key on a local or memory disk',
69
70
  X_STORAGE_PUT_FAILED: 'the object could not be written',
70
71
  X_STORAGE_READ_FAILED: 'the object could not be read',
72
+ X_STORAGE_OBJECT_LOCKED: 'the object is under retention or a legal hold',
71
73
  };
72
74
 
73
75
  // One unconditional call, so a second package claiming one of storage's codes throws
@@ -167,7 +169,7 @@ export const putTooLarge = (
167
169
  (disk === 's3'
168
170
  ? `s3Driver({ bucket, maxPutBytes: ${bytes} })`
169
171
  : disk === 'memory'
170
- ? `memoryDriver({ maxPutBytes: ${bytes} })`
172
+ ? `memoryStorageDriver({ maxPutBytes: ${bytes} })`
171
173
  : `localDriver({ root, maxPutBytes: ${bytes} })`),
172
174
  meta: { disk, key, bytes, maxBytes },
173
175
  });
@@ -191,7 +193,7 @@ export const getTooLarge = (
191
193
  (disk === 's3'
192
194
  ? `s3Driver({ bucket, maxGetBytes: ${bytes} })`
193
195
  : disk === 'memory'
194
- ? `memoryDriver({ maxGetBytes: ${bytes} })`
196
+ ? `memoryStorageDriver({ maxGetBytes: ${bytes} })`
195
197
  : `localDriver({ root, maxGetBytes: ${bytes} })`),
196
198
  meta: { disk, key, bytes, maxBytes },
197
199
  });
@@ -199,8 +201,9 @@ export const getTooLarge = (
199
201
  /**
200
202
  * The local disk stores an object as a FILE at its key, so a key cannot also be the directory of
201
203
  * another: `put('a')` then `put('a/b')`, or the reverse, or the same pair one tree over in the
202
- * sidecars (`a` and `a.json/b`). It surfaced as a bare `ENOTDIR` / `EISDIR`. S3 and the memory
203
- * disk hold both — this is the local disk's own limit, refused by name rather than hidden.
204
+ * sidecars (`a` and `a.json/b`). It surfaced as a bare `ENOTDIR` / `EISDIR`. S3 holds both —
205
+ * this is the local disk's own limit, refused by name rather than hidden, and the memory disk
206
+ * emulates it (`driver-memory-conflict.ts`) because a test's disk stands in for the dev disk.
204
207
  *
205
208
  * The `fix` lists what is in the way: the remedy is storing one of the two under another key, and
206
209
  * which one is only decidable by a reader who can see both.
@@ -208,7 +211,7 @@ export const getTooLarge = (
208
211
  export const keyConflict = (disk: string, key: string, blocking: string): StorageError =>
209
212
  new StorageError({
210
213
  code: 'X_STORAGE_KEY_CONFLICT',
211
- cause: `the "${disk}" disk is a local one and cannot store "${key}": "${blocking}" is already on it, and a POSIX path is a file or a directory, never both — so one key cannot be a path prefix of another here. Store one of the two under a different key; an s3 disk holds both`,
214
+ cause: `the "${disk}" disk stores keys as POSIX paths (a local disk, or the memory disk standing in for one) and cannot store "${key}": "${blocking}" is already on it, and a POSIX path is a file or a directory, never both — so one key cannot be a path prefix of another here. Store one of the two under a different key; an s3 disk holds both`,
212
215
  // The REGISTERED name, never the driver kind: `disk('local')` on a disk registered as
213
216
  // `uploads` lists another disk, or answers X_STORAGE_DISK_UNKNOWN.
214
217
  fix: `disk(${renderFixLiteral(disk, "'local'")}).list({ prefix: ${renderFixLiteral(blocking, "'a/'")} })`,
@@ -258,6 +261,20 @@ export const contentTypeUnrecognised = (key: string, declared: string): StorageE
258
261
  meta: { key, declared },
259
262
  });
260
263
 
264
+ /**
265
+ * The XML twin of `contentTypeUnrecognised`: no signature names an XML type, so "matches no known
266
+ * signature" would be false here. The finding is that the bytes are not UTF-8 text — UTF-16, a
267
+ * legacy charset, a control byte — so the screen for script-carrying markup never saw what a
268
+ * browser would decode. Same shipped code: what a client retries on is unchanged.
269
+ */
270
+ export const xmlBodyUnreadable = (key: string, declared: string): StorageError =>
271
+ new StorageError({
272
+ code: 'X_STORAGE_TYPE_REJECTED',
273
+ cause: `"${key}" declares ${declared}, and its bytes are not readable UTF-8 text, so they cannot be screened for the script a browser would run from them`,
274
+ fix: `validateUpload({ key: ${renderFixLiteral(key, "'a/data.xml'")}, declaredContentType: ${renderFixLiteral(declared, "'application/xml'")}, bytes: new TextEncoder().encode(text) }, policy) — where text is the document decoded from its own charset (iconv -f UTF-16 -t UTF-8), with no byte-order mark and no control characters`,
275
+ meta: { key, declared },
276
+ });
277
+
261
278
  export const checksumMismatch = (key: string, declared: string, actual: string): StorageError =>
262
279
  new StorageError({
263
280
  code: 'X_STORAGE_CHECKSUM_MISMATCH',
package/src/index.ts CHANGED
@@ -31,7 +31,6 @@ export {
31
31
  export type {
32
32
  ByteLimit,
33
33
  ListOptions,
34
- ListPage,
35
34
  PutOptions,
36
35
  ServerSideEncryption,
37
36
  SignedUrlMethod,
@@ -55,17 +54,18 @@ export {
55
54
  } from './driver';
56
55
  export type { LocalDriverOptions } from './driver-local';
57
56
  export { localDriver } from './driver-local';
58
- export type { MemoryDriverOptions, MemoryStorageDriver } from './driver-memory';
59
- export { memoryDriver } from './driver-memory';
57
+ export type { MemoryStorageDriver, MemoryStorageDriverOptions } from './driver-memory';
58
+ export { memoryStorageDriver } from './driver-memory';
59
+ export type { S3DriverOptions } from './driver-s3';
60
+ export { s3Driver } from './driver-s3';
60
61
  export type {
61
62
  S3ClientLike,
62
- S3DriverOptions,
63
63
  S3FileLike,
64
64
  S3ListEntryLike,
65
65
  S3ListResultLike,
66
66
  S3StatLike,
67
- } from './driver-s3';
68
- export { s3Driver } from './driver-s3';
67
+ } from './driver-s3-client';
68
+ export type { S3FetchLike } from './driver-s3-signed';
69
69
  export type { StorageErrorCode, StorageErrorInit } from './errors';
70
70
  export {
71
71
  checksumMismatch,
@@ -94,6 +94,7 @@ export {
94
94
  signingSecretMissing,
95
95
  tooLarge,
96
96
  uploadFailed,
97
+ xmlBodyUnreadable,
97
98
  } from './errors';
98
99
  export type { GrantUploadInput, UploadGrant, UploadRequest } from './grant';
99
100
  export { grantUpload } from './grant';
@@ -119,6 +120,14 @@ export {
119
120
  VARIANT_FORMATS,
120
121
  variantKey,
121
122
  } from './image';
123
+ export type {
124
+ LockedAction,
125
+ ObjectLock,
126
+ ObjectLockOptions,
127
+ ObjectRetention,
128
+ RetentionMode,
129
+ } from './object-lock';
130
+ export { isLocked, ObjectLockedError, RETENTION_MODES } from './object-lock';
122
131
 
123
132
  export {
124
133
  assertSafeKey,
@@ -152,7 +161,6 @@ export {
152
161
  signConstraints,
153
162
  signedUrlBaseFor,
154
163
  signedUrlBasePath,
155
- timingSafeEqual,
156
164
  verifySignedUrl,
157
165
  } from './signed-url';
158
166
  export {