@lunora/storage 1.0.0-alpha.43 → 1.0.0-alpha.44

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/README.md CHANGED
@@ -95,6 +95,12 @@ import { createStorage } from "@lunora/storage";
95
95
 
96
96
  const storage = createStorage({
97
97
  bucket: env.FILES,
98
+ // Required. The name this bucket is registered under, bound into every signed
99
+ // URL's HMAC (and mirrored as `&bucket=`), so a URL minted for one bucket
100
+ // cannot be replayed against another sharing the signing secret. `"default"`
101
+ // for a single-bucket app; the registered name for anything reached through
102
+ // `createBucketStorage`.
103
+ bucketName: "default",
98
104
  publicBaseUrl: "https://cdn.acme.test",
99
105
  signingSecret: env.STORAGE_SECRET,
100
106
  });
package/dist/index.d.mts CHANGED
@@ -27,6 +27,20 @@ interface PresignedUrlOptions {
27
27
  }
28
28
  interface LunoraStorageOptions {
29
29
  bucket: R2BucketLike;
30
+ /**
31
+ * The name this bucket is registered under — the same string a
32
+ * `defineStorageRule({ bucket })` rule and the generated `StorageBucketName`
33
+ * union use. Bound into every signed URL's HMAC and mirrored on it as
34
+ * `&bucket=`, so a URL minted for one bucket can't be replayed against
35
+ * another sharing the signing secret, and the serving route can resolve
36
+ * which bucket to read.
37
+ *
38
+ * Required, and deliberately without a default: a defaulted name is how
39
+ * every bucket ended up signing as `"default"` and cross-verifying against
40
+ * each other. Pass `"default"` for a single-bucket app's `ctx.storage`, and
41
+ * the registered name for any bucket reached through `createBucketStorage`.
42
+ */
43
+ bucketName: string;
30
44
  /** Public base URL used by `getSignedUrl()`. Required for signed URLs. */
31
45
  publicBaseUrl?: string;
32
46
  /**
@@ -160,8 +174,15 @@ interface Storage {
160
174
  * the same read.
161
175
  */
162
176
  head: (key: string) => Promise<R2ObjectLike | null>;
177
+ /**
178
+ * List objects under `prefix`. With `options.delimiter` set, keys sharing a
179
+ * segment are rolled up into `delimitedPrefixes` (the "folders") and are NOT
180
+ * in `objects` — a folder browser needs both, so a listing whose `objects` is
181
+ * empty is not an empty directory.
182
+ */
163
183
  list: (prefix?: string, options?: ListOptions) => Promise<{
164
184
  cursor?: string;
185
+ delimitedPrefixes?: string[];
165
186
  objects: R2ObjectLike[];
166
187
  truncated?: boolean;
167
188
  }>;
@@ -207,19 +228,21 @@ interface BucketStorage extends Storage {
207
228
  *
208
229
  * ```ts
209
230
  * storage: (env) => createBucketStorage({
210
- * default: createStorage({ bucket: env.FILES }),
211
- * avatars: createStorage({ bucket: env.AVATARS }),
231
+ * default: createStorage({ bucket: env.FILES, bucketName: "default" }),
232
+ * avatars: createStorage({ bucket: env.AVATARS, bucketName: "avatars" }),
212
233
  * }),
213
234
  * // → ctx.storage.download(key) // default bucket
214
235
  * // → ctx.storage.bucket("avatars").store() // the avatars bucket
215
236
  * ```
216
237
  *
217
- * The bare accessor is tagged `"default"` the canonical name a
218
- * `defineStorageRule({ bucket: "default" })` rule and the generated
219
- * `StorageBucketName` union both use unless `options.default` names another
220
- * bucket (then the bare accessor takes that name). The binding it delegates to is
221
- * `options.default`, else the `"default"` key when present, else the first
222
- * registered bucket. Named buckets are reached with `bucket(name)`.
238
+ * The bare accessor is tagged with the name of the binding it actually
239
+ * delegates to: `options.default` when given, else `"default"` when that key
240
+ * exists (the canonical name a `defineStorageRule({ bucket: "default" })` rule
241
+ * and the generated `StorageBucketName` union use), else the first registered
242
+ * bucket. Tag and binding must agree or a `{ bucket: "avatars" }` rule would
243
+ * gate `bucket("avatars").download()` and not the identical bare
244
+ * `ctx.storage.download()` reaching the same R2 bucket. Named buckets are
245
+ * reached with `bucket(name)`.
223
246
  */
224
247
  declare const createBucketStorage: (buckets: Record<string, Storage>, options?: {
225
248
  default?: string;
@@ -251,10 +274,12 @@ declare const buildPresignedUrl: (parameters: PresignedUrlParams) => Promise<str
251
274
  * string carrying `exp` (unix seconds), `method` (`GET` or `PUT`) and `sig`
252
275
  * (a base64url HMAC).
253
276
  *
254
- * The HMAC canonical includes the URL host so a signature minted for one bucket
255
- * cannot be replayed against another host on the same signing secret. Even so,
256
- * the signing secret MUST NOT be shared across buckets/tenants host binding
257
- * narrows replay surface but is not a substitute for per-tenant key isolation.
277
+ * The HMAC canonical includes the URL host AND the `bucketName`, so a signature
278
+ * minted for one bucket cannot be replayed against another bucket (all buckets
279
+ * of one `.storage()` declaration share a base URL and signing secret) or
280
+ * another host. Even so, the signing secret MUST NOT be shared across
281
+ * tenants — this binding narrows replay surface but is not a substitute for
282
+ * per-tenant key isolation.
258
283
  *
259
284
  * The Worker route handling the signed download/upload (mounted at
260
285
  * `publicBaseUrl`'s origin, e.g. `GET /:key`) should call
@@ -262,12 +287,20 @@ declare const buildPresignedUrl: (parameters: PresignedUrlParams) => Promise<str
262
287
  * the R2 body. `baseUrl` must be a bare origin (no path) — see the module
263
288
  * docstring.
264
289
  */
265
- declare const buildSignedUrl: (args: SignedUrlOptions & {
290
+ declare const buildSignedUrl: (args: {
266
291
  baseUrl: string;
292
+ /** The bucket the URL addresses — bound into the HMAC and mirrored as `&bucket=`. */
293
+ bucketName: string;
267
294
  key: string;
268
295
  secret: string;
269
- }) => Promise<string>;
296
+ } & SignedUrlOptions) => Promise<string>;
270
297
  interface VerifyResult {
298
+ /**
299
+ * The bucket the URL was minted for. The serving route MUST resolve its R2
300
+ * binding from this (never from a caller-supplied bucket), since one signing
301
+ * secret covers every bucket of a `.storage()` declaration.
302
+ */
303
+ bucketName?: string;
271
304
  /** The pinned upload `Content-Type` carried by a PUT URL, when present. */
272
305
  contentType?: string;
273
306
  key?: string;
package/dist/index.d.ts CHANGED
@@ -27,6 +27,20 @@ interface PresignedUrlOptions {
27
27
  }
28
28
  interface LunoraStorageOptions {
29
29
  bucket: R2BucketLike;
30
+ /**
31
+ * The name this bucket is registered under — the same string a
32
+ * `defineStorageRule({ bucket })` rule and the generated `StorageBucketName`
33
+ * union use. Bound into every signed URL's HMAC and mirrored on it as
34
+ * `&bucket=`, so a URL minted for one bucket can't be replayed against
35
+ * another sharing the signing secret, and the serving route can resolve
36
+ * which bucket to read.
37
+ *
38
+ * Required, and deliberately without a default: a defaulted name is how
39
+ * every bucket ended up signing as `"default"` and cross-verifying against
40
+ * each other. Pass `"default"` for a single-bucket app's `ctx.storage`, and
41
+ * the registered name for any bucket reached through `createBucketStorage`.
42
+ */
43
+ bucketName: string;
30
44
  /** Public base URL used by `getSignedUrl()`. Required for signed URLs. */
31
45
  publicBaseUrl?: string;
32
46
  /**
@@ -160,8 +174,15 @@ interface Storage {
160
174
  * the same read.
161
175
  */
162
176
  head: (key: string) => Promise<R2ObjectLike | null>;
177
+ /**
178
+ * List objects under `prefix`. With `options.delimiter` set, keys sharing a
179
+ * segment are rolled up into `delimitedPrefixes` (the "folders") and are NOT
180
+ * in `objects` — a folder browser needs both, so a listing whose `objects` is
181
+ * empty is not an empty directory.
182
+ */
163
183
  list: (prefix?: string, options?: ListOptions) => Promise<{
164
184
  cursor?: string;
185
+ delimitedPrefixes?: string[];
165
186
  objects: R2ObjectLike[];
166
187
  truncated?: boolean;
167
188
  }>;
@@ -207,19 +228,21 @@ interface BucketStorage extends Storage {
207
228
  *
208
229
  * ```ts
209
230
  * storage: (env) => createBucketStorage({
210
- * default: createStorage({ bucket: env.FILES }),
211
- * avatars: createStorage({ bucket: env.AVATARS }),
231
+ * default: createStorage({ bucket: env.FILES, bucketName: "default" }),
232
+ * avatars: createStorage({ bucket: env.AVATARS, bucketName: "avatars" }),
212
233
  * }),
213
234
  * // → ctx.storage.download(key) // default bucket
214
235
  * // → ctx.storage.bucket("avatars").store() // the avatars bucket
215
236
  * ```
216
237
  *
217
- * The bare accessor is tagged `"default"` the canonical name a
218
- * `defineStorageRule({ bucket: "default" })` rule and the generated
219
- * `StorageBucketName` union both use unless `options.default` names another
220
- * bucket (then the bare accessor takes that name). The binding it delegates to is
221
- * `options.default`, else the `"default"` key when present, else the first
222
- * registered bucket. Named buckets are reached with `bucket(name)`.
238
+ * The bare accessor is tagged with the name of the binding it actually
239
+ * delegates to: `options.default` when given, else `"default"` when that key
240
+ * exists (the canonical name a `defineStorageRule({ bucket: "default" })` rule
241
+ * and the generated `StorageBucketName` union use), else the first registered
242
+ * bucket. Tag and binding must agree or a `{ bucket: "avatars" }` rule would
243
+ * gate `bucket("avatars").download()` and not the identical bare
244
+ * `ctx.storage.download()` reaching the same R2 bucket. Named buckets are
245
+ * reached with `bucket(name)`.
223
246
  */
224
247
  declare const createBucketStorage: (buckets: Record<string, Storage>, options?: {
225
248
  default?: string;
@@ -251,10 +274,12 @@ declare const buildPresignedUrl: (parameters: PresignedUrlParams) => Promise<str
251
274
  * string carrying `exp` (unix seconds), `method` (`GET` or `PUT`) and `sig`
252
275
  * (a base64url HMAC).
253
276
  *
254
- * The HMAC canonical includes the URL host so a signature minted for one bucket
255
- * cannot be replayed against another host on the same signing secret. Even so,
256
- * the signing secret MUST NOT be shared across buckets/tenants host binding
257
- * narrows replay surface but is not a substitute for per-tenant key isolation.
277
+ * The HMAC canonical includes the URL host AND the `bucketName`, so a signature
278
+ * minted for one bucket cannot be replayed against another bucket (all buckets
279
+ * of one `.storage()` declaration share a base URL and signing secret) or
280
+ * another host. Even so, the signing secret MUST NOT be shared across
281
+ * tenants — this binding narrows replay surface but is not a substitute for
282
+ * per-tenant key isolation.
258
283
  *
259
284
  * The Worker route handling the signed download/upload (mounted at
260
285
  * `publicBaseUrl`'s origin, e.g. `GET /:key`) should call
@@ -262,12 +287,20 @@ declare const buildPresignedUrl: (parameters: PresignedUrlParams) => Promise<str
262
287
  * the R2 body. `baseUrl` must be a bare origin (no path) — see the module
263
288
  * docstring.
264
289
  */
265
- declare const buildSignedUrl: (args: SignedUrlOptions & {
290
+ declare const buildSignedUrl: (args: {
266
291
  baseUrl: string;
292
+ /** The bucket the URL addresses — bound into the HMAC and mirrored as `&bucket=`. */
293
+ bucketName: string;
267
294
  key: string;
268
295
  secret: string;
269
- }) => Promise<string>;
296
+ } & SignedUrlOptions) => Promise<string>;
270
297
  interface VerifyResult {
298
+ /**
299
+ * The bucket the URL was minted for. The serving route MUST resolve its R2
300
+ * binding from this (never from a caller-supplied bucket), since one signing
301
+ * secret covers every bucket of a `.storage()` declaration.
302
+ */
303
+ bucketName?: string;
271
304
  /** The pinned upload `Content-Type` carried by a PUT URL, when present. */
272
305
  contentType?: string;
273
306
  key?: string;
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{createBucketStorage as o}from"./packem_shared/createBucketStorage-BE3H6x-j.mjs";import{createStorage as i,scopeKey as d}from"./packem_shared/createStorage-Fzu--nFr.mjs";import{buildPresignedUrl as g}from"./packem_shared/buildPresignedUrl-CnSO8AMP.mjs";import{buildSignedUrl as p,verifySignedUrl as a}from"./packem_shared/buildSignedUrl-mAx0Keqt.mjs";export{g as buildPresignedUrl,p as buildSignedUrl,o as createBucketStorage,i as createStorage,d as scopeKey,a as verifySignedUrl};
1
+ import{createBucketStorage as o}from"./packem_shared/createBucketStorage-D9fBoaEi.mjs";import{createStorage as i,scopeKey as d}from"./packem_shared/createStorage-D4MhrtNi.mjs";import{buildPresignedUrl as g}from"./packem_shared/buildPresignedUrl-CnSO8AMP.mjs";import{buildSignedUrl as p,verifySignedUrl as a}from"./packem_shared/buildSignedUrl-BTSX3QHT.mjs";export{g as buildPresignedUrl,p as buildSignedUrl,o as createBucketStorage,i as createStorage,d as scopeKey,a as verifySignedUrl};
@@ -0,0 +1,6 @@
1
+ import{LunoraError as u}from"@lunora/errors";import{i as R,b as f,e as p,s as y,t as U,f as N,c as $,v as w,M as I}from"./internal-B2FsuR4l.mjs";const S=/^\//,b=(e,s,r,t,o,a)=>{const n=`${e}
2
+ ${s.toLowerCase()}
3
+ ${r}
4
+ ${t}
5
+ ${String(o)}`;return a===void 0?n:`${n}
6
+ ${a}`},x=async e=>{const s=e.method??"GET",r=e.expiresInSeconds??3600,t=w(r,I);if(t!==void 0)throw new u("VALIDATION_ERROR",`@lunora/storage: ${t}`);const o=s==="PUT"?e.contentType:void 0;let a="";try{a=new URL(e.baseUrl).pathname}catch{}if(a!==""&&!R(a))throw new u("VALIDATION_ERROR",`@lunora/storage: baseUrl must not carry a path ("${a}") — the key is verified from the full URL pathname, so a subpath base would make every signed URL fail verification`);if(f(e.key),f(e.bucketName),e.bucketName==="")throw new u("VALIDATION_ERROR","@lunora/storage: bucketName must not be empty");const n=Math.floor(Date.now()/1e3)+r,c=p(e.baseUrl),i=await y(e.secret,b(s,c,e.bucketName,e.key,n,o)),m=U(e.baseUrl),l=e.key.split("/").map(h=>encodeURIComponent(h)).join("/"),d=o===void 0?"":`&ct=${encodeURIComponent(o)}`;return`${m}/${l}?exp=${String(n)}&method=${s}&bucket=${encodeURIComponent(e.bucketName)}&sig=${i}${d}`},E=async(e,s,r)=>{let t;try{t=e instanceof URL?e:new URL(e)}catch{return{reason:"malformed",valid:!1}}const o=t.searchParams.get("exp"),a=o===null?Number.NaN:Number(o),n=t.searchParams.get("sig"),c=t.searchParams.get("method")??"GET",i=t.searchParams.get("bucket"),m=t.searchParams.get("ct")??void 0;if(!n||!i||!Number.isInteger(a))return{reason:"malformed",valid:!1};if(a<Math.floor(Date.now()/1e3))return{reason:"expired",valid:!1};if(c!=="GET"&&c!=="PUT")return{reason:"malformed",valid:!1};let l,d;try{l=t.pathname.replace(S,"").split("/").map(v=>decodeURIComponent(v)).join("/"),f(l),f(i),d=N(n)}catch{return{reason:"malformed",valid:!1}}const h=r?.expectedHost===void 0?t.host:p(r.expectedHost);return await $(s,b(c,h,i,l,a,m),d)?{bucketName:i,contentType:m,key:l,method:c,valid:!0}:{reason:"bad_signature",valid:!1}};export{x as buildSignedUrl,E as verifySignedUrl};
@@ -0,0 +1 @@
1
+ import{LunoraError as n}from"@lunora/errors";const g=(e,r={})=>{const a=Object.keys(e),[u]=a;if(u===void 0)throw new n("INTERNAL","@lunora/storage: createBucketStorage requires at least one bucket");if(r.default!==void 0&&!e[r.default])throw new n("INTERNAL",`@lunora/storage: default bucket "${r.default}" is not in the bucket map (have: ${a.join(", ")})`);const t=r.default??(e.default?"default":u),c=e[t];if(c===void 0)throw new n("INTERNAL",`@lunora/storage: default bucket "${t}" is not in the bucket map (have: ${a.join(", ")})`);const i=[...new Set([t,...a])],d=o=>{const f=o===t?c:e[o];if(!f)throw new n("INTERNAL",`@lunora/storage: no bucket registered for "${o}". Known buckets: ${i.join(", ")}`);return{...f,bucket:l=>d(l),bucketName:o}};return d(t)};export{g as createBucketStorage};
@@ -0,0 +1 @@
1
+ import{LunoraError as n}from"@lunora/errors";import{t as p,h as L,a as T}from"./internal-B2FsuR4l.mjs";import{buildPresignedUrl as U}from"./buildPresignedUrl-CnSO8AMP.mjs";import{buildSignedUrl as N}from"./buildSignedUrl-BTSX3QHT.mjs";const w=1024,E=1e3,M=100,I=e=>{const a=new Uint8Array(e);let i="";for(const u of a)i+=String.fromCodePoint(u);return btoa(i)},S=e=>{const a=e.checksums?.sha256;if(a===void 0)return e;const i=T(a),u=I(a);return new Proxy(e,{get(l,s){if(s==="sha256")return i;if(s==="sha256Base64")return u;const d=Reflect.get(l,s,l);return typeof d=="function"?d.bind(l):d},has(l,s){return s==="sha256"||s==="sha256Base64"||Reflect.has(l,s)}})},k=e=>{const a=e.checksums?.sha256;return{contentType:e.httpMetadata?.contentType,customMetadata:e.customMetadata,key:e.key,sha256:a===void 0?void 0:T(a),size:e.size,uploaded:e.uploaded===void 0?void 0:e.uploaded.getTime()}},R=e=>{const a=e.checksums?.sha256;return{checksums:e.checksums,customMetadata:e.customMetadata,etag:e.etag,httpEtag:e.httpEtag,httpMetadata:e.httpMetadata,key:e.key,sha256:a===void 0?void 0:T(a),sha256Base64:a===void 0?void 0:I(a),size:e.size,uploaded:e.uploaded}},x=(e,a)=>{let i=0;const u=s=>s instanceof ArrayBuffer||ArrayBuffer.isView(s)?s.byteLength:void 0,l=new TransformStream({transform(s,d){const m=u(s);if(m===void 0){d.error(new Error("@lunora/storage: stream chunk is not a byte chunk; cannot enforce maxSize"));return}if(i+=m,i>a){d.error(new Error(`@lunora/storage: stream body exceeds maxSize (> ${String(a)} bytes)`));return}d.enqueue(s)}});return e.pipeThrough(l)},_=new TextEncoder,b=e=>_.encode(e).length,c=e=>{if(typeof e!="string"||e.length===0)throw new n("VALIDATION_ERROR","@lunora/storage: key must be a non-empty string");if(b(e)>w)throw new n("VALIDATION_ERROR",`@lunora/storage: key exceeds ${String(w)}-byte limit`);if(e.includes("\0"))throw new n("VALIDATION_ERROR","@lunora/storage: key contains NUL byte");if(L(e))throw new n("VALIDATION_ERROR","@lunora/storage: key contains a control character (including CR/LF)");if(e.startsWith("/"))throw new n("VALIDATION_ERROR","@lunora/storage: key must not start with `/`");if(e.split("/").includes(".."))throw new n("VALIDATION_ERROR","@lunora/storage: key contains a `..` path component")},v=e=>{if(e.allowedContentTypes!==void 0){if(e.contentType===void 0)throw new n("VALIDATION_ERROR","@lunora/storage: contentType is required when allowedContentTypes is set");if(!e.allowedContentTypes.includes(e.contentType))throw new n("VALIDATION_ERROR",`@lunora/storage: contentType "${e.contentType}" not in allowedContentTypes`)}},O=(e,a)=>{c(e),c(a);const u=`${e.endsWith("/")?e.slice(0,-1):e}/${a}`;if(b(u)>w)throw new n("VALIDATION_ERROR",`@lunora/storage: scoped key exceeds ${String(w)}-byte limit`);return u},K=e=>{if(!e.bucket)throw new n("INTERNAL","@lunora/storage: `bucket` is required");if(typeof e.bucketName!="string"||e.bucketName==="")throw new n("INTERNAL",'@lunora/storage: `bucketName` is required — pass the name this bucket is registered under (`"default"` for a single-bucket app)');const a=async(r,t,o={})=>{c(r),v(o);let f=t;if(typeof o.maxSize=="number"){let h;if(t instanceof ArrayBuffer?h=t.byteLength:t instanceof Blob&&(h=t.size),h!==void 0&&h>o.maxSize)throw new n("PAYLOAD_TOO_LARGE",`@lunora/storage: body exceeds maxSize (${String(h)} > ${String(o.maxSize)})`);t instanceof ReadableStream&&(f=x(t,o.maxSize))}const g=await e.bucket.put(r,f,{customMetadata:o.customMetadata,httpMetadata:o.contentType?{contentType:o.contentType}:void 0,...o.sha256===void 0?{}:{sha256:o.sha256}});return{etag:g.etag,httpEtag:g.httpEtag??`"${g.etag}"`,key:g.key}},i=async(r,t={})=>{c(r);const o=await(t.range?e.bucket.get(r,{range:t.range}):e.bucket.get(r));return o&&S(o)},u=async r=>{c(r),await e.bucket.delete(r)},l=async r=>{c(r);const t=e.bucket.head?await e.bucket.head(r):await e.bucket.get(r,{range:{length:0}});return t&&R(t)},s=async r=>{const t=await l(r);return t&&k(t)},d=async(r,t={})=>{if(r?.includes("\0"))throw new n("VALIDATION_ERROR","@lunora/storage: prefix contains NUL byte");const o=t.limit??M,f=Math.min(Math.max(1,Math.floor(o)),E),g=await e.bucket.list({cursor:t.cursor,delimiter:t.delimiter,limit:f,prefix:r}),{delimitedPrefixes:h}=g;return{cursor:g.cursor,delimitedPrefixes:h,objects:g.objects.map(A=>R(A)),truncated:g.truncated}},m=r=>{if(!e.publicBaseUrl)throw new n("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getUrl()");c(r);const t=r.split("/").map(o=>encodeURIComponent(o)).join("/");return`${p(e.publicBaseUrl)}/${t}`},y=async(r,t={})=>{if(!e.publicBaseUrl)throw new n("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getSignedUrl()");if(!e.signingSecret)throw new n("INTERNAL","@lunora/storage: `signingSecret` is required for getSignedUrl()");return c(r),N({baseUrl:e.publicBaseUrl,bucketName:e.bucketName,contentType:t.contentType,expiresInSeconds:t.expiresInSeconds,key:r,method:t.method,secret:e.signingSecret})};return{createMultipartUpload:async(r,t={})=>{if(c(r),!e.bucket.createMultipartUpload)throw new n("INTERNAL","@lunora/storage: bucket binding does not support multipart uploads (createMultipartUpload)");return e.bucket.createMultipartUpload(r,{customMetadata:t.customMetadata,httpMetadata:t.contentType?{contentType:t.contentType}:void 0})},delete:u,download:i,generateUploadUrl:async(r,t={})=>y(r,{contentType:t.contentType,expiresInSeconds:t.expiresInSeconds,method:"PUT"}),getMetadata:s,getPresignedUrl:async(r,t={})=>{if(!e.s3)throw new n("INTERNAL","@lunora/storage: `s3` credentials are required for getPresignedUrl() — pass { accountId, accessKeyId, secretAccessKey, bucket }");return c(r),U({credentials:e.s3,expiresInSeconds:t.expiresInSeconds,key:r,method:t.method})},getSignedUrl:y,getUrl:m,head:l,list:d,resumeMultipartUpload:(r,t)=>{if(c(r),typeof t!="string"||t.length===0)throw new n("VALIDATION_ERROR","@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");if(!e.bucket.resumeMultipartUpload)throw new n("INTERNAL","@lunora/storage: bucket binding does not support multipart uploads (resumeMultipartUpload)");return e.bucket.resumeMultipartUpload(r,t)},store:a,upload:a}};export{K as createStorage,O as scopeKey};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/storage",
3
- "version": "1.0.0-alpha.43",
3
+ "version": "1.0.0-alpha.44",
4
4
  "description": "R2-backed storage for Lunora: typed buckets and signed URLs",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@lunora/errors": "1.0.0-alpha.26",
54
- "@lunora/platform": "1.0.0-alpha.21",
54
+ "@lunora/platform": "1.0.0-alpha.22",
55
55
  "@visulima/storage": "2.0.0",
56
56
  "aws4fetch": "1.0.20"
57
57
  },
@@ -1,5 +0,0 @@
1
- import{LunoraError as h}from"@lunora/errors";import{i as U,b as p,e as u,s as R,t as b,f as S,c as $,v as w,M as L}from"./internal-B2FsuR4l.mjs";const T=/^\//,v=(e,s,n,t,o)=>{const a=`${e}
2
- ${s.toLowerCase()}
3
- ${n}
4
- ${String(t)}`;return o===void 0?a:`${a}
5
- ${o}`},g=async e=>{const s=e.method??"GET",n=e.expiresInSeconds??3600,t=w(n,L);if(t!==void 0)throw new h("VALIDATION_ERROR",`@lunora/storage: ${t}`);const o=s==="PUT"?e.contentType:void 0;let a="";try{a=new URL(e.baseUrl).pathname}catch{}if(a!==""&&!U(a))throw new h("VALIDATION_ERROR",`@lunora/storage: baseUrl must not carry a path ("${a}") — the key is verified from the full URL pathname, so a subpath base would make every signed URL fail verification`);p(e.key);const c=Math.floor(Date.now()/1e3)+n,r=u(e.baseUrl),l=await R(e.secret,v(s,r,e.key,c,o)),i=b(e.baseUrl),d=e.key.split("/").map(f=>encodeURIComponent(f)).join("/"),m=o===void 0?"":`&ct=${encodeURIComponent(o)}`;return`${i}/${d}?exp=${String(c)}&method=${s}&sig=${l}${m}`},E=async(e,s,n)=>{let t;try{t=e instanceof URL?e:new URL(e)}catch{return{reason:"malformed",valid:!1}}const o=t.searchParams.get("exp"),a=o===null?Number.NaN:Number(o),c=t.searchParams.get("sig"),r=t.searchParams.get("method")??"GET",l=t.searchParams.get("ct")??void 0;if(!c||!Number.isInteger(a))return{reason:"malformed",valid:!1};if(a<Math.floor(Date.now()/1e3))return{reason:"expired",valid:!1};if(r!=="GET"&&r!=="PUT")return{reason:"malformed",valid:!1};let i,d;try{i=t.pathname.replace(T,"").split("/").map(y=>decodeURIComponent(y)).join("/"),p(i),d=S(c)}catch{return{reason:"malformed",valid:!1}}const m=n?.expectedHost===void 0?t.host:u(n.expectedHost);return await $(s,v(r,m,i,a,l),d)?{contentType:l,key:i,method:r,valid:!0}:{reason:"bad_signature",valid:!1}};export{g as buildSignedUrl,E as verifySignedUrl};
@@ -1 +0,0 @@
1
- import{LunoraError as n}from"@lunora/errors";const g=(e,r={})=>{const a=Object.keys(e),[u]=a;if(u===void 0)throw new n("INTERNAL","@lunora/storage: createBucketStorage requires at least one bucket");if(r.default!==void 0&&!e[r.default])throw new n("INTERNAL",`@lunora/storage: default bucket "${r.default}" is not in the bucket map (have: ${a.join(", ")})`);const t=r.default??"default",c=e[t]??e[u];if(c===void 0)throw new n("INTERNAL",`@lunora/storage: default bucket "${t}" is not in the bucket map (have: ${a.join(", ")})`);const f=[...new Set([t,...a])],i=o=>{const d=o===t?c:e[o];if(!d)throw new n("INTERNAL",`@lunora/storage: no bucket registered for "${o}". Known buckets: ${f.join(", ")}`);return{...d,bucket:s=>i(s),bucketName:o}};return i(t)};export{g as createBucketStorage};
@@ -1 +0,0 @@
1
- import{LunoraError as n}from"@lunora/errors";import{t as U,h as b,a as T}from"./internal-B2FsuR4l.mjs";import{buildPresignedUrl as p}from"./buildPresignedUrl-CnSO8AMP.mjs";import{buildSignedUrl as L}from"./buildSignedUrl-mAx0Keqt.mjs";const w=1024,M=1e3,S=100,I=e=>{const a=new Uint8Array(e);let i="";for(const u of a)i+=String.fromCodePoint(u);return btoa(i)},E=e=>{const a=e.checksums?.sha256;if(a===void 0)return e;const i=T(a),u=I(a);return new Proxy(e,{get(l,s){if(s==="sha256")return i;if(s==="sha256Base64")return u;const d=Reflect.get(l,s,l);return typeof d=="function"?d.bind(l):d},has(l,s){return s==="sha256"||s==="sha256Base64"||Reflect.has(l,s)}})},N=e=>{const a=e.checksums?.sha256;return{contentType:e.httpMetadata?.contentType,customMetadata:e.customMetadata,key:e.key,sha256:a===void 0?void 0:T(a),size:e.size,uploaded:e.uploaded===void 0?void 0:e.uploaded.getTime()}},R=e=>{const a=e.checksums?.sha256;return{checksums:e.checksums,customMetadata:e.customMetadata,etag:e.etag,httpEtag:e.httpEtag,httpMetadata:e.httpMetadata,key:e.key,sha256:a===void 0?void 0:T(a),sha256Base64:a===void 0?void 0:I(a),size:e.size,uploaded:e.uploaded}},x=(e,a)=>{let i=0;const u=s=>s instanceof ArrayBuffer||ArrayBuffer.isView(s)?s.byteLength:void 0,l=new TransformStream({transform(s,d){const m=u(s);if(m===void 0){d.error(new Error("@lunora/storage: stream chunk is not a byte chunk; cannot enforce maxSize"));return}if(i+=m,i>a){d.error(new Error(`@lunora/storage: stream body exceeds maxSize (> ${String(a)} bytes)`));return}d.enqueue(s)}});return e.pipeThrough(l)},_=new TextEncoder,A=e=>_.encode(e).length,c=e=>{if(typeof e!="string"||e.length===0)throw new n("VALIDATION_ERROR","@lunora/storage: key must be a non-empty string");if(A(e)>w)throw new n("VALIDATION_ERROR",`@lunora/storage: key exceeds ${String(w)}-byte limit`);if(e.includes("\0"))throw new n("VALIDATION_ERROR","@lunora/storage: key contains NUL byte");if(b(e))throw new n("VALIDATION_ERROR","@lunora/storage: key contains a control character (including CR/LF)");if(e.startsWith("/"))throw new n("VALIDATION_ERROR","@lunora/storage: key must not start with `/`");if(e.split("/").includes(".."))throw new n("VALIDATION_ERROR","@lunora/storage: key contains a `..` path component")},v=e=>{if(e.allowedContentTypes!==void 0){if(e.contentType===void 0)throw new n("VALIDATION_ERROR","@lunora/storage: contentType is required when allowedContentTypes is set");if(!e.allowedContentTypes.includes(e.contentType))throw new n("VALIDATION_ERROR",`@lunora/storage: contentType "${e.contentType}" not in allowedContentTypes`)}},q=(e,a)=>{c(e),c(a);const u=`${e.endsWith("/")?e.slice(0,-1):e}/${a}`;if(A(u)>w)throw new n("VALIDATION_ERROR",`@lunora/storage: scoped key exceeds ${String(w)}-byte limit`);return u},O=e=>{if(!e.bucket)throw new n("INTERNAL","@lunora/storage: `bucket` is required");const a=async(r,t,o={})=>{c(r),v(o);let f=t;if(typeof o.maxSize=="number"){let h;if(t instanceof ArrayBuffer?h=t.byteLength:t instanceof Blob&&(h=t.size),h!==void 0&&h>o.maxSize)throw new n("PAYLOAD_TOO_LARGE",`@lunora/storage: body exceeds maxSize (${String(h)} > ${String(o.maxSize)})`);t instanceof ReadableStream&&(f=x(t,o.maxSize))}const g=await e.bucket.put(r,f,{customMetadata:o.customMetadata,httpMetadata:o.contentType?{contentType:o.contentType}:void 0,...o.sha256===void 0?{}:{sha256:o.sha256}});return{etag:g.etag,httpEtag:g.httpEtag??`"${g.etag}"`,key:g.key}},i=async(r,t={})=>{c(r);const o=await(t.range?e.bucket.get(r,{range:t.range}):e.bucket.get(r));return o&&E(o)},u=async r=>{c(r),await e.bucket.delete(r)},l=async r=>{c(r);const t=e.bucket.head?await e.bucket.head(r):await e.bucket.get(r,{range:{length:0}});return t&&R(t)},s=async r=>{const t=await l(r);return t&&N(t)},d=async(r,t={})=>{if(r?.includes("\0"))throw new n("VALIDATION_ERROR","@lunora/storage: prefix contains NUL byte");const o=t.limit??S,f=Math.min(Math.max(1,Math.floor(o)),M),g=await e.bucket.list({cursor:t.cursor,delimiter:t.delimiter,limit:f,prefix:r});return{cursor:g.cursor,objects:g.objects.map(h=>R(h)),truncated:g.truncated}},m=r=>{if(!e.publicBaseUrl)throw new n("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getUrl()");c(r);const t=r.split("/").map(o=>encodeURIComponent(o)).join("/");return`${U(e.publicBaseUrl)}/${t}`},y=async(r,t={})=>{if(!e.publicBaseUrl)throw new n("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getSignedUrl()");if(!e.signingSecret)throw new n("INTERNAL","@lunora/storage: `signingSecret` is required for getSignedUrl()");return c(r),L({baseUrl:e.publicBaseUrl,contentType:t.contentType,expiresInSeconds:t.expiresInSeconds,key:r,method:t.method,secret:e.signingSecret})};return{createMultipartUpload:async(r,t={})=>{if(c(r),!e.bucket.createMultipartUpload)throw new n("INTERNAL","@lunora/storage: bucket binding does not support multipart uploads (createMultipartUpload)");return e.bucket.createMultipartUpload(r,{customMetadata:t.customMetadata,httpMetadata:t.contentType?{contentType:t.contentType}:void 0})},delete:u,download:i,generateUploadUrl:async(r,t={})=>y(r,{contentType:t.contentType,expiresInSeconds:t.expiresInSeconds,method:"PUT"}),getMetadata:s,getPresignedUrl:async(r,t={})=>{if(!e.s3)throw new n("INTERNAL","@lunora/storage: `s3` credentials are required for getPresignedUrl() — pass { accountId, accessKeyId, secretAccessKey, bucket }");return c(r),p({credentials:e.s3,expiresInSeconds:t.expiresInSeconds,key:r,method:t.method})},getSignedUrl:y,getUrl:m,head:l,list:d,resumeMultipartUpload:(r,t)=>{if(c(r),typeof t!="string"||t.length===0)throw new n("VALIDATION_ERROR","@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");if(!e.bucket.resumeMultipartUpload)throw new n("INTERNAL","@lunora/storage: bucket binding does not support multipart uploads (resumeMultipartUpload)");return e.bucket.resumeMultipartUpload(r,t)},store:a,upload:a}};export{O as createStorage,q as scopeKey};