@lunora/storage 1.0.0-alpha.1 → 1.0.0-alpha.10

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/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * A single-range read against R2: an `{ offset, length }` window (at least one
3
- * bound required, mirroring R2's own `R2Range`) or a `{ suffix }` tail. The
4
- * subset of `R2Range` that {@link Storage.download} forwards so a caller can
5
- * stream just the bytes it needs instead of the whole object.
6
- */
2
+ * A single-range read against R2: an `{ offset, length }` window (at least one
3
+ * bound required, mirroring R2's own `R2Range`) or a `{ suffix }` tail. The
4
+ * subset of `R2Range` that {@link Storage.download} forwards so a caller can
5
+ * stream just the bytes it needs instead of the whole object.
6
+ */
7
7
  type R2RangeLike = {
8
8
  length: number;
9
9
  offset?: number;
@@ -14,15 +14,15 @@ type R2RangeLike = {
14
14
  suffix: number;
15
15
  };
16
16
  /**
17
- * Minimal projection of `R2Bucket`. Declared structurally so unit tests can
18
- * pass a plain object double; the real binding satisfies the same shape.
19
- */
17
+ * Minimal projection of `R2Bucket`. Declared structurally so unit tests can
18
+ * pass a plain object double; the real binding satisfies the same shape.
19
+ */
20
20
  interface R2BucketLike {
21
21
  /**
22
- * Begin a multipart upload (R2 `createMultipartUpload`). Optional so existing
23
- * test doubles still satisfy the type; {@link Storage.createMultipartUpload}
24
- * throws a clear error when the binding lacks it.
25
- */
22
+ * Begin a multipart upload (R2 `createMultipartUpload`). Optional so existing
23
+ * test doubles still satisfy the type; {@link Storage.createMultipartUpload}
24
+ * throws a clear error when the binding lacks it.
25
+ */
26
26
  createMultipartUpload?: (key: string, options?: {
27
27
  customMetadata?: Record<string, string>;
28
28
  httpMetadata?: {
@@ -34,11 +34,11 @@ interface R2BucketLike {
34
34
  range?: R2RangeLike;
35
35
  }) => Promise<R2ObjectBodyLike | null>;
36
36
  /**
37
- * Fetch an object's metadata without its body (R2 HEAD). Returns `null` when
38
- * the object is absent. Declared optional so existing test doubles that only
39
- * implement `get`/`put`/`list`/`delete` still satisfy the type; callers that
40
- * need metadata fall back to a 0-length ranged `get()` when `head` is absent.
41
- */
37
+ * Fetch an object's metadata without its body (R2 HEAD). Returns `null` when
38
+ * the object is absent. Declared optional so existing test doubles that only
39
+ * implement `get`/`put`/`list`/`delete` still satisfy the type; callers that
40
+ * need metadata fall back to a 0-length ranged `get()` when `head` is absent.
41
+ */
42
42
  head?: (key: string) => Promise<R2ObjectLike | null>;
43
43
  list: (options?: {
44
44
  cursor?: string;
@@ -65,11 +65,11 @@ interface R2UploadedPartLike {
65
65
  partNumber: number;
66
66
  }
67
67
  /**
68
- * An in-progress multipart upload, mirroring R2's `R2MultipartUpload`. Each part
69
- * (except the last) must be uniform in size. The object does not guarantee the
70
- * underlying upload still exists — a parallel `complete`/`abort` can invalidate
71
- * it — so wrap each call in error handling.
72
- */
68
+ * An in-progress multipart upload, mirroring R2's `R2MultipartUpload`. Each part
69
+ * (except the last) must be uniform in size. The object does not guarantee the
70
+ * underlying upload still exists — a parallel `complete`/`abort` can invalidate
71
+ * it — so wrap each call in error handling.
72
+ */
73
73
  interface R2MultipartUploadLike {
74
74
  /** Abort the upload, discarding any uploaded parts. */
75
75
  abort: () => Promise<void>;
@@ -84,44 +84,44 @@ interface R2MultipartUploadLike {
84
84
  }
85
85
  interface R2ObjectLike {
86
86
  /**
87
- * R2-computed checksums. The real binding exposes `sha256` as an
88
- * `ArrayBuffer` (present only when R2 stored a SHA-256 for the object);
89
- * declared optional so fakes and non-checksummed objects type-check.
90
- */
87
+ * R2-computed checksums. The real binding exposes `sha256` as an
88
+ * `ArrayBuffer` (present only when R2 stored a SHA-256 for the object);
89
+ * declared optional so fakes and non-checksummed objects type-check.
90
+ */
91
91
  checksums?: {
92
92
  sha256?: ArrayBuffer;
93
93
  };
94
94
  customMetadata?: Record<string, string>;
95
95
  etag: string;
96
96
  /**
97
- * The quoted form of {@link R2ObjectLike.etag} (e.g. `"abc123"`), suitable
98
- * for emitting directly as an HTTP `ETag` header. The real binding always
99
- * provides it; declared optional so existing doubles that only set `etag`
100
- * still type-check (callers fall back to quoting `etag`).
101
- */
97
+ * The quoted form of {@link R2ObjectLike.etag} (e.g. `"abc123"`), suitable
98
+ * for emitting directly as an HTTP `ETag` header. The real binding always
99
+ * provides it; declared optional so existing doubles that only set `etag`
100
+ * still type-check (callers fall back to quoting `etag`).
101
+ */
102
102
  httpEtag?: string;
103
103
  httpMetadata?: {
104
104
  contentType?: string;
105
105
  };
106
106
  key: string;
107
107
  /**
108
- * Hex-encoded SHA-256 of the object body, surfaced by `download()`/`list()`
109
- * when R2 carries a checksum (derived from {@link R2ObjectLike.checksums}).
110
- */
108
+ * Hex-encoded SHA-256 of the object body, surfaced by `download()`/`list()`
109
+ * when R2 carries a checksum (derived from {@link R2ObjectLike.checksums}).
110
+ */
111
111
  sha256?: string;
112
112
  /**
113
- * Base64-encoded SHA-256 of the object body, surfaced alongside
114
- * {@link R2ObjectLike.sha256} from the same checksum. Base64 is the encoding
115
- * RFC 9530 digest headers (`Repr-Digest`/`Content-Digest`) require, so HTTP
116
- * layers can emit a spec-compliant digest without re-deriving it.
117
- */
113
+ * Base64-encoded SHA-256 of the object body, surfaced alongside
114
+ * {@link R2ObjectLike.sha256} from the same checksum. Base64 is the encoding
115
+ * RFC 9530 digest headers (`Repr-Digest`/`Content-Digest`) require, so HTTP
116
+ * layers can emit a spec-compliant digest without re-deriving it.
117
+ */
118
118
  sha256Base64?: string;
119
119
  size: number;
120
120
  /**
121
- * When the object was written. The real binding exposes this as a `Date`;
122
- * declared optional so fakes that omit it still type-check.
123
- * {@link Storage.getMetadata} normalises it to epoch ms.
124
- */
121
+ * When the object was written. The real binding exposes this as a `Date`;
122
+ * declared optional so fakes that omit it still type-check.
123
+ * {@link Storage.getMetadata} normalises it to epoch ms.
124
+ */
125
125
  uploaded?: Date;
126
126
  }
127
127
  interface R2ObjectBodyLike extends R2ObjectLike {
@@ -130,11 +130,11 @@ interface R2ObjectBodyLike extends R2ObjectLike {
130
130
  text: () => Promise<string>;
131
131
  }
132
132
  /**
133
- * R2 S3-API credentials for {@link Storage.getPresignedUrl}. These are an R2 API
134
- * token's Access Key ID / Secret Access Key (NOT a Cloudflare API token), plus
135
- * the account id and bucket name. Required only if you call `getPresignedUrl`;
136
- * the worker-signed URL path (`getSignedUrl`) needs none of this.
137
- */
133
+ * R2 S3-API credentials for {@link Storage.getPresignedUrl}. These are an R2 API
134
+ * token's Access Key ID / Secret Access Key (NOT a Cloudflare API token), plus
135
+ * the account id and bucket name. Required only if you call `getPresignedUrl`;
136
+ * the worker-signed URL path (`getSignedUrl`) needs none of this.
137
+ */
138
138
  interface R2S3Credentials {
139
139
  /** R2 S3 Access Key ID. */
140
140
  accessKeyId: string;
@@ -159,10 +159,10 @@ interface LunoraStorageOptions {
159
159
  /** Public base URL used by `getSignedUrl()`. Required for signed URLs. */
160
160
  publicBaseUrl?: string;
161
161
  /**
162
- * R2 S3-API credentials enabling {@link Storage.getPresignedUrl} (native S3
163
- * presigned URLs that hit R2 directly, bypassing the Worker). Omit to use
164
- * only the worker-signed URL path.
165
- */
162
+ * R2 S3-API credentials enabling {@link Storage.getPresignedUrl} (native S3
163
+ * presigned URLs that hit R2 directly, bypassing the Worker). Omit to use
164
+ * only the worker-signed URL path.
165
+ */
166
166
  s3?: R2S3Credentials;
167
167
  /** HMAC secret used by the worker-signed URL helper. Required for signed URLs. */
168
168
  signingSecret?: string;
@@ -173,13 +173,13 @@ interface UploadOptions {
173
173
  contentType?: string;
174
174
  customMetadata?: Record<string, string>;
175
175
  /**
176
- * Maximum body size in bytes. For `ArrayBuffer`/`Blob` sources the length is
177
- * known up front and rejected before the upload starts. For a
178
- * `ReadableStream` the length isn't known synchronously, so the stream is
179
- * piped through a byte counter that aborts the upload once the limit is
180
- * exceeded — this also guards against R2 silently accepting/truncating an
181
- * unbounded stream.
182
- */
176
+ * Maximum body size in bytes. For `ArrayBuffer`/`Blob` sources the length is
177
+ * known up front and rejected before the upload starts. For a
178
+ * `ReadableStream` the length isn't known synchronously, so the stream is
179
+ * piped through a byte counter that aborts the upload once the limit is
180
+ * exceeded — this also guards against R2 silently accepting/truncating an
181
+ * unbounded stream.
182
+ */
183
183
  maxSize?: number;
184
184
  }
185
185
  interface ListOptions {
@@ -191,20 +191,20 @@ interface ListOptions {
191
191
  }
192
192
  interface SignedUrlOptions {
193
193
  /**
194
- * Pin the `Content-Type` an uploader must send on a `method: "PUT"` URL.
195
- * Baked into the HMAC canonical so the signature only authorizes a PUT with
196
- * exactly this content-type; mirrored on the URL as `&amp;ct=...`. Ignored for
197
- * `GET` URLs (a download has no request body content-type to pin).
198
- */
194
+ * Pin the `Content-Type` an uploader must send on a `method: "PUT"` URL.
195
+ * Baked into the HMAC canonical so the signature only authorizes a PUT with
196
+ * exactly this content-type; mirrored on the URL as `&amp;ct=...`. Ignored for
197
+ * `GET` URLs (a download has no request body content-type to pin).
198
+ */
199
199
  contentType?: string;
200
200
  expiresInSeconds?: number;
201
201
  method?: "GET" | "PUT";
202
202
  }
203
203
  /**
204
- * Per-object metadata returned by {@link Storage.getMetadata} — a flat,
205
- * body-free projection of {@link R2ObjectLike}. Mirrors the shape Convex
206
- * surfaces for `ctx.storage.getMetadata` / the `_storage` system table.
207
- */
204
+ * Per-object metadata returned by {@link Storage.getMetadata} — a flat,
205
+ * body-free projection of {@link R2ObjectLike}. Mirrors the shape Convex
206
+ * surfaces for `ctx.storage.getMetadata` / the `_storage` system table.
207
+ */
208
208
  interface ObjectMetadata {
209
209
  /** The object's `Content-Type` (R2 `httpMetadata.contentType`), if recorded. */
210
210
  contentType?: string;
@@ -221,49 +221,49 @@ interface ObjectMetadata {
221
221
  }
222
222
  interface Storage {
223
223
  /**
224
- * Begin a native R2 **multipart upload** for very large objects — upload
225
- * parts (each uniform in size except the last), then `complete` with the
226
- * returned parts (or `abort`). Wraps R2's `createMultipartUpload`; throws if
227
- * the bound bucket doesn't support it. For ordinary uploads use
228
- * {@link Storage.upload} / {@link Storage.store}.
229
- */
224
+ * Begin a native R2 **multipart upload** for very large objects — upload
225
+ * parts (each uniform in size except the last), then `complete` with the
226
+ * returned parts (or `abort`). Wraps R2's `createMultipartUpload`; throws if
227
+ * the bound bucket doesn't support it. For ordinary uploads use
228
+ * {@link Storage.upload} / {@link Storage.store}.
229
+ */
230
230
  createMultipartUpload: (key: string, options?: {
231
231
  contentType?: string;
232
232
  customMetadata?: Record<string, string>;
233
233
  }) => Promise<R2MultipartUploadLike>;
234
234
  delete: (key: string) => Promise<void>;
235
235
  /**
236
- * Fetch a stored object's metadata + body. Pass `options.range` to stream
237
- * only a byte window (R2 resolves the range server-side, so the unwanted
238
- * bytes never reach the Worker) — `download(key)` reads the whole object.
239
- */
236
+ * Fetch a stored object's metadata + body. Pass `options.range` to stream
237
+ * only a byte window (R2 resolves the range server-side, so the unwanted
238
+ * bytes never reach the Worker) — `download(key)` reads the whole object.
239
+ */
240
240
  download: (key: string, options?: {
241
241
  range?: R2RangeLike;
242
242
  }) => Promise<R2ObjectBodyLike | null>;
243
243
  /**
244
- * Mint a short-lived signed `PUT` URL a client can upload directly to,
245
- * optionally pinning the request `Content-Type`. Convex-compatible alias
246
- * built on {@link Storage.getSignedUrl} with `method: "PUT"`.
247
- */
244
+ * Mint a short-lived signed `PUT` URL a client can upload directly to,
245
+ * optionally pinning the request `Content-Type`. Convex-compatible alias
246
+ * built on {@link Storage.getSignedUrl} with `method: "PUT"`.
247
+ */
248
248
  generateUploadUrl: (key: string, options?: {
249
249
  contentType?: string;
250
250
  expiresInSeconds?: number;
251
251
  }) => Promise<string>;
252
252
  /**
253
- * Read a stored object's metadata (size, content-type, sha256, upload time,
254
- * custom metadata) without fetching its body. Returns `null` when the object
255
- * is absent. Backed by an R2 HEAD (`bucket.head`) when available, falling
256
- * back to a 0-length ranged `get()` otherwise. Mirrors Convex's
257
- * `ctx.storage.getMetadata`.
258
- */
253
+ * Read a stored object's metadata (size, content-type, sha256, upload time,
254
+ * custom metadata) without fetching its body. Returns `null` when the object
255
+ * is absent. Backed by an R2 HEAD (`bucket.head`) when available, falling
256
+ * back to a 0-length ranged `get()` otherwise. Mirrors Convex's
257
+ * `ctx.storage.getMetadata`.
258
+ */
259
259
  getMetadata: (key: string) => Promise<ObjectMetadata | null>;
260
260
  /**
261
- * Mint a native S3 **presigned URL** (SigV4) that hits R2 directly, bypassing
262
- * the Worker. Use for large downloads/uploads where you don't need per-request
263
- * app gating and want the bytes off the Worker's CPU/bandwidth budget. Requires
264
- * {@link LunoraStorageOptions.s3} credentials; throws if they're absent. For
265
- * app-gated access (auth/policy/rate-limit) prefer {@link Storage.getSignedUrl}.
266
- */
261
+ * Mint a native S3 **presigned URL** (SigV4) that hits R2 directly, bypassing
262
+ * the Worker. Use for large downloads/uploads where you don't need per-request
263
+ * app gating and want the bytes off the Worker's CPU/bandwidth budget. Requires
264
+ * {@link LunoraStorageOptions.s3} credentials; throws if they're absent. For
265
+ * app-gated access (auth/policy/rate-limit) prefer {@link Storage.getSignedUrl}.
266
+ */
267
267
  getPresignedUrl: (key: string, options?: PresignedUrlOptions) => Promise<string>;
268
268
  getSignedUrl: (key: string, options?: SignedUrlOptions) => Promise<string>;
269
269
  getUrl: (key: string) => string;
@@ -273,16 +273,16 @@ interface Storage {
273
273
  truncated?: boolean;
274
274
  }>;
275
275
  /**
276
- * Resume an in-progress multipart upload by its `uploadId` (e.g. across
277
- * requests). Wraps R2's `resumeMultipartUpload`; the id is not validated by
278
- * R2, so a stale id surfaces as an error on the first `uploadPart`/`complete`.
279
- */
276
+ * Resume an in-progress multipart upload by its `uploadId` (e.g. across
277
+ * requests). Wraps R2's `resumeMultipartUpload`; the id is not validated by
278
+ * R2, so a stale id surfaces as an error on the first `uploadPart`/`complete`.
279
+ */
280
280
  resumeMultipartUpload: (key: string, uploadId: string) => R2MultipartUploadLike;
281
281
  /**
282
- * Upload `body` to `key`, returning the stored key + etag. Convex-compatible
283
- * alias for {@link Storage.upload} — it accepts the same {@link UploadOptions}
284
- * so the `maxSize` / `allowedContentTypes` guards aren't lost behind the alias.
285
- */
282
+ * Upload `body` to `key`, returning the stored key + etag. Convex-compatible
283
+ * alias for {@link Storage.upload} — it accepts the same {@link UploadOptions}
284
+ * so the `maxSize` / `allowedContentTypes` guards aren't lost behind the alias.
285
+ */
286
286
  store: (key: string, body: ReadableStream | ArrayBuffer | Blob, options?: UploadOptions) => Promise<{
287
287
  etag: string;
288
288
  httpEtag: string;
@@ -295,11 +295,11 @@ interface Storage {
295
295
  }>;
296
296
  }
297
297
  /**
298
- * A bucket-aware {@link Storage}: the methods target a default bucket, and
299
- * `bucket(name)` selects a different named bucket (declared via
300
- * `v.storage("name")`). `bucketName` is the bucket the current accessor targets
301
- * — the storage-rules middleware reads it to scope `(bucket, operation)` rules.
302
- */
298
+ * A bucket-aware {@link Storage}: the methods target a default bucket, and
299
+ * `bucket(name)` selects a different named bucket (declared via
300
+ * `v.storage("name")`). `bucketName` is the bucket the current accessor targets
301
+ * — the storage-rules middleware reads it to scope `(bucket, operation)` rules.
302
+ */
303
303
  interface BucketStorage extends Storage {
304
304
  /** Select a named bucket. Unknown names throw with the list of registered buckets. */
305
305
  bucket: (name: string) => BucketStorage;
@@ -307,36 +307,36 @@ interface BucketStorage extends Storage {
307
307
  readonly bucketName: string;
308
308
  }
309
309
  /**
310
- * Compose several per-bucket {@link Storage} instances into one bucket-aware
311
- * accessor. The bare methods (`download` / `store` / …) target the default
312
- * bucket; `bucket(name)` switches to another. Each accessor is tagged with its
313
- * `bucketName` so `storageRules(...)` can enforce per-bucket.
314
- *
315
- * ```ts
316
- * storage: (env) => createBucketStorage({
317
- * default: createStorage({ bucket: env.FILES }),
318
- * avatars: createStorage({ bucket: env.AVATARS }),
319
- * }),
320
- * // → ctx.storage.download(key) // default bucket
321
- * // → ctx.storage.bucket("avatars").store() // the avatars bucket
322
- * ```
323
- *
324
- * The bare accessor is tagged `"default"` — the canonical name a
325
- * `defineStorageRule({ bucket: "default" })` rule and the generated
326
- * `StorageBucketName` union both use — unless `options.default` names another
327
- * bucket (then the bare accessor takes that name). The binding it delegates to is
328
- * `options.default`, else the `"default"` key when present, else the first
329
- * registered bucket. Named buckets are reached with `bucket(name)`.
330
- */
310
+ * Compose several per-bucket {@link Storage} instances into one bucket-aware
311
+ * accessor. The bare methods (`download` / `store` / …) target the default
312
+ * bucket; `bucket(name)` switches to another. Each accessor is tagged with its
313
+ * `bucketName` so `storageRules(...)` can enforce per-bucket.
314
+ *
315
+ * ```ts
316
+ * storage: (env) => createBucketStorage({
317
+ * default: createStorage({ bucket: env.FILES }),
318
+ * avatars: createStorage({ bucket: env.AVATARS }),
319
+ * }),
320
+ * // → ctx.storage.download(key) // default bucket
321
+ * // → ctx.storage.bucket("avatars").store() // the avatars bucket
322
+ * ```
323
+ *
324
+ * The bare accessor is tagged `"default"` — the canonical name a
325
+ * `defineStorageRule({ bucket: "default" })` rule and the generated
326
+ * `StorageBucketName` union both use — unless `options.default` names another
327
+ * bucket (then the bare accessor takes that name). The binding it delegates to is
328
+ * `options.default`, else the `"default"` key when present, else the first
329
+ * registered bucket. Named buckets are reached with `bucket(name)`.
330
+ */
331
331
  declare const createBucketStorage: (buckets: Record<string, Storage>, options?: {
332
332
  default?: string;
333
333
  }) => BucketStorage;
334
334
  /**
335
- * Compose a per-tenant key from a scope prefix and a caller-supplied key.
336
- * Both halves are validated — the prefix may not contain `..` or NUL either,
337
- * and the resulting key must stay under R2's length ceiling. Recommended for
338
- * any multi-tenant deployment so client-supplied keys can't address peer data.
339
- */
335
+ * Compose a per-tenant key from a scope prefix and a caller-supplied key.
336
+ * Both halves are validated — the prefix may not contain `..` or NUL either,
337
+ * and the resulting key must stay under R2's length ceiling. Recommended for
338
+ * any multi-tenant deployment so client-supplied keys can't address peer data.
339
+ */
340
340
  declare const scopeKey: (prefix: string, key: string) => string;
341
341
  declare const createStorage: (options: LunoraStorageOptions) => Storage;
342
342
  /** Parameters accepted by {@link buildPresignedUrl}. */
@@ -353,25 +353,25 @@ interface PresignedUrlParams {
353
353
  now?: () => number;
354
354
  }
355
355
  /**
356
- * Build a native S3 presigned URL for an R2 object using SigV4 query-string
357
- * auth. The returned URL points at R2's S3 endpoint and carries the full
358
- * signature, so it authorizes a single `GET`/`PUT` on `key` until it expires —
359
- * no Worker round-trip.
360
- */
356
+ * Build a native S3 presigned URL for an R2 object using SigV4 query-string
357
+ * auth. The returned URL points at R2's S3 endpoint and carries the full
358
+ * signature, so it authorizes a single `GET`/`PUT` on `key` until it expires —
359
+ * no Worker round-trip.
360
+ */
361
361
  declare const buildPresignedUrl: (parameters: PresignedUrlParams) => Promise<string>;
362
362
  /**
363
- * Worker-signed URL: the `publicBaseUrl` joined to the object `key`, plus a query
364
- * string carrying `exp` (unix seconds), `method` (`GET` or `PUT`) and `sig`
365
- * (a base64url HMAC).
366
- *
367
- * The HMAC canonical includes the URL host so a signature minted for one bucket
368
- * cannot be replayed against another host on the same signing secret. Even so,
369
- * the signing secret MUST NOT be shared across buckets/tenants — host binding
370
- * narrows replay surface but is not a substitute for per-tenant key isolation.
371
- *
372
- * The Worker handling `GET /storage/:key` should call {@link verifySignedUrl}
373
- * to validate the signature + expiry before streaming the R2 body.
374
- */
363
+ * Worker-signed URL: the `publicBaseUrl` joined to the object `key`, plus a query
364
+ * string carrying `exp` (unix seconds), `method` (`GET` or `PUT`) and `sig`
365
+ * (a base64url HMAC).
366
+ *
367
+ * The HMAC canonical includes the URL host so a signature minted for one bucket
368
+ * cannot be replayed against another host on the same signing secret. Even so,
369
+ * the signing secret MUST NOT be shared across buckets/tenants — host binding
370
+ * narrows replay surface but is not a substitute for per-tenant key isolation.
371
+ *
372
+ * The Worker handling `GET /storage/:key` should call {@link verifySignedUrl}
373
+ * to validate the signature + expiry before streaming the R2 body.
374
+ */
375
375
  declare const buildSignedUrl: (args: SignedUrlOptions & {
376
376
  baseUrl: string;
377
377
  key: string;
@@ -383,23 +383,23 @@ interface VerifyResult {
383
383
  key?: string;
384
384
  method?: "GET" | "PUT";
385
385
  /**
386
- * Internal-only failure reason for server logs/diagnostics. **Do not echo
387
- * to clients** — a precise reason ("expired" vs "bad_signature") is a
388
- * signing oracle. Public responses should expose only `valid`.
389
- */
386
+ * Internal-only failure reason for server logs/diagnostics. **Do not echo
387
+ * to clients** — a precise reason ("expired" vs "bad_signature") is a
388
+ * signing oracle. Public responses should expose only `valid`.
389
+ */
390
390
  reason?: "bad_signature" | "expired" | "malformed";
391
391
  valid: boolean;
392
392
  }
393
393
  /**
394
- * Verify a {@link buildSignedUrl} output. By default the signature is
395
- * canonicalized against the inbound `url.host`, which matches the build-side
396
- * host whenever the URL being verified is the URL that was minted. In a
397
- * topology where the host the Worker sees differs from the configured
398
- * `publicBaseUrl` host (e.g. a CDN host vs a Worker route that rewrites
399
- * `Host`), pass `expectedHost` (the `publicBaseUrl` host) so verification
400
- * canonicalizes against the same host the signature was minted for instead of
401
- * failing every request as `bad_signature`.
402
- */
394
+ * Verify a {@link buildSignedUrl} output. By default the signature is
395
+ * canonicalized against the inbound `url.host`, which matches the build-side
396
+ * host whenever the URL being verified is the URL that was minted. In a
397
+ * topology where the host the Worker sees differs from the configured
398
+ * `publicBaseUrl` host (e.g. a CDN host vs a Worker route that rewrites
399
+ * `Host`), pass `expectedHost` (the `publicBaseUrl` host) so verification
400
+ * canonicalizes against the same host the signature was minted for instead of
401
+ * failing every request as `bad_signature`.
402
+ */
403
403
  declare const verifySignedUrl: (input: string | URL, secret: string, options?: {
404
404
  expectedHost?: string;
405
405
  }) => Promise<VerifyResult>;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export { createBucketStorage } from './packem_shared/createBucketStorage-4Xk7-5CN.mjs';
2
- export { createStorage, scopeKey } from './packem_shared/scopeKey-Bs_iJ1Mx.mjs';
3
- export { buildPresignedUrl } from './packem_shared/buildPresignedUrl-DzPwi1bY.mjs';
4
- export { buildSignedUrl, verifySignedUrl } from './packem_shared/buildSignedUrl-ZzB16yPl.mjs';
1
+ export { createBucketStorage } from './packem_shared/createBucketStorage-JYdrS4e5.mjs';
2
+ export { createStorage, scopeKey } from './packem_shared/createStorage-Bu6GJB25.mjs';
3
+ export { buildPresignedUrl } from './packem_shared/buildPresignedUrl-B5f1wvz6.mjs';
4
+ export { buildSignedUrl, verifySignedUrl } from './packem_shared/buildSignedUrl-rt-6azia.mjs';
@@ -1,3 +1,5 @@
1
+ import { a as toHex } from './internal-Dqk0MrAj.mjs';
2
+
1
3
  const REGION = "auto";
2
4
  const SERVICE = "s3";
3
5
  const ALGORITHM = "AWS4-HMAC-SHA256";
@@ -11,14 +13,6 @@ const compareEntries = (a, b) => {
11
13
  }
12
14
  return a[0] > b[0] ? 1 : 0;
13
15
  };
14
- const toHex = (buffer) => {
15
- const bytes = new Uint8Array(buffer);
16
- let out = "";
17
- for (const byte of bytes) {
18
- out += byte.toString(16).padStart(2, "0");
19
- }
20
- return out;
21
- };
22
16
  const encodeRfc3986 = (value) => encodeURIComponent(value).replaceAll(/[!'()*]/gu, (char) => `%${char.codePointAt(0)?.toString(16).toUpperCase() ?? ""}`);
23
17
  const encodeKey = (key) => key.split("/").map((segment) => encodeRfc3986(segment)).join("/");
24
18
  const sha256Hex = async (input) => toHex(await crypto.subtle.digest("SHA-256", textEncoder.encode(input)));
@@ -1,7 +1,19 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { t as trimTrailingSlashes } from './internal-Dqk0MrAj.mjs';
3
+
4
+ const evictOldestEntry = (map, capacity) => {
5
+ if (map.size < capacity) {
6
+ return;
7
+ }
8
+ const oldest = map.keys().next().value;
9
+ if (oldest !== void 0) {
10
+ map.delete(oldest);
11
+ }
12
+ };
13
+
1
14
  const textEncoder = new TextEncoder();
2
- const MAX_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60;
15
+ const MAX_SIGNED_URL_TTL_SECONDS = 7 * 24 * 60 * 60;
3
16
  const SCHEME_PREFIX_RE = /^[a-z][a-z0-9+\-.]*:\/\//i;
4
- const LEADING_SLASH_RE = /^\//;
5
17
  const toBase64Url = (bytes) => {
6
18
  const binary = String.fromCodePoint(...bytes);
7
19
  return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
@@ -15,24 +27,18 @@ const fromBase64Url = (input) => {
15
27
  }
16
28
  return bytes;
17
29
  };
30
+ const KEY_CACHE_MAX = 64;
18
31
  const keyCache = /* @__PURE__ */ new Map();
19
32
  const importHmacKey = async (secret) => {
20
33
  const cached = keyCache.get(secret);
21
34
  if (cached) {
22
35
  return cached;
23
36
  }
37
+ evictOldestEntry(keyCache, KEY_CACHE_MAX);
24
38
  const keyPromise = crypto.subtle.importKey("raw", textEncoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign", "verify"]);
25
39
  keyCache.set(secret, keyPromise);
26
40
  return keyPromise;
27
41
  };
28
- const canonicalize = (method, host, key, exp, contentType) => {
29
- const base = `${method}
30
- ${host.toLowerCase()}
31
- ${key}
32
- ${String(exp)}`;
33
- return contentType === void 0 ? base : `${base}
34
- ${contentType}`;
35
- };
36
42
  const extractHost = (input) => {
37
43
  try {
38
44
  return new URL(input).host;
@@ -41,22 +47,39 @@ const extractHost = (input) => {
41
47
  return noScheme.split("/")[0] ?? "";
42
48
  }
43
49
  };
50
+ const signCanonical = async (secret, canonical) => {
51
+ const cryptoKey = await importHmacKey(secret);
52
+ const signature = await crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(canonical));
53
+ return toBase64Url(new Uint8Array(signature));
54
+ };
55
+ const verifyCanonical = async (secret, canonical, sigBytes) => {
56
+ const cryptoKey = await importHmacKey(secret);
57
+ return crypto.subtle.verify("HMAC", cryptoKey, sigBytes, textEncoder.encode(canonical));
58
+ };
59
+
60
+ const LEADING_SLASH_RE = /^\//;
61
+ const canonicalize = (method, host, key, exp, contentType) => {
62
+ const base = `${method}
63
+ ${host.toLowerCase()}
64
+ ${key}
65
+ ${String(exp)}`;
66
+ return contentType === void 0 ? base : `${base}
67
+ ${contentType}`;
68
+ };
44
69
  const buildSignedUrl = async (args) => {
45
70
  const method = args.method ?? "GET";
46
71
  const expiresInSeconds = args.expiresInSeconds ?? 60 * 60;
47
72
  if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
48
- throw new Error("@lunora/storage: expiresInSeconds must be a positive finite number");
73
+ throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: expiresInSeconds must be a positive finite number");
49
74
  }
50
- if (expiresInSeconds > MAX_EXPIRES_IN_SECONDS) {
51
- throw new Error(`@lunora/storage: expiresInSeconds must not exceed ${String(MAX_EXPIRES_IN_SECONDS)} (7 days)`);
75
+ if (expiresInSeconds > MAX_SIGNED_URL_TTL_SECONDS) {
76
+ throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: expiresInSeconds must not exceed ${String(MAX_SIGNED_URL_TTL_SECONDS)} (7 days)`);
52
77
  }
53
78
  const contentType = method === "PUT" ? args.contentType : void 0;
54
79
  const exp = Math.floor(Date.now() / 1e3) + expiresInSeconds;
55
80
  const host = extractHost(args.baseUrl);
56
- const cryptoKey = await importHmacKey(args.secret);
57
- const signature = await crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(canonicalize(method, host, args.key, exp, contentType)));
58
- const sig = toBase64Url(new Uint8Array(signature));
59
- const base = args.baseUrl.endsWith("/") ? args.baseUrl.slice(0, -1) : args.baseUrl;
81
+ const sig = await signCanonical(args.secret, canonicalize(method, host, args.key, exp, contentType));
82
+ const base = trimTrailingSlashes(args.baseUrl);
60
83
  const safeKey = args.key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
61
84
  const ctParameter = contentType === void 0 ? "" : `&ct=${encodeURIComponent(contentType)}`;
62
85
  return `${base}/${safeKey}?exp=${String(exp)}&method=${method}&sig=${sig}${ctParameter}`;
@@ -91,13 +114,7 @@ const verifySignedUrl = async (input, secret, options) => {
91
114
  return { reason: "malformed", valid: false };
92
115
  }
93
116
  const host = options?.expectedHost === void 0 ? url.host : extractHost(options.expectedHost);
94
- const cryptoKey = await importHmacKey(secret);
95
- const valid = await crypto.subtle.verify(
96
- "HMAC",
97
- cryptoKey,
98
- sigBytes,
99
- textEncoder.encode(canonicalize(method, host, key, exp, contentType))
100
- );
117
+ const valid = await verifyCanonical(secret, canonicalize(method, host, key, exp, contentType), sigBytes);
101
118
  if (!valid) {
102
119
  return { reason: "bad_signature", valid: false };
103
120
  }
@@ -1,22 +1,24 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
1
3
  const createBucketStorage = (buckets, options = {}) => {
2
4
  const names = Object.keys(buckets);
3
5
  const [firstName] = names;
4
6
  if (firstName === void 0) {
5
- throw new Error("@lunora/storage: createBucketStorage requires at least one bucket");
7
+ throw new LunoraError("INTERNAL", "@lunora/storage: createBucketStorage requires at least one bucket");
6
8
  }
7
9
  if (options.default !== void 0 && !buckets[options.default]) {
8
- throw new Error(`@lunora/storage: default bucket "${options.default}" is not in the bucket map (have: ${names.join(", ")})`);
10
+ throw new LunoraError("INTERNAL", `@lunora/storage: default bucket "${options.default}" is not in the bucket map (have: ${names.join(", ")})`);
9
11
  }
10
12
  const defaultTag = options.default ?? "default";
11
13
  const defaultBinding = buckets[defaultTag] ?? buckets[firstName];
12
14
  if (defaultBinding === void 0) {
13
- throw new Error(`@lunora/storage: default bucket "${defaultTag}" is not in the bucket map (have: ${names.join(", ")})`);
15
+ throw new LunoraError("INTERNAL", `@lunora/storage: default bucket "${defaultTag}" is not in the bucket map (have: ${names.join(", ")})`);
14
16
  }
15
17
  const addressable = [.../* @__PURE__ */ new Set([defaultTag, ...names])];
16
18
  const make = (name) => {
17
19
  const target = name === defaultTag ? defaultBinding : buckets[name];
18
20
  if (!target) {
19
- throw new Error(`@lunora/storage: no bucket registered for "${name}". Known buckets: ${addressable.join(", ")}`);
21
+ throw new LunoraError("INTERNAL", `@lunora/storage: no bucket registered for "${name}". Known buckets: ${addressable.join(", ")}`);
20
22
  }
21
23
  return { ...target, bucket: (next) => make(next), bucketName: name };
22
24
  };