@ultimat3/storage 23.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
@@ -4,11 +4,22 @@
4
4
  // The client is built lazily on first use so importing this module never opens a socket, and
5
5
  // credentials arrive as env var NAMES: a literal key in app.config.ts is a key in git.
6
6
 
7
- import { ConfigInvalidError, EnvMissingError, finiteCount, stringField } from '@ultimat3/core';
8
7
  import {
8
+ type Clock,
9
+ finiteCount,
10
+ isUltimateError,
11
+ NotImplementedError,
12
+ type Page,
13
+ pageOf,
14
+ renderFixLiteral,
15
+ renderFixShellArg,
16
+ systemClock,
17
+ } from '@ultimat3/core';
18
+ import {
19
+ assertListOptions,
20
+ assertPutOptions,
9
21
  DEFAULT_CONTENT_TYPE,
10
22
  type ListOptions,
11
- type ListPage,
12
23
  type PutOptions,
13
24
  resolveListLimit,
14
25
  type SignedUrlOptions,
@@ -20,61 +31,28 @@ import {
20
31
  sha256Base64,
21
32
  toBytes,
22
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';
23
37
  import { regionMismatch } from './driver-s3-region';
38
+ import type { S3FetchLike } from './driver-s3-signed';
24
39
  import {
25
40
  checksumMismatch,
26
41
  deleteFailed,
42
+ getTooLarge,
43
+ isStorageError,
27
44
  listFailed,
28
45
  objectNotFound,
29
- storageNotImplemented,
46
+ putFailed,
47
+ readFailed,
30
48
  } from './errors';
49
+ import { assertObjectLockOptions, type ObjectLock } from './object-lock';
31
50
  import { assertSafeKey } from './path';
32
51
  import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
33
52
  import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
34
53
 
35
54
  const DRIVER_NAME = 's3';
36
55
 
37
- /** Structural view of `Bun.S3Client` — typing it here keeps `bun-types` out of the contract. */
38
- export interface S3FileLike {
39
- /** `S3FileLike` is in the union because Bun's own `write` takes an `S3File` — that is `copy`. */
40
- write(data: Uint8Array | Blob | S3FileLike, options?: { type?: string }): Promise<number>;
41
- arrayBuffer(): Promise<ArrayBuffer>;
42
- exists(): Promise<boolean>;
43
- delete(): Promise<void>;
44
- stream(): ReadableStream<Uint8Array>;
45
- stat(): Promise<S3StatLike>;
46
- presign(options: { method?: string; expiresIn?: number; type?: string }): string;
47
- }
48
-
49
- export interface S3StatLike {
50
- readonly size: number;
51
- readonly type?: string | undefined;
52
- readonly etag?: string | undefined;
53
- readonly lastModified?: string | Date | undefined;
54
- }
55
-
56
- export interface S3ListEntryLike {
57
- readonly key?: string | undefined;
58
- readonly size?: number | undefined;
59
- readonly eTag?: string | undefined;
60
- readonly lastModified?: string | Date | undefined;
61
- }
62
-
63
- export interface S3ListResultLike {
64
- readonly contents?: readonly S3ListEntryLike[] | undefined;
65
- readonly isTruncated?: boolean | undefined;
66
- readonly nextContinuationToken?: string | undefined;
67
- }
68
-
69
- export interface S3ClientLike {
70
- file(key: string): S3FileLike;
71
- list(input: {
72
- prefix?: string;
73
- maxKeys?: number;
74
- continuationToken?: string;
75
- }): Promise<S3ListResultLike>;
76
- }
77
-
78
56
  export interface S3DriverOptions {
79
57
  readonly bucket: string;
80
58
  /**
@@ -95,6 +73,15 @@ export interface S3DriverOptions {
95
73
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
96
74
  /** Injected in tests; production constructs `Bun.S3Client`. */
97
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;
98
85
  /**
99
86
  * Ceiling on ONE server-side `put()`, because `put()` buffers the whole body. Defaults to the
100
87
  * upload policy's ceiling — the same number for the same fact. Raise it for a disk that really
@@ -102,91 +89,20 @@ export interface S3DriverOptions {
102
89
  * and S3's single-PUT limit is 5GB regardless of what this says.
103
90
  */
104
91
  readonly maxPutBytes?: number | undefined;
92
+ /**
93
+ * Ceiling on ONE `get()`, because `get()` buffers the whole object — and on this disk nothing
94
+ * bounds what a presigned PUT stored (`SignedUrlOptions.maxBytes`). Defaults to `maxPutBytes`;
95
+ * an object past it is read with `stream()`.
96
+ */
97
+ readonly maxGetBytes?: number | undefined;
105
98
  }
106
99
 
107
- interface S3ClientConstructor {
108
- new (options: Record<string, unknown>): S3ClientLike;
109
- }
110
-
111
- function requireEnv(
112
- env: Readonly<Record<string, string | undefined>>,
113
- name: string,
114
- partner: string,
115
- ): string {
116
- const value = env[name];
117
- if (value === undefined || value === '') {
118
- throw new EnvMissingError({
119
- cause: `${name} is not set, so the s3 disk cannot authenticate`,
120
- fix: `set ${name} and ${partner} in .env (or the container's secret store), then re-run`,
121
- meta: { missing: name },
122
- });
123
- }
124
- return value;
125
- }
126
-
127
- function buildClient(options: S3DriverOptions): S3ClientLike {
128
- if (options.client !== undefined) return options.client;
129
- if (options.bucket === '') {
130
- throw new ConfigInvalidError({
131
- cause: 's3 disk was defined without a bucket',
132
- fix: 'set storage.disks.<name>.bucket in app.config.ts',
133
- });
134
- }
135
- const env = options.env ?? process.env;
136
- const idVar = options.accessKeyIdEnv ?? 'S3_ACCESS_KEY_ID';
137
- const secretVar = options.secretAccessKeyEnv ?? 'S3_SECRET_ACCESS_KEY';
138
- const Client = (Bun as unknown as { S3Client?: S3ClientConstructor }).S3Client;
139
- if (Client === undefined) {
140
- throw new ConfigInvalidError({
141
- cause: 'Bun.S3Client is unavailable in this runtime',
142
- fix: 'upgrade the runtime: bun upgrade # the s3 disk needs bun >= 1.3',
143
- });
144
- }
145
- const tokenVar = options.sessionTokenEnv;
146
- const sessionToken = tokenVar === undefined ? undefined : env[tokenVar];
147
- // Bun's flag is the inverse: path style means "not virtual hosted".
148
- const pathStyle = options.forcePathStyle;
149
- return new Client({
150
- bucket: options.bucket,
151
- accessKeyId: requireEnv(env, idVar, secretVar),
152
- secretAccessKey: requireEnv(env, secretVar, idVar),
153
- ...(options.region === undefined ? {} : { region: options.region }),
154
- ...(options.endpoint === undefined ? {} : { endpoint: options.endpoint }),
155
- ...(pathStyle === undefined ? {} : { virtualHostedStyle: !pathStyle }),
156
- ...(sessionToken === undefined ? {} : { sessionToken }),
157
- });
158
- }
159
-
160
- const toDate = (value: string | Date | undefined): Date =>
161
- value === undefined ? new Date(0) : value instanceof Date ? value : new Date(value);
162
-
163
- /** One numeric field off a value that may fight being read — `stringField`'s missing twin. */
164
- function numberField(value: unknown, key: string): number | undefined {
165
- if (typeof value !== 'object' || value === null) return undefined;
166
- try {
167
- const held = (value as Record<string, unknown>)[key];
168
- return typeof held === 'number' ? held : undefined;
169
- } catch {
170
- return undefined;
171
- }
172
- }
173
-
174
- /** The provider codes that mean "there is nothing at that key", and nothing wider. */
175
- const ABSENT_OBJECT_CODES: ReadonlySet<string> = new Set(['NoSuchKey', 'NotFound', 'ENOENT']);
176
-
177
100
  /**
178
- * The ONE delete failure the contract calls success. `AccessDenied`, `SlowDown`, an expired
179
- * credential and a reset connection are none of them, and the previous `.catch(() => undefined)`
180
- * reported all four as deleted — which is how an erasure sweep certifies data it never removed.
181
- *
182
- * Read structurally: an `S3Error` is `name: 'S3Error'` with a `code` the service returned, and
183
- * every field of a value this process did not build is a getter that can throw.
101
+ * A missing `LastModified` is ABSENT, never `new Date(0)`: epoch 0 is older than every window, so
102
+ * `sweepOrphans` deleted an upload on the strength of a field the provider never sent.
184
103
  */
185
- function isAbsentObject(error: unknown): boolean {
186
- const code = stringField(error, 'code');
187
- if (code !== undefined && ABSENT_OBJECT_CODES.has(code)) return true;
188
- return numberField(error, 'statusCode') === 404 || numberField(error, 'status') === 404;
189
- }
104
+ const dated = (value: string | Date | undefined): { readonly lastModified?: Date } =>
105
+ value === undefined ? {} : { lastModified: value instanceof Date ? value : new Date(value) };
190
106
 
191
107
  /**
192
108
  * Everything `put()` may be handed that this driver cannot honour, refused before a byte moves.
@@ -194,25 +110,22 @@ function isAbsentObject(error: unknown): boolean {
194
110
  * `PutOptions` so that "this disk cannot prove per-object encryption" is something an engineer
195
111
  * meets while writing the call, not while answering a security review.
196
112
  */
197
- function refuseUnsupportedPut(bucket: string, key: string, putOptions?: PutOptions): void {
198
- if (putOptions?.metadata !== undefined || putOptions?.cacheControl !== undefined) {
199
- const uri = `s3://${bucket}/${key}`;
200
- throw storageNotImplemented(
201
- 'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet)',
202
- `drop metadata/cacheControl from put(), or set them out of band: ` +
203
- `aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
204
- );
205
- }
113
+ function refuseUnsupportedPut(bucket: string, putOptions?: PutOptions): void {
206
114
  const sse = putOptions?.serverSideEncryption;
207
115
  if (sse === undefined) return;
208
- const rule =
209
- sse.algorithm === 'aws:kms'
210
- ? `{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"${sse.kmsKeyId ?? '<key-arn>'}"}`
211
- : '{"SSEAlgorithm":"AES256"}';
212
- throw storageNotImplemented(
213
- 'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption)',
214
- `set it bucket-wide, then drop serverSideEncryption from put(): aws s3api put-bucket-encryption --bucket ${bucket} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":${rule},"BucketKeyEnabled":true}]}'`,
215
- );
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>'");
121
+ throw new NotImplementedError({
122
+ cause:
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()',
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}]}'`,
128
+ });
216
129
  }
217
130
 
218
131
  export function s3Driver(options: S3DriverOptions): StorageDriver {
@@ -224,6 +137,16 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
224
137
  options.maxPutBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : options.maxPutBytes,
225
138
  1,
226
139
  );
140
+ const maxGetBytes = finiteCount(
141
+ 'the s3 driver',
142
+ 'maxGetBytes',
143
+ options.maxGetBytes === undefined ? maxPutBytes : options.maxGetBytes,
144
+ 1,
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;
227
150
  let client: S3ClientLike | undefined;
228
151
  const conn = (): S3ClientLike => {
229
152
  client ??= buildClient(options);
@@ -240,28 +163,68 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
240
163
  const mismatch = regionMismatch(options.bucket, error);
241
164
  if (mismatch !== undefined) throw mismatch;
242
165
  };
243
- const misconfigured = (error: unknown): never => {
244
- throwIfMisconfigured(error);
245
- throw error;
246
- };
166
+ /**
167
+ * A write the provider refused, coded: a denied `s3:PutObject`, a throttle, an expired
168
+ * credential. The bare `S3Error` used to escape `put()` and `copy()` exactly as it once escaped
169
+ * `list()` — no code, no fix, an anonymous 500.
170
+ */
171
+ const refusedWrite =
172
+ (key: string) =>
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;
177
+ throwIfMisconfigured(error);
178
+ throw putFailed(
179
+ DRIVER_NAME,
180
+ key,
181
+ error,
182
+ `aws s3api get-bucket-policy --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")}`,
183
+ );
184
+ };
185
+
186
+ /**
187
+ * A read the provider refused, coded — `refusedWrite`'s twin. A 404 is "not found" wherever it
188
+ * arrives: an object deleted between `exists()` and the read is absent, not unreadable.
189
+ */
190
+ const refusedRead =
191
+ (key: string) =>
192
+ (error: unknown): never => {
193
+ if (isStorageError(error)) throw error;
194
+ if (isAbsentObject(error)) throw objectNotFound(diskName, key);
195
+ throwIfMisconfigured(error);
196
+ throw readFailed(
197
+ DRIVER_NAME,
198
+ key,
199
+ error,
200
+ `aws s3api head-object --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(key, "'<key>'")}`,
201
+ );
202
+ };
203
+ const present = (key: string): Promise<boolean> =>
204
+ conn().file(key).exists().catch(refusedRead(key));
247
205
 
248
206
  const statObject = async (key: string): Promise<StorageObject> => {
249
- const stat = await conn().file(key).stat();
207
+ const stat = await conn().file(key).stat().catch(refusedRead(key));
250
208
  return {
251
209
  key,
252
210
  size: stat.size,
253
211
  contentType: stat.type ?? DEFAULT_CONTENT_TYPE,
254
212
  etag: stat.etag ?? '',
255
- lastModified: toDate(stat.lastModified),
213
+ ...dated(stat.lastModified),
256
214
  };
257
215
  };
258
216
 
259
217
  return {
260
218
  name: DRIVER_NAME,
219
+ registerAs(name: string): void {
220
+ diskName = name;
221
+ },
261
222
 
262
223
  async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
263
224
  const safe = assertSafeKey(key);
264
- refuseUnsupportedPut(options.bucket, safe, putOptions);
225
+ refuseUnsupportedPut(options.bucket, putOptions);
226
+ assertPutOptions(DRIVER_NAME, putOptions);
227
+ assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
265
228
  // Buffered on purpose: size and checksum must be known before the object exists — so this
266
229
  // path is for objects that FIT IN MEMORY, and `maxPutBytes` is what makes that a contract
267
230
  // rather than a hope. User uploads never come through here: they go direct to the bucket
@@ -272,25 +235,49 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
272
235
  const actual = sha256Base64(bytes);
273
236
  if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
274
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
+ }
275
252
  await conn()
276
253
  .file(safe)
277
254
  .write(bytes, { type: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE })
278
- .catch(misconfigured);
255
+ .catch(refusedWrite(safe));
279
256
  return statObject(safe);
280
257
  },
281
258
 
259
+ async stat(key: string): Promise<StorageObject | undefined> {
260
+ const safe = assertSafeKey(key);
261
+ return (await present(safe)) ? statObject(safe) : undefined;
262
+ },
263
+
282
264
  async get(key: string): Promise<StorageRead> {
283
265
  const safe = assertSafeKey(key);
284
266
  const file = conn().file(safe);
285
- if (!(await file.exists())) throw objectNotFound(DRIVER_NAME, safe);
286
- const bytes = new Uint8Array(await file.arrayBuffer());
287
- return { object: await statObject(safe), bytes };
267
+ if (!(await present(safe))) throw objectNotFound(diskName, safe);
268
+ // The HEAD comes FIRST, and it is the whole point: a client PUT straight into the bucket is
269
+ // bounded by nothing on this disk, so `arrayBuffer()` on whatever is there was heap growth
270
+ // the uploader chose. Refused on the provider's own size before a byte is read.
271
+ const object = await statObject(safe);
272
+ if (object.size > maxGetBytes) throw getTooLarge(DRIVER_NAME, safe, object.size, maxGetBytes);
273
+ const bytes = new Uint8Array(await file.arrayBuffer().catch(refusedRead(safe)));
274
+ return { object, bytes };
288
275
  },
289
276
 
290
277
  async stream(key: string): Promise<ReadableStream<Uint8Array>> {
291
278
  const safe = assertSafeKey(key);
292
279
  const file = conn().file(safe);
293
- if (!(await file.exists())) throw objectNotFound(DRIVER_NAME, safe);
280
+ if (!(await present(safe))) throw objectNotFound(diskName, safe);
294
281
  return file.stream();
295
282
  },
296
283
 
@@ -305,12 +292,12 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
305
292
  const source = assertSafeKey(from);
306
293
  const destination = assertSafeKey(to);
307
294
  const file = conn().file(source);
308
- if (!(await file.exists())) throw objectNotFound(DRIVER_NAME, source);
309
- const stat = await file.stat();
295
+ if (!(await present(source))) throw objectNotFound(diskName, source);
296
+ const stat = await file.stat().catch(refusedRead(source));
310
297
  await conn()
311
298
  .file(destination)
312
299
  .write(file, { type: stat.type ?? DEFAULT_CONTENT_TYPE })
313
- .catch(misconfigured);
300
+ .catch(refusedWrite(destination));
314
301
  return statObject(destination);
315
302
  },
316
303
 
@@ -327,19 +314,36 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
327
314
  DRIVER_NAME,
328
315
  safe,
329
316
  error,
330
- `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>'")}`,
331
319
  );
332
320
  }
333
321
  },
334
322
 
335
323
  async exists(key: string): Promise<boolean> {
336
- return conn().file(assertSafeKey(key)).exists();
324
+ return present(assertSafeKey(key));
325
+ },
326
+
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
+ );
337
340
  },
338
341
 
339
- async list(listOptions?: ListOptions): Promise<ListPage> {
342
+ async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
340
343
  const prefix = listOptions?.prefix ?? '';
341
344
  // Refused at the seam both drivers share, before the provider is asked: `maxKeys: 0` used to
342
345
  // go straight through, while the local disk answered a complete-looking empty page.
346
+ assertListOptions(listOptions);
343
347
  const maxKeys = resolveListLimit(listOptions?.limit);
344
348
  // `conn()` OUTSIDE the try: a missing credential or an absent `Bun.S3Client` is this disk
345
349
  // misconfigured, and it already answers with its own code and its own fix.
@@ -349,7 +353,9 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
349
353
  result = await client.list({
350
354
  maxKeys,
351
355
  ...(listOptions?.prefix === undefined ? {} : { prefix: listOptions.prefix }),
352
- ...(listOptions?.cursor === undefined ? {} : { continuationToken: listOptions.cursor }),
356
+ ...(typeof listOptions?.cursor === 'string'
357
+ ? { continuationToken: listOptions.cursor }
358
+ : {}),
353
359
  });
354
360
  } catch (error) {
355
361
  // A bare `S3Error` used to escape here, uncoded: no `X_*`, no `fix`, no `--json` shape, and
@@ -360,7 +366,11 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
360
366
  DRIVER_NAME,
361
367
  prefix,
362
368
  error,
363
- `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>'")}`,
364
374
  );
365
375
  }
366
376
  const objects: StorageListEntry[] = [];
@@ -375,13 +385,16 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
375
385
  key: entry.key,
376
386
  size: entry.size ?? 0,
377
387
  etag: entry.eTag ?? '',
378
- lastModified: toDate(entry.lastModified),
388
+ ...dated(entry.lastModified),
379
389
  });
380
390
  }
381
- const cursor = result.nextContinuationToken;
382
- return result.isTruncated === true && cursor !== undefined
383
- ? { objects, truncated: true, cursor }
384
- : { 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
+ );
385
398
  },
386
399
 
387
400
  /**