@ultimat3/storage 21.0.0 → 22.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/CLAUDE.md CHANGED
@@ -15,31 +15,16 @@ Tier 1. Object storage: named disks, safe keys, signed URLs, sniffed uploads.
15
15
  wrap it in its own `action()` with its own policy. See `docs/architecture/17-uploads.md`.
16
16
 
17
17
  - **Every byte ceiling and every TTL is screened where it is DECLARED, through core's
18
- `finiteCount` and nothing else, `As of 2026-08-26`** — `uploadPolicy({ maxBytes })`, both
19
- drivers' `maxPutBytes`, `createUploadGrant({ expiresInMs })`, `buildSignedUrl`'s two and the s3
20
- driver's presign TTL. There was briefly a private `assertFiniteSignedUrlBound` here saying the
21
- same thing in its own words; a second finite-bound path is one that drifts from the shared
22
- contract, which is exactly what `jobs`, `realtime` and `query` each proved with a copy of their
23
- own. Both presigners now call the one function, which is what `driver-parity.test.ts` needs to
24
- stay true.
25
- Measured: `uploadPolicy({ maxBytes: Number.NaN })` accepted a 5,000,016-byte PNG through
26
- `validateUpload`, because `size > NaN` is false — the one number deciding how much a caller may
27
- store stopped deciding anything. Variant `quality` is core's `assertFiniteImageQuality` and NOT a
28
- second screen: `variantKey` never reaches the encoder, so a copy that disagreed would mint
29
- `q150` keys for bytes `transformImageBytes` then refuses.
30
- - **The default is taken on `undefined` and on nothing else, `As of 2026-08-26`** —
31
- `options.x === undefined ? D : options.x`, never `options.x ?? D`. `??` coalesces on `null` too,
32
- so an explicitly blanked key in a decoded JSON config took the default BEFORE the screen above
33
- could refuse it: the mirror of the `NaN` half, where one value slips past the guard and the other
34
- past the default, and both end in a bound nobody chose. Every site above, `quality` included.
18
+ `finiteCount` and nothing else** — `uploadPolicy({ maxBytes })`, both drivers' `maxPutBytes`,
19
+ `createUploadGrant({ expiresInMs })`, `buildSignedUrl` and the s3 presign TTL. No private copy:
20
+ `size > NaN` is false, so an unscreened `NaN` ceiling stops deciding anything. Variant `quality`
21
+ is core's `assertFiniteImageQuality`, not a second screen.
22
+ - **The default is taken on `undefined` and on nothing else** — `x === undefined ? D : x`, never
23
+ `x ?? D`: `??` defaults a blanked `null` before the screen can refuse it.
35
24
 
36
- - **`MAX_KEY_LENGTH` is BYTES, and is measured in bytes, `As of 2026-08-26`.** S3's limit is "a
37
- sequence of Unicode characters whose UTF-8 encoding is at most 1,024 bytes long", and `path.ts`
38
- measured `key.length` — UTF-16 code units — while its message said "chars". A code-unit count is
39
- never MORE than the UTF-8 byte count, so a non-ASCII key over the real limit passed this guard
40
- and was refused by the store instead: 400 CJK characters is 400 units and 1,200 bytes. Keeping
41
- local and remote disks interchangeable is the whole reason the ceiling exists, so it has to be
42
- the store's ceiling. Same defect and same fix as `@ultimat3/cache`'s surrogate-key guard.
25
+ - **`MAX_KEY_LENGTH` is UTF-8 BYTES, measured in bytes** — S3's limit. A UTF-16 `length` passed
26
+ 400 CJK characters (1,200 bytes) that the store then refused. Same fix as `@ultimat3/cache`'s
27
+ surrogate-key guard.
43
28
 
44
29
  | Rule | |
45
30
  |---|---|
@@ -78,16 +63,9 @@ Gotchas:
78
63
  in a single `test()` per claim, so neither disk can move alone. Where the two genuinely cannot
79
64
  agree it pins the DIVERGENCE, with the reason — that is the honest form, and it still fails the
80
65
  day either half changes.
81
- - **The local `list()` globs with `dot: true`, and the `META_DIR` skip is the REAL filter**
82
- (`As of 2026-08`). `Bun.Glob('**/*')` matches no dot-prefixed entry, so every object whose key
83
- had one — `.hidden.txt`, `org/o1/pending/.x.png`, the `.metadata/a.json` `path.test.ts` pins as
84
- legal — was missing from the listing while `put`/`get`/`exists` handled it normally and
85
- `s3Driver.list()` returned it: the key space and the listing disagreed by construction.
86
- `sweepOrphans` pages through `list()`, so those objects were reported as erased while still on
87
- disk — a false erasure report by OMISSION, the same lie a swallowed listing error tells. The
88
- `.meta/` skip one line below was unreachable until this landed and this file called it "a second
89
- line of defence"; it is now the only thing keeping the sidecar tree out of the object namespace,
90
- and it folds case for `isSafeKey`'s reason. Pinned in `driver-parity.test.ts`.
66
+ - **The local `list()` globs with `dot: true`, and the `META_DIR` skip is the real filter.**
67
+ Without `dot`, dot-prefixed keys were missing from the listing while `put`/`get` handled them —
68
+ and `sweepOrphans` pages through `list()`. Pinned in `driver-parity.test.ts`.
91
69
  - **A `list({ limit })` is a positive integer or a refusal** — `resolveListLimit` at the
92
70
  `ListOptions` seam, so both disks answer one way. `limit: 0` used to slice `[0, 0)` on the local
93
71
  disk and then drop its own `truncated` flag (`truncated && last !== undefined`, with an empty
@@ -112,14 +90,9 @@ Gotchas:
112
90
  for the http error map to render but a 500. `sweepOrphans` walks `list()`, so the local swallow
113
91
  was a false-erasure report a layer up. `ENOENT` (a root nobody has written to) is still an empty
114
92
  page; everything else is `X_STORAGE_LIST_FAILED`, whose `fix` the DRIVER supplies.
115
- - **`head()` NEVER reads an object's bytes, and `list()` is why** (`As of 2026-08`). It hashed a
116
- sidecar-less object to invent an etag, under a comment saying "`list()` must not read every file
117
- it lists" — which is what `list()` then did, one whole buffered object per listed row,
118
- sequentially, and `copy()` inherited it, so a copy documented as never routing bytes through the
119
- heap buffered the entire source. A listing that cannot know an etag reports `''`, which is what
120
- the s3 listing already answers. `get()` hashes out of bytes it already holds; `copy()` passes
121
- `hash: true`, because the sidecar it writes at the destination would otherwise carry `etag: ''`
122
- as a durable lie.
93
+ - **`head()` and `list()` NEVER read an object's bytes.** A listing that cannot know an etag
94
+ reports `''`, as the s3 listing does. `get()` hashes bytes it already holds; `copy()` passes
95
+ `hash: true` so the destination sidecar never records `etag: ''`.
123
96
  - **`put({ metadata })` / `put({ cacheControl })` is the one `PutOptions` pair the disks disagree
124
97
  about, and it is pinned rather than resolved.** Bun's `S3File.write` exposes `type`, `acl` and
125
98
  `storageClass` and no header hook for `x-amz-meta-*` or `Cache-Control`, so `s3Driver` refuses
@@ -181,19 +154,10 @@ Gotchas:
181
154
  may be `NODE_ENV`'s, never a variable the process did not set. `usesDevStorageSecret()` is the
182
155
  `x doctor` predicate, mirroring core's `usesDevCursorSecret()`; it reads the env var, so a disk
183
156
  handed an explicit `signingSecret` is outside its question.
184
- - **`localDriver({ env })` and `usesDevStorageSecret({ env })` are ONE question about ONE table**
185
- (`As of 2026-08-23`). The predicate learned core's `env` slot first, so `dev-runtime.ts`'s guard
186
- — `!isLocal({ env }) && usesDevStorageSecret({ env })` — asked about the BOOT while the
187
- constructor it guards still read `process.env` for the secret, for `isLocal()` and for the
188
- environment its refusal names. An embedding caller whose env is not the process's (`serveApp({ env })`,
189
- a test fixture) got the verdict from one table and the behaviour from another, in the dangerous
190
- direction: a production boot with no secret, launched from a development shell that has one,
191
- signing every grant with the published literal. All three reads now come off `options.env ??
192
- process.env`, so a bare `localDriver({ root })` is unchanged and additive.
193
- `driver-local-boot.test.ts` pins it by mutation — reverting any one read to `process.env` fails.
194
- That file is the CONSTRUCTION half, split off `driver-local.test.ts` at the line ceiling along
195
- the seam the guard already draws: nothing in it writes a byte, so it needs no temporary
196
- directory, and `driver-local.test.ts` keeps everything the disk actually does with an object.
157
+ - **`localDriver({ env })` and `usesDevStorageSecret({ env })` are ONE question about ONE table**:
158
+ the secret, `isLocal()` and the refusal's environment all read `options.env ?? process.env`, so
159
+ the boot's guard and the constructor it guards cannot answer from two tables.
160
+ `driver-local-boot.test.ts` pins it by mutation.
197
161
  - **The mounted read half is `@ultimat3/cli`'s `dev-storage.ts`, not this package.** `GET
198
162
  /_storage/:disk/*key` gates on `@ultimat3/policy`'s `evaluate()` (`storage:read`), which is tier
199
163
  2 and unreachable from here — so a "serve this object" helper in this package could only ever be
@@ -212,19 +176,11 @@ Gotchas:
212
176
  Its image failures (`X_IMAGE_UNSUPPORTED`, `X_IMAGE_DECODE_FAILED`) surface unwrapped —
213
177
  wrapping them in a `StorageError` would give one failure two codes. `variantKey` now RAISES
214
178
  core's `imageUnsupported()` as well as passing them through, for that same reason.
215
- - **`VARIANT_FORMATS` is this package's format vocabulary, and `IMAGE_FORMATS` is core's.** Until
216
- 9.0.0 both packages exported `IMAGE_FORMATS` **and** `ImageFormat` from their own barrels over
217
- different sets (core: `png|jpeg|webp|avif|gif|svg`, what it can PROBE; storage: `avif|webp|jpeg|png`),
218
- so a caller narrowing on storage's held a type saying `gif` and `svg` could not occur and a
219
- `probeImage()` value that was one — and `variantKey('photos/hero.gif', { format })` minted
220
- `photos/hero@full.undefined`, a well-formed writable key naming a file nothing can serve. The set
221
- is now a strict subset **by the compiler**: `as const satisfies readonly ImageFormat[]`, so a
222
- member core cannot name is a build error here. `isVariantFormat` takes `string` on purpose, so
223
- `probeImage(bytes).format` narrows through it with no cast. `avif` is in the set and `gif`/`svg`
224
- are not because the question is what a variant KEY can carry, never what core can transform —
225
- core decodes `gif` perfectly well, and naming this set `TRANSFORMABLE_FORMATS` would have been
226
- the same lie one rename later. `scripts/render-modes.ts` holds the `IMAGE_FORMATS` row (`by:
227
- 'name'`) and `image.test.ts` fails the day either core name reappears in `src/index.ts`.
179
+ - **`VARIANT_FORMATS` is this package's format vocabulary, and `IMAGE_FORMATS` is core's** (what
180
+ core can PROBE). `as const satisfies readonly ImageFormat[]` makes it a strict subset by the
181
+ compiler; `isVariantFormat` takes `string` so `probeImage(bytes).format` narrows with no cast.
182
+ `scripts/render-modes.ts` holds the `IMAGE_FORMATS` row and `image.test.ts` fails if either core
183
+ name reappears in `src/index.ts`.
228
184
  - `transformImage()` must encode at exactly `fitDimensions()`'s size, and passes `format`
229
185
  explicitly (`?? 'webp'`, which then rejects): bytes that disagree with the `variantKey`
230
186
  extension, or with the `width`/`height` `@ultimat3/seo` inlined, are the layout shift the
@@ -281,8 +237,8 @@ Gotchas:
281
237
  default header, and `decodeError` keeps a refusal `X_STORAGE_UPLOAD_FAILED` with the disk's
282
238
  status. A request with no response at all is the transport's `X_CLIENT_TRANSPORT_FAILED` — it was a
283
239
  bare `TypeError` before. This file calls no `fetch`; the XHR above is the one exemption.
284
- - **Bundle, `bun build --target=browser --minify`, `import { uploadFile } from '@ultimat3/storage'`**
285
- (`As of 2026-09-22`): **11,822 B** at 20.2.1, **17,767 B** on `clientTransport`. The +5.9 kB is
286
- core's transport graph, paid only where the chunk did not already carry it — an island that
287
- also calls an action or a query already does.
240
+ - **Bundle** (`bun build --target=browser --minify`, `uploadFile` only, `As of 2026-09-22`):
241
+ **17,767 B** on `clientTransport`; the +5.9 kB over 20.2.1 is core's transport graph.
288
242
  - `exactOptionalPropertyTypes` is on — declare optional fields as `x?: T | undefined`.
243
+
244
+ Why each rule above is shaped the way it is: [`docs/history/storage.md`](../../docs/history/storage.md).
package/README.md CHANGED
@@ -106,7 +106,7 @@ The HMAC covers the **constraints**, not just the key —
106
106
  A client that edits `?x-max=` invalidates the signature — it cannot widen what it was granted.
107
107
 
108
108
  The HMAC key is `signingSecret`, else `STORAGE_SIGNING_SECRET`, else the shipped
109
- `DEV_SIGNING_SECRET` — and **only in `development` or `test`**. Anywhere else `localDriver` refuses
109
+ `DEV_SIGNING_SECRET` — and **only in `development` or `test`**, read with a fallback of `production`: a process that names no environment at all (`ULTIMATE_ENV` and `NODE_ENV` both unset) is refused like a production one, `As of 2026-09-23`. Anywhere else `localDriver` refuses
110
110
  to construct (`X_ENV_MISSING`) unless one of those two is set to something that is not the shipped
111
111
  literal: the dev literal is published in this repo, so signing with it lets anyone mint a `PUT` for
112
112
  any key with a `maxBytes` and `contentType` of their choosing, which `acceptSignedUpload` then
@@ -189,9 +189,9 @@ An upload happens **before** the row it belongs to exists, so it lands at
189
189
  | `pendingKey(orgId, uploadName(id, filename))` | `org/o1/pending/u-1.png` |
190
190
  | `quarantineKey(orgId, name)` | `org/o1/pending/quarantine/u-1.png` |
191
191
  | `attachmentKey(orgId, { entity, id, field }, name)` | `org/o1/post/p-1/cover/u-1.png` |
192
- | `promoteAttachment({ disk, key, orgId, target })` | `copy` then `delete` — never the reverse |
192
+ | `promoteAttachment({ disk, key, orgId, target })` | `copy` then `delete` — never the reverse. The key must be a **pending** one (`isPendingKey`): an attached key a client sent back is another row's file, and moving it would delete the victim's copy (`X_STORAGE_NOT_PENDING`) |
193
193
  | `releaseQuarantine({ disk, key, orgId })` | quarantine → pending; returns the released key |
194
- | `sweepOrphans({ disk, orgId, olderThanMs })` | `{ deleted, failed }` for stale `pending/` keys |
194
+ | `sweepOrphans({ disk, orgId, olderThanMs })` | `{ deleted, failed }` for stale `pending/` keys. `olderThanMs` is a whole number of 0 or more, refused otherwise (`X_INVARIANT`) — `NaN` read as "everything is old enough" and deleted an upload made a moment ago |
195
195
 
196
196
  The filename contributes nothing but its extension, and only if it matches `.[a-z0-9]{1,12}`.
197
197
  `sweepOrphans` can only reach the `pending/` prefix of one org — a sweep that could touch an
@@ -239,11 +239,21 @@ Inside `pending/` deliberately: an upload nobody ever scanned is still an orphan
239
239
  | `X_STORAGE_DELETE_FAILED` | the disk REFUSED a delete — denied `s3:DeleteObject`, a throttle, an expired credential, a read-only mount. An **absent** key is still not an error |
240
240
  | `X_STORAGE_LIST_FAILED` | the disk REFUSED a listing — denied `s3:ListBucket`, a throttle, an unreadable root. An **empty** disk is still not an error |
241
241
  | `X_STORAGE_QUARANTINED` | `promoteAttachment` on a key nothing has released from `pending/quarantine/` |
242
+ | `X_STORAGE_NOT_PENDING` | `promoteAttachment` on a key outside the org's `pending/` prefix — most often another row's attached key |
242
243
  | `X_NOT_IMPLEMENTED` | S3 user metadata / cache-control; `serverSideEncryption` on either driver |
243
244
  | `X_ENV_MISSING` | core's: S3 credential env vars, or a `localDriver` built outside development where neither `signingSecret` nor `STORAGE_SIGNING_SECRET` holds a secret other than the published `DEV_SIGNING_SECRET` |
244
245
  | `X_IMAGE_UNSUPPORTED` | core's: an `avif` encode, a source no built-in decoder reads, or a `variantKey` format no variant can carry |
245
246
  | `X_IMAGE_DECODE_FAILED` | core's: truncated or corrupt image bytes |
246
247
 
248
+ ### Error classes
249
+
250
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
251
+ a job boundary the class is gone and the `code` is what survives — match on that.
252
+
253
+ | Class | Code | Declared in |
254
+ |---|---|---|
255
+ | `StorageError` | any `StorageErrorCode` — `STORAGE_ERROR_CODES` | `src/errors.ts` |
256
+
247
257
  ## Images
248
258
 
249
259
  `variantKey()`, `srcsetDescriptors()`, `fitDimensions()` are pure — `@ultimat3/seo` builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/storage",
3
- "version": "21.0.0",
3
+ "version": "22.0.0",
4
4
  "description": "Named disks over Bun.file and Bun.s3: safe keys, signed URLs, sniffed uploads",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,6 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "21.0.0"
34
+ "@ultimat3/core": "22.0.0"
35
35
  }
36
36
  }
package/src/attachment.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  // so the tenant prefix is a construction, not a check somebody remembered to write.
6
6
 
7
7
  import type { Clock } from '@ultimat3/core';
8
- import { renderThrowable, systemClock } from '@ultimat3/core';
8
+ import { finiteCount, renderThrowable, systemClock } from '@ultimat3/core';
9
9
  import type { ListPage, StorageDriver, StorageListEntry, StorageObject } from './driver';
10
- import { orgMismatch, quarantined } from './errors';
10
+ import { notPending, orgMismatch, quarantined } from './errors';
11
11
  import { isWithinOrg, orgPrefix, scopedKey } from './path';
12
12
 
13
13
  /** The one segment an unattached upload lives under. `sweepOrphans` reads only this prefix. */
@@ -137,6 +137,9 @@ export interface PromoteAttachmentInput {
137
137
  */
138
138
  export async function promoteAttachment(input: PromoteAttachmentInput): Promise<StorageObject> {
139
139
  if (!isWithinOrg(input.key, input.orgId)) throw orgMismatch(input.key, input.orgId);
140
+ // Pending, not merely in-org: an attached key a client sent back belongs to another row, and the
141
+ // copy-then-delete below would move that row's file here and delete theirs.
142
+ if (!isPendingKey(input.key, input.orgId)) throw notPending(input.key, input.orgId);
140
143
  if (isQuarantinedKey(input.key, input.orgId)) throw quarantined(input.key, input.orgId);
141
144
  const name = input.key.slice(input.key.lastIndexOf('/') + 1);
142
145
  const attached = attachmentKey(input.orgId, input.target, name);
@@ -187,7 +190,10 @@ export interface SweepResult {
187
190
  */
188
191
  export async function sweepOrphans(input: SweepOrphansInput): Promise<SweepResult> {
189
192
  const clock = input.clock ?? systemClock;
190
- const cutoff = clock.now().getTime() - input.olderThanMs;
193
+ // Screened first: `now - NaN` is NaN and `lastModified > NaN` is false — the "old enough"
194
+ // answer — so `olderThanMs: NaN` deleted an upload created a moment ago.
195
+ const olderThanMs = finiteCount('sweepOrphans', 'olderThanMs', input.olderThanMs, 0);
196
+ const cutoff = clock.now().getTime() - olderThanMs;
191
197
  const prefix = pendingPrefix(input.orgId);
192
198
  const deleted: string[] = [];
193
199
  const failed: SweepFailure[] = [];
@@ -190,8 +190,11 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
190
190
  supplied === undefined || supplied === '' || supplied === DEV_SIGNING_SECRET
191
191
  ? undefined
192
192
  : supplied;
193
- if (configured === undefined && !isLocal({ env }))
194
- throw signingSecretMissing(resolveEnvironment({ env }));
193
+ // FAILS CLOSED: a process that names no environment resolves as `production` here, the answer
194
+ // core's `assertNoDevSecretsOutsideLocal` gives. `isLocal`'s own fallback is `development`, so the
195
+ // process that forgot to say signed with the published key — exactly the one that must not.
196
+ if (configured === undefined && !isLocal({ env, fallback: 'production' }))
197
+ throw signingSecretMissing(resolveEnvironment({ env, fallback: 'production' }));
195
198
  const secret = configured ?? DEV_SIGNING_SECRET;
196
199
 
197
200
  const filePath = (key: string): string => `${root}/${key}`;
package/src/errors.ts CHANGED
@@ -20,6 +20,7 @@ export const STORAGE_OWNED_ERROR_CODES = [
20
20
  'X_STORAGE_DELETE_FAILED',
21
21
  'X_STORAGE_LIST_FAILED',
22
22
  'X_STORAGE_QUARANTINED',
23
+ 'X_STORAGE_NOT_PENDING',
23
24
  ] as const;
24
25
 
25
26
  /**
@@ -55,6 +56,7 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
55
56
  X_STORAGE_DELETE_FAILED: 'the object could not be deleted',
56
57
  X_STORAGE_LIST_FAILED: 'the objects could not be listed',
57
58
  X_STORAGE_QUARANTINED: 'the object is still in quarantine',
59
+ X_STORAGE_NOT_PENDING: 'the key is not a pending upload',
58
60
  };
59
61
 
60
62
  // One unconditional call, so a second package claiming one of storage's codes throws
@@ -295,6 +297,19 @@ export const quarantined = (key: string, orgId: string): StorageError =>
295
297
  meta: { key, orgId },
296
298
  });
297
299
 
300
+ /**
301
+ * A key inside the org and outside its `pending/` prefix — most often another row's attached key
302
+ * a client sent back. Promotion copies then deletes, so accepting it moved the victim's file onto
303
+ * this row and removed it from theirs.
304
+ */
305
+ export const notPending = (key: string, orgId: string): StorageError =>
306
+ new StorageError({
307
+ code: 'X_STORAGE_NOT_PENDING',
308
+ cause: `"${key}" is not under org "${orgId}"'s pending/ prefix, so it is not an upload waiting for a row — it may already belong to one`,
309
+ fix: 'promote the key grantUpload returned with no target: promoteAttachment({ disk, key: pendingKey(orgId, name), orgId, target })',
310
+ meta: { key, orgId },
311
+ });
312
+
298
313
  /** The key is well-formed and unforged, and still belongs to somebody else. */
299
314
  export const orgMismatch = (key: string, orgId: string): StorageError =>
300
315
  new StorageError({