@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/CLAUDE.md CHANGED
@@ -15,8 +15,9 @@ 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** — `uploadPolicy({ maxBytes })`, both drivers' `maxPutBytes`,
19
- `createUploadGrant({ expiresInMs })`, `buildSignedUrl` and the s3 presign TTL. No private copy:
18
+ `finiteCount` and nothing else** — `uploadPolicy({ maxBytes })`, every driver's `maxPutBytes`
19
+ and `maxGetBytes`, variant `width`/`height`, `grantUpload({ expiresInMs })`, `buildSignedUrl`
20
+ and the s3 presign TTL. No private copy:
20
21
  `size > NaN` is false, so an unscreened `NaN` ceiling stops deciding anything. Variant `quality`
21
22
  is core's `assertFiniteImageQuality`, not a second screen.
22
23
  - **The default is taken on `undefined` and on nothing else** — `x === undefined ? D : x`, never
@@ -37,14 +38,24 @@ Tier 1. Object storage: named disks, safe keys, signed URLs, sniffed uploads.
37
38
 
38
39
  | File | Owns |
39
40
  |---|---|
40
- | `driver.ts` | `StorageDriver` contract (8 methods) + bounded `toBytes`/`sha256Base64`/`etagOf` |
41
+ | `driver.ts` | `StorageDriver` contract (9 methods; `registerAs`/`verifySigned`/`retentionOf` optional), `assertPutOptions`, + bounded `toBytes`/`sha256Base64`/`etagOf` |
41
42
  | `driver-local.ts` | dev default over `Bun.file`, `.meta/` sidecars, `Bun.Glob` listing |
42
- | `driver-memory.ts` | `memoryDriver()` — a test's disk over a `Map`: `localDriver`'s contract and refusals, `objects()` for an assertion about the bucket. `driver-memory.test.ts` runs each claim on BOTH disks. Signs through `resolveSigningSecret` (`driver-local.ts`), the one rule both share |
43
- | `driver-s3.ts` | `Bun.S3Client`, built lazily (import must never open a socket) |
43
+ | `driver-local-write.ts` | the local WRITE: staged under `.meta/.tmp/`, renamed marker → sidecar → bytes, `X_STORAGE_KEY_CONFLICT` / `X_STORAGE_PUT_FAILED`, the per-key queue |
44
+ | `driver-local-read.ts` | `headObject`: sidecar + pending marker → trusted, re-checked, or untyped |
45
+ | `driver-local-sidecar.ts` | the sidecar shape and its one parser |
46
+ | `signing-secret.ts` | `DEV_SIGNING_SECRET`, `usesDevStorageSecret`, `resolveSigningSecret` |
47
+ | `driver-memory-conflict.ts` | `claim()`'s conflict rule over keys |
48
+ | `driver-memory.ts` | `memoryStorageDriver()` — a test's disk over a `Map`: `localDriver`'s contract and refusals, `objects()` for an assertion about the bucket. `driver-memory.test.ts` runs each claim on BOTH disks. Signs through `resolveSigningSecret` (`signing-secret.ts`), the one rule both share |
49
+ | `driver-s3.ts` | the s3 disk: Bun where it can, the signed wire where it cannot |
50
+ | `driver-s3-client.ts` | `S3*Like` + `buildClient`, lazy (import never opens a socket) |
51
+ | `driver-s3-signed.ts` | signed requests: Bun's addressing restated, `requireEnv`, `fetch`, refusal as `S3Error` fields |
52
+ | `driver-s3-lock.ts` | the signed PUT's headers; `?retention` / `?legal-hold` |
53
+ | `object-lock.ts` | lock types, the `put()` screen, `isLocked`, the local/memory refusals |
44
54
  | `driver-s3-region.ts` | a provider's wrong-region refusal → `X_CONFIG_INVALID` whose fix names `S3_REGION` |
45
55
  | `path.ts` | key validation + `META_DIR` + `scopedKey`/`isWithinOrg`/`isTenantScoped` tenant boundary |
46
56
  | `signed-url.ts` | HMAC over the constraint tuple, constant-time verify |
47
57
  | `upload.ts` | magic-byte sniff + size/allowlist/checksum policy |
58
+ | `iso-bmff.ts` | the `ftyp` MAJOR BRAND → media type (AVIF, HEIC, MOV, M4A, MP4, 3GP) |
48
59
  | `image.ts` | deterministic variant keys; byte path over core's pipeline (png/jpeg encode only) |
49
60
  | | `VARIANT_FORMATS` — what a variant KEY can carry — and NOT `IMAGE_FORMATS`, which is core's and means what core can PROBE. See below |
50
61
  | | `variantKey` is the cache identity `@ultimat3/cli`'s `/media/*` route looks a variant up by — derived, never stored, so a request that misses transforms once and every later one is a disk read |
@@ -60,6 +71,32 @@ bun run typecheck
60
71
  ```
61
72
 
62
73
  Gotchas:
74
+ - **The local disk refuses a key that is another key's path, and that is pinned, not fixed**
75
+ (`As of 2026-10`). `put('a')` then `put('a/b')` — or the reverse, or `a` beside `a.json/b` in the
76
+ sidecar tree — is `X_STORAGE_KEY_CONFLICT`, decided by `claim()` for BOTH paths before either is
77
+ touched. Suffixed on-disk names would lift it and orphan every existing root's objects without a
78
+ migration; do not change the layout without one. Memory emulates it; s3 holds both. A key that
79
+ is only a directory is ABSENT to `delete()` (test a refusal with `driver-local-fixture.ts`).
80
+ - **The local commit order is marker → sidecar → bytes → clear marker. Do not reorder**
81
+ (`As of 2026-10`). Bytes-first serves new bytes under the old type; sidecar-first alone serves
82
+ old bytes under the new one. The marker holds the new etag, so `headObject` knows the one state
83
+ in which the sidecar may not describe the bytes: `get`/`stat`/`copy` re-check against the bytes,
84
+ `list` reports no type and `etag: ''`. A fresh key is absent until its bytes land.
85
+ `driver-local-crash.test.ts` dies at every step through `WriteSteps`. Writers and `get()` of one
86
+ key also queue in-process (`keyedQueue`) — unqueued, two puts interleaved 3/3.
87
+ - **A refused write is `X_STORAGE_PUT_FAILED` and a refused read `X_STORAGE_READ_FAILED`**, on
88
+ local and s3 (both were bare throws); a 404 mid-read stays `X_STORAGE_NOT_FOUND`; `assertPutOptions` holds `metadata`/`cacheControl`/`contentType` to their types on
89
+ all three drivers before a byte moves.
90
+ - **`get()` has a ceiling on every disk** (`maxGetBytes`, default `maxPutBytes`), decided on the
91
+ disk's own size before a byte is read. `promoteAttachment` REQUIRES the policy and measures with
92
+ `stat()`, which is a REQUIRED driver method — on s3 it is the first measurement of a direct upload.
93
+ - **`lastModified` is optional — absent when the provider sent none**, never epoch 0 and never an
94
+ invalid `Date`. `sweepOrphans` spares an object with no proven age.
95
+ - **The base PATH is inside the signature** (`SIGNED_URL_VERSION` `v2`): every local disk shares
96
+ one secret, so the canonical string must name the disk. Path, not origin — the verifying route
97
+ sees a path — and `signedUrlBasePath` is how an absolute `baseUrl` is compared.
98
+ - **`isWithinOrg` never throws**: an org id that cannot be one contains nothing.
99
+ - **A variant key keeps the whole source key** (`hero.png@w640.webp`): two cache identities.
63
100
  - **A driver's semantics are pinned in ONE test with the other driver beside them.**
64
101
  `driver-parity.test.ts` drives `localDriver` over a temp dir and `s3Driver` over `FakeS3Client`
65
102
  in a single `test()` per claim, so neither disk can move alone. Where the two genuinely cannot
@@ -74,35 +111,28 @@ Gotchas:
74
111
  page), so a paging caller read "complete, and there is nothing here" over a full disk, while the
75
112
  s3 disk handed `maxKeys: 0` to the provider. Core's `assert` (`X_INVARIANT`), for the reason
76
113
  `@ultimat3/seo`'s `chunk()` uses it: a bound with no code of its own is still a coded refusal.
114
+ - **`list()` answers core's `Page<StorageListEntry>`, and no storage-local page type exists**
115
+ (`As of 25.0.0`; `ListPage` is deleted). Every driver builds it with core's `pageOf`, so
116
+ `hasMore` is derived from the cursor and cannot disagree with it. The cursor is minted only when
117
+ a row past the page exists — never from `rows.length === limit`, the full-last-page defect a
118
+ query's cursor had — and the full-last-page case is pinned on memory, local and live s3.
77
119
  - **`META_DIR` is reserved case-INSENSITIVELY** (`As of 2026-08`), exactly as `isTenantScoped`
78
- folds and for the same filesystem: `.META/a.txt.json` was a legal key that writes
79
- `<root>/.META/a.txt.json`, which on APFS and NTFS IS `<root>/.meta/a.txt.json` — the sidecar for
80
- object `a.txt` — so a caller able to name a key rewrote another object's recorded `contentType`.
81
- The whole SEGMENT is compared, never a prefix: `.metadata/a.json` is an ordinary key and stays
82
- one, and `path.test.ts` pins both halves.
83
- - **`disk(name)` resolves through a `Map`, never `config.disks[name]`.** The bracket read walked
84
- the prototype chain, so `disk('constructor')` handed back the `Object` function and the next
85
- `.put()` was a bare `TypeError` — `X_STORAGE_DISK_UNKNOWN` unreachable for `constructor`,
86
- `toString`, `valueOf`, `hasOwnProperty` and `__proto__`, in a function whose own
87
- `default:` check already read `Object.keys`.
120
+ folds: on APFS/NTFS `.META/` IS `.meta/`. The whole SEGMENT is compared, never a prefix
121
+ (`.metadata/a.json` is an ordinary key); `path.test.ts` pins both halves.
122
+ - **`disk(name)` resolves through a `Map`, never `config.disks[name]`**: the bracket read walked
123
+ the prototype chain (`disk('constructor')` was the `Object` function, not `X_STORAGE_DISK_UNKNOWN`).
88
124
  - **`list()` is idempotent for an EMPTY disk and for nothing else** (`As of 2026-08`) — exactly
89
- `delete()`'s rule, one call to the left, and both drivers broke it in opposite directions. The
90
- local one caught EVERYTHING and answered `{ objects: [], truncated: false }`, so `EACCES` on the
91
- root read as "this disk is empty"; the s3 one let a bare `S3Error` escape uncoded, with nothing
92
- for the http error map to render but a 500. `sweepOrphans` walks `list()`, so the local swallow
93
- was a false-erasure report a layer up. `ENOENT` (a root nobody has written to) is still an empty
94
- page; everything else is `X_STORAGE_LIST_FAILED`, whose `fix` the DRIVER supplies.
95
- - **`head()` and `list()` NEVER read an object's bytes.** A listing that cannot know an etag
96
- reports `''`, as the s3 listing does. `get()` hashes bytes it already holds; `copy()` passes
97
- `hash: true` so the destination sidecar never records `etag: ''`.
98
- - **`put({ metadata })` / `put({ cacheControl })` is the one `PutOptions` pair the disks disagree
99
- about, and it is pinned rather than resolved.** Bun's `S3File.write` exposes `type`, `acl` and
100
- `storageClass` and no header hook for `x-amz-meta-*` or `Cache-Control`, so `s3Driver` refuses
101
- (`X_NOT_IMPLEMENTED`, with the out-of-band `aws s3 cp` in the fix) while `localDriver` stores both
102
- in its sidecar and reads them back. **Do not "fix" this by making the local disk refuse too** —
103
- that deletes a working capability and the two `StorageListEntry` fields that carry it, to buy
104
- symmetry with a limitation that is Bun's and temporary. The day the hook lands, the s3 half of
105
- `driver-parity.test.ts` fails and the resolution is to make s3 store them.
125
+ `delete()`'s rule. `ENOENT` (a root nobody has written to) is an empty page; everything else is
126
+ `X_STORAGE_LIST_FAILED`, whose `fix` the DRIVER supplies.
127
+ - **`head()` and `list()` NEVER read an object's bytes** — an unknowable etag is `''`, as on s3.
128
+ `get()` hashes bytes it holds; `copy()` passes `hash: true`, so no sidecar records `etag: ''`.
129
+ - **s3 `put()` with `metadata`/`cacheControl`/`retention`/`legalHold` is ONE signed PUT** (core's
130
+ `signAwsRequest`; Bun's `write` takes no header); a plain `put()` stays on Bun. Reads stay Bun's
131
+ HEAD, so s3 `get()` reports neither field — pinned in `driver-parity.test.ts`.
132
+ - **Object Lock: local/memory REFUSE delete/overwrite/copy-onto** (`X_STORAGE_OBJECT_LOCKED`);
133
+ S3 accepts both (delete marker, new version; the locked version stays). Pinned divergence: one
134
+ version per key. A pair in doubt never unlocks. `NoSuchObjectLockConfiguration` is a 404.
135
+ - **A live suite signs on a wall clock** (`performance`): `Date` is frozen → `RequestTimeTooSkewed`.
106
136
  - **`signedUrl({ maxBytes })` is signed on `local` and unenforceable on `s3`, and the s3 driver may
107
137
  NOT refuse it.** `grantUpload` passes `maxBytes: policy.maxBytes` on every grant, so a refusal
108
138
  would break every s3 upload an app mints. S3 has no request header for a size and Bun's `presign`
@@ -160,7 +190,7 @@ Gotchas:
160
190
  the secret, `isLocal()` and the refusal's environment all read `options.env ?? process.env`, so
161
191
  the boot's guard and the constructor it guards cannot answer from two tables.
162
192
  `driver-local-boot.test.ts` pins it by mutation.
163
- - **The mounted read half is `@ultimat3/cli`'s `dev-storage.ts`, not this package.** `GET
193
+ - **The mounted read half is `@ultimat3/cli`'s `runtime-storage.ts`, not this package.** `GET
164
194
  /_storage/:disk/*key` gates on `@ultimat3/policy`'s `evaluate()` (`storage:read`), which is tier
165
195
  2 and unreachable from here — so a "serve this object" helper in this package could only ever be
166
196
  a second authz path. This package's contribution to that route is `assertSafeKey`,
@@ -196,11 +226,11 @@ Gotchas:
196
226
  held to `[A-Za-z0-9-]`: a provider's sentence may put nothing else into a `fix:`. A HEAD has no
197
227
  body, so `exists()`/`stat()` never see it. `driver-s3.live.test.ts` runs with no region on purpose.
198
228
  - The signature check runs BEFORE the expiry check. Do not reorder.
199
- - `timingSafeEqual` is `@ultimat3/core`'s (`signed-url.ts` imports and re-exports it) — the same
229
+ - `timingSafeEqual` is `@ultimat3/core`'s (`signed-url.ts` imports it) — the same
200
230
  implementation `@ultimat3/auth` uses, not a second copy. Add new secret comparisons through it.
201
231
  - `verifySignedUrl` never throws, and `parseConstraints` is where that is kept: the key is decoded
202
232
  through the guarded `decodeSegment`, so a `%ZZ` in the path is `'malformed'` rather than the bare
203
- `URIError` `decodeURIComponent` raises. Same shape as `@ultimat3/auth`'s `decodeCookieValue`.
233
+ `URIError` `decodeURIComponent` raises. Same shape as `@ultimat3/core`'s `readCookie`.
204
234
  - `acceptSignedUpload` refuses a URL signed with **no** content type (`unconstrained`). `grantUpload`
205
235
  always sets one, so such a URL is hand-rolled, and trusting the uploader's header instead is the
206
236
  only other option.
@@ -214,12 +244,10 @@ Gotchas:
214
244
  happens to be named `local`. An explicit `baseUrl` on `localDriver` outranks the registration
215
245
  (the operator saying where the route is mounted); an unregistered driver still mints under
216
246
  `local`. One driver instance under two disk names is refused at `defineStorage`
217
- (`X_CONFIG_INVALID`) — it could only mint under one of them. Before that, the base was stated
218
- twice (`/_storage/local` in the driver, `/_storage` in `verifySignedUrl`'s default) and NO
219
- genuine URL verified at all: the key parsed as `local/<key>`. `@ultimat3/cli`'s
247
+ (`X_CONFIG_INVALID`) — it could only mint under one of them. `@ultimat3/cli`'s
220
248
  `STORAGE_BASE_PATH` is a third statement of the mount prefix and should import
221
249
  `DEFAULT_SIGNED_URL_BASE` instead.
222
- - **`accept.ts` asks the `isTenantScoped`/`isWithinOrg` PAIR, exactly as `dev-storage.ts` does.**
250
+ - **`accept.ts` asks the `isTenantScoped`/`isWithinOrg` PAIR, exactly as `runtime-storage.ts` does.**
223
251
  `isWithinOrg` alone refused every un-scoped key, so an app's own `brand/logo.png` was unreachable
224
252
  through a URL it had just signed. `isTenantScoped` is case-INSENSITIVE and `isWithinOrg` is not:
225
253
  `Org/o2/x` and `org/o2/x` are one file on APFS/NTFS, so the fold has to count as tenant-scoped
@@ -233,9 +261,8 @@ Gotchas:
233
261
  signed URL is a capability; a leaked capability must still not cross a tenant.
234
262
  - `X_STORAGE_ORG_MISMATCH` maps to **404**, not 403 (`@ultimat3/http`'s `error-map.ts`). 403 would
235
263
  confirm a key exists to the one caller who must not learn it.
236
- - The local driver reports the **filesystem's** `lastModified`, not the injected `Clock`, so
237
- `sweepOrphans` cannot be tested against it with a frozen clock — `attachment.test.ts` uses a
238
- stub driver with authored timestamps, and `driver-local.test.ts` proves the disk half.
264
+ - **Local `lastModified` is the `Clock`'s, kept in the sidecar** — one instant from `put`/`copy`
265
+ to every read; mtime only with no trusted sidecar.
239
266
  - `upload-client.ts` defaults to `XMLHttpRequest`, not `fetch`: `fetch` reports no upload
240
267
  progress in any shipping browser, and a bar that jumps 0 → 100 is a bar that is lying. It is
241
268
  **the declared XHR seam** of plan 101 (decision 13): the one `XMLHttpRequest` the framework
package/README.md CHANGED
@@ -23,10 +23,10 @@ Swapping `local` for `s3` in `app.config.ts` changes no call site. `x dev` needs
23
23
  | Driver | Backing | For | Signed URLs |
24
24
  |---|---|---|---|
25
25
  | `localDriver` | `Bun.file`/`Bun.write`, one root dir | dev, tests, single-node | HMAC + dev route |
26
- | `s3Driver` | `Bun.s3` | prod: any S3-compatible endpoint — AWS, R2, a self-hosted gateway | provider presign |
27
- | `memoryDriver` | a `Map` in this process | a TEST's disk — never a deployment's: a restart is every object gone | HMAC, the local disk's own |
26
+ | `s3Driver` | `Bun.s3`, plus core's `signAwsRequest` over `fetch` for the requests Bun has no option for | prod: any S3-compatible endpoint — AWS, R2, a self-hosted gateway | provider presign |
27
+ | `memoryStorageDriver` | a `Map` in this process | a TEST's disk — never a deployment's: a restart is every object gone | HMAC, the local disk's own |
28
28
 
29
- `memoryDriver()` is what a suite holds instead of a temp directory or a hand-written fake: it
29
+ `memoryStorageDriver()` is what a suite holds instead of a temp directory or a hand-written fake: it
30
30
  answers every method `localDriver` does, refuses what it refuses (an unsafe key, a wrong checksum,
31
31
  a body past `maxPutBytes`, `serverSideEncryption`), and signs under the same rule — the published
32
32
  development key in `development` and `test` only. `objects()` hands back a COPY of every stored
@@ -34,13 +34,13 @@ object's bytes by key, for the assertion a test makes about the bucket itself.
34
34
 
35
35
  ```ts
36
36
  import { afterEach, beforeEach } from 'bun:test';
37
- import { defineStorage, disk, memoryDriver, resetStorage } from '@ultimat3/storage';
37
+ import { defineStorage, disk, memoryStorageDriver, resetStorage } from '@ultimat3/storage';
38
38
 
39
39
  // A fresh driver per test: `resetStorage()` forgets the REGISTRY, never a driver's objects, so a
40
40
  // driver held across tests carries every earlier test's writes.
41
- let sessions = memoryDriver();
41
+ let sessions = memoryStorageDriver();
42
42
  beforeEach(() => {
43
- sessions = memoryDriver();
43
+ sessions = memoryStorageDriver();
44
44
  defineStorage({ disks: { sessions } });
45
45
  });
46
46
  afterEach(() => resetStorage());
@@ -64,10 +64,71 @@ network, but never this process's heap. Both arguments go through `assertSafeKey
64
64
  One S3 driver covers all three backends — the difference is `endpoint` + `forcePathStyle`.
65
65
  Credentials are **env var NAMES** (`accessKeyIdEnv`, default `S3_ACCESS_KEY_ID`), never
66
66
  literals: a key in `app.config.ts` is a key in git. Missing ones throw `X_ENV_MISSING`.
67
+ Endpoint, region and session token resolve ONCE for both transports, as Bun reads them: the
68
+ option, then `S3_ENDPOINT`/`AWS_ENDPOINT`, `S3_REGION`/`AWS_REGION`,
69
+ `S3_SESSION_TOKEN`/`AWS_SESSION_TOKEN` (or the one `sessionTokenEnv` names), from
70
+ `s3Driver({ env })` or `process.env`. `s3Driver({ fetch, clock })` inject the signed path's transport and its clock (tests; default the
71
+ global `fetch` and `systemClock`); `S3FetchLike` is the shape.
67
72
  `localDriver` keeps content type, etag, `cacheControl` and `metadata` in a `<root>/.meta/`
68
73
  sidecar so `get()`/`list()` round-trip everything `put()` was handed; sidecars never appear in
69
- `list()`. `s3Driver` cannot: it refuses `cacheControl`/`metadata` on `put()` (`X_NOT_IMPLEMENTED`,
70
- Bun exposes no header hook yet).
74
+ `list()`. `s3Driver` STORES both — a `put()` carrying `cacheControl`, `metadata`, `retention` or
75
+ `legalHold` goes past the Bun client as one SigV4-signed PUT (`x-amz-meta-*`, `Cache-Control`,
76
+ `Content-MD5`), against the same endpoint, region and credentials — but its `get()`/`stat()` read
77
+ through Bun's HEAD, which exposes neither, so there they are absent rather than invented. A plain
78
+ `put()` stays on the Bun client. A metadata name must be a header token and every value printable
79
+ ASCII (`X_INVARIANT` otherwise): S3 carries them as headers.
80
+
81
+ **The local disk writes an object by rename, in an order no crash can make lie.** An object there
82
+ is two files, so a `put()` or `copy()` stages bytes, sidecar and a *pending marker* (the new etag)
83
+ under `<root>/.meta/.tmp/` and then: renames the marker to `.meta/<key>.json.pending`, renames the
84
+ sidecar, renames the bytes, removes the marker. What a reader sees if the process dies:
85
+
86
+ | Died before | Fresh key | Overwrite |
87
+ |---|---|---|
88
+ | the marker or the sidecar rename | absent | the previous object, whole |
89
+ | the bytes rename | absent (a sidecar alone is no object) | the previous BYTES; `get`/`stat` answer `application/octet-stream` and the bytes' own etag, `list` no type and `etag: ''` — never the new type |
90
+ | clearing the marker | the new object to `get`/`stat` (re-checked against the bytes); `list` reports it untyped until the next put | same |
91
+
92
+ No row serves a content type or an etag the bytes were not checked against. A marker that
93
+ cannot be cleared after the renames landed is not a failed put — the object is committed and the
94
+ last row is what it leaves. `stat()`, `list()` and a copy's source read queue behind the key's
95
+ writer exactly as `get()` does. Writers and `get()` of
96
+ ONE key are also serialised inside a process, so two concurrent puts cannot interleave; across
97
+ processes the marker is what covers the window. `stream()` hands back bytes with no metadata and
98
+ checks nothing. The layout is otherwise unchanged — an existing root needs no migration.
99
+
100
+ **A refused write is coded.** `EACCES`, `ENOSPC`, `EROFS` or a root that is a file on the local
101
+ disk, and a provider's refused PUT on s3, are `X_STORAGE_PUT_FAILED` from `put()` and `copy()`,
102
+ with the errno or provider code in the cause. `put()` options are held to their types first
103
+ (`assertPutOptions`: string `contentType`/`cacheControl`, a plain object of strings for
104
+ `metadata`, `X_INVARIANT` otherwise) on all three drivers. A refused READ is `X_STORAGE_READ_FAILED` the same
105
+ way. No driver method leaves with a bare throw for a wrong-typed argument either: a key that is not
106
+ a string is `X_STORAGE_PATH_UNSAFE`, a body that is not bytes, a `Blob` or a stream and a
107
+ non-string `list()` prefix or cursor are `X_INVARIANT` (`driver-read-failed.test.ts` sweeps every
108
+ method on every disk). The one thing no code can cover is a `stream()` that fails after it was
109
+ handed back: that is the stream's own error.
110
+
111
+ **A key cannot be another key's path on the local disk — or on the memory disk standing in for
112
+ it.** A POSIX path is a file or a directory: `put('a')` then `put('a/b')` — or the reverse, a
113
+ `copy()` onto either, or `a` beside `a.json/b`, which collide in the sidecar tree — is
114
+ `X_STORAGE_KEY_CONFLICT`, refused before a byte moves, where it used to be a bare `ENOTDIR` /
115
+ `EISDIR`. Its `fix` names the disk as `defineStorage` registered it, not the driver kind.
116
+ `memoryStorageDriver` refuses the same keys with the same `blocking` (a suite on it stands in for
117
+ `x dev`'s disk); `s3Driver` holds both. `driver-local-memory-parity.test.ts` holds local and memory
118
+ to one answer, `driver-contract.test.ts` pins the s3 divergence. `delete()` of a key that is only
119
+ another key's directory (`delete('c')` beside `c/d`) is deleting an absent key: not an error, and
120
+ nothing beneath it is touched.
121
+
122
+ `stat(key)` answers what a read would — size, type, etag, age — **without the bytes**, and
123
+ `undefined` for nothing there. It is a required `StorageDriver` method: `promoteAttachment` measures
124
+ with it, and a driver that cannot measure does not compile.
125
+
126
+ `lastModified` is **optional** on a listing entry and on a `StorageObject`: absent when the
127
+ provider reported none, never epoch 0 — which `sweepOrphans` read as "older than any window" and
128
+ deleted. A reader that needs an age handles `undefined`; the sweep spares it. On the local and
129
+ memory disks it is the write's instant on the disk's injected `clock`: what `put()`/`copy()`
130
+ returned is what every later `stat`/`get`/`list` reports (the local disk records it in the
131
+ sidecar; an object written before that, or with no trusted sidecar, reports the file's mtime).
71
132
 
72
133
  **`StorageListEntry.contentType` is optional; `StorageObject.contentType` is not.** S3's
73
134
  `ListObjectsV2` returns no Content-Type, so a listed s3 object simply has none — reading the real
@@ -86,6 +147,28 @@ disk answered `exists()` true and `list()` nothing for the same object. **`limit
86
147
  integer or a refusal** (`X_INVARIANT`, at the `ListOptions` seam): `limit: 0` used to read back as
87
148
  a complete, empty page on the local disk and as `maxKeys: 0` on s3.
88
149
 
150
+ **A listing is core's ONE `Page`** — `Page<StorageListEntry>`, the shape an entity `findMany` and
151
+ a query `.page()` answer: `{ rows, nextCursor, hasMore }`, `nextCursor` a string exactly when
152
+ `hasMore` is true. The cursor exists only when another object does — the local and memory disks
153
+ look one key past the page, s3 reads `IsTruncated` and takes `NextContinuationToken` — so a full
154
+ last page answers `hasMore: false`, never a cursor to an empty page. `cursor: null` is the first
155
+ page, so a walk threads `nextCursor` straight back:
156
+
157
+ ```ts
158
+ import type { Page } from '@ultimat3/core';
159
+ import { disk, type StorageListEntry } from '@ultimat3/storage';
160
+
161
+ const keys: string[] = [];
162
+ let cursor: string | null = null;
163
+ do {
164
+ const page: Page<StorageListEntry> = await disk('uploads').list({ prefix: 'org/o1/', cursor });
165
+ keys.push(...page.rows.map((object) => object.key));
166
+ cursor = page.nextCursor;
167
+ } while (cursor !== null);
168
+ ```
169
+
170
+ Before 25.0.0 it was `ListPage { objects, truncated, cursor? }`, its own shape beside core's.
171
+
89
172
  ## `put()` is for objects that fit in memory
90
173
 
91
174
  `put()` buffers the whole body — size and checksum have to be known before the object exists —
@@ -100,6 +183,42 @@ is the architecture and not an optimisation — see the round trip below. Raise
100
183
  for a disk that really does write large objects server-side, and remember S3 caps a single PUT at
101
184
  5GB whatever you set.
102
185
 
186
+ **`get()` buffers too, so it has the same ceiling**: `maxGetBytes`, on all three drivers,
187
+ defaulting to that disk's `maxPutBytes`. An object past it is `X_STORAGE_TOO_LARGE` — decided on
188
+ the disk's own size (a HEAD on s3) before a byte is read — and is read with `stream()`. It exists
189
+ because an object's size is not the server's to choose: a presigned PUT lands in a bucket
190
+ unmeasured, so a later `get()` of it was heap growth the uploader picked.
191
+
192
+ ## Write-once objects (S3 Object Lock)
193
+
194
+ ```ts
195
+ import { systemClock } from '@ultimat3/core';
196
+ import { disk, isLocked } from '@ultimat3/storage';
197
+
198
+ const key = 'org/o1/ledger.csv';
199
+ const bytes = new TextEncoder().encode('a,b\n');
200
+ const now = systemClock.now();
201
+ await disk('ledger').put(key, bytes, {
202
+ retention: { mode: 'COMPLIANCE', retainUntil: new Date(now.getTime() + 7 * 86_400_000) },
203
+ legalHold: true,
204
+ });
205
+ const lock = await disk('ledger').retentionOf?.(key); // { retention: { mode, retainUntil }, legalHold: true }
206
+ isLocked(lock, now); // a hold, or a retention still ahead
207
+ ```
208
+
209
+ | | s3 | local, memory |
210
+ |---|---|---|
211
+ | `put({ retention, legalHold })` | `x-amz-object-lock-mode` / `-retain-until-date` (ISO) / `-legal-hold` (`ON`/`OFF`) on the signed PUT — the bucket must be CREATED with Object Lock | recorded in the sidecar (local) or beside the bytes (memory) |
212
+ | `retentionOf(key)` | signed `GET ?retention` + `?legal-hold`; a bucket without Object Lock, or an object with none, is `{ legalHold: false }` | from what `put()` recorded |
213
+ | `delete` / overwrite of a locked object | sent: S3 locks a VERSION, so a DELETE writes a delete marker and a PUT a newer version — the locked version stays | **refused** — `X_STORAGE_OBJECT_LOCKED` (`ObjectLockedError`, HTTP 409; `.lock` carries the lock) — until it lapses: one version per key, so refusing is the only way to keep the bytes |
214
+
215
+ `retainUntil` must be a valid instant in the future and `mode` `GOVERNANCE` or `COMPLIANCE`
216
+ (`X_INVARIANT` before a byte moves). `COMPLIANCE` cannot be shortened by anyone; extending a
217
+ retention, releasing a hold or bypassing `GOVERNANCE` is out of band:
218
+ `aws s3api put-object-retention` / `put-object-legal-hold`. A `copy()` does not carry the source's
219
+ lock — on S3 it is per version and set by the request. The local refusal is in-process
220
+ (`keyedQueue`), like every other local write rule.
221
+
103
222
  ## Server-side encryption, storage classes, lifecycle
104
223
 
105
224
  `PutOptions.serverSideEncryption` exists so the gap is visible **at the type level**, and every
@@ -123,7 +242,9 @@ it, `put('.meta/a/b.json', …)` overwrote the recorded content type of `a/b` an
123
242
  that object answered attacker HTML from the app's own origin. No sanitising — a key that needed
124
243
  fixing was built wrong.
125
244
  `scopedKey('org-1', 'avatars', 'a.png')` is `org/org-1/avatars/a.png`; guard every
126
- client-supplied key with `isWithinOrg(key, ctx.actor.orgId)`. A surface that serves objects pairs
245
+ client-supplied key with `isWithinOrg(key, ctx.actor.orgId)` — a predicate that never throws: an
246
+ org id that cannot be one (empty, or carrying a separator) contains nothing, so an actor with no
247
+ org claim is `false` and not an `X_STORAGE_PATH_UNSAFE` blaming the key. A surface that serves objects pairs
127
248
  it with `isTenantScoped(key)`: only a key already inside `org/` is another tenant's to refuse, so
128
249
  `disk().put('brand/logo.png', …)` stays reachable while `org/org-2/…` never is. `accept.ts` asks
129
250
  the pair too. `isTenantScoped` folds case (`Org/`, `ORG/`) and `isWithinOrg` does not, so a
@@ -132,9 +253,17 @@ case-variant prefix — one directory, not two, on APFS or NTFS — is refused r
132
253
  ## Signed URLs
133
254
 
134
255
  The HMAC covers the **constraints**, not just the key —
135
- `v1 \n METHOD \n key \n expiresAt \n maxBytes \n contentType`.
256
+ `v2 \n basePath \n METHOD \n key \n expiresAt \n maxBytes \n contentType`.
136
257
  A client that edits `?x-max=` invalidates the signature — it cannot widen what it was granted.
137
258
 
259
+ `basePath` is the disk: the PATH of the base the URL was minted under (`/_storage/<registered
260
+ name>`). Every local disk signs with the one `STORAGE_SIGNING_SECRET`, so without it a URL for
261
+ `uploads/<key>` verified on `private/<key>` with only the path segment edited. The origin is not
262
+ signed — the route that verifies is handed a path — and an absolute `baseUrl`
263
+ (`https://cdn.example.com/_storage/local`) is compared by its pathname (`signedUrlBasePath`), where
264
+ it used to make every URL minted under it `malformed`. `canonicalRequest` and `signConstraints`
265
+ take the base path as their last argument.
266
+
138
267
  The HMAC key is `signingSecret`, else `STORAGE_SIGNING_SECRET`, else the shipped
139
268
  `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
140
269
  to construct (`X_ENV_MISSING`) unless one of those two is set to something that is not the shipped
@@ -161,10 +290,20 @@ it on every grant, so refusing would break every s3 upload an app mints.
161
290
 
162
291
  `Content-Type` is attacker-controlled. A `.png` that is really an HTML document is stored XSS
163
292
  the moment a surface serves it back with the declared type. `validateUpload()` reads the magic
164
- bytes (PNG, JPEG, GIF, WebP, PDF, ZIP/OOXML, SVG, MP4, HTML, plain text) and rejects any
165
- payload whose bytes contradict the declaration. Checks run cheapest-first: key → size →
293
+ bytes (PNG, JPEG, GIF, WebP, PDF, ZIP/OOXML, SVG, the ISO-BMFF family, HTML, plain text) and
294
+ rejects any payload whose bytes contradict the declaration. Checks run cheapest-first: key → size →
166
295
  allowlist → sniff → checksum.
167
296
 
297
+ Every ISO base media file opens with the same `ftyp` box, so the box alone says "container" and
298
+ the **major brand** after it decides (`iso-bmff.ts`): `avif`/`avis` → `image/avif`, `heic`/`heix`…
299
+ → `image/heic`, `qt ` → `video/quicktime`, `M4A `/`M4B ` → `audio/mp4`, `3gp*` → `video/3gpp`,
300
+ anything else → `video/mp4`. Two brands name only a container and stand in as `application/zip`
301
+ does for OOXML: `video/mp4` for a declared `audio/mp4` (an audio-only MP4 carries `isom`), and
302
+ `image/heif` (`mif1`) for `image/heic` and `image/avif`. `audio/x-m4a` and `video/x-m4v` — what a
303
+ browser reports for a picked file — normalise to `audio/mp4` and `video/mp4`, and
304
+ `uploadPolicy({ allowedContentTypes })` normalises its list the same way, so an alias in an
305
+ allowlist names the type it means.
306
+
168
307
  `validateUpload({ key, declaredContentType, bytes }, uploadPolicy({ maxBytes: 5e6 }))`
169
308
 
170
309
  ## The direct-upload round trip
@@ -226,7 +365,7 @@ An upload happens **before** the row it belongs to exists, so it lands at
226
365
  | `pendingKey(orgId, uploadName(id, filename))` | `org/o1/pending/u-1.png` |
227
366
  | `quarantineKey(orgId, name)` | `org/o1/pending/quarantine/u-1.png` |
228
367
  | `attachmentKey(orgId, { entity, id, field }, name)` | `org/o1/post/p-1/cover/u-1.png` |
229
- | `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`) |
368
+ | `promoteAttachment({ disk, key, orgId, target, policy })` | `stat`, `copy`, then `delete` — never the reverse. **`policy` is required**: the object is measured here (`stat().size` over `policy.maxBytes` is `X_STORAGE_TOO_LARGE`, and it stays under `pending/` for the sweep), because an s3 presign bounds no size. A retry after the source is gone answers the already-attached object instead of `X_STORAGE_NOT_FOUND`. 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`) |
230
369
  | `releaseQuarantine({ disk, key, orgId })` | quarantine → pending; returns the released key |
231
370
  | `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 |
232
371
 
@@ -266,7 +405,7 @@ Inside `pending/` deliberately: an upload nobody ever scanned is still an orphan
266
405
  | `X_STORAGE_DISK_UNKNOWN` | `disk(name)` is not in `storage.disks`; cause lists the real ones |
267
406
  | `X_STORAGE_NOT_FOUND` | `get`/`stream` on a key that does not exist |
268
407
  | `X_STORAGE_PATH_UNSAFE` | traversal, absolute key, backslash, NUL, `%2e`, empty segment |
269
- | `X_STORAGE_TOO_LARGE` | payload over the policy `maxBytes`, or over a disk's `maxPutBytes` |
408
+ | `X_STORAGE_TOO_LARGE` | payload over the policy `maxBytes` (at validation, or measured by `promoteAttachment`), or over a disk's `maxPutBytes` / `maxGetBytes` |
270
409
  | `X_STORAGE_TYPE_REJECTED` | declared type off the allowlist, or contradicted by magic bytes |
271
410
  | `X_STORAGE_CHECKSUM_MISMATCH` | supplied base64 SHA-256 does not describe the bytes |
272
411
  | `X_STORAGE_URL_INVALID` | a signed request that does not match what was signed — edited constraint, wrong base, wrong method, contradicting `Content-Type` |
@@ -277,7 +416,11 @@ Inside `pending/` deliberately: an upload nobody ever scanned is still an orphan
277
416
  | `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 |
278
417
  | `X_STORAGE_QUARANTINED` | `promoteAttachment` on a key nothing has released from `pending/quarantine/` |
279
418
  | `X_STORAGE_NOT_PENDING` | `promoteAttachment` on a key outside the org's `pending/` prefix — most often another row's attached key |
280
- | `X_NOT_IMPLEMENTED` | S3 user metadata / cache-control; `serverSideEncryption` on either driver |
419
+ | `X_STORAGE_PUT_FAILED` | the disk REFUSED a `put()`/`copy()` — `EACCES`, `ENOSPC`, `EROFS`, a provider's refused PUT |
420
+ | `X_STORAGE_OBJECT_LOCKED` | local, memory: a delete, overwrite or copy onto a key under retention or a legal hold; the cause names the key, the mode and `retainUntil` or the hold, and the bytes are unchanged. Never raised by s3, where the provider keeps the locked version |
421
+ | `X_STORAGE_READ_FAILED` | the disk REFUSED a `get`/`stat`/`exists`/`stream` or the read half of a `copy` — a denied `s3:GetObject`, a throttle, an unreadable file. An **absent** object is still `X_STORAGE_NOT_FOUND`, including one deleted between the existence check and the read |
422
+ | `X_STORAGE_KEY_CONFLICT` | local and memory disks (never s3): the key's path is another key's directory, or the reverse (`a` and `a/b`) |
423
+ | `X_NOT_IMPLEMENTED` | `serverSideEncryption` on any driver |
281
424
  | `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` |
282
425
  | `X_IMAGE_UNSUPPORTED` | core's: an `avif` encode, a source no built-in decoder reads, or a `variantKey` format no variant can carry |
283
426
  | `X_IMAGE_DECODE_FAILED` | core's: truncated or corrupt image bytes |
@@ -296,6 +439,13 @@ a job boundary the class is gone and the `code` is what survives — match on th
296
439
  `variantKey()`, `srcsetDescriptors()`, `fitDimensions()` are pure — `@ultimat3/seo` builds
297
440
  `srcset` from them without decoding a byte.
298
441
 
442
+ A variant key is the WHOLE source key plus the transform: `photos/hero.png` at width 640 is
443
+ `photos/hero.png@w640.webp`. The source extension stays in, so `hero.png` and `hero.jpg` are two
444
+ cache identities — it was cut, and both minted `photos/hero@w640.webp`. `width` and `height` are
445
+ whole numbers of at least 1 on `variantKey` and `fitDimensions` (`X_INVARIANT` otherwise — no
446
+ `wNaN`, no `w1.5`). `fitDimensions` never answers a zero edge (1000×1 at width 100 is 100×1) and
447
+ never upscales except under `fit: 'cover'`, which is the exact box asked for.
448
+
299
449
  **`VARIANT_FORMATS` / `VariantFormat` are what a variant KEY can carry, `As of 2026-08`** —
300
450
  `avif`, `webp`, `jpeg`, `png` — and this package exports no `IMAGE_FORMATS` and no `ImageFormat`. Those two names are
301
451
  `@ultimat3/core`'s, over the six formats it can PROBE (`png`, `jpeg`, `webp`, `avif`, `gif`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/storage",
3
- "version": "23.0.0",
3
+ "version": "25.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",
@@ -25,13 +25,13 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.0"
28
+ "bun": ">=1.4.2"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "23.0.0"
35
+ "@ultimat3/core": "25.0.0"
36
36
  }
37
37
  }
package/src/accept.ts CHANGED
@@ -113,12 +113,9 @@ async function constraintsFor(
113
113
  // The PAIR is the question "does this key belong to somebody else?". `isWithinOrg` alone
114
114
  // answered `false` for every un-scoped key, so an app's own `brand/logo.png` was unreachable
115
115
  // through a URL it had just signed — `path.ts` says so and `dev-storage.ts` already asks it this
116
- // way. An actor with no org is inside no org, so every tenant-scoped key is somebody else's;
117
- // checked here because `isWithinOrg` reads an empty org as a malformed key and would blame the
118
- // URL for the actor's missing claim.
119
- const orgId = input.orgId;
120
- if (isTenantScoped(constraints.key) && (orgId === '' || !isWithinOrg(constraints.key, orgId))) {
121
- throw orgMismatch(constraints.key, orgId);
116
+ // way. An actor with no org is inside no org, and `isWithinOrg` answers exactly that.
117
+ if (isTenantScoped(constraints.key) && !isWithinOrg(constraints.key, input.orgId)) {
118
+ throw orgMismatch(constraints.key, input.orgId);
122
119
  }
123
120
  return constraints;
124
121
  }
package/src/attachment.ts CHANGED
@@ -5,10 +5,11 @@
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 { finiteCount, renderThrowable, systemClock } from '@ultimat3/core';
9
- import type { ListPage, StorageDriver, StorageListEntry, StorageObject } from './driver';
10
- import { notPending, orgMismatch, quarantined } from './errors';
8
+ import { assert, finiteCount, renderThrowable, systemClock } from '@ultimat3/core';
9
+ import type { StorageDriver, StorageListEntry, StorageObject } from './driver';
10
+ import { notPending, objectNotFound, orgMismatch, quarantined, tooLarge } from './errors';
11
11
  import { isWithinOrg, orgPrefix, scopedKey } from './path';
12
+ import type { UploadPolicy } from './upload';
12
13
 
13
14
  /** The one segment an unattached upload lives under. `sweepOrphans` reads only this prefix. */
14
15
  export const PENDING_SEGMENT = 'pending';
@@ -123,13 +124,27 @@ export interface PromoteAttachmentInput {
123
124
  readonly key: string;
124
125
  readonly orgId: string;
125
126
  readonly target: AttachmentTarget;
127
+ /**
128
+ * The policy the upload was GRANTED under — required, never defaulted. On a bucket-backed disk
129
+ * the presigned PUT carries no size, so `policy.maxBytes` bound nothing until this call measured
130
+ * the object; a default here would silently replace an app's own ceiling with the framework's.
131
+ */
132
+ readonly policy: UploadPolicy;
126
133
  }
127
134
 
128
135
  /**
129
136
  * Move a pending upload onto the row that now exists. Copy first, delete second: a delete that
130
137
  * ran first would lose the bytes on a failed write, and the sweep would have collected them
131
- * anyway. Re-promoting an already-promoted key is `X_STORAGE_NOT_FOUND` from the copy, never a
132
- * silent no-op that leaves the caller believing a file is attached.
138
+ * anyway.
139
+ *
140
+ * MEASURED before it moves: `stat().size` over `policy.maxBytes` is `X_STORAGE_TOO_LARGE`, and the
141
+ * object stays under `pending/` for `sweepOrphans` — never at a key a row points to. `stat` and
142
+ * not `get`: this is the call that learns the size, so it cannot be one that buffers it.
143
+ *
144
+ * Idempotent across a retry: a source that is gone with the destination already there answers the
145
+ * destination. Promotion is copy-then-delete inside a request whose row write can still roll back,
146
+ * and the retry used to raise `X_STORAGE_NOT_FOUND` for a file sitting exactly where it belonged.
147
+ * A source that is gone with NO destination is still `X_STORAGE_NOT_FOUND`.
133
148
  *
134
149
  * The copy is `disk.copy`, not `get` + `put`: promotion used to download the whole object into
135
150
  * this process and upload it again, so attaching a 500MB file moved a gigabyte through the pod
@@ -141,10 +156,27 @@ export async function promoteAttachment(input: PromoteAttachmentInput): Promise<
141
156
  // copy-then-delete below would move that row's file here and delete theirs.
142
157
  if (!isPendingKey(input.key, input.orgId)) throw notPending(input.key, input.orgId);
143
158
  if (isQuarantinedKey(input.key, input.orgId)) throw quarantined(input.key, input.orgId);
159
+ // The type says required; an untyped caller upgrading from the four-field call does not read
160
+ // types, and `input.policy.maxBytes` on `undefined` is a bare `TypeError` two lines down.
161
+ assert(
162
+ typeof input.policy?.maxBytes === 'number',
163
+ 'promoteAttachment was called with no policy, so nothing bounds the size of the upload it attaches',
164
+ 'promoteAttachment({ disk, key, orgId, target, policy: uploadPolicy({ maxBytes }) }) — the policy the upload was granted under',
165
+ );
166
+ const disk = input.disk;
144
167
  const name = input.key.slice(input.key.lastIndexOf('/') + 1);
145
168
  const attached = attachmentKey(input.orgId, input.target, name);
146
- const object = await input.disk.copy(input.key, attached);
147
- await input.disk.delete(input.key);
169
+ const pending = await disk.stat(input.key);
170
+ if (pending === undefined) {
171
+ const already = await disk.stat(attached);
172
+ if (already !== undefined) return already;
173
+ throw objectNotFound(disk.name, input.key);
174
+ }
175
+ if (pending.size > input.policy.maxBytes) {
176
+ throw tooLarge(input.key, pending.size, input.policy.maxBytes);
177
+ }
178
+ const object = await disk.copy(input.key, attached);
179
+ await disk.delete(input.key);
148
180
  return object;
149
181
  }
150
182
 
@@ -197,14 +229,12 @@ export async function sweepOrphans(input: SweepOrphansInput): Promise<SweepResul
197
229
  const prefix = pendingPrefix(input.orgId);
198
230
  const deleted: string[] = [];
199
231
  const failed: SweepFailure[] = [];
200
- let cursor: string | undefined;
232
+ let cursor: string | null = null;
201
233
  do {
202
- const page: ListPage = await input.disk.list({
203
- prefix,
204
- ...(cursor === undefined ? {} : { cursor }),
205
- });
206
- for (const object of page.objects) {
207
- if (object.lastModified.getTime() > cutoff) continue;
234
+ const page = await input.disk.list({ prefix, cursor });
235
+ for (const object of page.rows) {
236
+ // An object whose age the provider did not report is SPARED: only a proven age is swept.
237
+ if (object.lastModified === undefined || object.lastModified.getTime() > cutoff) continue;
208
238
  if ((await input.keep?.(object)) === true) continue;
209
239
  try {
210
240
  await input.disk.delete(object.key);
@@ -213,7 +243,7 @@ export async function sweepOrphans(input: SweepOrphansInput): Promise<SweepResul
213
243
  failed.push({ key: object.key, reason: renderThrowable(error) });
214
244
  }
215
245
  }
216
- cursor = page.truncated ? page.cursor : undefined;
217
- } while (cursor !== undefined);
246
+ cursor = page.nextCursor;
247
+ } while (cursor !== null);
218
248
  return { deleted, failed };
219
249
  }