@ultimat3/storage 15.0.0 → 17.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 +32 -2
- package/package.json +2 -2
- package/src/driver-local.ts +9 -1
- package/src/driver-s3.ts +21 -3
- package/src/grant.ts +12 -2
- package/src/image.ts +12 -2
- package/src/path.ts +15 -3
- package/src/signed-url.ts +25 -2
- package/src/upload.ts +13 -1
package/CLAUDE.md
CHANGED
|
@@ -14,6 +14,33 @@ Tier 1. Object storage: named disks, safe keys, signed URLs, sniffed uploads.
|
|
|
14
14
|
fits the table is `@ultimat3/auth`'s: ship the server function (`grantUpload`), let the app
|
|
15
15
|
wrap it in its own `action()` with its own policy. See `docs/architecture/17-uploads.md`.
|
|
16
16
|
|
|
17
|
+
- **Every byte ceiling and every TTL is screened where it is DECLARED, through core's
|
|
18
|
+
`finiteCount` and nothing else, `As of 2026-08-26`** — `uploadPolicy({ maxBytes })`, both
|
|
19
|
+
drivers' `maxPutBytes`, `createUploadGrant({ expiresInMs })`, `buildSignedUrl`'s two and the s3
|
|
20
|
+
driver's presign TTL. There was briefly a private `assertFiniteSignedUrlBound` here saying the
|
|
21
|
+
same thing in its own words; a second finite-bound path is one that drifts from the shared
|
|
22
|
+
contract, which is exactly what `jobs`, `realtime` and `query` each proved with a copy of their
|
|
23
|
+
own. Both presigners now call the one function, which is what `driver-parity.test.ts` needs to
|
|
24
|
+
stay true.
|
|
25
|
+
Measured: `uploadPolicy({ maxBytes: Number.NaN })` accepted a 5,000,016-byte PNG through
|
|
26
|
+
`validateUpload`, because `size > NaN` is false — the one number deciding how much a caller may
|
|
27
|
+
store stopped deciding anything. Variant `quality` is core's `assertFiniteImageQuality` and NOT a
|
|
28
|
+
second screen: `variantKey` never reaches the encoder, so a copy that disagreed would mint
|
|
29
|
+
`q150` keys for bytes `transformImageBytes` then refuses.
|
|
30
|
+
- **The default is taken on `undefined` and on nothing else, `As of 2026-08-26`** —
|
|
31
|
+
`options.x === undefined ? D : options.x`, never `options.x ?? D`. `??` coalesces on `null` too,
|
|
32
|
+
so an explicitly blanked key in a decoded JSON config took the default BEFORE the screen above
|
|
33
|
+
could refuse it: the mirror of the `NaN` half, where one value slips past the guard and the other
|
|
34
|
+
past the default, and both end in a bound nobody chose. Every site above, `quality` included.
|
|
35
|
+
|
|
36
|
+
- **`MAX_KEY_LENGTH` is BYTES, and is measured in bytes, `As of 2026-08-26`.** S3's limit is "a
|
|
37
|
+
sequence of Unicode characters whose UTF-8 encoding is at most 1,024 bytes long", and `path.ts`
|
|
38
|
+
measured `key.length` — UTF-16 code units — while its message said "chars". A code-unit count is
|
|
39
|
+
never MORE than the UTF-8 byte count, so a non-ASCII key over the real limit passed this guard
|
|
40
|
+
and was refused by the store instead: 400 CJK characters is 400 units and 1,200 bytes. Keeping
|
|
41
|
+
local and remote disks interchangeable is the whole reason the ceiling exists, so it has to be
|
|
42
|
+
the store's ceiling. Same defect and same fix as `@ultimat3/cache`'s surrogate-key guard.
|
|
43
|
+
|
|
17
44
|
| Rule | |
|
|
18
45
|
|---|---|
|
|
19
46
|
| Errors | `StorageError` + one factory per code in `errors.ts`; never `throw new Error` |
|
|
@@ -162,8 +189,11 @@ Gotchas:
|
|
|
162
189
|
a test fixture) got the verdict from one table and the behaviour from another, in the dangerous
|
|
163
190
|
direction: a production boot with no secret, launched from a development shell that has one,
|
|
164
191
|
signing every grant with the published literal. All three reads now come off `options.env ??
|
|
165
|
-
process.env`, so a bare `localDriver({ root })` is unchanged and additive.
|
|
166
|
-
pins it by mutation — reverting any one read to `process.env` fails.
|
|
192
|
+
process.env`, so a bare `localDriver({ root })` is unchanged and additive.
|
|
193
|
+
`driver-local-boot.test.ts` pins it by mutation — reverting any one read to `process.env` fails.
|
|
194
|
+
That file is the CONSTRUCTION half, split off `driver-local.test.ts` at the line ceiling along
|
|
195
|
+
the seam the guard already draws: nothing in it writes a byte, so it needs no temporary
|
|
196
|
+
directory, and `driver-local.test.ts` keeps everything the disk actually does with an object.
|
|
167
197
|
- **The mounted read half is `@ultimat3/cli`'s `dev-storage.ts`, not this package.** `GET
|
|
168
198
|
/_storage/:disk/*key` gates on `@ultimat3/policy`'s `evaluate()` (`storage:read`), which is tier
|
|
169
199
|
2 and unreachable from here — so a "serve this object" helper in this package could only ever be
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/storage",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.0",
|
|
4
4
|
"description": "Named disks over Bun.file and Bun.s3: safe keys, signed URLs, sniffed uploads",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "17.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/driver-local.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
type Clock,
|
|
8
|
+
finiteCount,
|
|
8
9
|
isLocal,
|
|
9
10
|
type ResolveEnvironmentOptions,
|
|
10
11
|
resolveEnvironment,
|
|
@@ -159,7 +160,14 @@ const isMissingFile = (error: unknown): boolean => stringField(error, 'code') ==
|
|
|
159
160
|
|
|
160
161
|
export function localDriver(options: LocalDriverOptions): StorageDriver {
|
|
161
162
|
const root = options.root.replace(/\/+$/, '');
|
|
162
|
-
|
|
163
|
+
// `=== undefined`, never `??`: `??` coalesces on `null` too, so an explicitly blanked key in a
|
|
164
|
+
// decoded JSON config took the default instead of the refusal `finiteCount` is here to raise.
|
|
165
|
+
const maxPutBytes = finiteCount(
|
|
166
|
+
'the local disk driver',
|
|
167
|
+
'maxPutBytes',
|
|
168
|
+
options.maxPutBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : options.maxPutBytes,
|
|
169
|
+
1,
|
|
170
|
+
);
|
|
163
171
|
const clock = options.clock ?? systemClock;
|
|
164
172
|
// The segment is the disk's REGISTERED name, learned from `defineStorage` at boot — the driver
|
|
165
173
|
// kind is not a mount point, and minting under it made every disk not literally named `local`
|
package/src/driver-s3.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// The client is built lazily on first use so importing this module never opens a socket, and
|
|
4
4
|
// credentials arrive as env var NAMES: a literal key in app.config.ts is a key in git.
|
|
5
5
|
|
|
6
|
-
import { ConfigInvalidError, EnvMissingError, stringField } from '@ultimat3/core';
|
|
6
|
+
import { ConfigInvalidError, EnvMissingError, finiteCount, stringField } from '@ultimat3/core';
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_CONTENT_TYPE,
|
|
9
9
|
type ListOptions,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
storageNotImplemented,
|
|
28
28
|
} from './errors';
|
|
29
29
|
import { assertSafeKey } from './path';
|
|
30
|
+
import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
|
|
30
31
|
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
|
|
31
32
|
|
|
32
33
|
const DRIVER_NAME = 's3';
|
|
@@ -209,7 +210,14 @@ function refuseUnsupportedPut(bucket: string, key: string, putOptions?: PutOptio
|
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
212
|
-
|
|
213
|
+
// `=== undefined`, never `??`: `??` coalesces on `null` too, so an explicitly blanked key in a
|
|
214
|
+
// decoded JSON config took the default instead of the refusal `finiteCount` is here to raise.
|
|
215
|
+
const maxPutBytes = finiteCount(
|
|
216
|
+
'the s3 driver',
|
|
217
|
+
'maxPutBytes',
|
|
218
|
+
options.maxPutBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : options.maxPutBytes,
|
|
219
|
+
1,
|
|
220
|
+
);
|
|
213
221
|
let client: S3ClientLike | undefined;
|
|
214
222
|
const conn = (): S3ClientLike => {
|
|
215
223
|
client ??= buildClient(options);
|
|
@@ -356,7 +364,17 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
356
364
|
* `maxBytes` — S3 has no header for it, so size stays a server-side policy check.
|
|
357
365
|
*/
|
|
358
366
|
async signedUrl(key: string, urlOptions?: SignedUrlOptions): Promise<string> {
|
|
359
|
-
|
|
367
|
+
// Screened with the SAME function `buildSignedUrl` applies on the local disk — one
|
|
368
|
+
// finite-bound path for both presigners, never a private copy per package: `Math.ceil(NaN /
|
|
369
|
+
// 1000)` is `NaN`, so `X-Amz-Expires=NaN` went to AWS and the app got a 403 on a link it
|
|
370
|
+
// believed it had just minted. `=== undefined` rather than `??`, so an explicit `null` is
|
|
371
|
+
// refused instead of silently taking the default.
|
|
372
|
+
const expiresInMs = finiteCount(
|
|
373
|
+
'the s3 driver',
|
|
374
|
+
'expiresInMs',
|
|
375
|
+
urlOptions?.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : urlOptions.expiresInMs,
|
|
376
|
+
1,
|
|
377
|
+
);
|
|
360
378
|
return conn()
|
|
361
379
|
.file(assertSafeKey(key))
|
|
362
380
|
.presign({
|
package/src/grant.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// only client input that survives is the filename's extension, and only if it survives a regex.
|
|
6
6
|
|
|
7
7
|
import type { Clock } from '@ultimat3/core';
|
|
8
|
-
import { systemClock } from '@ultimat3/core';
|
|
8
|
+
import { finiteCount, systemClock } from '@ultimat3/core';
|
|
9
9
|
import type { AttachmentTarget } from './attachment';
|
|
10
10
|
import { attachmentKey, pendingKey, quarantineKey, uploadName } from './attachment';
|
|
11
11
|
import type { StorageDriver } from './driver';
|
|
@@ -82,7 +82,17 @@ export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant>
|
|
|
82
82
|
throw tooLarge(key, size, policy.maxBytes);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// Refused here as well as in the presigner one call down: `expiresAt: now + NaN` is `NaN` in the
|
|
86
|
+
// grant this function RETURNS, and the message a caller can act on names `createUploadGrant`'s
|
|
87
|
+
// own option rather than `buildSignedUrl`'s.
|
|
88
|
+
// `=== undefined`, never `??`, for the reason `buildSignedUrl` states: `??` coalesces on `null`
|
|
89
|
+
// too, so an explicit one took the default instead of this refusal.
|
|
90
|
+
const expiresInMs = finiteCount(
|
|
91
|
+
'createUploadGrant',
|
|
92
|
+
'expiresInMs',
|
|
93
|
+
input.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : input.expiresInMs,
|
|
94
|
+
1,
|
|
95
|
+
);
|
|
86
96
|
const url = await input.disk.signedUrl(key, {
|
|
87
97
|
method: 'PUT',
|
|
88
98
|
maxBytes: policy.maxBytes,
|
package/src/image.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// bytes, `transformImage()` returns exactly the size `fitDimensions()` already promised.
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
assertFiniteImageQuality,
|
|
7
8
|
blurDataUrl,
|
|
8
9
|
type ImageFormat,
|
|
9
10
|
imageUnsupported,
|
|
@@ -90,7 +91,14 @@ export function variantKey(sourceKey: string, transform: ImageTransform): string
|
|
|
90
91
|
if (transform.width !== undefined) parts.push(`w${transform.width}`);
|
|
91
92
|
if (transform.height !== undefined) parts.push(`h${transform.height}`);
|
|
92
93
|
if (transform.fit !== undefined) parts.push(transform.fit);
|
|
93
|
-
|
|
94
|
+
// Core's screen, not a second one: this function never reaches the encoder, so without it a
|
|
95
|
+
// `q150` or a `qNaN` variant key is minted for bytes `transformImageBytes` then refuses — and
|
|
96
|
+
// an unbounded quality is an unbounded number of distinct keys in the bucket.
|
|
97
|
+
// `=== undefined`, never `??`: `??` coalesces on `null` too, so an explicit one was spelled
|
|
98
|
+
// into a key as the default rather than refused.
|
|
99
|
+
const quality = assertFiniteImageQuality(
|
|
100
|
+
transform.quality === undefined ? DEFAULT_QUALITY : transform.quality,
|
|
101
|
+
);
|
|
94
102
|
if (quality !== DEFAULT_QUALITY) parts.push(`q${quality}`);
|
|
95
103
|
if (parts.length === 0) parts.push('full');
|
|
96
104
|
return assertSafeKey(`${stem}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
|
|
@@ -173,7 +181,9 @@ export async function transformImage(
|
|
|
173
181
|
// what stops a rounded edge leaving a transparent (in JPEG: black) sliver inside it.
|
|
174
182
|
fit: 'cover',
|
|
175
183
|
format: transform.format ?? 'webp',
|
|
176
|
-
quality:
|
|
184
|
+
quality: assertFiniteImageQuality(
|
|
185
|
+
transform.quality === undefined ? DEFAULT_QUALITY : transform.quality,
|
|
186
|
+
),
|
|
177
187
|
});
|
|
178
188
|
}
|
|
179
189
|
|
package/src/path.ts
CHANGED
|
@@ -5,8 +5,19 @@
|
|
|
5
5
|
|
|
6
6
|
import { pathUnsafe } from './errors';
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* S3's own limit; keeping local and remote disks interchangeable requires the same ceiling.
|
|
10
|
+
*
|
|
11
|
+
* BYTES, and S3 says so — "a sequence of Unicode characters whose UTF-8 encoding is at most 1,024
|
|
12
|
+
* bytes long". It was measured with `String.length`, which is UTF-16 code units, and a code-unit
|
|
13
|
+
* count is never MORE than the UTF-8 byte count — so every non-ASCII key over the real limit
|
|
14
|
+
* passed this guard and was refused by the store instead, which is the one thing a shared ceiling
|
|
15
|
+
* exists to prevent. `MAX_KEY_LENGTH` keeps its name: it is exported, and the number is unchanged.
|
|
16
|
+
*/
|
|
9
17
|
export const MAX_KEY_LENGTH = 1024;
|
|
18
|
+
|
|
19
|
+
/** What the wire counts. The key is at most ~1 KB, so this is cheaper than deciding not to. */
|
|
20
|
+
const utf8Bytes = (value: string): number => new TextEncoder().encode(value).byteLength;
|
|
10
21
|
export const ORG_PREFIX = 'org';
|
|
11
22
|
|
|
12
23
|
/**
|
|
@@ -32,8 +43,9 @@ function hasControlByte(key: string): boolean {
|
|
|
32
43
|
|
|
33
44
|
function unsafeReason(key: string): string | undefined {
|
|
34
45
|
if (key.length === 0) return 'is empty';
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
const bytes = utf8Bytes(key);
|
|
47
|
+
if (bytes > MAX_KEY_LENGTH) {
|
|
48
|
+
return `is ${bytes} bytes, over the ${MAX_KEY_LENGTH}-byte limit`;
|
|
37
49
|
}
|
|
38
50
|
if (hasControlByte(key)) return 'contains a NUL or control byte';
|
|
39
51
|
if (key.includes('\\')) return 'contains a backslash';
|
package/src/signed-url.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// a 2GB executable. Verification never throws and never short-circuits on expiry before the
|
|
5
5
|
// signature, so a forged URL can never learn "the signature was fine, just late".
|
|
6
6
|
|
|
7
|
-
import { type Clock, systemClock, timingSafeEqual } from '@ultimat3/core';
|
|
7
|
+
import { type Clock, finiteCount, systemClock, timingSafeEqual } from '@ultimat3/core';
|
|
8
8
|
import type { SignedUrlMethod } from './driver';
|
|
9
9
|
import { assertSafeKey, isSafeKey } from './path';
|
|
10
10
|
|
|
@@ -102,13 +102,36 @@ export async function signConstraints(
|
|
|
102
102
|
const trimBase = (base: string): string => base.replace(/\/+$/, '');
|
|
103
103
|
const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The mint's side of the screen `parseConstraints` already applies to the read. `finiteCount` from
|
|
107
|
+
* `@ultimat3/core` is that screen — a private copy of it lived here, and a second finite-bound path
|
|
108
|
+
* is one that drifts from the shared contract, which is what `jobs`, `realtime` and `query` each
|
|
109
|
+
* proved with a byte-identical copy of their own. The S3 driver calls the same function directly,
|
|
110
|
+
* because it presigns through the provider rather than through this one, and two presigners that
|
|
111
|
+
* refuse different inputs is the disagreement `driver-parity.test.ts` exists to catch.
|
|
112
|
+
*
|
|
113
|
+
* A URL is minted here and verified elsewhere, so a number this function writes and that one
|
|
114
|
+
* refuses is a link that is dead on arrival — the app's own call succeeded, and the only report is
|
|
115
|
+
* a 400 at a recipient who cannot fix it. `expiresInMs: NaN` is exactly that: `now + NaN` is
|
|
116
|
+
* `NaN`, `String` writes the literal `NaN` into `x-exp`, and `Number.isSafeInteger` on the other
|
|
117
|
+
* side says no.
|
|
118
|
+
*/
|
|
105
119
|
export async function buildSignedUrl(input: SignedUrlInput): Promise<string> {
|
|
106
120
|
const key = assertSafeKey(input.key);
|
|
107
121
|
const clock = input.clock ?? systemClock;
|
|
122
|
+
// `=== undefined`, never `??`: `??` coalesces on `null` too, so an explicit `null` would take
|
|
123
|
+
// the default instead of the refusal `finiteCount` is here to raise.
|
|
124
|
+
const expiresInMs = finiteCount(
|
|
125
|
+
'buildSignedUrl',
|
|
126
|
+
'expiresInMs',
|
|
127
|
+
input.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : input.expiresInMs,
|
|
128
|
+
1,
|
|
129
|
+
);
|
|
130
|
+
if (input.maxBytes !== undefined) finiteCount('buildSignedUrl', 'maxBytes', input.maxBytes, 0);
|
|
108
131
|
const constraints: SignedUrlConstraints = {
|
|
109
132
|
key,
|
|
110
133
|
method: input.method ?? 'GET',
|
|
111
|
-
expiresAt: clock.now().getTime() +
|
|
134
|
+
expiresAt: clock.now().getTime() + expiresInMs,
|
|
112
135
|
maxBytes: input.maxBytes,
|
|
113
136
|
// An empty string is not a content type, and `canonicalRequest` renders it and `undefined`
|
|
114
137
|
// identically — so minting one would produce a URL indistinguishable from an unconstrained
|
package/src/upload.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// is a stored-XSS delivery vehicle the moment any surface serves it back with the declared
|
|
5
5
|
// type, so the magic bytes decide and a contradiction is rejected outright.
|
|
6
6
|
|
|
7
|
+
import { finiteCount } from '@ultimat3/core';
|
|
7
8
|
import { sha256Base64 } from './driver';
|
|
8
9
|
import { checksumMismatch, contentTypeMismatch, contentTypeNotAllowed, tooLarge } from './errors';
|
|
9
10
|
import { assertSafeKey } from './path';
|
|
@@ -43,7 +44,18 @@ export interface UploadPolicyInit {
|
|
|
43
44
|
|
|
44
45
|
export function uploadPolicy(init: UploadPolicyInit = {}): UploadPolicy {
|
|
45
46
|
return {
|
|
46
|
-
|
|
47
|
+
// Screened where it is DECLARED, because every reader of it is a comparison: `size >
|
|
48
|
+
// policy.maxBytes` is false for a `NaN` ceiling, so the cap that decides how much a caller may
|
|
49
|
+
// store stops deciding anything. Measured: a 5,000,016-byte PNG passed `validateUpload` under
|
|
50
|
+
// `uploadPolicy({ maxBytes: Number.NaN })`.
|
|
51
|
+
// `=== undefined`, never `??`: `??` coalesces on `null` too, so an explicitly blanked key in
|
|
52
|
+
// a decoded JSON config took the default instead of the refusal beside it.
|
|
53
|
+
maxBytes: finiteCount(
|
|
54
|
+
'uploadPolicy',
|
|
55
|
+
'maxBytes',
|
|
56
|
+
init.maxBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : init.maxBytes,
|
|
57
|
+
1,
|
|
58
|
+
),
|
|
47
59
|
allowedContentTypes: init.allowedContentTypes ?? IMAGE_CONTENT_TYPES,
|
|
48
60
|
requireChecksum: init.requireChecksum ?? false,
|
|
49
61
|
};
|