@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 +169 -0
- package/README.md +156 -7
- package/package.json +3 -2
- package/src/accept.ts +125 -0
- package/src/attachment.ts +213 -0
- package/src/driver-local.ts +185 -27
- package/src/driver-s3-fixture.ts +164 -0
- package/src/driver-s3.ts +142 -25
- package/src/driver.ts +126 -11
- package/src/errors.ts +169 -2
- package/src/grant.ts +100 -0
- package/src/index.ts +63 -1
- package/src/path.ts +23 -1
- package/src/signed-url.ts +34 -16
- package/src/upload-client.ts +172 -0
- package/src/upload.ts +9 -7
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
|
|
32
|
-
`put()
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
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": "
|
|
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": "
|
|
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
|
+
}
|