@ultimat3/storage 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -157,6 +157,23 @@ Gotchas:
157
157
  - `acceptSignedUpload` refuses a URL signed with **no** content type (`unconstrained`). `grantUpload`
158
158
  always sets one, so such a URL is hand-rolled, and trusting the uploader's header instead is the
159
159
  only other option.
160
+ - **The signed base is declared ONCE, in `signedUrlBaseFor(driverName)`.** `localDriver` mints
161
+ under it and `accept.ts` defaults to `signedUrlBaseFor(disk.name)` — the same base, arrived at
162
+ from the disk the caller already passed. It was stated twice (`/_storage/local` in the driver,
163
+ `/_storage` in `verifySignedUrl`'s default), so with both defaults NO genuine URL verified: the
164
+ key parsed as `local/<key>` and every grant died as `signature-mismatch`. `@ultimat3/cli`'s
165
+ `STORAGE_BASE_PATH` is a third statement of the mount prefix and should import
166
+ `DEFAULT_SIGNED_URL_BASE` instead.
167
+ - **`accept.ts` asks the `isTenantScoped`/`isWithinOrg` PAIR, exactly as `dev-storage.ts` does.**
168
+ `isWithinOrg` alone refused every un-scoped key, so an app's own `brand/logo.png` was unreachable
169
+ through a URL it had just signed. `isTenantScoped` is case-INSENSITIVE and `isWithinOrg` is not:
170
+ `Org/o2/x` and `org/o2/x` are one file on APFS/NTFS, so the fold has to count as tenant-scoped
171
+ and then fail the exact-case membership test. Do not "simplify" either half.
172
+ - **`AcceptSignedUploadInput.checksum` is what makes `uploadPolicy({ requireChecksum: true })`
173
+ reachable.** Without it that option could only ever fail, because nothing on the accept path
174
+ could declare a hash. It travels like `declaredContentType`: the route reads a header and hands
175
+ it over, and `validateUpload` hashes the bytes itself. The browser half does NOT send one — a
176
+ custom header on an S3 presigned PUT is a signature question this package cannot answer.
160
177
  - `orgId` is required on both halves of `accept.ts` and is the ACTOR's, never a request field. A
161
178
  signed URL is a capability; a leaked capability must still not cross a tenant.
162
179
  - `X_STORAGE_ORG_MISMATCH` maps to **404**, not 403 (`@ultimat3/http`'s `error-map.ts`). 403 would
package/README.md CHANGED
@@ -89,7 +89,9 @@ fixing was built wrong.
89
89
  `scopedKey('org-1', 'avatars', 'a.png')` is `org/org-1/avatars/a.png`; guard every
90
90
  client-supplied key with `isWithinOrg(key, ctx.actor.orgId)`. A surface that serves objects pairs
91
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.
92
+ `disk().put('brand/logo.png', …)` stays reachable while `org/org-2/…` never is. `accept.ts` asks
93
+ the pair too. `isTenantScoped` folds case (`Org/`, `ORG/`) and `isWithinOrg` does not, so a
94
+ case-variant prefix — one directory, not two, on APFS or NTFS — is refused rather than matched.
93
95
 
94
96
  ## Signed URLs
95
97
 
@@ -144,8 +146,10 @@ const { key } = await uploadFile({ file, grant: (request) => api.requestUpload(r
144
146
 
145
147
  // 3. server, in the route mounted at `/_storage`: take it back, or refuse
146
148
  const object = await acceptSignedUpload({
147
- url: request.url, secret, baseUrl: '/_storage/local',
148
- disk: disk('uploads'), orgId: ctx.actor.orgId,
149
+ url: request.url, // baseUrl defaults to signedUrlBaseFor(disk.name) — the
150
+ secret, // same base the driver signed under. Pass one only for a
151
+ disk: disk('uploads'), // route mounted somewhere other than /_storage/<driver>.
152
+ orgId: ctx.actor.orgId,
149
153
  bytes, declaredContentType: request.headers.get('content-type') ?? undefined,
150
154
  policy: uploadPolicy({ maxBytes: 5e6 }),
151
155
  });
@@ -154,7 +158,12 @@ const object = await acceptSignedUpload({
154
158
  `acceptSignedUpload` refuses on any of: a signature that does not verify, an expired grant, a
155
159
  `PUT` grant replayed as a `GET`, a key outside the actor's org, more bytes than the signature
156
160
  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.
161
+ `readSignedObject` is the GET half and applies the same verification and the same org check
162
+ which is the `isTenantScoped`/`isWithinOrg` pair, so an app's own un-scoped `brand/logo.png` is
163
+ readable through a URL it signed and `org/org-2/…` still is not.
164
+ `uploadPolicy({ requireChecksum: true })` needs the request's declared hash, which travels as
165
+ `checksum` exactly as the content type travels as `declaredContentType`: a header the route reads
166
+ and hands over, hashed again here and refused on any disagreement.
158
167
  Neither owns a `Request`, a `Response` or a status number — mounting is the host's job, and
159
168
  `@ultimat3/http` is the only layer that turns an `X_*` code into a status.
160
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/storage",
3
- "version": "2.0.0",
3
+ "version": "3.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": "2.0.0"
34
+ "@ultimat3/core": "3.0.0"
35
35
  }
36
36
  }
package/src/accept.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  import type { Clock } from '@ultimat3/core';
10
10
  import type { SignedUrlMethod, StorageDriver, StorageObject, StorageRead } from './driver';
11
11
  import { orgMismatch, signedUrlExpired, signedUrlRejected, tooLarge } from './errors';
12
- import { isWithinOrg } from './path';
12
+ import { isTenantScoped, isWithinOrg } from './path';
13
13
  import type { SignedUrlConstraints } from './signed-url';
14
- import { verifySignedUrl } from './signed-url';
14
+ import { signedUrlBaseFor, verifySignedUrl } from './signed-url';
15
15
  import type { UploadPolicy } from './upload';
16
16
  import { normalizeContentType, uploadPolicy, validateUpload } from './upload';
17
17
 
@@ -19,6 +19,11 @@ export interface SignedRequestInput {
19
19
  /** Absolute or route-relative — `verifySignedUrl` parses both. */
20
20
  readonly url: string;
21
21
  readonly secret: string;
22
+ /**
23
+ * Defaults to the base THIS disk signs under (`signedUrlBaseFor(disk.name)`), never to the bare
24
+ * mount prefix: a second default here made every URL `localDriver` mints a signature-mismatch,
25
+ * because the key parsed as `local/<key>`. Pass one only for a route mounted somewhere else.
26
+ */
22
27
  readonly baseUrl?: string | undefined;
23
28
  readonly disk: StorageDriver;
24
29
  /**
@@ -33,6 +38,13 @@ export interface AcceptSignedUploadInput extends SignedRequestInput {
33
38
  readonly bytes: Uint8Array;
34
39
  /** The transport's `Content-Type`. Refused unless it equals the type the grant signed. */
35
40
  readonly declaredContentType?: string | undefined;
41
+ /**
42
+ * The transport's declared base64 SHA-256, travelling exactly as `declaredContentType` does: a
43
+ * header the route reads and hands over, trusted for nothing — the bytes are hashed here and a
44
+ * disagreement is refused. Without this field `uploadPolicy({ requireChecksum: true })` could
45
+ * only ever fail, since nothing on this path could ever declare one.
46
+ */
47
+ readonly checksum?: string | undefined;
36
48
  readonly policy?: UploadPolicy | undefined;
37
49
  }
38
50
 
@@ -48,7 +60,7 @@ async function constraintsFor(
48
60
  const result = await verifySignedUrl({
49
61
  url: input.url,
50
62
  secret: input.secret,
51
- ...(input.baseUrl === undefined ? {} : { baseUrl: input.baseUrl }),
63
+ baseUrl: input.baseUrl ?? signedUrlBaseFor(input.disk.name),
52
64
  ...(input.clock === undefined ? {} : { clock: input.clock }),
53
65
  });
54
66
  if (!result.ok) {
@@ -63,8 +75,15 @@ async function constraintsFor(
63
75
  `the URL is signed for ${constraints.method}, and this is a ${method}`,
64
76
  );
65
77
  }
66
- if (!isWithinOrg(constraints.key, input.orgId)) {
67
- throw orgMismatch(constraints.key, input.orgId);
78
+ // The PAIR is the question "does this key belong to somebody else?". `isWithinOrg` alone
79
+ // answered `false` for every un-scoped key, so an app's own `brand/logo.png` was unreachable
80
+ // through a URL it had just signed — `path.ts` says so and `dev-storage.ts` already asks it this
81
+ // way. An actor with no org is inside no org, so every tenant-scoped key is somebody else's;
82
+ // checked here because `isWithinOrg` reads an empty org as a malformed key and would blame the
83
+ // URL for the actor's missing claim.
84
+ const orgId = input.orgId;
85
+ if (isTenantScoped(constraints.key) && (orgId === '' || !isWithinOrg(constraints.key, orgId))) {
86
+ throw orgMismatch(constraints.key, orgId);
68
87
  }
69
88
  return constraints;
70
89
  }
@@ -106,7 +125,12 @@ export async function acceptSignedUpload(input: AcceptSignedUploadInput): Promis
106
125
 
107
126
  const policy = input.policy ?? uploadPolicy();
108
127
  const validated = validateUpload(
109
- { key, declaredContentType: signed, bytes: input.bytes },
128
+ {
129
+ key,
130
+ declaredContentType: signed,
131
+ bytes: input.bytes,
132
+ ...(input.checksum === undefined ? {} : { checksum: input.checksum }),
133
+ },
110
134
  policy,
111
135
  );
112
136
  return input.disk.put(validated.key, validated.bytes, {
@@ -29,7 +29,7 @@ import {
29
29
  storageNotImplemented,
30
30
  } from './errors';
31
31
  import { assertSafeKey, META_DIR } from './path';
32
- import { buildSignedUrl } from './signed-url';
32
+ import { buildSignedUrl, signedUrlBaseFor } from './signed-url';
33
33
  import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
34
34
 
35
35
  const DRIVER_NAME = 'local';
@@ -130,7 +130,7 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
130
130
  const root = options.root.replace(/\/+$/, '');
131
131
  const maxPutBytes = options.maxPutBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
132
132
  const clock = options.clock ?? systemClock;
133
- const baseUrl = options.baseUrl ?? `/_storage/${DRIVER_NAME}`;
133
+ const baseUrl = options.baseUrl ?? signedUrlBaseFor(DRIVER_NAME);
134
134
  // A dev disk must work with zero config. Outside development the fallback is refused rather
135
135
  // than used: the literal is published, so signing with it hands every reader the power to mint
136
136
  // a PUT for any key with any size and type limit — which `acceptSignedUpload` then trusts over
package/src/index.ts CHANGED
@@ -141,6 +141,7 @@ export {
141
141
  SIGNED_URL_PARAMS,
142
142
  SIGNED_URL_VERSION,
143
143
  signConstraints,
144
+ signedUrlBaseFor,
144
145
  timingSafeEqual,
145
146
  verifySignedUrl,
146
147
  } from './signed-url';
package/src/path.ts CHANGED
@@ -102,9 +102,15 @@ export function isWithinOrg(key: string, orgId: string): boolean {
102
102
  * two apart: `isWithinOrg` alone would answer `false` for every un-scoped key and make an app's
103
103
  * own shared assets unreachable, and dropping the check would make one tenant's prefix readable
104
104
  * by another. The pair is the question "does this key belong to somebody else?".
105
+ *
106
+ * Case-INSENSITIVE, and that is the load-bearing half: `Org/o2/a.png` and `org/o2/a.png` are one
107
+ * file on a case-insensitive filesystem (APFS, NTFS), so an exact-case test would answer "not a
108
+ * tenant's" for a key that reads another tenant's bytes on every macOS dev disk. `isWithinOrg` is
109
+ * exact-case and stays so, so a folded prefix is refused outright rather than matched — `org/` is
110
+ * the only spelling `scopedKey` mints, so nothing legitimate arrives in any other.
105
111
  */
106
112
  export function isTenantScoped(key: string): boolean {
107
- return key.startsWith(`${ORG_PREFIX}/`);
113
+ return key.slice(0, ORG_PREFIX.length + 1).toLowerCase() === `${ORG_PREFIX}/`;
108
114
  }
109
115
 
110
116
  /** `org/o1/a/b.png` -> `org/o1/a`. Empty for a top-level key. */
package/src/signed-url.ts CHANGED
@@ -17,6 +17,17 @@ export const DEFAULT_SIGNED_URL_TTL_MS = 900_000;
17
17
  /** The dev server mounts the download/upload route here; S3 disks never use it. */
18
18
  export const DEFAULT_SIGNED_URL_BASE = '/_storage';
19
19
 
20
+ /**
21
+ * The base ONE disk's own URLs hang off: the mount prefix plus the driver's name, because the
22
+ * mounted route is `${DEFAULT_SIGNED_URL_BASE}/:disk/*key` and the disk segment is inside the
23
+ * path the HMAC is recovered from. Declared once and called by both halves — the driver that
24
+ * mints and `accept.ts` that verifies — because a base stated twice is a base that drifts, and a
25
+ * drifted base makes every genuine URL a `signature-mismatch`: the key parses as `local/<key>`.
26
+ */
27
+ export function signedUrlBaseFor(driverName: string): string {
28
+ return `${DEFAULT_SIGNED_URL_BASE}/${driverName}`;
29
+ }
30
+
20
31
  export const SIGNED_URL_PARAMS = {
21
32
  method: 'x-method',
22
33
  expires: 'x-exp',