@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/src/errors.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // rejected upload must tell the caller which constraint fired and where that constraint is
3
3
  // configured, or the caller retries the same bytes forever.
4
4
 
5
- import { errorDocsUrl, registerErrorCodes, UltimateError } from '@ultimat3/core';
5
+ import { errorDocsUrl, registerErrorCodes, renderThrowable, UltimateError } from '@ultimat3/core';
6
6
 
7
7
  /** Codes this package declares and owns. */
8
8
  export const STORAGE_OWNED_ERROR_CODES = [
@@ -12,14 +12,23 @@ export const STORAGE_OWNED_ERROR_CODES = [
12
12
  'X_STORAGE_TOO_LARGE',
13
13
  'X_STORAGE_TYPE_REJECTED',
14
14
  'X_STORAGE_CHECKSUM_MISMATCH',
15
+ 'X_STORAGE_URL_INVALID',
16
+ 'X_STORAGE_URL_EXPIRED',
17
+ 'X_STORAGE_ORG_MISMATCH',
18
+ 'X_STORAGE_UPLOAD_FAILED',
19
+ 'X_STORAGE_DELETE_FAILED',
20
+ 'X_STORAGE_LIST_FAILED',
21
+ 'X_STORAGE_QUARANTINED',
15
22
  ] as const;
16
23
 
17
24
  /**
18
25
  * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `storageNotImplemented()` throws it and this package
19
26
  * keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
20
27
  * `X_IMAGE_UNSUPPORTED` / `X_IMAGE_DECODE_FAILED` are core's too and surface unwrapped (`image.ts`).
28
+ * `X_ENV_MISSING` is core's for the same reason: an unset `STORAGE_SIGNING_SECRET` outside
29
+ * development is a missing environment variable, not a storage concept needing its own code.
21
30
  */
22
- export const STORAGE_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
31
+ export const STORAGE_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_ENV_MISSING'] as const;
23
32
 
24
33
  /** Every code storage can throw through `StorageError`: the owned ones plus the borrowed one. */
25
34
  export const STORAGE_ERROR_CODES = [
@@ -37,6 +46,13 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
37
46
  X_STORAGE_TOO_LARGE: 'payload exceeds the upload size limit',
38
47
  X_STORAGE_TYPE_REJECTED: 'content type is not allowed for this upload',
39
48
  X_STORAGE_CHECKSUM_MISMATCH: 'bytes do not match the declared checksum',
49
+ X_STORAGE_URL_INVALID: 'signed URL does not match its signature',
50
+ X_STORAGE_URL_EXPIRED: 'signed URL is past its expiry',
51
+ X_STORAGE_ORG_MISMATCH: 'object key belongs to another org',
52
+ X_STORAGE_UPLOAD_FAILED: 'the signed upload was refused',
53
+ X_STORAGE_DELETE_FAILED: 'the object could not be deleted',
54
+ X_STORAGE_LIST_FAILED: 'the objects could not be listed',
55
+ X_STORAGE_QUARANTINED: 'the object is still in quarantine',
40
56
  };
41
57
 
42
58
  // One unconditional call, so a second package claiming one of storage's codes throws
@@ -113,6 +129,33 @@ export const tooLarge = (key: string, bytes: number, maxBytes: number): StorageE
113
129
  meta: { key, bytes, maxBytes },
114
130
  });
115
131
 
132
+ /**
133
+ * The SERVER-side `put()` ceiling — a different fact from the policy limit `tooLarge` reports.
134
+ * `put()` buffers, so a body past the ceiling is heap growth whose size the sender chooses: the
135
+ * pod is OOM-killed and the caller sees a dropped connection instead of a refusal. Same CODE as
136
+ * the policy limit, because it is the same answer to the same question (413, "too big"), and a
137
+ * different `fix`, because raising `uploadPolicy` does not raise this one.
138
+ *
139
+ * `bytes` is what the disk had measured when it stopped, which for a stream is a floor rather
140
+ * than the body's real length — the point of stopping is not reading the rest.
141
+ */
142
+ export const putTooLarge = (
143
+ disk: string,
144
+ key: string,
145
+ bytes: number,
146
+ maxBytes: number,
147
+ ): StorageError =>
148
+ new StorageError({
149
+ code: 'X_STORAGE_TOO_LARGE',
150
+ cause: `"${key}" measured ${bytes}B against the ${disk} disk's put ceiling of ${maxBytes}B, and put() buffers the whole body`,
151
+ fix:
152
+ `send it direct with grantUpload({ disk, orgId, request }) instead of put(), or raise the ceiling: ` +
153
+ (disk === 's3'
154
+ ? `s3Driver({ bucket, maxPutBytes: ${bytes} })`
155
+ : `localDriver({ root, maxPutBytes: ${bytes} })`),
156
+ meta: { disk, key, bytes, maxBytes },
157
+ });
158
+
116
159
  /** The declared type is not on the allowlist at all. */
117
160
  export const contentTypeNotAllowed = (
118
161
  key: string,
@@ -143,6 +186,130 @@ export const checksumMismatch = (key: string, declared: string, actual: string):
143
186
  meta: { key, declared, actual },
144
187
  });
145
188
 
189
+ /**
190
+ * A signed request that does not match what was signed — a tampered constraint, a URL outside
191
+ * the mounted base, a method the grant never covered, a header the signature contradicts.
192
+ * ONE code for all of them on purpose: telling a forger which half of the tuple they got wrong
193
+ * is an oracle, and `meta.reason` is there for the server's own log.
194
+ */
195
+ export const signedUrlRejected = (reason: string, detail: string): StorageError =>
196
+ new StorageError({
197
+ code: 'X_STORAGE_URL_INVALID',
198
+ cause: `the signed request was rejected as ${reason}: ${detail}`,
199
+ fix: 'mint a fresh one with grantUpload({ disk, orgId, request }) and send it unedited — every constraint is inside the signature',
200
+ meta: { reason, detail },
201
+ });
202
+
203
+ export const signedUrlExpired = (key: string, detail: string): StorageError =>
204
+ new StorageError({
205
+ code: 'X_STORAGE_URL_EXPIRED',
206
+ cause: `the signed request for "${key}" ${detail}`,
207
+ fix: 'call grantUpload({ disk, orgId, request, expiresInMs }) again — a longer expiresInMs widens the window the signature grants',
208
+ meta: { key, detail },
209
+ });
210
+
211
+ /**
212
+ * A delete the provider REFUSED — a denied `s3:DeleteObject`, a throttle, an expired credential,
213
+ * a read-only mount. Deleting an absent key stays idempotent and never reaches here; everything
214
+ * else does, because the alternative shipped for a year: `.catch(() => undefined)` turned 200
215
+ * denied deletes into 200 successes, and an erasure sweep reported data gone that was still there.
216
+ *
217
+ * The `fix` is the driver's, not this factory's: the executable command that reproduces the
218
+ * refusal with the provider's own words differs per disk, and a generic one is a round trip.
219
+ */
220
+ export const deleteFailed = (
221
+ disk: string,
222
+ key: string,
223
+ error: unknown,
224
+ fix: string,
225
+ ): StorageError => {
226
+ const reason = renderThrowable(error);
227
+ return new StorageError({
228
+ code: 'X_STORAGE_DELETE_FAILED',
229
+ cause: `disk "${disk}" refused DELETE "${key}": ${reason}`,
230
+ fix,
231
+ meta: { disk, key, reason },
232
+ });
233
+ };
234
+
235
+ /**
236
+ * A listing the disk REFUSED — a denied `s3:ListBucket`, a throttle, an expired credential, a root
237
+ * this process cannot read. Exactly `deleteFailed`'s shape and for exactly its reason: an empty
238
+ * page and an unreadable one are indistinguishable to a caller, and `sweepOrphans` walks `list()`,
239
+ * so a swallowed refusal reports "no orphans" for a prefix nothing could see — the false-erasure
240
+ * report the `delete()` fix already closed one call to the left.
241
+ *
242
+ * A root that does not exist yet is NOT this: a disk nobody has written to is honestly empty, and
243
+ * both drivers answer it with an empty page.
244
+ *
245
+ * The `fix` is the driver's, for `deleteFailed`'s reason: the command that reproduces the refusal
246
+ * with the disk's own words differs per driver, and a generic one costs a round trip.
247
+ */
248
+ export const listFailed = (
249
+ disk: string,
250
+ prefix: string,
251
+ error: unknown,
252
+ fix: string,
253
+ ): StorageError => {
254
+ const reason = renderThrowable(error);
255
+ return new StorageError({
256
+ code: 'X_STORAGE_LIST_FAILED',
257
+ cause: `disk "${disk}" refused to list "${prefix === '' ? '(the whole disk)' : prefix}": ${reason}`,
258
+ fix,
259
+ meta: { disk, prefix, reason },
260
+ });
261
+ };
262
+
263
+ /**
264
+ * A key still under the quarantine prefix. The framework never scans bytes — that is the app's
265
+ * job — so the only thing it can enforce is that nothing leaves quarantine without the app
266
+ * saying so, which is what `promoteAttachment` refusing this key means.
267
+ */
268
+ export const quarantined = (key: string, orgId: string): StorageError =>
269
+ new StorageError({
270
+ code: 'X_STORAGE_QUARANTINED',
271
+ cause: `"${key}" is still under the quarantine prefix, so nothing has cleared it for use`,
272
+ fix: `scan the bytes, then releaseQuarantine({ disk, key: '${key}', orgId: '${orgId}' }) — promote the key it returns`,
273
+ meta: { key, orgId },
274
+ });
275
+
276
+ /** The key is well-formed and unforged, and still belongs to somebody else. */
277
+ export const orgMismatch = (key: string, orgId: string): StorageError =>
278
+ new StorageError({
279
+ code: 'X_STORAGE_ORG_MISMATCH',
280
+ cause: `key "${key}" is not inside org "${orgId}"`,
281
+ fix: `build it with scopedKey('${orgId}', ...parts), and pass the ACTOR's org as orgId — never one read off the request`,
282
+ meta: { key, orgId },
283
+ });
284
+
285
+ /** The client half: the disk answered the presigned PUT with something other than 2xx. */
286
+ export const uploadFailed = (path: string, status: number, detail: string): StorageError =>
287
+ new StorageError({
288
+ code: 'X_STORAGE_UPLOAD_FAILED',
289
+ cause: `PUT ${path} answered ${status}: ${detail === '' ? 'no body' : detail}`,
290
+ fix: 'call uploadFile({ file, grant }) again for a fresh grant; a 4xx here is the constraint named in the body, not a transport fault',
291
+ meta: { path, status, detail },
292
+ });
293
+
294
+ /**
295
+ * The local disk fell back to the shipped dev signing key outside development.
296
+ *
297
+ * That literal is published in this repo, so anyone holding it can mint a signed `PUT` for any
298
+ * key — including another org's — with `maxBytes` and `contentType` of their choosing, and
299
+ * `acceptSignedUpload` trusts the signed constraints over the app's `uploadPolicy`. A 200KB
300
+ * avatar grant becomes an unlimited upload of any type. Refused at construction, not at the
301
+ * first `signedUrl()`: a process that cannot sign safely must not finish booting.
302
+ */
303
+ export const signingSecretMissing = (environment: string): StorageError =>
304
+ new StorageError({
305
+ code: 'X_ENV_MISSING',
306
+ // The environment names what `resolveEnvironment()` resolved, which may have come from
307
+ // NODE_ENV — naming ULTIMATE_ENV here reported a variable the process never set.
308
+ cause: `the local disk has no usable signing secret (no signingSecret option, and STORAGE_SIGNING_SECRET is unset, empty or the published development key) and the resolved environment is "${environment}", so it would sign URLs with the shipped development key`,
309
+ fix: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
310
+ meta: { key: 'STORAGE_SIGNING_SECRET', environment },
311
+ });
312
+
146
313
  /** An interface-complete driver whose remote half is not bound yet. Always carries a fix. */
147
314
  export const storageNotImplemented = (feature: string, fix: string): StorageError =>
148
315
  new StorageError({
package/src/grant.ts ADDED
@@ -0,0 +1,100 @@
1
+ // Single responsibility: minting ONE presigned PUT for ONE file, with the tenant prefix and the
2
+ // policy already inside the signature. The client never names the key — it asks for a grant and
3
+ // is told one — so a caller cannot aim an upload at another org's prefix, at an attached row it
4
+ // does not own, or at a size the policy never allowed. Everything this returns is derived; the
5
+ // only client input that survives is the filename's extension, and only if it survives a regex.
6
+
7
+ import type { Clock } from '@ultimat3/core';
8
+ import { systemClock } from '@ultimat3/core';
9
+ import type { AttachmentTarget } from './attachment';
10
+ import { attachmentKey, pendingKey, quarantineKey, uploadName } from './attachment';
11
+ import type { StorageDriver } from './driver';
12
+ import { contentTypeNotAllowed, tooLarge } from './errors';
13
+ import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
14
+ import type { UploadPolicy } from './upload';
15
+ import { normalizeContentType, uploadPolicy } from './upload';
16
+
17
+ export interface UploadRequest {
18
+ /** The client's own name for the file. Used for its extension and nothing else. */
19
+ readonly filename: string;
20
+ readonly contentType: string;
21
+ /**
22
+ * Optional, and never trusted: it only buys the client an early refusal. The bytes are counted
23
+ * again by `acceptSignedUpload`, which is the authority, so a lie here changes nothing.
24
+ */
25
+ readonly size?: number | undefined;
26
+ }
27
+
28
+ export interface UploadGrant {
29
+ /** Where the bytes will live. The app stores this on the row, never the URL. */
30
+ readonly key: string;
31
+ readonly url: string;
32
+ readonly method: 'PUT';
33
+ /** Normalised. The client MUST send exactly this as `Content-Type` or the accept refuses. */
34
+ readonly contentType: string;
35
+ readonly maxBytes: number;
36
+ /** Epoch ms. The grant's own view of the window; the signature is what actually enforces it. */
37
+ readonly expiresAt: number;
38
+ }
39
+
40
+ export interface GrantUploadInput {
41
+ readonly disk: StorageDriver;
42
+ /** The ACTOR's org, resolved server-side. A value read off the request is a tenant bypass. */
43
+ readonly orgId: string;
44
+ readonly request: UploadRequest;
45
+ readonly policy?: UploadPolicy | undefined;
46
+ /** Absent means the row does not exist yet, so the key lands under `pending/`. */
47
+ readonly target?: AttachmentTarget | undefined;
48
+ /**
49
+ * Land the bytes under `pending/quarantine/` instead, so nothing can promote them until the
50
+ * app's scan job calls `releaseQuarantine`. Only meaningful without a `target`: an upload
51
+ * aimed straight at a row has no promotion step left to gate.
52
+ */
53
+ readonly quarantine?: boolean | undefined;
54
+ readonly expiresInMs?: number | undefined;
55
+ readonly clock?: Clock | undefined;
56
+ /** Determinism seam for tests. Defaults to `crypto.randomUUID()`. */
57
+ readonly uploadId?: (() => string) | undefined;
58
+ }
59
+
60
+ /**
61
+ * Refuses before it signs. A URL only exists for a request the policy already accepted, so an
62
+ * over-limit or disallowed upload costs one round trip instead of a full transfer — and the
63
+ * signature then carries the same two constraints, so the client cannot widen either of them.
64
+ */
65
+ export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant> {
66
+ const policy = input.policy ?? uploadPolicy();
67
+ const clock = input.clock ?? systemClock;
68
+ const declared = normalizeContentType(input.request.contentType);
69
+ const name = uploadName(
70
+ (input.uploadId ?? (() => crypto.randomUUID()))(),
71
+ input.request.filename,
72
+ );
73
+ const pending =
74
+ input.quarantine === true ? quarantineKey(input.orgId, name) : pendingKey(input.orgId, name);
75
+ const key = input.target === undefined ? pending : attachmentKey(input.orgId, input.target, name);
76
+
77
+ if (!policy.allowedContentTypes.includes(declared)) {
78
+ throw contentTypeNotAllowed(key, declared, policy.allowedContentTypes);
79
+ }
80
+ const size = input.request.size;
81
+ if (size !== undefined && Number.isSafeInteger(size) && size > policy.maxBytes) {
82
+ throw tooLarge(key, size, policy.maxBytes);
83
+ }
84
+
85
+ const expiresInMs = input.expiresInMs ?? DEFAULT_SIGNED_URL_TTL_MS;
86
+ const url = await input.disk.signedUrl(key, {
87
+ method: 'PUT',
88
+ maxBytes: policy.maxBytes,
89
+ contentType: declared,
90
+ expiresInMs,
91
+ });
92
+ return {
93
+ key,
94
+ url,
95
+ method: 'PUT',
96
+ contentType: declared,
97
+ maxBytes: policy.maxBytes,
98
+ expiresAt: clock.now().getTime() + expiresInMs,
99
+ };
100
+ }
package/src/index.ts CHANGED
@@ -1,14 +1,44 @@
1
1
  // Single responsibility: the public API of @ultimat3/storage. Explicit named exports only —
2
2
  // every consumer imports from here, so this list is the package's contract.
3
3
 
4
+ export type { AcceptSignedUploadInput, SignedRequestInput } from './accept';
5
+ export { acceptSignedUpload, readSignedObject } from './accept';
4
6
  export type {
7
+ AttachmentTarget,
8
+ PromoteAttachmentInput,
9
+ ReleaseQuarantineInput,
10
+ SweepFailure,
11
+ SweepOrphansInput,
12
+ SweepResult,
13
+ } from './attachment';
14
+ export {
15
+ attachmentKey,
16
+ attachmentPrefix,
17
+ isPendingKey,
18
+ isQuarantinedKey,
19
+ PENDING_SEGMENT,
20
+ pendingKey,
21
+ pendingPrefix,
22
+ promoteAttachment,
23
+ QUARANTINE_SEGMENT,
24
+ quarantineKey,
25
+ quarantinePrefix,
26
+ releaseQuarantine,
27
+ sweepOrphans,
28
+ uploadExtension,
29
+ uploadName,
30
+ } from './attachment';
31
+ export type {
32
+ ByteLimit,
5
33
  ListOptions,
6
34
  ListPage,
7
35
  PutOptions,
36
+ ServerSideEncryption,
8
37
  SignedUrlMethod,
9
38
  SignedUrlOptions,
10
39
  StorageBody,
11
40
  StorageDriver,
41
+ StorageListEntry,
12
42
  StorageObject,
13
43
  StorageRead,
14
44
  } from './driver';
@@ -20,7 +50,12 @@ export {
20
50
  toBytes,
21
51
  } from './driver';
22
52
  export type { LocalDriverOptions } from './driver-local';
23
- export { localDriver } from './driver-local';
53
+ export {
54
+ DEV_SIGNING_SECRET,
55
+ localDriver,
56
+ STORAGE_SIGNING_SECRET_KEY,
57
+ usesDevStorageSecret,
58
+ } from './driver-local';
24
59
  export type {
25
60
  S3ClientLike,
26
61
  S3DriverOptions,
@@ -35,16 +70,27 @@ export {
35
70
  checksumMismatch,
36
71
  contentTypeMismatch,
37
72
  contentTypeNotAllowed,
73
+ deleteFailed,
38
74
  diskUnknown,
39
75
  isStorageError,
76
+ listFailed,
40
77
  objectNotFound,
78
+ orgMismatch,
41
79
  pathUnsafe,
80
+ putTooLarge,
81
+ quarantined,
42
82
  STORAGE_ERROR_CODES,
43
83
  STORAGE_ERROR_TITLES,
44
84
  StorageError,
85
+ signedUrlExpired,
86
+ signedUrlRejected,
87
+ signingSecretMissing,
45
88
  storageNotImplemented,
46
89
  tooLarge,
90
+ uploadFailed,
47
91
  } from './errors';
92
+ export type { GrantUploadInput, UploadGrant, UploadRequest } from './grant';
93
+ export { grantUpload } from './grant';
48
94
  export type {
49
95
  ImageFit,
50
96
  ImageFormat,
@@ -68,11 +114,13 @@ export {
68
114
  export {
69
115
  assertSafeKey,
70
116
  isSafeKey,
117
+ isTenantScoped,
71
118
  isWithinOrg,
72
119
  joinKey,
73
120
  keyDirname,
74
121
  keyExtname,
75
122
  MAX_KEY_LENGTH,
123
+ META_DIR,
76
124
  ORG_PREFIX,
77
125
  orgPrefix,
78
126
  scopedKey,
@@ -109,3 +157,17 @@ export {
109
157
  uploadPolicy,
110
158
  validateUpload,
111
159
  } from './upload';
160
+ export type {
161
+ SignedPut,
162
+ SignedPutInput,
163
+ UploadedFile,
164
+ UploadFileInput,
165
+ UploadProgress,
166
+ UploadSource,
167
+ } from './upload-client';
168
+ export {
169
+ defaultSignedPut,
170
+ fetchSignedPut,
171
+ uploadFile,
172
+ xhrSignedPut,
173
+ } from './upload-client';
package/src/path.ts CHANGED
@@ -9,6 +9,15 @@ import { pathUnsafe } from './errors';
9
9
  export const MAX_KEY_LENGTH = 1024;
10
10
  export const ORG_PREFIX = 'org';
11
11
 
12
+ /**
13
+ * Reserved first segment: the local driver's sidecar namespace, where an object's recorded
14
+ * content type and etag live. Without the reservation `<root>/.meta/a/b.json` was a legal object
15
+ * key, so an uploader could overwrite the sidecar for `a/b` and make a route serving that object
16
+ * answer `text/html` from the app's own origin. Reserved for EVERY driver, not just the local
17
+ * one — a key that is valid on S3 and refused on disk is two key rules.
18
+ */
19
+ export const META_DIR = '.meta';
20
+
12
21
  // `%2e%2e%2f` decodes to `../` in any layer that decodes twice (proxy, then framework).
13
22
  const ENCODED_SEPARATOR = /%(?:2e|2f|5c|00)/i;
14
23
 
@@ -30,7 +39,9 @@ function unsafeReason(key: string): string | undefined {
30
39
  if (key.includes('\\')) return 'contains a backslash';
31
40
  if (key.startsWith('/')) return 'is absolute (leading "/")';
32
41
  if (ENCODED_SEPARATOR.test(key)) return 'contains a percent-encoded separator (%2e/%2f/%5c)';
33
- for (const segment of key.split('/')) {
42
+ const segments = key.split('/');
43
+ if (segments[0] === META_DIR) return `starts with the reserved "${META_DIR}" segment`;
44
+ for (const segment of segments) {
34
45
  if (segment.length === 0) return 'contains an empty segment ("//" or a trailing "/")';
35
46
  if (segment === '.' || segment === '..') return `contains a "${segment}" segment`;
36
47
  if (segment !== segment.trim()) return `has a padded segment ${JSON.stringify(segment)}`;
@@ -85,6 +96,17 @@ export function isWithinOrg(key: string, orgId: string): boolean {
85
96
  return isSafeKey(key) && key.startsWith(orgPrefix(orgId));
86
97
  }
87
98
 
99
+ /**
100
+ * Whether the key lives in the tenant namespace at all — `scopedKey` and `grantUpload` build
101
+ * `org/<id>/…`, `disk().put('logo.png', …)` does not. A surface serving objects has to tell the
102
+ * two apart: `isWithinOrg` alone would answer `false` for every un-scoped key and make an app's
103
+ * own shared assets unreachable, and dropping the check would make one tenant's prefix readable
104
+ * by another. The pair is the question "does this key belong to somebody else?".
105
+ */
106
+ export function isTenantScoped(key: string): boolean {
107
+ return key.startsWith(`${ORG_PREFIX}/`);
108
+ }
109
+
88
110
  /** `org/o1/a/b.png` -> `org/o1/a`. Empty for a top-level key. */
89
111
  export function keyDirname(key: string): string {
90
112
  const cut = key.lastIndexOf('/');
package/src/signed-url.ts CHANGED
@@ -4,10 +4,14 @@
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 } from '@ultimat3/core';
7
+ import { type Clock, systemClock, timingSafeEqual } from '@ultimat3/core';
8
8
  import type { SignedUrlMethod } from './driver';
9
9
  import { assertSafeKey, isSafeKey } from './path';
10
10
 
11
+ /** Re-exported so every existing `from '@ultimat3/storage'` import keeps working — the
12
+ * implementation now lives in `@ultimat3/core`, shared with `@ultimat3/auth`. */
13
+ export { timingSafeEqual };
14
+
11
15
  export const SIGNED_URL_VERSION = 'v1';
12
16
  export const DEFAULT_SIGNED_URL_TTL_MS = 900_000;
13
17
  /** The dev server mounts the download/upload route here; S3 disks never use it. */
@@ -84,16 +88,6 @@ export async function signConstraints(
84
88
  return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
85
89
  }
86
90
 
87
- /** Length is public (fixed-width hex); the byte comparison must not early-exit. */
88
- export function timingSafeEqual(a: string, b: string): boolean {
89
- if (a.length !== b.length) return false;
90
- let diff = 0;
91
- for (let index = 0; index < a.length; index += 1) {
92
- diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
93
- }
94
- return diff === 0;
95
- }
96
-
97
91
  const trimBase = (base: string): string => base.replace(/\/+$/, '');
98
92
  const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
99
93
 
@@ -105,7 +99,11 @@ export async function buildSignedUrl(input: SignedUrlInput): Promise<string> {
105
99
  method: input.method ?? 'GET',
106
100
  expiresAt: clock.now().getTime() + (input.expiresInMs ?? DEFAULT_SIGNED_URL_TTL_MS),
107
101
  maxBytes: input.maxBytes,
108
- contentType: input.contentType,
102
+ // An empty string is not a content type, and `canonicalRequest` renders it and `undefined`
103
+ // identically — so minting one would produce a URL indistinguishable from an unconstrained
104
+ // one while `acceptSignedUpload`'s `unconstrained` refusal (which tests `undefined`) stayed
105
+ // silent. One spelling for "no content type", here and at the parse below.
106
+ contentType: input.contentType === '' ? undefined : input.contentType,
109
107
  };
110
108
  const params = new URLSearchParams();
111
109
  params.set(SIGNED_URL_PARAMS.method, constraints.method);
@@ -135,13 +133,29 @@ const fail = (reason: SignedUrlFailure, detail: string): SignedUrlVerification =
135
133
  detail,
136
134
  });
137
135
 
136
+ /**
137
+ * `undefined` instead of the bare `URIError` `decodeURIComponent('%ZZ')` throws. The URL is
138
+ * attacker-supplied and the header's promise is that verification never throws — an exception
139
+ * here would escape as an uncoded 500 for a caller whose URL is simply malformed. Nothing is
140
+ * loosened: `buildSignedUrl` percent-encodes every segment, so a segment that will not decode
141
+ * was never minted by this package.
142
+ */
143
+ const decodeSegment = (segment: string): string | undefined => {
144
+ try {
145
+ return decodeURIComponent(segment);
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ };
150
+
138
151
  function parseConstraints(url: URL, base: string): SignedUrlConstraints | SignedUrlFailure {
139
152
  if (!url.pathname.startsWith(`${base}/`)) return 'malformed';
140
- const key = url.pathname
153
+ const segments = url.pathname
141
154
  .slice(base.length + 1)
142
155
  .split('/')
143
- .map(decodeURIComponent)
144
- .join('/');
156
+ .map(decodeSegment);
157
+ if (segments.includes(undefined)) return 'malformed';
158
+ const key = segments.join('/');
145
159
  if (!isSafeKey(key)) return 'unsafe-key';
146
160
  const method = url.searchParams.get(SIGNED_URL_PARAMS.method) ?? 'GET';
147
161
  if (method !== 'GET' && method !== 'PUT') return 'malformed';
@@ -152,12 +166,16 @@ function parseConstraints(url: URL, base: string): SignedUrlConstraints | Signed
152
166
  if (maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || maxBytes < 0)) {
153
167
  return 'malformed';
154
168
  }
169
+ const rawContentType = url.searchParams.get(SIGNED_URL_PARAMS.contentType);
170
+ // An EMPTY `x-ct` is malformed, never "no content type": absent and empty share one canonical
171
+ // string, so accepting it let `&x-ct=` be appended to a URL signed with none and still verify.
172
+ if (rawContentType === '') return 'malformed';
155
173
  return {
156
174
  key,
157
175
  method,
158
176
  expiresAt,
159
177
  maxBytes,
160
- contentType: url.searchParams.get(SIGNED_URL_PARAMS.contentType) ?? undefined,
178
+ contentType: rawContentType ?? undefined,
161
179
  };
162
180
  }
163
181