@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.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;
@@ -23,7 +24,12 @@ export interface StorageListEntry {
23
24
  /** Absent means the driver's listing does not carry it. Never a fabricated default. */
24
25
  readonly contentType?: string | undefined;
25
26
  readonly etag: string;
26
- readonly lastModified: Date;
27
+ /**
28
+ * ABSENT when the provider did not say — never epoch 0, which every age test reads as "older
29
+ * than any window", and never an invalid `Date`, which is a value a reader has to remember to
30
+ * test. A reader that needs an age handles `undefined`; `sweepOrphans` spares it.
31
+ */
32
+ readonly lastModified?: Date | undefined;
27
33
  /** Present only when the driver actually stored what `put()` was handed — never invented. */
28
34
  readonly cacheControl?: string | undefined;
29
35
  readonly metadata?: Readonly<Record<string, string>> | undefined;
@@ -48,7 +54,12 @@ export interface ServerSideEncryption {
48
54
  readonly kmsKeyId?: string | undefined;
49
55
  }
50
56
 
51
- 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 {
52
63
  readonly contentType?: string | undefined;
53
64
  readonly cacheControl?: string | undefined;
54
65
  readonly metadata?: Readonly<Record<string, string>> | undefined;
@@ -60,18 +71,15 @@ export interface PutOptions {
60
71
 
61
72
  export interface ListOptions {
62
73
  readonly prefix?: string | undefined;
63
- /** Opaque; pass back the `cursor` of the previous page. */
64
- 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;
65
80
  readonly limit?: number | undefined;
66
81
  }
67
82
 
68
- export interface ListPage {
69
- readonly objects: readonly StorageListEntry[];
70
- readonly truncated: boolean;
71
- /** Absent when `truncated` is false. */
72
- readonly cursor?: string | undefined;
73
- }
74
-
75
83
  export interface StorageRead {
76
84
  readonly object: StorageObject;
77
85
  readonly bytes: Uint8Array;
@@ -107,14 +115,27 @@ export interface StorageDriver {
107
115
  */
108
116
  readonly signedUrlBase?: string | undefined;
109
117
  /**
110
- * `defineStorage` tells the driver the key it was registered under, at boot, once. A driver that
111
- * mints its own URLs hangs them off THAT, never off `name`: the mounted `/_storage/:disk/*key`
112
- * route resolves the segment through the registry, so a disk registered as `uploads` whose URLs
113
- * say `local` 404s every signature it just wrote. Optional — a driver with no URLs of its own
114
- * (`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`.
115
125
  */
116
126
  registerAs?(diskName: string): void;
117
127
  put(key: string, body: StorageBody, options?: PutOptions): Promise<StorageObject>;
128
+ /**
129
+ * What a read would say about the object, WITHOUT its bytes — `undefined` when nothing is there.
130
+ * `promoteAttachment` decides on `stat().size`, because a presigned PUT lands on the disk
131
+ * unmeasured and `get()` is the one call that must not be how its size is learned. Required: a
132
+ * driver that cannot measure cannot be promoted from, and that is a compile error, not a refusal.
133
+ */
134
+ stat(key: string): Promise<StorageObject | undefined>;
135
+ /**
136
+ * The whole object, BUFFERED — so bounded: an object over the driver's `maxGetBytes` is
137
+ * `X_STORAGE_TOO_LARGE`, exactly as `put()` refuses a body over `maxPutBytes`. Past it, `stream()`.
138
+ */
118
139
  get(key: string): Promise<StorageRead>;
119
140
  /** Bytes without buffering — the only safe path for anything over a few MB. */
120
141
  stream(key: string): Promise<ReadableStream<Uint8Array>>;
@@ -131,8 +152,20 @@ export interface StorageDriver {
131
152
  */
132
153
  delete(key: string): Promise<void>;
133
154
  exists(key: string): Promise<boolean>;
134
- 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>>;
135
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>;
136
169
  /**
137
170
  * Verify a URL THIS disk minted — without surrendering the key it minted with.
138
171
  *
@@ -164,7 +197,7 @@ export const DEFAULT_LIST_LIMIT = 1000;
164
197
  * The page size a `list()` honours — refused rather than clamped when it cannot be one.
165
198
  *
166
199
  * At the `ListOptions` seam and not inside a driver, because the two answered a non-positive
167
- * 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
168
201
  * `list({ limit: 0 })` over a full disk reported a COMPLETE empty listing, while the s3 disk
169
202
  * handed `maxKeys: 0` straight to the provider. `sweepOrphans` pages through `list()`, so a page
170
203
  * that is empty and claims to be complete is a false erasure report — the same lie a swallowed
@@ -181,6 +214,45 @@ export function resolveListLimit(limit: number | undefined): number {
181
214
  return limit;
182
215
  }
183
216
 
217
+ const isStringRecord = (value: unknown): boolean =>
218
+ typeof value === 'object' &&
219
+ value !== null &&
220
+ !Array.isArray(value) &&
221
+ Object.values(value).every((entry) => typeof entry === 'string');
222
+
223
+ /**
224
+ * What `put()` was told ABOUT the bytes, held to its type before a byte moves. An untyped caller's
225
+ * `metadata: { n: 1n }` reached `JSON.stringify` on the local disk as a bare `TypeError`, and the
226
+ * memory disk stored it — against the `Record<string, string>` every reader is typed on.
227
+ */
228
+ export function assertPutOptions(driver: string, options: PutOptions | undefined): void {
229
+ if (options === undefined) return;
230
+ for (const name of ['contentType', 'cacheControl'] as const) {
231
+ assert(
232
+ options[name] === undefined || typeof options[name] === 'string',
233
+ `put() on the ${driver} disk was given a ${name} that is not a string`,
234
+ `put(key, body, { ${name}: 'a string' }) — or omit ${name}`,
235
+ );
236
+ }
237
+ assert(
238
+ options.metadata === undefined || isStringRecord(options.metadata),
239
+ `put() on the ${driver} disk was given metadata that is not a plain object of string values`,
240
+ "put(key, body, { metadata: { name: 'value' } }) — every value a string: String(n) a number, JSON.stringify anything nested",
241
+ );
242
+ }
243
+
244
+ /** A `prefix` or a `cursor` that is not a string reached `startsWith` and `<=` as whatever it was. */
245
+ export function assertListOptions(options: ListOptions | undefined): void {
246
+ for (const name of ['prefix', 'cursor'] as const) {
247
+ const value = options?.[name];
248
+ assert(
249
+ value === undefined || typeof value === 'string' || (name === 'cursor' && value === null),
250
+ `list() was given a ${name} that is not a string`,
251
+ `disk.list({ ${name}: 'a string' }) — or omit ${name}`,
252
+ );
253
+ }
254
+ }
255
+
184
256
  /** What `toBytes` needs to refuse a body: the ceiling, and the key to name in the refusal. */
185
257
  export interface ByteLimit {
186
258
  readonly driver: string;
@@ -212,6 +284,13 @@ export async function toBytes(body: StorageBody, limit: ByteLimit): Promise<Uint
212
284
  }
213
285
  return new Uint8Array(await body.arrayBuffer());
214
286
  }
287
+ // The type says so; an untyped caller's `put(key, 'a string')` reached `getReader` as a
288
+ // bare `TypeError`.
289
+ assert(
290
+ body instanceof ReadableStream,
291
+ `put() on the ${limit.driver} disk was given a body that is not a Uint8Array, a Blob or a ReadableStream`,
292
+ "put(key, new TextEncoder().encode(text)) — bytes, never a string: encode it, or pass the request's own body stream",
293
+ );
215
294
  return readBounded(body, limit);
216
295
  }
217
296
 
package/src/errors.ts CHANGED
@@ -2,7 +2,12 @@
2
2
  // rejected upload must tell the caller which constraint fired and where that constraint is
3
3
  // configured, or the caller retries the same bytes forever.
4
4
 
5
- import { registerErrorCodes, renderThrowable, UltimateError } from '@ultimat3/core';
5
+ import {
6
+ registerErrorCodes,
7
+ renderFixLiteral,
8
+ renderThrowable,
9
+ UltimateError,
10
+ } from '@ultimat3/core';
6
11
 
7
12
  /** Codes this package declares and owns. */
8
13
  export const STORAGE_OWNED_ERROR_CODES = [
@@ -21,11 +26,15 @@ export const STORAGE_OWNED_ERROR_CODES = [
21
26
  'X_STORAGE_LIST_FAILED',
22
27
  'X_STORAGE_QUARANTINED',
23
28
  'X_STORAGE_NOT_PENDING',
29
+ 'X_STORAGE_KEY_CONFLICT',
30
+ 'X_STORAGE_PUT_FAILED',
31
+ 'X_STORAGE_READ_FAILED',
32
+ 'X_STORAGE_OBJECT_LOCKED',
24
33
  ] as const;
25
34
 
26
35
  /**
27
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `storageNotImplemented()` throws it and this package
28
- * keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
36
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. The drivers throw core's own `NotImplementedError`
37
+ * and this package keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
29
38
  * `X_IMAGE_UNSUPPORTED` / `X_IMAGE_DECODE_FAILED` are core's too and surface unwrapped (`image.ts`).
30
39
  * `X_ENV_MISSING` is core's for the same reason: an unset `STORAGE_SIGNING_SECRET` outside
31
40
  * development is a missing environment variable, not a storage concept needing its own code.
@@ -57,6 +66,10 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
57
66
  X_STORAGE_LIST_FAILED: 'the objects could not be listed',
58
67
  X_STORAGE_QUARANTINED: 'the object is still in quarantine',
59
68
  X_STORAGE_NOT_PENDING: 'the key is not a pending upload',
69
+ X_STORAGE_KEY_CONFLICT: 'the key collides with another key on a local or memory disk',
70
+ X_STORAGE_PUT_FAILED: 'the object could not be written',
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',
60
73
  };
61
74
 
62
75
  // One unconditional call, so a second package claiming one of storage's codes throws
@@ -156,11 +169,55 @@ export const putTooLarge = (
156
169
  (disk === 's3'
157
170
  ? `s3Driver({ bucket, maxPutBytes: ${bytes} })`
158
171
  : disk === 'memory'
159
- ? `memoryDriver({ maxPutBytes: ${bytes} })`
172
+ ? `memoryStorageDriver({ maxPutBytes: ${bytes} })`
160
173
  : `localDriver({ root, maxPutBytes: ${bytes} })`),
161
174
  meta: { disk, key, bytes, maxBytes },
162
175
  });
163
176
 
177
+ /**
178
+ * The SERVER-side `get()` ceiling — `putTooLarge`'s twin. `get()` buffers the whole object, and an
179
+ * object's size is not this process's to choose: a presigned PUT lands in the bucket unmeasured,
180
+ * so a later `get()` of it was heap growth the uploader picked.
181
+ */
182
+ export const getTooLarge = (
183
+ disk: string,
184
+ key: string,
185
+ bytes: number,
186
+ maxBytes: number,
187
+ ): StorageError =>
188
+ new StorageError({
189
+ code: 'X_STORAGE_TOO_LARGE',
190
+ cause: `"${key}" is ${bytes}B against the ${disk} disk's get ceiling of ${maxBytes}B, and get() buffers the whole object`,
191
+ fix:
192
+ `read it with disk.stream(key) instead of get(), or raise the ceiling: ` +
193
+ (disk === 's3'
194
+ ? `s3Driver({ bucket, maxGetBytes: ${bytes} })`
195
+ : disk === 'memory'
196
+ ? `memoryStorageDriver({ maxGetBytes: ${bytes} })`
197
+ : `localDriver({ root, maxGetBytes: ${bytes} })`),
198
+ meta: { disk, key, bytes, maxBytes },
199
+ });
200
+
201
+ /**
202
+ * The local disk stores an object as a FILE at its key, so a key cannot also be the directory of
203
+ * another: `put('a')` then `put('a/b')`, or the reverse, or the same pair one tree over in the
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.
207
+ *
208
+ * The `fix` lists what is in the way: the remedy is storing one of the two under another key, and
209
+ * which one is only decidable by a reader who can see both.
210
+ */
211
+ export const keyConflict = (disk: string, key: string, blocking: string): StorageError =>
212
+ new StorageError({
213
+ code: 'X_STORAGE_KEY_CONFLICT',
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`,
215
+ // The REGISTERED name, never the driver kind: `disk('local')` on a disk registered as
216
+ // `uploads` lists another disk, or answers X_STORAGE_DISK_UNKNOWN.
217
+ fix: `disk(${renderFixLiteral(disk, "'local'")}).list({ prefix: ${renderFixLiteral(blocking, "'a/'")} })`,
218
+ meta: { disk, key, blocking },
219
+ });
220
+
164
221
  /** The declared type is not on the allowlist at all. */
165
222
  export const contentTypeNotAllowed = (
166
223
  key: string,
@@ -204,6 +261,20 @@ export const contentTypeUnrecognised = (key: string, declared: string): StorageE
204
261
  meta: { key, declared },
205
262
  });
206
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
+
207
278
  export const checksumMismatch = (key: string, declared: string, actual: string): StorageError =>
208
279
  new StorageError({
209
280
  code: 'X_STORAGE_CHECKSUM_MISMATCH',
@@ -258,6 +329,43 @@ export const deleteFailed = (
258
329
  });
259
330
  };
260
331
 
332
+ /**
333
+ * A write the disk REFUSED — `deleteFailed`'s twin for `put()` and `copy()`. The local disk's
334
+ * `EACCES` / `ENOSPC` / `EROFS` left as a bare filesystem `Error`, and a provider's refused PUT as
335
+ * a bare `S3Error`: no code, no fix, nothing for the http error map to render but an anonymous 500.
336
+ * The `fix` is the driver's, for `deleteFailed`'s reason.
337
+ */
338
+ export const putFailed = (disk: string, key: string, error: unknown, fix: string): StorageError => {
339
+ const reason = renderThrowable(error);
340
+ return new StorageError({
341
+ code: 'X_STORAGE_PUT_FAILED',
342
+ cause: `disk "${disk}" refused to write "${key}": ${reason}`,
343
+ fix,
344
+ meta: { disk, key, reason },
345
+ });
346
+ };
347
+
348
+ /**
349
+ * A read the disk REFUSED — `putFailed`'s read-side twin, for `get`, `stat`, `exists`, `stream` and
350
+ * the read half of `copy`. A denied `s3:GetObject`, a throttle, `EACCES`, `EIO`. An object that is
351
+ * simply absent is `objectNotFound`, never this: "not there" and "could not look" need opposite
352
+ * answers from a caller. The `fix` is the driver's, for `deleteFailed`'s reason.
353
+ */
354
+ export const readFailed = (
355
+ disk: string,
356
+ key: string,
357
+ error: unknown,
358
+ fix: string,
359
+ ): StorageError => {
360
+ const reason = renderThrowable(error);
361
+ return new StorageError({
362
+ code: 'X_STORAGE_READ_FAILED',
363
+ cause: `disk "${disk}" refused to read "${key}": ${reason}`,
364
+ fix,
365
+ meta: { disk, key, reason },
366
+ });
367
+ };
368
+
261
369
  /**
262
370
  * A listing the disk REFUSED — a denied `s3:ListBucket`, a throttle, an expired credential, a root
263
371
  * this process cannot read. Exactly `deleteFailed`'s shape and for exactly its reason: an empty
@@ -363,12 +471,3 @@ export const signingSecretMissing = (environment: string, disk = 'local'): Stora
363
471
  fix: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
364
472
  meta: { key: 'STORAGE_SIGNING_SECRET', environment },
365
473
  });
366
-
367
- /** An interface-complete driver whose remote half is not bound yet. Always carries a fix. */
368
- export const storageNotImplemented = (feature: string, fix: string): StorageError =>
369
- new StorageError({
370
- code: 'X_NOT_IMPLEMENTED',
371
- cause: `${feature} is declared but not implemented in @ultimat3/storage`,
372
- fix,
373
- meta: { feature },
374
- });
package/src/grant.ts CHANGED
@@ -83,12 +83,12 @@ export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant>
83
83
  }
84
84
 
85
85
  // Refused here as well as in the presigner one call down: `expiresAt: now + NaN` is `NaN` in the
86
- // grant this function RETURNS, and the message a caller can act on names `createUploadGrant`'s
86
+ // grant this function RETURNS, and the message a caller can act on names `grantUpload`'s
87
87
  // own option rather than `buildSignedUrl`'s.
88
88
  // `=== undefined`, never `??`, for the reason `buildSignedUrl` states: `??` coalesces on `null`
89
89
  // too, so an explicit one took the default instead of this refusal.
90
90
  const expiresInMs = finiteCount(
91
- 'createUploadGrant',
91
+ 'grantUpload',
92
92
  'expiresInMs',
93
93
  input.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : input.expiresInMs,
94
94
  1,
package/src/image.ts CHANGED
@@ -6,12 +6,13 @@
6
6
  import {
7
7
  assertFiniteImageQuality,
8
8
  blurDataUrl,
9
+ finiteCount,
9
10
  type ImageFormat,
10
11
  imageUnsupported,
11
12
  probeImage,
12
13
  transformImageBytes,
13
14
  } from '@ultimat3/core';
14
- import { assertSafeKey, keyExtname } from './path';
15
+ import { assertSafeKey } from './path';
15
16
 
16
17
  /**
17
18
  * The formats a stored VARIANT can be minted in — a strict subset of the ones `@ultimat3/core`
@@ -67,13 +68,28 @@ const FORMAT_EXTENSIONS: Readonly<Record<VariantFormat, string>> = {
67
68
  png: 'png',
68
69
  };
69
70
 
71
+ /**
72
+ * One edge of a variant box: a whole number of pixels, at least one. Core's `finiteCount`, for the
73
+ * reason `quality` goes through core's screen below — `{ width: NaN }` minted `a@wNaN.webp`, a
74
+ * writable key for bytes no encoder can produce, and `w1.5` / `w-3` are an unbounded number of
75
+ * keys in a bucket. `!== undefined`, never a truthiness test: an explicit `null` is refused.
76
+ */
77
+ function edge(subject: string, option: 'width' | 'height', value: number | undefined): void {
78
+ if (value !== undefined) finiteCount(subject, option, value, 1);
79
+ }
80
+
70
81
  /**
71
82
  * Derived, not stored: the same source + transform always yields the same key, so a variant
72
83
  * is a cache lookup rather than a database row.
84
+ *
85
+ * The WHOLE source key is the stem, extension included. It used to be cut, so `p/hero.png` and
86
+ * `p/hero.jpg` minted one `p/hero@w100.webp` and whichever was requested first was served for
87
+ * both — two sources, one cache identity.
73
88
  */
74
89
  export function variantKey(sourceKey: string, transform: ImageTransform): string {
75
90
  const safe = assertSafeKey(sourceKey);
76
- const stem = safe.slice(0, safe.length - keyExtname(safe).length);
91
+ edge('variantKey', 'width', transform.width);
92
+ edge('variantKey', 'height', transform.height);
77
93
  // `string`, not `VariantFormat`: a format off a query string or through an `as` reaches here as
78
94
  // any string core can probe, and indexing FORMAT_EXTENSIONS with `gif` minted
79
95
  // `photos/hero@full.undefined` — a well-formed, writable key naming a file nothing can serve.
@@ -101,7 +117,7 @@ export function variantKey(sourceKey: string, transform: ImageTransform): string
101
117
  );
102
118
  if (quality !== DEFAULT_QUALITY) parts.push(`q${quality}`);
103
119
  if (parts.length === 0) parts.push('full');
104
- return assertSafeKey(`${stem}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
120
+ return assertSafeKey(`${safe}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
105
121
  }
106
122
 
107
123
  export interface SrcsetDescriptor {
@@ -139,23 +155,33 @@ export function srcsetDescriptors(
139
155
  }));
140
156
  }
141
157
 
142
- /** Aspect-ratio fitting. `cover` rounds up so the box is always fully covered. */
158
+ /** A derived edge is never zero: 1000x1 at width 100 rounded to a height no encoder can fill. */
159
+ const atLeastOne = (pixels: number): number => Math.max(1, Math.round(pixels));
160
+
161
+ /**
162
+ * Aspect-ratio fitting. `cover` is the exact box asked for; everything else fits INSIDE the box
163
+ * and never above the source — upscaling is never useful, whichever edges were named.
164
+ */
143
165
  export function fitDimensions(source: ImageSize, transform: ImageTransform): ImageSize {
144
166
  const ratio = source.height / source.width;
145
167
  const { width, height } = transform;
168
+ edge('fitDimensions', 'width', width);
169
+ edge('fitDimensions', 'height', height);
146
170
  if (width === undefined && height === undefined) return source;
147
171
  if (height === undefined) {
148
172
  const target = Math.min(width ?? source.width, source.width);
149
- return { width: target, height: Math.round(target * ratio) };
173
+ return { width: target, height: atLeastOne(target * ratio) };
150
174
  }
151
175
  if (width === undefined) {
152
176
  const target = Math.min(height, source.height);
153
- return { width: Math.round(target / ratio), height: target };
177
+ return { width: atLeastOne(target / ratio), height: target };
154
178
  }
155
179
  // `cover` crops to the exact box; `contain` scales down until both sides fit.
156
180
  if (transform.fit === 'cover') return { width, height };
157
- const scale = Math.min(width / source.width, height / source.height);
158
- return { width: Math.round(source.width * scale), height: Math.round(source.height * scale) };
181
+ // Clamped at 1: a single named edge already stopped at the source, and two of them did not —
182
+ // a 40x20 source asked for 400x400 came back 400x200.
183
+ const scale = Math.min(1, width / source.width, height / source.height);
184
+ return { width: atLeastOne(source.width * scale), height: atLeastOne(source.height * scale) };
159
185
  }
160
186
 
161
187
  /**
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,
@@ -43,33 +42,30 @@ export type {
43
42
  StorageRead,
44
43
  } from './driver';
45
44
  export {
45
+ // Exported for the same reason `toBytes` is: a driver written outside this package has to
46
+ // refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
47
+ assertPutOptions,
46
48
  DEFAULT_CONTENT_TYPE,
47
49
  DEFAULT_LIST_LIMIT,
48
50
  etagOf,
49
- // Exported for the same reason `toBytes` is: a driver written outside this package has to
50
- // refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
51
51
  resolveListLimit,
52
52
  sha256Base64,
53
53
  toBytes,
54
54
  } from './driver';
55
55
  export type { LocalDriverOptions } from './driver-local';
56
- export {
57
- DEV_SIGNING_SECRET,
58
- localDriver,
59
- STORAGE_SIGNING_SECRET_KEY,
60
- usesDevStorageSecret,
61
- } from './driver-local';
62
- export type { MemoryDriverOptions, MemoryStorageDriver } from './driver-memory';
63
- export { memoryDriver } from './driver-memory';
56
+ export { localDriver } from './driver-local';
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';
64
61
  export type {
65
62
  S3ClientLike,
66
- S3DriverOptions,
67
63
  S3FileLike,
68
64
  S3ListEntryLike,
69
65
  S3ListResultLike,
70
66
  S3StatLike,
71
- } from './driver-s3';
72
- export { s3Driver } from './driver-s3';
67
+ } from './driver-s3-client';
68
+ export type { S3FetchLike } from './driver-s3-signed';
73
69
  export type { StorageErrorCode, StorageErrorInit } from './errors';
74
70
  export {
75
71
  checksumMismatch,
@@ -78,13 +74,17 @@ export {
78
74
  contentTypeUnrecognised,
79
75
  deleteFailed,
80
76
  diskUnknown,
77
+ getTooLarge,
81
78
  isStorageError,
79
+ keyConflict,
82
80
  listFailed,
83
81
  objectNotFound,
84
82
  orgMismatch,
85
83
  pathUnsafe,
84
+ putFailed,
86
85
  putTooLarge,
87
86
  quarantined,
87
+ readFailed,
88
88
  STORAGE_ERROR_CODES,
89
89
  STORAGE_ERROR_TITLES,
90
90
  StorageError,
@@ -92,9 +92,9 @@ export {
92
92
  signedUrlRejected,
93
93
  signedUrlUnverifiable,
94
94
  signingSecretMissing,
95
- storageNotImplemented,
96
95
  tooLarge,
97
96
  uploadFailed,
97
+ xmlBodyUnreadable,
98
98
  } from './errors';
99
99
  export type { GrantUploadInput, UploadGrant, UploadRequest } from './grant';
100
100
  export { grantUpload } from './grant';
@@ -120,6 +120,14 @@ export {
120
120
  VARIANT_FORMATS,
121
121
  variantKey,
122
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';
123
131
 
124
132
  export {
125
133
  assertSafeKey,
@@ -152,9 +160,14 @@ export {
152
160
  SIGNED_URL_VERSION,
153
161
  signConstraints,
154
162
  signedUrlBaseFor,
155
- timingSafeEqual,
163
+ signedUrlBasePath,
156
164
  verifySignedUrl,
157
165
  } from './signed-url';
166
+ export {
167
+ DEV_SIGNING_SECRET,
168
+ STORAGE_SIGNING_SECRET_KEY,
169
+ usesDevStorageSecret,
170
+ } from './signing-secret';
158
171
  export type { Storage, StorageConfig } from './storage';
159
172
  export { definedStorage, defineStorage, disk, resetStorage, storage } from './storage';
160
173
  export type { UploadCandidate, UploadPolicy, UploadPolicyInit, ValidatedUpload } from './upload';
@@ -0,0 +1,50 @@
1
+ // Single responsibility: which media type an ISO base media file is. Every MP4, MOV, M4A, HEIC
2
+ // and AVIF opens with the same `ftyp` box, so the box alone says "container" — the four-byte MAJOR
3
+ // BRAND after it is what says which one, and reading only the box labelled all of them `video/mp4`.
4
+
5
+ const ascii = (bytes: Uint8Array, offset: number): string =>
6
+ String.fromCharCode(...bytes.subarray(offset, offset + 4));
7
+
8
+ /** The type a major brand cannot narrow: a container, as `application/zip` is one for OOXML. */
9
+ export const ISO_BMFF_CONTAINER = 'video/mp4';
10
+
11
+ /** HEIF's structural brands — a still HEIC and a still AVIF both ship under `mif1`. */
12
+ export const HEIF_CONTAINER = 'image/heif';
13
+
14
+ // A `Map`, never an object literal: the brand is four bytes off an upload, and `BRANDS['cons']`
15
+ // is harmless only until a brand spells a prototype key.
16
+ const BRANDS: ReadonlyMap<string, string> = new Map([
17
+ ['avif', 'image/avif'],
18
+ ['avis', 'image/avif'],
19
+ ['heic', 'image/heic'],
20
+ ['heix', 'image/heic'],
21
+ ['heim', 'image/heic'],
22
+ ['heis', 'image/heic'],
23
+ ['hevc', 'image/heic'],
24
+ ['hevx', 'image/heic'],
25
+ ['mif1', HEIF_CONTAINER],
26
+ ['msf1', HEIF_CONTAINER],
27
+ ['qt ', 'video/quicktime'],
28
+ ['M4A ', 'audio/mp4'],
29
+ ['M4B ', 'audio/mp4'],
30
+ ['M4P ', 'audio/mp4'],
31
+ ['3gp4', 'video/3gpp'],
32
+ ['3gp5', 'video/3gpp'],
33
+ ['3gp6', 'video/3gpp'],
34
+ ['3g2a', 'video/3gpp2'],
35
+ ]);
36
+
37
+ /** Every type `sniffIsoBmff` can answer — what `validateUpload` reads "could confirm" from. */
38
+ export const ISO_BMFF_TYPES: readonly string[] = [
39
+ ...new Set([ISO_BMFF_CONTAINER, ...BRANDS.values()]),
40
+ ];
41
+
42
+ /**
43
+ * `undefined` when the bytes do not open with a `ftyp` box carrying a brand. A brand this table
44
+ * does not list answers the container: it is an ISO base media file, which is all the box proves,
45
+ * and that is the answer every brand got before this file existed.
46
+ */
47
+ export function sniffIsoBmff(bytes: Uint8Array): string | undefined {
48
+ if (bytes.byteLength < 12 || ascii(bytes, 4) !== 'ftyp') return undefined;
49
+ return BRANDS.get(ascii(bytes, 8)) ?? ISO_BMFF_CONTAINER;
50
+ }