@ultimat3/storage 1.1.0 → 2.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 ADDED
@@ -0,0 +1,169 @@
1
+ # @ultimat3/storage — agent notes
2
+
3
+ Tier 1. Object storage: named disks, safe keys, signed URLs, sniffed uploads.
4
+
5
+ - May import: `@ultimat3/core`, `@ultimat3/schema`. Nothing else. No npm dependencies.
6
+ - Must NOT know about HTTP, entities, actions, render — a route calls `acceptSignedUpload()`,
7
+ this package never owns one. Consumers: `http`, `seo`, `admin`, `cli`.
8
+ - **The line is a `Request`/`Response`/status, not a URL string.** `accept.ts` takes a url, bytes
9
+ and a content-type string and answers with a `StorageObject` or throws; the caller turns that
10
+ into a response, because `@ultimat3/http`'s `error-map.ts` is the only place a code becomes a
11
+ status. A handler here would be a second status table.
12
+ - **Tier 1 is why there is no `uploadAction()`.** `action` is tier 3, so this package cannot
13
+ return one — `llm()` works only because `@ultimat3/ai` sits *above* `action`. The shape that
14
+ fits the table is `@ultimat3/auth`'s: ship the server function (`grantUpload`), let the app
15
+ wrap it in its own `action()` with its own policy. See `docs/architecture/17-uploads.md`.
16
+
17
+ | Rule | |
18
+ |---|---|
19
+ | Errors | `StorageError` + one factory per code in `errors.ts`; never `throw new Error` |
20
+ | New code | add to `STORAGE_ERROR_CODES` **and** `STORAGE_ERROR_TITLES` |
21
+ | Keys | every driver method starts with `assertSafeKey()`. No exceptions, no sanitising. `META_DIR` (`.meta`) is a reserved first segment on every driver — see below |
22
+ | Time | take a `Clock`; never `Date.now()` |
23
+ | Bytes | `Uint8Array \| ReadableStream \| Blob`. Never a base64 string, never unbounded — `toBytes(body, limit)` |
24
+ | Exports | explicit in `src/index.ts`; no `export *` |
25
+
26
+ | File | Owns |
27
+ |---|---|
28
+ | `driver.ts` | `StorageDriver` contract (8 methods) + bounded `toBytes`/`sha256Base64`/`etagOf` |
29
+ | `driver-local.ts` | dev default over `Bun.file`, `.meta/` sidecars, `Bun.Glob` listing |
30
+ | `driver-s3.ts` | `Bun.S3Client`, built lazily (import must never open a socket) |
31
+ | `path.ts` | key validation + `META_DIR` + `scopedKey`/`isWithinOrg`/`isTenantScoped` tenant boundary |
32
+ | `signed-url.ts` | HMAC over the constraint tuple, constant-time verify |
33
+ | `upload.ts` | magic-byte sniff + size/allowlist/checksum policy |
34
+ | `image.ts` | deterministic variant keys; byte path over core's pipeline (png/jpeg encode only) |
35
+ | | `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 |
36
+ | `storage.ts` | `defineStorage` + module-level `storage()` / `disk()` |
37
+ | `grant.ts` | `grantUpload` — the ONE way a presigned PUT is minted; the client never names a key |
38
+ | `accept.ts` | `acceptSignedUpload` / `readSignedObject` — the two decisions a `/_storage` route is made of |
39
+ | `attachment.ts` | `pending/` → row promotion, the `quarantine/` segment, and `sweepOrphans` |
40
+ | `upload-client.ts` | the browser half: grant → PUT with progress → key. No `Bun.*`, no `node:` |
41
+
42
+ ```bash
43
+ bun test # from packages/storage
44
+ bun run typecheck
45
+ ```
46
+
47
+ Gotchas:
48
+ - **A driver's semantics are pinned in ONE test with the other driver beside them.**
49
+ `driver-parity.test.ts` drives `localDriver` over a temp dir and `s3Driver` over `FakeS3Client`
50
+ in a single `test()` per claim, so neither disk can move alone. Where the two genuinely cannot
51
+ agree it pins the DIVERGENCE, with the reason — that is the honest form, and it still fails the
52
+ day either half changes.
53
+ - **`list()` is idempotent for an EMPTY disk and for nothing else** (`As of 2026-08`) — exactly
54
+ `delete()`'s rule, one call to the left, and both drivers broke it in opposite directions. The
55
+ local one caught EVERYTHING and answered `{ objects: [], truncated: false }`, so `EACCES` on the
56
+ root read as "this disk is empty"; the s3 one let a bare `S3Error` escape uncoded, with nothing
57
+ for the http error map to render but a 500. `sweepOrphans` walks `list()`, so the local swallow
58
+ was a false-erasure report a layer up. `ENOENT` (a root nobody has written to) is still an empty
59
+ page; everything else is `X_STORAGE_LIST_FAILED`, whose `fix` the DRIVER supplies.
60
+ - **`head()` NEVER reads an object's bytes, and `list()` is why** (`As of 2026-08`). It hashed a
61
+ sidecar-less object to invent an etag, under a comment saying "`list()` must not read every file
62
+ it lists" — which is what `list()` then did, one whole buffered object per listed row,
63
+ sequentially, and `copy()` inherited it, so a copy documented as never routing bytes through the
64
+ heap buffered the entire source. A listing that cannot know an etag reports `''`, which is what
65
+ the s3 listing already answers. `get()` hashes out of bytes it already holds; `copy()` passes
66
+ `hash: true`, because the sidecar it writes at the destination would otherwise carry `etag: ''`
67
+ as a durable lie.
68
+ - **`put({ metadata })` / `put({ cacheControl })` is the one `PutOptions` pair the disks disagree
69
+ about, and it is pinned rather than resolved.** Bun's `S3File.write` exposes `type`, `acl` and
70
+ `storageClass` and no header hook for `x-amz-meta-*` or `Cache-Control`, so `s3Driver` refuses
71
+ (`X_NOT_IMPLEMENTED`, with the out-of-band `aws s3 cp` in the fix) while `localDriver` stores both
72
+ in its sidecar and reads them back. **Do not "fix" this by making the local disk refuse too** —
73
+ that deletes a working capability and the two `StorageListEntry` fields that carry it, to buy
74
+ symmetry with a limitation that is Bun's and temporary. The day the hook lands, the s3 half of
75
+ `driver-parity.test.ts` fails and the resolution is to make s3 store them.
76
+ - **`signedUrl({ maxBytes })` is signed on `local` and unenforceable on `s3`, and the s3 driver may
77
+ NOT refuse it.** `grantUpload` passes `maxBytes: policy.maxBytes` on every grant, so a refusal
78
+ would break every s3 upload an app mints. S3 has no request header for a size and Bun's `presign`
79
+ covers method, expiry and type — so on the production disk the client PUTs straight into the
80
+ bucket and nothing between the grant and the object holds the ceiling. That belongs to a bucket
81
+ rule or a post-upload `object.size` check, neither of which is a driver's; `SignedUrlOptions`
82
+ says so and `driver-parity.test.ts` pins both halves.
83
+ - **`delete()` is idempotent for an ABSENT key and for nothing else.** Both drivers used to end in
84
+ `.catch(() => undefined)`, so a denied `s3:DeleteObject`, a `SlowDown`, an expired credential and
85
+ a read-only mount all resolved as success — and `sweepOrphans` returned them as deleted, which
86
+ is a GDPR erasure report certifying data that is still in the bucket. The s3 driver classifies
87
+ structurally (`code` in `NoSuchKey`/`NotFound`/`ENOENT`, or a 404 `statusCode`/`status`; an
88
+ `S3Error` is a plain `Error` with those fields and every read of one can throw), the local
89
+ driver on `ENOENT`. Everything else is `X_STORAGE_DELETE_FAILED`, whose `fix` the DRIVER
90
+ supplies — the command that reproduces the refusal differs per disk.
91
+ - **`toBytes` takes a `ByteLimit`, and there is no unbounded variant.** `put()` buffers, so the
92
+ ceiling is what keeps a route piping a 4GB body into `disk.put()` from being an OOM kill rather
93
+ than a refusal. `Uint8Array`/`Blob` are refused on their declared length; a `ReadableStream` is
94
+ read chunk-by-chunk and **cancelled** past the line, so at most one chunk over is ever held.
95
+ Default `maxPutBytes` is `DEFAULT_MAX_UPLOAD_BYTES` — one constant, imported by both drivers
96
+ from `upload.ts`, because the server-side ceiling and the upload ceiling are the same fact.
97
+ - **`serverSideEncryption` is declared and refused by BOTH drivers.** Bun exposes `acl`,
98
+ `storageClass` and `type` and nothing for `x-amz-server-side-encryption*`, and a POSIX file is
99
+ not encrypted at all. Refusing on the dev disk too is deliberate: a `put()` that works in `x dev`
100
+ and throws in production is two rules. Lifecycle, storage classes and replication stay out
101
+ entirely — bucket-side, terraform's, axiom 7.
102
+ - **Quarantine is `pending/quarantine/`, INSIDE the pending prefix.** So `sweepOrphans` collects a
103
+ never-scanned upload with no second prefix to walk, and `isPendingKey` stays true for it.
104
+ `promoteAttachment` refuses a quarantined key (`X_STORAGE_QUARANTINED`); `releaseQuarantine` is
105
+ the app's scan verdict, and returns the ordinary pending key promotion accepts. Ultimate ships
106
+ no scanner — axiom 8, and `application/zip` is an accepted OOXML container by design.
107
+ - **`StorageListEntry` vs `StorageObject`.** A listing's `contentType` is optional; a `get()`'s is
108
+ not. `ListObjectsV2` returns no Content-Type, and the s3 driver used to invent
109
+ `application/octet-stream` while the local driver read the truth from its sidecar — two drivers
110
+ disagreeing about one object. The local `list()` now omits it too when there is no sidecar.
111
+ - **`META_DIR` lives in `path.ts`, not in `driver-local.ts`.** The sidecar namespace overlapped the
112
+ object namespace: `put('a/b', png)` writes `<root>/.meta/a/b.json`, and `.meta/a/b.json` was
113
+ itself a legal key, so an uploader could rewrite another object's recorded `contentType` to
114
+ `text/html` and have a route serve attacker HTML from the app's origin. Reserved in
115
+ `assertSafeKey`, so it holds for S3 too — a key valid on one driver and refused on another is two
116
+ key rules. The `list()` skip stays as a second line of defence.
117
+ - **`localDriver` refuses to construct outside development without a usable signing secret**
118
+ (`X_ENV_MISSING`, borrowed from core). Usable means neither the `signingSecret` option nor
119
+ `STORAGE_SIGNING_SECRET` is missing, empty **or** the published `DEV_SIGNING_SECRET` — pasting
120
+ the literal into `app.config.ts` configures nothing, so it is refused exactly as its absence is.
121
+ `DEV_SIGNING_SECRET` is published in this repo, and `acceptSignedUpload` trusts a signed URL's
122
+ constraints over the app's `uploadPolicy` — so the fallback is a universal grant to mint any
123
+ `PUT`. Refused at construction, not at the first `signedUrl()`: a process that cannot sign safely
124
+ must not finish booting. The cause names the environment `resolveEnvironment()` resolved, which
125
+ may be `NODE_ENV`'s, never a variable the process did not set. `usesDevStorageSecret()` is the
126
+ `x doctor` predicate, mirroring core's `usesDevCursorSecret()`; it reads the env var, so a disk
127
+ handed an explicit `signingSecret` is outside its question.
128
+ - **The mounted read half is `@ultimat3/cli`'s `dev-storage.ts`, not this package.** `GET
129
+ /_storage/:disk/*key` gates on `@ultimat3/policy`'s `evaluate()` (`storage:read`), which is tier
130
+ 2 and unreachable from here — so a "serve this object" helper in this package could only ever be
131
+ a second authz path. This package's contribution to that route is `assertSafeKey`,
132
+ `isTenantScoped`/`isWithinOrg` and the driver's own `contentType`/`etag`; the `Response` is the
133
+ host's, exactly as `accept.ts`'s header says.
134
+ - `acceptSignedUpload` still has no mounted route: the signing secret is closed over inside
135
+ `localDriver` and no `StorageDriver` method exposes it, so a host cannot verify a signed PUT
136
+ through the `Storage` seam it is handed. Serving reads needed none of it (policy decides, not a
137
+ signature); mounting the write half needs that seam question answered first.
138
+ - `X_NOT_IMPLEMENTED` is core's — it belongs in `STORAGE_BORROWED_ERROR_CODES` (codes, no title)
139
+ and never in the registration call. A `hasErrorCode()` guard there would suppress the
140
+ `X_ERROR_CODE_DUPLICATE` two owners of one code are supposed to get. Tests need `resetStorage()`
141
+ in `beforeEach`.
142
+ - `image.ts` owns no pixels: core's `transformImageBytes`/`blurDataUrl` are the only scaler.
143
+ Its image failures (`X_IMAGE_UNSUPPORTED`, `X_IMAGE_DECODE_FAILED`) surface unwrapped —
144
+ wrapping them in a `StorageError` would give one failure two codes.
145
+ - `transformImage()` must encode at exactly `fitDimensions()`'s size, and passes `format`
146
+ explicitly (`?? 'webp'`, which then rejects): bytes that disagree with the `variantKey`
147
+ extension, or with the `width`/`height` `@ultimat3/seo` inlined, are the layout shift the
148
+ whole path exists to prevent. That is why it derives the box itself and asks core for
149
+ `fit: 'cover'` — core's `contain` letterboxes to the requested box, this API fits inside it.
150
+ - Bun's S3 flag is `virtualHostedStyle`; our `forcePathStyle` is its inverse.
151
+ - The signature check runs BEFORE the expiry check. Do not reorder.
152
+ - `timingSafeEqual` is `@ultimat3/core`'s (`signed-url.ts` imports and re-exports it) — the same
153
+ implementation `@ultimat3/auth` uses, not a second copy. Add new secret comparisons through it.
154
+ - `verifySignedUrl` never throws, and `parseConstraints` is where that is kept: the key is decoded
155
+ through the guarded `decodeSegment`, so a `%ZZ` in the path is `'malformed'` rather than the bare
156
+ `URIError` `decodeURIComponent` raises. Same shape as `@ultimat3/auth`'s `decodeCookieValue`.
157
+ - `acceptSignedUpload` refuses a URL signed with **no** content type (`unconstrained`). `grantUpload`
158
+ always sets one, so such a URL is hand-rolled, and trusting the uploader's header instead is the
159
+ only other option.
160
+ - `orgId` is required on both halves of `accept.ts` and is the ACTOR's, never a request field. A
161
+ signed URL is a capability; a leaked capability must still not cross a tenant.
162
+ - `X_STORAGE_ORG_MISMATCH` maps to **404**, not 403 (`@ultimat3/http`'s `error-map.ts`). 403 would
163
+ confirm a key exists to the one caller who must not learn it.
164
+ - The local driver reports the **filesystem's** `lastModified`, not the injected `Clock`, so
165
+ `sweepOrphans` cannot be tested against it with a frozen clock — `attachment.test.ts` uses a
166
+ stub driver with authored timestamps, and `driver-local.test.ts` proves the disk half.
167
+ - `upload-client.ts` defaults to `XMLHttpRequest`, not `fetch`: `fetch` reports no upload
168
+ progress in any shipping browser, and a bar that jumps 0 → 100 is a bar that is lying.
169
+ - `exactOptionalPropertyTypes` is on — declare optional fields as `x?: T | undefined`.
package/README.md CHANGED
@@ -25,30 +25,93 @@ Swapping `local` for `s3` in `app.config.ts` changes no call site. `x dev` needs
25
25
  | `localDriver` | `Bun.file`/`Bun.write`, one root dir | dev, tests, single-node | HMAC + dev route |
26
26
  | `s3Driver` | `Bun.s3` | prod: MinIO, R2, AWS | provider presign |
27
27
 
28
+ `copy(from, to)` is on the contract so a promotion is not a download-and-reupload:
29
+ `promoteAttachment` used to `get()` the whole object into the app and `put()` it back, a gigabyte
30
+ through the pod to rename a 500MB attachment. `localDriver` does a real file copy; `s3Driver`
31
+ hands the source `S3File` to `write()` — Bun exposes no `CopyObject`, so the bytes still cross the
32
+ network, but never this process's heap. Both arguments go through `assertSafeKey`.
33
+
28
34
  One S3 driver covers all three backends — the difference is `endpoint` + `forcePathStyle`.
29
35
  Credentials are **env var NAMES** (`accessKeyIdEnv`, default `S3_ACCESS_KEY_ID`), never
30
36
  literals: a key in `app.config.ts` is a key in git. Missing ones throw `X_ENV_MISSING`.
31
- `localDriver` keeps content type and etag in a `<root>/.meta/` sidecar so `get()` round-trips
32
- `put()`; sidecars never appear in `list()`.
37
+ `localDriver` keeps content type, etag, `cacheControl` and `metadata` in a `<root>/.meta/`
38
+ sidecar so `get()`/`list()` round-trip everything `put()` was handed; sidecars never appear in
39
+ `list()`. `s3Driver` cannot: it refuses `cacheControl`/`metadata` on `put()` (`X_NOT_IMPLEMENTED`,
40
+ Bun exposes no header hook yet).
41
+
42
+ **`StorageListEntry.contentType` is optional; `StorageObject.contentType` is not.** S3's
43
+ `ListObjectsV2` returns no Content-Type, so a listed s3 object simply has none — reading the real
44
+ value would cost one `HeadObject` per row, which is what `list()` exists to avoid. It used to
45
+ report `application/octet-stream`, indistinguishable from an object that really is one, while the
46
+ local driver read the truth out of its sidecar: a caller filtering a listing by content type got
47
+ everything on `local` and nothing on `s3`. `get()` always answers a full `StorageObject`.
48
+
49
+ The etag follows the same rule: a listed object with no sidecar reports `etag: ''`, because
50
+ answering otherwise means reading and hashing the whole object — which is what the local `list()`
51
+ used to do, once per sidecar-less row, sequentially. `get()` hashes out of bytes it already holds.
52
+
53
+ ## `put()` is for objects that fit in memory
54
+
55
+ `put()` buffers the whole body — size and checksum have to be known before the object exists —
56
+ so every disk enforces a ceiling, `maxPutBytes`, defaulting to `DEFAULT_MAX_UPLOAD_BYTES` (10MB).
57
+ Past it is `X_STORAGE_TOO_LARGE`, raised **before** the bytes are resident: a `Uint8Array` and a
58
+ `Blob` already know their length, and a `ReadableStream` is cancelled the moment the running total
59
+ crosses the line. Without it a route piping a 4GB request body into `disk.put(key, req.body)` grew
60
+ the heap by 4GB and the kubelet killed the pod.
61
+
62
+ **User uploads never go through `put()`.** They go direct to the disk through `grantUpload`, which
63
+ is the architecture and not an optimisation — see the round trip below. Raise `maxPutBytes` only
64
+ for a disk that really does write large objects server-side, and remember S3 caps a single PUT at
65
+ 5GB whatever you set.
66
+
67
+ ## Server-side encryption, storage classes, lifecycle
68
+
69
+ `PutOptions.serverSideEncryption` exists so the gap is visible **at the type level**, and every
70
+ shipped driver refuses it (`X_NOT_IMPLEMENTED`) with the out-of-band command in the `fix`.
71
+ `Bun.S3Client` exposes `acl`, `storageClass` and `type` and nothing for
72
+ `x-amz-server-side-encryption*`; a local disk writes plain files. A typed refusal an engineer
73
+ meets at the call site beats a silent absence discovered during a security review.
74
+
75
+ Encryption at rest is therefore a **bucket default**, and so are lifecycle rules, storage classes
76
+ and cross-region replication: all four are bucket-side configuration and belong to terraform, not
77
+ to the framework (axiom 7 — zero platform primitives). Ultimate half-building any of them would
78
+ be a second place to look for the same setting.
33
79
 
34
80
  ## Keys
35
81
 
36
82
  `assertSafeKey()` runs on every key before it reaches a driver. Rejected: `..` segments,
37
83
  absolute keys, backslashes, NUL/control bytes, percent-encoded separators (`%2e`, `%2f`),
38
- empty segments, over 1024 chars. No sanitising a key that needed fixing was built wrong.
84
+ empty segments, over 1024 chars, and a first segment of `.meta` (`META_DIR`) the local driver's
85
+ sidecar namespace, reserved on **every** driver so one key rule covers disk and S3 alike. Without
86
+ it, `put('.meta/a/b.json', …)` overwrote the recorded content type of `a/b` and a route serving
87
+ that object answered attacker HTML from the app's own origin. No sanitising — a key that needed
88
+ fixing was built wrong.
39
89
  `scopedKey('org-1', 'avatars', 'a.png')` is `org/org-1/avatars/a.png`; guard every
40
- client-supplied key with `isWithinOrg(key, ctx.actor.orgId)`.
90
+ client-supplied key with `isWithinOrg(key, ctx.actor.orgId)`. A surface that serves objects pairs
91
+ it with `isTenantScoped(key)`: only a key already inside `org/` is another tenant's to refuse, so
92
+ `disk().put('brand/logo.png', …)` stays reachable while `org/org-2/…` never is.
41
93
 
42
94
  ## Signed URLs
43
95
 
44
96
  The HMAC covers the **constraints**, not just the key —
45
97
  `v1 \n METHOD \n key \n expiresAt \n maxBytes \n contentType`.
46
98
  A client that edits `?x-max=` invalidates the signature — it cannot widen what it was granted.
99
+
100
+ The HMAC key is `signingSecret`, else `STORAGE_SIGNING_SECRET`, else the shipped
101
+ `DEV_SIGNING_SECRET` — and **only in `development` or `test`**. Anywhere else `localDriver` refuses
102
+ to construct (`X_ENV_MISSING`) unless one of those two is set to something that is not the shipped
103
+ literal: the dev literal is published in this repo, so signing with it lets anyone mint a `PUT` for
104
+ any key with a `maxBytes` and `contentType` of their choosing, which `acceptSignedUpload` then
105
+ trusts over the app's own `uploadPolicy`. Setting `STORAGE_SIGNING_SECRET=$DEV_SIGNING_SECRET`, or
106
+ pasting the literal into `signingSecret`, is refused exactly as an unset variable is. `usesDevStorageSecret()` is the
107
+ `x doctor` probe for it, the twin of core's `usesDevCursorSecret()`.
47
108
  Verification is constant-time, checks the signature *before* the expiry (a forged URL never
48
109
  learns it was merely late), takes a `Clock` so tests freeze time, and returns
49
110
  `{ ok: false, reason }` rather than throwing — `malformed | unsafe-key | signature-mismatch |
50
111
  expired`. S3 presign covers method, expiry and content type but **not** `maxBytes`: S3 has no
51
- header for it, so size stays a server-side `validateUpload()` check.
112
+ header for it, so a bucket-backed disk's ceiling is a bucket rule or a post-upload `object.size`
113
+ check, never the signature. `s3Driver` does not refuse `maxBytes` either — `grantUpload` supplies
114
+ it on every grant, so refusing would break every s3 upload an app mints.
52
115
 
53
116
  ## Uploads sniff the content type
54
117
 
@@ -60,6 +123,84 @@ allowlist → sniff → checksum.
60
123
 
61
124
  `validateUpload({ key, declaredContentType, bytes }, uploadPolicy({ maxBytes: 5e6 }))`
62
125
 
126
+ ## The direct-upload round trip
127
+
128
+ Three calls, one per hop. The **client never names the key** — it asks for a grant and is told
129
+ one — so it cannot aim an upload at another tenant, at a row it does not own, or at a size the
130
+ policy never allowed.
131
+
132
+ ```ts
133
+ // 1. server, inside an action's handle: mint the grant
134
+ const grant = await grantUpload({
135
+ disk: disk('uploads'),
136
+ orgId: ctx.actor.orgId, // the ACTOR's org, never one read off the request
137
+ request: { filename, contentType, size },
138
+ policy: uploadPolicy({ maxBytes: 5e6 }),
139
+ // target: { entity: 'post', id, field: 'cover' } — omit it and the key lands under `pending/`
140
+ });
141
+
142
+ // 2. browser: PUT at it, with real progress, and get the key back
143
+ const { key } = await uploadFile({ file, grant: (request) => api.requestUpload(request), onProgress });
144
+
145
+ // 3. server, in the route mounted at `/_storage`: take it back, or refuse
146
+ const object = await acceptSignedUpload({
147
+ url: request.url, secret, baseUrl: '/_storage/local',
148
+ disk: disk('uploads'), orgId: ctx.actor.orgId,
149
+ bytes, declaredContentType: request.headers.get('content-type') ?? undefined,
150
+ policy: uploadPolicy({ maxBytes: 5e6 }),
151
+ });
152
+ ```
153
+
154
+ `acceptSignedUpload` refuses on any of: a signature that does not verify, an expired grant, a
155
+ `PUT` grant replayed as a `GET`, a key outside the actor's org, more bytes than the signature
156
+ granted, a `Content-Type` the signature does not cover, or magic bytes that contradict it.
157
+ `readSignedObject` is the GET half and applies the same verification and the same org check.
158
+ Neither owns a `Request`, a `Response` or a status number — mounting is the host's job, and
159
+ `@ultimat3/http` is the only layer that turns an `X_*` code into a status.
160
+
161
+ ## Attachments and orphans
162
+
163
+ An upload happens **before** the row it belongs to exists, so it lands at
164
+ `org/<orgId>/pending/<uploadId><ext>` and is promoted once there is an id:
165
+
166
+ | Call | Key |
167
+ |---|---|
168
+ | `pendingKey(orgId, uploadName(id, filename))` | `org/o1/pending/u-1.png` |
169
+ | `quarantineKey(orgId, name)` | `org/o1/pending/quarantine/u-1.png` |
170
+ | `attachmentKey(orgId, { entity, id, field }, name)` | `org/o1/post/p-1/cover/u-1.png` |
171
+ | `promoteAttachment({ disk, key, orgId, target })` | `copy` then `delete` — never the reverse |
172
+ | `releaseQuarantine({ disk, key, orgId })` | quarantine → pending; returns the released key |
173
+ | `sweepOrphans({ disk, orgId, olderThanMs })` | `{ deleted, failed }` for stale `pending/` keys |
174
+
175
+ The filename contributes nothing but its extension, and only if it matches `.[a-z0-9]{1,12}`.
176
+ `sweepOrphans` can only reach the `pending/` prefix of one org — a sweep that could touch an
177
+ attached key is a job that deletes production data the first time an app forgets to promote.
178
+
179
+ **Two lists, because one cannot be wrong.** `sweepOrphans` answers `{ deleted, failed }`: a
180
+ refused delete goes in `failed` with the disk's own words and the sweep keeps going. A single
181
+ array of "deleted" keys put every refusal in it, so an erasure sweep over a bucket whose policy
182
+ had lost `s3:DeleteObject` reported 200 objects gone that were all still there.
183
+
184
+ ### Quarantine is the mechanism; the scanner is your app's
185
+
186
+ Magic-byte sniffing closes stored XSS. It is **not** malware scanning, and `application/zip` is
187
+ accepted on purpose as the OOXML container, so a macro-laden `.docx` passes `validateUpload`
188
+ exactly as a clean one does. A scanner is a business decision with a vendor, a licence and a
189
+ latency budget (axiom 8), so what ships is the place to put one — one more segment in a
190
+ convention that already existed:
191
+
192
+ ```ts
193
+ const grant = await grantUpload({ disk, orgId, request, quarantine: true });
194
+ // → org/<orgId>/pending/quarantine/<uploadId><ext>
195
+
196
+ // promoteAttachment on that key throws X_STORAGE_QUARANTINED. Your scan job decides:
197
+ const released = await releaseQuarantine({ disk, key, orgId }); // clean
198
+ await promoteAttachment({ disk, key: released, orgId, target });
199
+ ```
200
+
201
+ Inside `pending/` deliberately: an upload nobody ever scanned is still an orphan, so
202
+ `sweepOrphans` collects it with no second prefix to walk.
203
+
63
204
  ## Errors
64
205
 
65
206
  | Code | Fires when |
@@ -67,10 +208,18 @@ allowlist → sniff → checksum.
67
208
  | `X_STORAGE_DISK_UNKNOWN` | `disk(name)` is not in `storage.disks`; cause lists the real ones |
68
209
  | `X_STORAGE_NOT_FOUND` | `get`/`stream` on a key that does not exist |
69
210
  | `X_STORAGE_PATH_UNSAFE` | traversal, absolute key, backslash, NUL, `%2e`, empty segment |
70
- | `X_STORAGE_TOO_LARGE` | payload over the policy `maxBytes` |
211
+ | `X_STORAGE_TOO_LARGE` | payload over the policy `maxBytes`, or over a disk's `maxPutBytes` |
71
212
  | `X_STORAGE_TYPE_REJECTED` | declared type off the allowlist, or contradicted by magic bytes |
72
213
  | `X_STORAGE_CHECKSUM_MISMATCH` | supplied base64 SHA-256 does not describe the bytes |
73
- | `X_NOT_IMPLEMENTED` | S3 user metadata |
214
+ | `X_STORAGE_URL_INVALID` | a signed request that does not match what was signed — edited constraint, wrong base, wrong method, contradicting `Content-Type` |
215
+ | `X_STORAGE_URL_EXPIRED` | the grant's window closed; the signature was fine |
216
+ | `X_STORAGE_ORG_MISMATCH` | the key is well-formed and unforged, and belongs to another org |
217
+ | `X_STORAGE_UPLOAD_FAILED` | client half: the presigned `PUT` answered non-2xx or never landed |
218
+ | `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 |
219
+ | `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 |
220
+ | `X_STORAGE_QUARANTINED` | `promoteAttachment` on a key nothing has released from `pending/quarantine/` |
221
+ | `X_NOT_IMPLEMENTED` | S3 user metadata / cache-control; `serverSideEncryption` on either driver |
222
+ | `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` |
74
223
  | `X_IMAGE_UNSUPPORTED` | core's: an `avif`/`webp` encode, or a source no built-in decoder reads |
75
224
  | `X_IMAGE_DECODE_FAILED` | core's: truncated or corrupt image bytes |
76
225
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/storage",
3
- "version": "1.1.0",
3
+ "version": "2.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",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,6 +31,6 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "1.1.0"
34
+ "@ultimat3/core": "2.0.0"
34
35
  }
35
36
  }
package/src/accept.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Single responsibility: the two server-side decisions a mounted `/_storage` route is made of —
2
+ // "may these bytes be written at that key?" and "may this actor read that key?". Transport-free
3
+ // on purpose: this package owns no `Request`, no `Response` and no status number (statuses are
4
+ // `@ultimat3/http`'s alone), so a route is a handler around these two calls and nothing else.
5
+ //
6
+ // Every step fails CLOSED and throws — a forged, expired, oversized, cross-tenant or
7
+ // wrong-typed request never reaches `disk.put`, and no path returns a "warning".
8
+
9
+ import type { Clock } from '@ultimat3/core';
10
+ import type { SignedUrlMethod, StorageDriver, StorageObject, StorageRead } from './driver';
11
+ import { orgMismatch, signedUrlExpired, signedUrlRejected, tooLarge } from './errors';
12
+ import { isWithinOrg } from './path';
13
+ import type { SignedUrlConstraints } from './signed-url';
14
+ import { verifySignedUrl } from './signed-url';
15
+ import type { UploadPolicy } from './upload';
16
+ import { normalizeContentType, uploadPolicy, validateUpload } from './upload';
17
+
18
+ export interface SignedRequestInput {
19
+ /** Absolute or route-relative — `verifySignedUrl` parses both. */
20
+ readonly url: string;
21
+ readonly secret: string;
22
+ readonly baseUrl?: string | undefined;
23
+ readonly disk: StorageDriver;
24
+ /**
25
+ * The ACTOR's org. Required, and checked against the key: a signed URL is a capability, and a
26
+ * capability that leaks must still not read another tenant's object. Unreachable, not unguessable.
27
+ */
28
+ readonly orgId: string;
29
+ readonly clock?: Clock | undefined;
30
+ }
31
+
32
+ export interface AcceptSignedUploadInput extends SignedRequestInput {
33
+ readonly bytes: Uint8Array;
34
+ /** The transport's `Content-Type`. Refused unless it equals the type the grant signed. */
35
+ readonly declaredContentType?: string | undefined;
36
+ readonly policy?: UploadPolicy | undefined;
37
+ }
38
+
39
+ /**
40
+ * The signature check runs first and the expiry second — `verifySignedUrl` owns that order, and
41
+ * the reason it exists is that a forged URL must never learn "the signature was fine, just late".
42
+ * The org check runs on the verified key, so an attacker cannot probe org names with a fake one.
43
+ */
44
+ async function constraintsFor(
45
+ input: SignedRequestInput,
46
+ method: SignedUrlMethod,
47
+ ): Promise<SignedUrlConstraints> {
48
+ const result = await verifySignedUrl({
49
+ url: input.url,
50
+ secret: input.secret,
51
+ ...(input.baseUrl === undefined ? {} : { baseUrl: input.baseUrl }),
52
+ ...(input.clock === undefined ? {} : { clock: input.clock }),
53
+ });
54
+ if (!result.ok) {
55
+ throw result.reason === 'expired'
56
+ ? signedUrlExpired(input.url, result.detail)
57
+ : signedUrlRejected(result.reason, result.detail);
58
+ }
59
+ const constraints = result.constraints;
60
+ if (constraints.method !== method) {
61
+ throw signedUrlRejected(
62
+ 'method-mismatch',
63
+ `the URL is signed for ${constraints.method}, and this is a ${method}`,
64
+ );
65
+ }
66
+ if (!isWithinOrg(constraints.key, input.orgId)) {
67
+ throw orgMismatch(constraints.key, input.orgId);
68
+ }
69
+ return constraints;
70
+ }
71
+
72
+ /**
73
+ * Take a presigned PUT and write it, or refuse. Four gates the client cannot move because all
74
+ * four are inside the signature or inside the bytes: the signature itself, the expiry, the byte
75
+ * count against the signed ceiling, and the declared type against both the signature and the
76
+ * magic bytes. `validateUpload` runs last because it is the only one that reads the whole body.
77
+ */
78
+ export async function acceptSignedUpload(input: AcceptSignedUploadInput): Promise<StorageObject> {
79
+ const constraints = await constraintsFor(input, 'PUT');
80
+ const key = constraints.key;
81
+
82
+ // A PUT signed with no content type bounds nothing — any bytes, any type, at that key. The
83
+ // grant always sets one, so this is a hand-rolled URL, and refusing is the only fail-closed
84
+ // answer: the alternative is trusting whatever header the uploader sends.
85
+ const signed = constraints.contentType;
86
+ if (signed === undefined) {
87
+ throw signedUrlRejected(
88
+ 'unconstrained',
89
+ `"${key}" was signed with no content type, so nothing bounds what may be stored there`,
90
+ );
91
+ }
92
+ const declaredHeader = input.declaredContentType;
93
+ const declared = declaredHeader === undefined ? signed : normalizeContentType(declaredHeader);
94
+ if (declared !== signed) {
95
+ throw signedUrlRejected(
96
+ 'content-type-mismatch',
97
+ `the request declares ${declared} and the signature covers ${signed}`,
98
+ );
99
+ }
100
+
101
+ // The signed ceiling, checked before the policy's: the two can differ once a policy is edited
102
+ // between the grant and the upload, and the narrower one is the one that was granted.
103
+ const maxBytes = constraints.maxBytes;
104
+ const size = input.bytes.byteLength;
105
+ if (maxBytes !== undefined && size > maxBytes) throw tooLarge(key, size, maxBytes);
106
+
107
+ const policy = input.policy ?? uploadPolicy();
108
+ const validated = validateUpload(
109
+ { key, declaredContentType: signed, bytes: input.bytes },
110
+ policy,
111
+ );
112
+ return input.disk.put(validated.key, validated.bytes, {
113
+ contentType: validated.contentType,
114
+ checksum: validated.checksum,
115
+ });
116
+ }
117
+
118
+ /**
119
+ * The GET half. Same verification, same tenant boundary — a download URL that skipped either
120
+ * would make every object readable by anyone who ever saw one link.
121
+ */
122
+ export async function readSignedObject(input: SignedRequestInput): Promise<StorageRead> {
123
+ const constraints = await constraintsFor(input, 'GET');
124
+ return input.disk.get(constraints.key);
125
+ }