@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 +69 -42
- package/README.md +165 -15
- package/package.json +3 -3
- package/src/accept.ts +3 -6
- package/src/attachment.ts +46 -16
- package/src/driver-local-read.ts +114 -0
- package/src/driver-local-sidecar.ts +96 -0
- package/src/driver-local-write.ts +185 -0
- package/src/driver-local.ts +166 -188
- package/src/driver-memory-conflict.ts +45 -0
- package/src/driver-memory.ts +109 -25
- package/src/driver-s3-absent.ts +32 -0
- package/src/driver-s3-client.ts +94 -0
- package/src/driver-s3-lock.ts +131 -0
- package/src/driver-s3-signed.ts +220 -0
- package/src/driver-s3.ts +180 -167
- package/src/driver.ts +98 -19
- package/src/errors.ts +112 -13
- package/src/grant.ts +2 -2
- package/src/image.ts +34 -8
- package/src/index.ts +29 -16
- package/src/iso-bmff.ts +50 -0
- package/src/object-lock.ts +173 -0
- package/src/path.ts +17 -2
- package/src/signed-url.ts +41 -12
- package/src/signing-secret.ts +76 -0
- package/src/upload.ts +82 -5
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 })`,
|
|
19
|
-
`
|
|
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 (
|
|
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-
|
|
43
|
-
| `driver-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 `
|
|
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
|
|
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/
|
|
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.
|
|
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 `
|
|
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
|
-
-
|
|
237
|
-
|
|
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
|
-
| `
|
|
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
|
-
`
|
|
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,
|
|
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 =
|
|
41
|
+
let sessions = memoryStorageDriver();
|
|
42
42
|
beforeEach(() => {
|
|
43
|
-
sessions =
|
|
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`
|
|
70
|
-
Bun
|
|
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)
|
|
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
|
-
`
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
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": "
|
|
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.
|
|
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": "
|
|
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,
|
|
117
|
-
|
|
118
|
-
|
|
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 {
|
|
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.
|
|
132
|
-
*
|
|
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
|
|
147
|
-
|
|
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 |
|
|
232
|
+
let cursor: string | null = null;
|
|
201
233
|
do {
|
|
202
|
-
const page
|
|
203
|
-
|
|
204
|
-
|
|
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.
|
|
217
|
-
} while (cursor !==
|
|
246
|
+
cursor = page.nextCursor;
|
|
247
|
+
} while (cursor !== null);
|
|
218
248
|
return { deleted, failed };
|
|
219
249
|
}
|