@ultimat3/storage 22.15.0 → 24.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/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,14 @@ 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',
24
32
  ] as const;
25
33
 
26
34
  /**
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.
35
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. The drivers throw core's own `NotImplementedError`
36
+ * and this package keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
29
37
  * `X_IMAGE_UNSUPPORTED` / `X_IMAGE_DECODE_FAILED` are core's too and surface unwrapped (`image.ts`).
30
38
  * `X_ENV_MISSING` is core's for the same reason: an unset `STORAGE_SIGNING_SECRET` outside
31
39
  * development is a missing environment variable, not a storage concept needing its own code.
@@ -57,6 +65,9 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
57
65
  X_STORAGE_LIST_FAILED: 'the objects could not be listed',
58
66
  X_STORAGE_QUARANTINED: 'the object is still in quarantine',
59
67
  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_PUT_FAILED: 'the object could not be written',
70
+ X_STORAGE_READ_FAILED: 'the object could not be read',
60
71
  };
61
72
 
62
73
  // One unconditional call, so a second package claiming one of storage's codes throws
@@ -155,10 +166,55 @@ export const putTooLarge = (
155
166
  `send it direct with grantUpload({ disk, orgId, request }) instead of put(), or raise the ceiling: ` +
156
167
  (disk === 's3'
157
168
  ? `s3Driver({ bucket, maxPutBytes: ${bytes} })`
158
- : `localDriver({ root, maxPutBytes: ${bytes} })`),
169
+ : disk === 'memory'
170
+ ? `memoryDriver({ maxPutBytes: ${bytes} })`
171
+ : `localDriver({ root, maxPutBytes: ${bytes} })`),
159
172
  meta: { disk, key, bytes, maxBytes },
160
173
  });
161
174
 
175
+ /**
176
+ * The SERVER-side `get()` ceiling — `putTooLarge`'s twin. `get()` buffers the whole object, and an
177
+ * object's size is not this process's to choose: a presigned PUT lands in the bucket unmeasured,
178
+ * so a later `get()` of it was heap growth the uploader picked.
179
+ */
180
+ export const getTooLarge = (
181
+ disk: string,
182
+ key: string,
183
+ bytes: number,
184
+ maxBytes: number,
185
+ ): StorageError =>
186
+ new StorageError({
187
+ code: 'X_STORAGE_TOO_LARGE',
188
+ cause: `"${key}" is ${bytes}B against the ${disk} disk's get ceiling of ${maxBytes}B, and get() buffers the whole object`,
189
+ fix:
190
+ `read it with disk.stream(key) instead of get(), or raise the ceiling: ` +
191
+ (disk === 's3'
192
+ ? `s3Driver({ bucket, maxGetBytes: ${bytes} })`
193
+ : disk === 'memory'
194
+ ? `memoryDriver({ maxGetBytes: ${bytes} })`
195
+ : `localDriver({ root, maxGetBytes: ${bytes} })`),
196
+ meta: { disk, key, bytes, maxBytes },
197
+ });
198
+
199
+ /**
200
+ * The local disk stores an object as a FILE at its key, so a key cannot also be the directory of
201
+ * 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
+ *
205
+ * The `fix` lists what is in the way: the remedy is storing one of the two under another key, and
206
+ * which one is only decidable by a reader who can see both.
207
+ */
208
+ export const keyConflict = (disk: string, key: string, blocking: string): StorageError =>
209
+ new StorageError({
210
+ 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`,
212
+ // The REGISTERED name, never the driver kind: `disk('local')` on a disk registered as
213
+ // `uploads` lists another disk, or answers X_STORAGE_DISK_UNKNOWN.
214
+ fix: `disk(${renderFixLiteral(disk, "'local'")}).list({ prefix: ${renderFixLiteral(blocking, "'a/'")} })`,
215
+ meta: { disk, key, blocking },
216
+ });
217
+
162
218
  /** The declared type is not on the allowlist at all. */
163
219
  export const contentTypeNotAllowed = (
164
220
  key: string,
@@ -256,6 +312,43 @@ export const deleteFailed = (
256
312
  });
257
313
  };
258
314
 
315
+ /**
316
+ * A write the disk REFUSED — `deleteFailed`'s twin for `put()` and `copy()`. The local disk's
317
+ * `EACCES` / `ENOSPC` / `EROFS` left as a bare filesystem `Error`, and a provider's refused PUT as
318
+ * a bare `S3Error`: no code, no fix, nothing for the http error map to render but an anonymous 500.
319
+ * The `fix` is the driver's, for `deleteFailed`'s reason.
320
+ */
321
+ export const putFailed = (disk: string, key: string, error: unknown, fix: string): StorageError => {
322
+ const reason = renderThrowable(error);
323
+ return new StorageError({
324
+ code: 'X_STORAGE_PUT_FAILED',
325
+ cause: `disk "${disk}" refused to write "${key}": ${reason}`,
326
+ fix,
327
+ meta: { disk, key, reason },
328
+ });
329
+ };
330
+
331
+ /**
332
+ * A read the disk REFUSED — `putFailed`'s read-side twin, for `get`, `stat`, `exists`, `stream` and
333
+ * the read half of `copy`. A denied `s3:GetObject`, a throttle, `EACCES`, `EIO`. An object that is
334
+ * simply absent is `objectNotFound`, never this: "not there" and "could not look" need opposite
335
+ * answers from a caller. The `fix` is the driver's, for `deleteFailed`'s reason.
336
+ */
337
+ export const readFailed = (
338
+ disk: string,
339
+ key: string,
340
+ error: unknown,
341
+ fix: string,
342
+ ): StorageError => {
343
+ const reason = renderThrowable(error);
344
+ return new StorageError({
345
+ code: 'X_STORAGE_READ_FAILED',
346
+ cause: `disk "${disk}" refused to read "${key}": ${reason}`,
347
+ fix,
348
+ meta: { disk, key, reason },
349
+ });
350
+ };
351
+
259
352
  /**
260
353
  * A listing the disk REFUSED — a denied `s3:ListBucket`, a throttle, an expired credential, a root
261
354
  * this process cannot read. Exactly `deleteFailed`'s shape and for exactly its reason: an empty
@@ -352,21 +445,12 @@ export const signedUrlUnverifiable = (diskName: string): StorageError =>
352
445
  meta: { disk: diskName },
353
446
  });
354
447
 
355
- export const signingSecretMissing = (environment: string): StorageError =>
448
+ export const signingSecretMissing = (environment: string, disk = 'local'): StorageError =>
356
449
  new StorageError({
357
450
  code: 'X_ENV_MISSING',
358
451
  // The environment names what `resolveEnvironment()` resolved, which may have come from
359
452
  // NODE_ENV — naming ULTIMATE_ENV here reported a variable the process never set.
360
- cause: `the local disk has no usable signing secret (no signingSecret option, and STORAGE_SIGNING_SECRET is unset, empty or the published development key) and the resolved environment is "${environment}", so it would sign URLs with the shipped development key`,
453
+ cause: `the ${disk} disk has no usable signing secret (no signingSecret option, and STORAGE_SIGNING_SECRET is unset, empty or the published development key) and the resolved environment is "${environment}", so it would sign URLs with the shipped development key`,
361
454
  fix: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
362
455
  meta: { key: 'STORAGE_SIGNING_SECRET', environment },
363
456
  });
364
-
365
- /** An interface-complete driver whose remote half is not bound yet. Always carries a fix. */
366
- export const storageNotImplemented = (feature: string, fix: string): StorageError =>
367
- new StorageError({
368
- code: 'X_NOT_IMPLEMENTED',
369
- cause: `${feature} is declared but not implemented in @ultimat3/storage`,
370
- fix,
371
- meta: { feature },
372
- });
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
@@ -43,22 +43,20 @@ export type {
43
43
  StorageRead,
44
44
  } from './driver';
45
45
  export {
46
+ // Exported for the same reason `toBytes` is: a driver written outside this package has to
47
+ // refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
48
+ assertPutOptions,
46
49
  DEFAULT_CONTENT_TYPE,
47
50
  DEFAULT_LIST_LIMIT,
48
51
  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
52
  resolveListLimit,
52
53
  sha256Base64,
53
54
  toBytes,
54
55
  } from './driver';
55
56
  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';
57
+ export { localDriver } from './driver-local';
58
+ export type { MemoryDriverOptions, MemoryStorageDriver } from './driver-memory';
59
+ export { memoryDriver } from './driver-memory';
62
60
  export type {
63
61
  S3ClientLike,
64
62
  S3DriverOptions,
@@ -76,13 +74,17 @@ export {
76
74
  contentTypeUnrecognised,
77
75
  deleteFailed,
78
76
  diskUnknown,
77
+ getTooLarge,
79
78
  isStorageError,
79
+ keyConflict,
80
80
  listFailed,
81
81
  objectNotFound,
82
82
  orgMismatch,
83
83
  pathUnsafe,
84
+ putFailed,
84
85
  putTooLarge,
85
86
  quarantined,
87
+ readFailed,
86
88
  STORAGE_ERROR_CODES,
87
89
  STORAGE_ERROR_TITLES,
88
90
  StorageError,
@@ -90,7 +92,6 @@ export {
90
92
  signedUrlRejected,
91
93
  signedUrlUnverifiable,
92
94
  signingSecretMissing,
93
- storageNotImplemented,
94
95
  tooLarge,
95
96
  uploadFailed,
96
97
  } from './errors';
@@ -150,9 +151,15 @@ export {
150
151
  SIGNED_URL_VERSION,
151
152
  signConstraints,
152
153
  signedUrlBaseFor,
154
+ signedUrlBasePath,
153
155
  timingSafeEqual,
154
156
  verifySignedUrl,
155
157
  } from './signed-url';
158
+ export {
159
+ DEV_SIGNING_SECRET,
160
+ STORAGE_SIGNING_SECRET_KEY,
161
+ usesDevStorageSecret,
162
+ } from './signing-secret';
156
163
  export type { Storage, StorageConfig } from './storage';
157
164
  export { definedStorage, defineStorage, disk, resetStorage, storage } from './storage';
158
165
  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
+ }
package/src/path.ts CHANGED
@@ -42,6 +42,9 @@ function hasControlByte(key: string): boolean {
42
42
  }
43
43
 
44
44
  function unsafeReason(key: string): string | undefined {
45
+ // The type says string; a key off an untyped caller is whatever it is, and `.length` on
46
+ // `undefined` was a bare `TypeError` out of every driver method at once.
47
+ if (typeof key !== 'string') return 'is not a string';
45
48
  if (key.length === 0) return 'is empty';
46
49
  const bytes = utf8Bytes(key);
47
50
  if (bytes > MAX_KEY_LENGTH) {
@@ -110,9 +113,21 @@ export function scopedKey(orgId: string, ...parts: readonly string[]): string {
110
113
  return joinKey(ORG_PREFIX, assertOrgId(orgId), ...parts);
111
114
  }
112
115
 
113
- /** Guard for read paths: a key handed in by a client must still belong to the actor's org. */
116
+ /**
117
+ * Guard for read paths: a key handed in by a client must still belong to the actor's org.
118
+ *
119
+ * A PREDICATE, so it answers and never throws. An org id that cannot be one — empty, or carrying
120
+ * a separator — has no keys inside it, and that is `false`: it used to raise
121
+ * `X_STORAGE_PATH_UNSAFE` through `orgPrefix`, blaming the KEY for an actor with no org, and every
122
+ * caller that knew wrote `orgId === '' ||` in front of it while the ones that did not threw.
123
+ */
114
124
  export function isWithinOrg(key: string, orgId: string): boolean {
115
- return isSafeKey(key) && key.startsWith(orgPrefix(orgId));
125
+ // `typeof`, because the caller is a guard on a request: an actor whose org claim is `undefined`
126
+ // at runtime must read as "inside no org", not as a `TypeError` out of `.includes`.
127
+ if (typeof key !== 'string' || typeof orgId !== 'string') return false;
128
+ const prefix = `${ORG_PREFIX}/${orgId}`;
129
+ if (orgId.includes('/') || !isSafeKey(prefix)) return false;
130
+ return isSafeKey(key) && key.startsWith(`${prefix}/`);
116
131
  }
117
132
 
118
133
  /**
package/src/signed-url.ts CHANGED
@@ -12,7 +12,8 @@ import { assertSafeKey, isSafeKey } from './path';
12
12
  * implementation now lives in `@ultimat3/core`, shared with `@ultimat3/auth`. */
13
13
  export { timingSafeEqual };
14
14
 
15
- export const SIGNED_URL_VERSION = 'v1';
15
+ /** `v2` since the canonical string carries the base path: a `v1` signature names no disk. */
16
+ export const SIGNED_URL_VERSION = 'v2';
16
17
  export const DEFAULT_SIGNED_URL_TTL_MS = 900_000;
17
18
  /** The dev server mounts the download/upload route here; S3 disks never use it. */
18
19
  export const DEFAULT_SIGNED_URL_BASE = '/_storage';
@@ -70,10 +71,34 @@ export type SignedUrlVerification =
70
71
  | { readonly ok: true; readonly constraints: SignedUrlConstraints }
71
72
  | { readonly ok: false; readonly reason: SignedUrlFailure; readonly detail: string };
72
73
 
73
- /** Newline-separated and order-fixed: an ambiguous canonical form is a forgeable one. */
74
- export function canonicalRequest(constraints: SignedUrlConstraints): string {
74
+ const trimBase = (base: string): string => base.replace(/\/+$/, '');
75
+
76
+ /**
77
+ * The PATH a base mounts at — what a route sees, and so the only part of a base both halves can
78
+ * agree on. An absolute `baseUrl` (`https://cdn.example.com/_storage/local`) names an origin too,
79
+ * and the verifier compared a request's pathname against the whole string: every URL minted under
80
+ * one was `malformed`. Never throws — a base `URL` cannot parse is compared as written.
81
+ */
82
+ export function signedUrlBasePath(baseUrl: string): string {
83
+ try {
84
+ return trimBase(new URL(baseUrl, 'http://storage.invalid').pathname);
85
+ } catch {
86
+ return trimBase(baseUrl);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Newline-separated and order-fixed: an ambiguous canonical form is a forgeable one.
92
+ *
93
+ * `basePath` is the disk: `/_storage/<registered name>`. Every local disk signs with the one
94
+ * `STORAGE_SIGNING_SECRET`, so without it a URL for `uploads/<key>` verified on `private/<key>`
95
+ * with only the path segment edited — a grant for one disk was a grant for all of them. The
96
+ * origin is deliberately outside it: the verifying route is handed a path.
97
+ */
98
+ export function canonicalRequest(constraints: SignedUrlConstraints, basePath: string): string {
75
99
  return [
76
100
  SIGNED_URL_VERSION,
101
+ basePath,
77
102
  constraints.method,
78
103
  constraints.key,
79
104
  String(constraints.expiresAt),
@@ -87,6 +112,7 @@ const encoder = new TextEncoder();
87
112
  export async function signConstraints(
88
113
  secret: string,
89
114
  constraints: SignedUrlConstraints,
115
+ basePath: string,
90
116
  ): Promise<string> {
91
117
  const key = await crypto.subtle.importKey(
92
118
  'raw',
@@ -95,11 +121,14 @@ export async function signConstraints(
95
121
  false,
96
122
  ['sign'],
97
123
  );
98
- const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(canonicalRequest(constraints)));
124
+ const mac = await crypto.subtle.sign(
125
+ 'HMAC',
126
+ key,
127
+ encoder.encode(canonicalRequest(constraints, basePath)),
128
+ );
99
129
  return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
100
130
  }
101
131
 
102
- const trimBase = (base: string): string => base.replace(/\/+$/, '');
103
132
  const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
104
133
 
105
134
  /**
@@ -148,8 +177,11 @@ export async function buildSignedUrl(input: SignedUrlInput): Promise<string> {
148
177
  if (constraints.contentType !== undefined) {
149
178
  params.set(SIGNED_URL_PARAMS.contentType, constraints.contentType);
150
179
  }
151
- params.set(SIGNED_URL_PARAMS.signature, await signConstraints(input.secret, constraints));
152
180
  const base = trimBase(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE);
181
+ params.set(
182
+ SIGNED_URL_PARAMS.signature,
183
+ await signConstraints(input.secret, constraints, signedUrlBasePath(base)),
184
+ );
153
185
  return `${base}/${encodeKey(key)}?${params.toString()}`;
154
186
  }
155
187
 
@@ -222,10 +254,11 @@ export async function verifySignedUrl(input: VerifySignedUrlInput): Promise<Sign
222
254
  }
223
255
  const signature = url.searchParams.get(SIGNED_URL_PARAMS.signature);
224
256
  if (signature === null) return fail('malformed', `no ${SIGNED_URL_PARAMS.signature} parameter`);
225
- const parsed = parseConstraints(url, trimBase(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE));
257
+ const basePath = signedUrlBasePath(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE);
258
+ const parsed = parseConstraints(url, basePath);
226
259
  if (typeof parsed === 'string') return fail(parsed, `${url.pathname} is not a signable request`);
227
260
 
228
- const expected = await signConstraints(input.secret, parsed);
261
+ const expected = await signConstraints(input.secret, parsed, basePath);
229
262
  if (!timingSafeEqual(expected, signature)) {
230
263
  return fail('signature-mismatch', 'the constraints do not match the signature');
231
264
  }
@@ -0,0 +1,71 @@
1
+ // Single responsibility: the secret a disk that mints its OWN URLs signs with — the published
2
+ // development fallback, the env key production must set, and the one rule (`localDriver` and
3
+ // `memoryDriver` both) for refusing the fallback outside a development or test environment.
4
+
5
+ import { isLocal, type ResolveEnvironmentOptions, resolveEnvironment } from '@ultimat3/core';
6
+ import { signingSecretMissing } from './errors';
7
+
8
+ /**
9
+ * The dev-only fallback signing key. A literal, not a per-process random one, so a restart does
10
+ * not invalidate every URL `x dev` handed out — and published in this repo, which is exactly why
11
+ * `localDriver` refuses to use it outside a development or test environment.
12
+ */
13
+ export const DEV_SIGNING_SECRET = 'ultimate-dev-signing-secret';
14
+
15
+ /** The env key production must set. Named once, read by the driver and by the predicate below. */
16
+ export const STORAGE_SIGNING_SECRET_KEY = 'STORAGE_SIGNING_SECRET';
17
+
18
+ /**
19
+ * True while a local disk built without an explicit `signingSecret` would sign with the shipped
20
+ * development key — `x doctor` reports it, exactly as it reports `usesDevCursorSecret()`.
21
+ *
22
+ * Reads the environment, not a driver instance: this is the same question `x doctor` asks about
23
+ * the cursor secret, and a disk handed an explicit `signingSecret` in `app.config.ts` never
24
+ * consults the variable at all.
25
+ *
26
+ * `env` is core's own slot, so this half of the guard reads the SAME table its other half does:
27
+ * `dev-runtime.ts` asks `!isLocal({ env }) && usesDevStorageSecret({ env })`, and an embedding
28
+ * caller (`serveApp({ env })`, a test fixture) whose `env` is not `process.env` used to get one
29
+ * answer about the boot and one about the process — for the decision of whether a disk may be
30
+ * signed with the published development key. Defaulted to `process.env`, so a bare call is
31
+ * unchanged.
32
+ */
33
+ export function usesDevStorageSecret(options?: Pick<ResolveEnvironmentOptions, 'env'>): boolean {
34
+ const source = options?.env ?? (process.env as Record<string, string | undefined>);
35
+ const configured = source[STORAGE_SIGNING_SECRET_KEY];
36
+ return configured === undefined || configured === '' || configured === DEV_SIGNING_SECRET;
37
+ }
38
+
39
+ /** The two options the rule reads; `LocalDriverOptions` carries both. */
40
+ export interface SigningSecretOptions {
41
+ readonly signingSecret?: string | undefined;
42
+ readonly env?: ResolveEnvironmentOptions['env'];
43
+ }
44
+
45
+ /**
46
+ * The secret a disk that mints its OWN URLs signs with — `localDriver` and `memoryDriver` both.
47
+ *
48
+ * A dev disk must work with zero config. Outside development the fallback is refused rather than
49
+ * used: the literal is published, so signing with it hands every reader the power to mint a PUT
50
+ * for any key with any size and type limit — which `acceptSignedUpload` then trusts over the app's
51
+ * own `uploadPolicy`. Refused at construction, so the boot fails rather than the first upload.
52
+ *
53
+ * The published literal counts as no secret at all, whichever way it arrives: an env var or an
54
+ * `app.config.ts` that pasted it in signs exactly as weakly as the fallback does. One table for
55
+ * all three reads — the secret, the environment test and the environment the refusal names.
56
+ * Splitting them is how the guard and the disk came to answer about two different processes.
57
+ */
58
+ export function resolveSigningSecret(disk: string, options: SigningSecretOptions): string {
59
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
60
+ const supplied = options.signingSecret ?? env[STORAGE_SIGNING_SECRET_KEY];
61
+ const configured =
62
+ supplied === undefined || supplied === '' || supplied === DEV_SIGNING_SECRET
63
+ ? undefined
64
+ : supplied;
65
+ // FAILS CLOSED: a process that names no environment resolves as `production` here, the answer
66
+ // core's `assertNoDevSecretsOutsideLocal` gives. `isLocal`'s own fallback is `development`, so the
67
+ // process that forgot to say signed with the published key — exactly the one that must not.
68
+ if (configured === undefined && !isLocal({ env, fallback: 'production' }))
69
+ throw signingSecretMissing(resolveEnvironment({ env, fallback: 'production' }), disk);
70
+ return configured ?? DEV_SIGNING_SECRET;
71
+ }