@lunora/storage 1.0.0-alpha.16 → 1.0.0-alpha.160

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/upload.d.ts CHANGED
@@ -1,5 +1,13 @@
1
+ import { BaseStorageOptions } from '@visulima/storage';
2
+ import { R2BucketLike } from '@lunora/platform';
1
3
  import { Tus } from '@visulima/storage/handler/http/fetch';
2
4
  import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
5
+ /**
6
+ * An R2 binding with the multipart API: a Worker's `env.BUCKET` (and
7
+ * miniflare's). The provider also relies on `put`'s `onlyIf` preconditions,
8
+ * which every R2 binding honors.
9
+ */
10
+ type R2UploadBucket = R2BucketLike & Required<Pick<R2BucketLike, "createMultipartUpload" | "resumeMultipartUpload">>;
3
11
  /** Resumable upload wire protocols the handler can speak. */
4
12
  type UploadProtocol = "chunked-rest" | "multipart" | "tus";
5
13
  /**
@@ -10,7 +18,7 @@ type UploadProtocol = "chunked-rest" | "multipart" | "tus";
10
18
  */
11
19
  declare const DEFAULT_MAX_UPLOAD_BYTES: number;
12
20
  type UploadHandlerOptions = ConstructorParameters<typeof Tus>[0];
13
- /** A `@visulima/storage` storage provider (e.g. {@link createR2UploadStorage} or a memory provider in tests). */
21
+ /** A `@visulima/storage` storage provider (e.g. `createR2UploadStorage` or a memory provider in tests). */
14
22
  type UploadStorage = UploadHandlerOptions["storage"];
15
23
  /**
16
24
  * The context handed to {@link CreateUploadHandlerOptions.authorize}. Everything
@@ -27,28 +35,117 @@ interface UploadAuthzContext {
27
35
  /** The parsed request URL (query params, upload-id path segment). */
28
36
  url: URL;
29
37
  }
38
+ declare const GRANTED: unique symbol;
39
+ /**
40
+ * A grant `authorize` answers to admit a request and hand the rest of it a
41
+ * `Context`. Opaque: only {@link UploadContext.grant} issues one, so a value
42
+ * the gate builds by hand, or picks up by mistake (`{ valid: false, context }`),
43
+ * is not a grant and is denied.
44
+ */
45
+ interface UploadGrant<Context = unknown> {
46
+ readonly [GRANTED]: Context;
47
+ }
48
+ /** What {@link CreateUploadHandlerOptions.authorize} answers: allow, deny, a grant, or the response to refuse with. */
49
+ type UploadAuthorizeResult<Context = unknown> = boolean | Response | UploadGrant<Context>;
50
+ /**
51
+ * A typed channel from `authorize` to the storage provider's callbacks
52
+ * (`filename`, `onCreate`, `onComplete`, …), which get only the file.
53
+ * `authorize` answers `grant(context)`, and the callbacks read the context back
54
+ * with `get()`, so one handler and one provider serve every caller.
55
+ */
56
+ interface UploadContext<Context> {
57
+ /**
58
+ * The context the current request was granted. Throws when there is none:
59
+ * outside an upload request, or when `authorize` answered `true` or a grant
60
+ * from another `UploadContext`.
61
+ */
62
+ get: () => Context;
63
+ /** A grant that admits the request and carries `context` to {@link UploadContext.get}. */
64
+ grant: (context: Context) => UploadGrant<Context>;
65
+ }
66
+ /**
67
+ * Create an {@link UploadContext}, typed by what `authorize` grants:
68
+ *
69
+ * ```ts
70
+ * const caller = createUploadContext<{ userId: string }>();
71
+ *
72
+ * filename: (file) => `uploads/${caller.get().userId}/${file.id}`,
73
+ * authorize: async ({ request }) => caller.grant({ userId: await userIdFrom(request) }),
74
+ * ```
75
+ */
76
+ declare const createUploadContext: <Context>() => UploadContext<Context>;
77
+ /**
78
+ * The context handed to {@link CreateUploadHandlerOptions.maxFileSizeFor}: the
79
+ * authorization context plus what the create request declares about the file.
80
+ * Everything here comes from the client, so it caps an upload by what the
81
+ * client SAYS it is; `allowMIME` on the provider is what checks the type.
82
+ */
83
+ interface UploadSizeContext<Context = unknown> extends UploadAuthzContext {
84
+ /**
85
+ * The declared MIME type, resolved the way the stored file's type is: on
86
+ * chunked REST the request's `Content-Type`; on TUS the `Upload-Metadata`
87
+ * `mimeType`, else `type`, else `filetype`; else
88
+ * `application/octet-stream`.
89
+ */
90
+ contentType: string;
91
+ /**
92
+ * The largest size the request declares (`Upload-Length`, `X-Total-Size`,
93
+ * `Content-Length`). A declared size that is not a non-negative integer
94
+ * reads as `Infinity`.
95
+ */
96
+ declaredSize: number | undefined;
97
+ /** The context `authorize` granted this request, or `undefined` when it answered `true` or there is no `authorize`. */
98
+ granted: Context | undefined;
99
+ /** The decoded TUS `Upload-Metadata`, or the chunked-REST `X-File-Metadata` JSON, as strings. */
100
+ metadata: Record<string, string>;
101
+ }
30
102
  /** Options for {@link createUploadHandler}. */
31
- interface CreateUploadHandlerOptions {
103
+ interface CreateUploadHandlerOptions<Context = unknown> {
32
104
  /**
33
105
  * The RLS gate. Runs before every upload request and denies fail-closed:
34
- * returning `false` **or throwing** yields a `403`. Omit only for a fully
35
- * public bucket — the whole point of this handler over the admin path is
36
- * that uploads are gated by *your* per-user policy, not an admin token.
106
+ * only `true` or an {@link UploadGrant} (from {@link UploadContext.grant})
107
+ * lets the request through; `false`, anything else, **or throwing** yields
108
+ * a `403`. A `Response` is answered instead of the `403`, for a refusal of
109
+ * your own (say a `429` with `Retry-After`): it is passed through as it
110
+ * is, except that a TUS route adds `Tus-Resumable` when it has none. It is
111
+ * meant for 4xx/5xx; a 2xx would tell the client an upload happened. Omit
112
+ * only for a fully public bucket — the whole point of this handler over
113
+ * the admin path is that uploads are gated by *your* per-user policy, not
114
+ * an admin token.
37
115
  *
38
116
  * Omitting it mounts an unauthenticated, unbounded-write endpoint, so
39
117
  * doing so logs a one-time warning (per handler) unless `silent`/`public`
40
118
  * says the omission is intentional.
41
119
  */
42
- authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
120
+ authorize?: (context: UploadAuthzContext) => UploadAuthorizeResult<Context> | Promise<UploadAuthorizeResult<Context>>;
43
121
  /**
44
122
  * Maximum accepted file size in bytes. Forwarded to the multipart parser
45
123
  * (protocol `"multipart"`) and, for `"tus"`/`"chunked-rest"`, enforced by
46
- * this handler itself against the request's declared size (`Upload-Length`
47
- * / `Content-Length`) — see {@link declaredUploadSize}. Defaults to
124
+ * this handler itself against the largest size the request declares across
125
+ * `Upload-Length` (TUS), `X-Total-Size` (chunked REST) and `Content-Length`
126
+ * — see {@link declaredUploadSize}. Defaults to
48
127
  * {@link DEFAULT_MAX_UPLOAD_BYTES} (100 MiB) — pass this to raise or lower
49
- * the ceiling; there is no unbounded option.
128
+ * the ceiling; there is no unbounded option. Must be a finite, non-negative
129
+ * number: anything else (notably a `NaN` from an unset env var) throws at
130
+ * construction rather than disabling the cap.
50
131
  */
51
132
  maxFileSize?: number;
133
+ /**
134
+ * A per-upload size cap, below `maxFileSize`: return the most bytes this
135
+ * upload may hold (say 10 MiB for `image/*`, 2 GiB for `video/*`), or
136
+ * `undefined` to leave `maxFileSize` as the only cap. It can only lower the
137
+ * cap, never raise it past `maxFileSize`.
138
+ *
139
+ * Runs after `authorize`, on the requests that create an upload (`POST`,
140
+ * and the chunked-REST `PUT`) of the `"tus"` and `"chunked-rest"`
141
+ * protocols; later chunks are bounded by the size declared at creation.
142
+ * Not applied to `"multipart"`, whose size is only known once the form is
143
+ * parsed. A create that declares no size is refused (`413`) when this
144
+ * returns a cap, since there is nothing to check it against. Throwing, or
145
+ * returning something that is not a finite, non-negative number, denies the
146
+ * request (`403`), fail-closed like `authorize`.
147
+ */
148
+ maxFileSizeFor?: (context: UploadSizeContext<Context>) => number | undefined | Promise<number | undefined>;
52
149
  /** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
53
150
  protocol?: UploadProtocol;
54
151
  /**
@@ -74,6 +171,62 @@ interface UploadHandler {
74
171
  /** The protocol this handler speaks. */
75
172
  protocol: UploadProtocol;
76
173
  }
174
+ /**
175
+ * Build an RLS-gated resumable upload handler over a `@visulima/storage`
176
+ * provider. Mount its {@link UploadHandler.fetch} on the route your client
177
+ * uploads to and drive it with `@visulima/storage-client`.
178
+ */
179
+ declare const createUploadHandler: <Context = unknown>(options: CreateUploadHandlerOptions<Context>) => UploadHandler;
180
+ /** Every part of an R2 multipart upload but the last is exactly this size: R2's 5 MiB minimum. */
181
+ declare const R2_PART_SIZE: number;
182
+ /**
183
+ * `T` without its index signatures. `BaseStorageOptions` has a
184
+ * `[key: string]: unknown` one, and `Omit` over it maps over `string`, which
185
+ * erases every declared option (each would be `unknown`).
186
+ */
187
+ type DeclaredKeys<T> = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; };
188
+ /**
189
+ * Options for {@link createR2BindingUploadStorage}. A type alias, not an
190
+ * interface: an interface gets no implicit index signature, so it would not
191
+ * pass to `AbstractBaseStorage`'s constructor.
192
+ */
193
+ type R2BindingUploadStorageOptions = Omit<DeclaredKeys<BaseStorageOptions>, "metaStorage"> & {
194
+ /**
195
+ * Key prefix the upload state objects and buffered segments are stored
196
+ * under, in the same bucket. Default `"_lunora/uploads/"`. No uploaded
197
+ * object may be named under it.
198
+ *
199
+ * The state of a finished upload stays (a few hundred bytes, so a `HEAD`
200
+ * can still answer that the upload is complete), and so does the state of
201
+ * one that was abandoned. Both accumulate: add an R2 object lifecycle rule
202
+ * that deletes objects under this prefix after a few days, longer than
203
+ * any upload you expect to take.
204
+ */
205
+ statePrefix?: string;
206
+ /**
207
+ * The scope a request's upload state is kept under, appended to `statePrefix`: the state of an upload
208
+ * is then readable only by requests in the same scope. Read it from the request's grant, e.g.
209
+ * `() => \`${caller.get().userId}/\``. Must return a string ending in `/`, or `""` for the unscoped root.
210
+ *
211
+ * `list()` reads only the unscoped root, so an upload kept under a scope is not listed.
212
+ */
213
+ stateScope?: () => string;
214
+ };
215
+ /**
216
+ * Build a resumable-upload provider for `createUploadHandler` over a Worker's
217
+ * R2 binding: no S3 credentials, and the same behaviour under `wrangler dev` /
218
+ * miniflare as in production.
219
+ *
220
+ * Speaks TUS (without the `checksum`, `concatenation` and
221
+ * `creation-defer-length` extensions), chunked REST, and multipart forms.
222
+ *
223
+ * Chunked-REST chunks have to arrive one at a time and in order. A chunk at any
224
+ * offset other than the upload's current one, or one sent while another is
225
+ * still streaming, is a `409` and stores nothing, and it never counts towards
226
+ * completion. The bundled `@visulima/storage-client` chunked-REST client
227
+ * sends one chunk at a time (since 1.0.8), so its uploads complete here.
228
+ */
229
+ declare const createR2BindingUploadStorage: (bucket: R2UploadBucket, options?: R2BindingUploadStorageOptions) => UploadStorage;
77
230
  /** R2 (S3-compatible) credentials + bucket for {@link createR2UploadStorage}. */
78
231
  interface R2UploadStorageOptions {
79
232
  /** R2 S3 API Access Key ID (from an R2 API token). */
@@ -83,9 +236,11 @@ interface R2UploadStorageOptions {
83
236
  /** Target R2 bucket name. */
84
237
  bucket: string;
85
238
  /**
86
- * Explicit R2 S3 endpoint. Defaults to
87
- * `https://&lt;accountId>.r2.cloudflarestorage.com`. Pass this to pin a
88
- * jurisdiction (e.g. `&lt;accountId>.eu.r2.cloudflarestorage.com`).
239
+ * Explicit R2 S3 account endpoint, an absolute `https://` URL. The bucket
240
+ * is appended as a path segment, unless the endpoint already names it
241
+ * (as its last path segment, or as the host's first label). Defaults to
242
+ * `https://<accountId>.r2.cloudflarestorage.com`. Pass this to pin a
243
+ * jurisdiction (e.g. `https://<accountId>.eu.r2.cloudflarestorage.com`).
89
244
  */
90
245
  endpoint?: string;
91
246
  /** Client-side multipart part size (bytes or a size string like `"16MB"`). */
@@ -97,13 +252,7 @@ interface R2UploadStorageOptions {
97
252
  path?: string;
98
253
  }
99
254
  /**
100
- * Build an RLS-gated resumable upload handler over a `@visulima/storage`
101
- * provider. Mount its {@link UploadHandler.fetch} on the route your client
102
- * uploads to and drive it with `@visulima/storage-client`.
103
- */
104
- declare const createUploadHandler: (options: CreateUploadHandlerOptions) => UploadHandler;
105
- /**
106
- * Build an R2-backed storage provider for {@link createUploadHandler} using
255
+ * Build an R2-backed storage provider for `createUploadHandler` using
107
256
  * `@visulima/storage`'s dependency-light `aws-light` provider (`aws4fetch`, no
108
257
  * AWS SDK). R2's S3 region alias is always `auto`.
109
258
  *
@@ -111,8 +260,56 @@ declare const createUploadHandler: (options: CreateUploadHandlerOptions) => Uplo
111
260
  * same credential shape `@lunora/storage`'s presigned-URL helpers take. In a
112
261
  * Worker the `aws-light` provider needs `nodejs_compat` (it imports
113
262
  * `node:stream`).
263
+ *
264
+ * The bucket is addressed path-style, `<endpoint>/<bucket>/<key>`.
265
+ *
266
+ * Each chunk becomes one multipart part, so every chunk but the last has to be
267
+ * the same size and at least 5 MiB, and chunks have to arrive in order (an
268
+ * out-of-order or repeated one is a `409`). The R2 binding provider
269
+ * (`./r2-binding-upload-storage`) has neither limit, needs no S3 credentials
270
+ * and runs under `wrangler dev` too.
271
+ *
272
+ * The provider takes no `@visulima/storage` limits of its own (its upload cap
273
+ * is the 5 TB default), so `maxFileSize` / `maxFileSizeFor` on the handler are
274
+ * the caps that apply.
114
275
  */
115
276
  declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
116
277
  secretAccessKey: string;
117
278
  }) => AwsLightStorage;
118
- export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES, type R2UploadStorageOptions, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadStorage, createR2UploadStorage, createUploadHandler };
279
+ export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
280
+ /**
281
+ * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
282
+ *
283
+ * A separate subpath so the base `@lunora/storage` entry stays lean: the
284
+ * `@visulima/storage` handler dependency only loads for apps that mount an
285
+ * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
286
+ * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
287
+ *
288
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
289
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
290
+ * compatibility to load at all: the import fails at load time otherwise, and
291
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
292
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
293
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
294
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
295
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
296
+ */
297
+ type R2BindingUploadStorageOptions,
298
+ /**
299
+ * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
300
+ *
301
+ * A separate subpath so the base `@lunora/storage` entry stays lean: the
302
+ * `@visulima/storage` handler dependency only loads for apps that mount an
303
+ * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
304
+ * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
305
+ *
306
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
307
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
308
+ * compatibility to load at all: the import fails at load time otherwise, and
309
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
310
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
311
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
312
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
313
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
314
+ */
315
+ type R2UploadBucket, type R2UploadStorageOptions, R2_PART_SIZE, type UploadAuthorizeResult, type UploadAuthzContext, type UploadContext, type UploadGrant, type UploadHandler, type UploadProtocol, type UploadSizeContext, type UploadStorage, createR2BindingUploadStorage, createR2UploadStorage, createUploadContext, createUploadHandler };
package/dist/upload.mjs CHANGED
@@ -1 +1 @@
1
- import{DEFAULT_MAX_UPLOAD_BYTES as r,createR2UploadStorage as o,createUploadHandler as t}from"./packem_shared/DEFAULT_MAX_UPLOAD_BYTES-CeL_tQBR.mjs";export{r as DEFAULT_MAX_UPLOAD_BYTES,o as createR2UploadStorage,t as createUploadHandler};
1
+ import{createR2BindingUploadStorage as r}from"./packem_shared/createR2BindingUploadStorage-A9OJQ4j_.mjs";import{createR2UploadStorage as t}from"./packem_shared/createR2UploadStorage-DGB1xJiD.mjs";import{DEFAULT_MAX_UPLOAD_BYTES as d,createUploadContext as U,createUploadHandler as l}from"./packem_shared/DEFAULT_MAX_UPLOAD_BYTES-BOaXqwP5.mjs";import{R}from"./packem_shared/r2-upload-part-writer-M7dEI00-.mjs";export{d as DEFAULT_MAX_UPLOAD_BYTES,R as R2_PART_SIZE,r as createR2BindingUploadStorage,t as createR2UploadStorage,U as createUploadContext,l as createUploadHandler};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/storage",
3
- "version": "1.0.0-alpha.16",
3
+ "version": "1.0.0-alpha.160",
4
4
  "description": "R2-backed storage for Lunora: typed buckets and signed URLs",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -50,9 +50,9 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@lunora/errors": "1.0.0-alpha.11",
54
- "@lunora/platform": "1.0.0-alpha.2",
55
- "@visulima/storage": "1.0.5",
53
+ "@lunora/errors": ">=1.0.0-alpha.51 <2.0.0-0",
54
+ "@lunora/platform": "1.0.0-alpha.75",
55
+ "@visulima/storage": "3.0.0",
56
56
  "aws4fetch": "1.0.20"
57
57
  },
58
58
  "engines": {
@@ -1 +0,0 @@
1
- import{Rest as d,Multipart as l,Tus as p}from"@visulima/storage/handler/http/fetch";import{AwsLightStorage as h}from"@visulima/storage/provider/aws-light";const m=100*1024*1024,u="1.0.0",i=e=>{const t={"content-type":"application/json"};return e==="tus"&&(t["Tus-Resumable"]=u),Response.json({error:{code:"FORBIDDEN",message:"Upload denied by authorization policy",name:"ForbiddenError"}},{headers:t,status:403})},g=e=>{const t={"content-type":"application/json"};return e==="tus"&&(t["Tus-Resumable"]=u),Response.json({error:{code:"REQUEST_ENTITY_TOO_LARGE",message:"Upload exceeds the configured maxFileSize",name:"RequestEntityTooLargeError"}},{headers:t,status:413})},f=(e,t)=>{if(t==="multipart")return;const o=e.headers.get("Upload-Length")??e.headers.get("Content-Length");if(o===null)return;const n=Number(o);return Number.isFinite(n)?n:void 0},R=(e,t)=>e==="chunked-rest"?new d(t):e==="multipart"?new l(t):new p(t),S=e=>{const t=e.protocol??"tus",o=e.maxFileSize??m,n={maxFileSize:o,storage:e.storage},c=R(t,n),{authorize:a}=e;return a===void 0&&!e.silent&&!e.public&&console.warn("@lunora/storage: createUploadHandler() has no `authorize` — this mounts an unauthenticated, unbounded-write endpoint. Pass an RLS `authorize` gate, or set `public: true` (or `silent: true`) to confirm this bucket is intentionally open."),{fetch:async r=>{const s=f(r,t);if(s!==void 0&&s>o)return g(t);if(a!==void 0)try{if(!await a({method:r.method,protocol:t,request:r,url:new URL(r.url)}))return i(t)}catch{return i(t)}return c.fetch(r)},protocol:t}},z=e=>new h({accessKeyId:e.accessKeyId,bucket:e.bucket,endpoint:e.endpoint??`https://${e.accountId}.r2.cloudflarestorage.com`,path:e.path??"/",region:"auto",secretAccessKey:e.secretAccessKey,...e.partSize===void 0?{}:{partSize:e.partSize}});export{m as DEFAULT_MAX_UPLOAD_BYTES,z as createR2UploadStorage,S as createUploadHandler};
@@ -1,4 +0,0 @@
1
- import{a as g}from"./internal-DpjlEJPE.mjs";const h="auto",y="s3",S="AWS4-HMAC-SHA256",M=1,X=10080*60,w=900,d=new TextEncoder,j=(e,t)=>e[0]<t[0]?-1:e[0]>t[0]?1:0,s=e=>encodeURIComponent(e).replaceAll(/[!'()*]/gu,t=>`%${t.codePointAt(0)?.toString(16).toUpperCase()??""}`),f=e=>e.split("/").map(t=>s(t)).join("/"),U=async e=>g(await crypto.subtle.digest("SHA-256",d.encode(e))),n=async(e,t)=>{const a=await crypto.subtle.importKey("raw",e,{hash:"SHA-256",name:"HMAC"},!1,["sign"]);return crypto.subtle.sign("HMAC",a,d.encode(t))},E=async(e,t)=>{const a=await n(d.encode(`AWS4${e}`),t),r=await n(a,h),o=await n(r,y);return n(o,"aws4_request")},K=e=>{const t=e.jurisdiction===void 0?"":`${e.jurisdiction}.`;return`${e.accountId}.${t}r2.cloudflarestorage.com`},P=e=>{const t=`${e.toISOString().replaceAll(/[:-]/gu,"").slice(0,15)}Z`;return{amzDate:t,dateStamp:t.slice(0,8)}},k=async e=>{const{credentials:t,key:a}=e,r=e.method??"GET",o=e.expiresInSeconds??w,z=Number.isFinite(o)?o:w,H=Math.min(Math.max(M,Math.floor(z)),X),m=K(t),D=new Date(e.now?.()??Date.now()),{amzDate:l,dateStamp:u}=P(D),A=`${u}/${h}/${y}/aws4_request`,p=`/${s(t.bucket)}/${f(a)}`,$=[["X-Amz-Algorithm",S],["X-Amz-Credential",`${t.accessKeyId}/${A}`],["X-Amz-Date",l],["X-Amz-Expires",H.toString()],["X-Amz-SignedHeaders","host"]].map(([i,c])=>[s(i),s(c)]).toSorted(j).map(([i,c])=>`${i}=${c}`).join("&"),b=[r,p,$,`host:${m}
2
- `,"host","UNSIGNED-PAYLOAD"].join(`
3
- `),x=[S,l,A,await U(b)].join(`
4
- `),C=await E(t.secretAccessKey,u),I=g(await n(C,x));return`https://${m}${p}?${$}&X-Amz-Signature=${I}`};export{k as buildPresignedUrl};
@@ -1 +0,0 @@
1
- import"@lunora/errors";import{b as d,v as l}from"./signed-url-4m6WhCR1.mjs";import"./internal-DpjlEJPE.mjs";export{d as buildSignedUrl,l as verifySignedUrl};
@@ -1 +0,0 @@
1
- import{LunoraError as u}from"@lunora/errors";const d=(t,o={})=>{const r=Object.keys(t),[n]=r;if(n===void 0)throw new u("INTERNAL","@lunora/storage: createBucketStorage requires at least one bucket");if(o.default!==void 0&&!t[o.default])throw new u("INTERNAL",`@lunora/storage: default bucket "${o.default}" is not in the bucket map (have: ${r.join(", ")})`);const e=o.default??"default",c=t[e]??t[n];if(c===void 0)throw new u("INTERNAL",`@lunora/storage: default bucket "${e}" is not in the bucket map (have: ${r.join(", ")})`);const f=[...new Set([e,...r])],i=a=>{const s=a===e?c:t[a];if(!s)throw new u("INTERNAL",`@lunora/storage: no bucket registered for "${a}". Known buckets: ${f.join(", ")}`);return{...s,bucket:k=>i(k),bucketName:a}};return i(e)};export{d as createBucketStorage};
@@ -1 +0,0 @@
1
- import{LunoraError as a}from"@lunora/errors";import{b as R,d as T}from"./signed-url-4m6WhCR1.mjs";import{t as k,a as f}from"./internal-DpjlEJPE.mjs";import{buildPresignedUrl as I}from"./buildPresignedUrl-TCv-5TAT.mjs";const m=1024,A=1e3,U=100,b=e=>{const n=new Uint8Array(e);let i="";for(const p of n)i+=String.fromCodePoint(p);return btoa(i)},S=e=>{const n=e.checksums?.sha256;if(n===void 0)return e;const i=f(n),p=b(n);return new Proxy(e,{get(u,s){if(s==="sha256")return i;if(s==="sha256Base64")return p;const l=Reflect.get(u,s,u);return typeof l=="function"?l.bind(u):l},has(u,s){return s==="sha256"||s==="sha256Base64"||Reflect.has(u,s)}})},w=e=>{const n=e.checksums?.sha256;return{contentType:e.httpMetadata?.contentType,customMetadata:e.customMetadata,key:e.key,sha256:n===void 0?void 0:f(n),size:e.size,uploaded:e.uploaded===void 0?void 0:e.uploaded.getTime()}},M=e=>{const n=e.checksums?.sha256;return{checksums:e.checksums,customMetadata:e.customMetadata,etag:e.etag,httpEtag:e.httpEtag,httpMetadata:e.httpMetadata,key:e.key,sha256:n===void 0?void 0:f(n),sha256Base64:n===void 0?void 0:b(n),size:e.size,uploaded:e.uploaded}},N=(e,n)=>{let i=0;const p=s=>s instanceof ArrayBuffer||ArrayBuffer.isView(s)?s.byteLength:void 0,u=new TransformStream({transform(s,l){const h=p(s);if(h===void 0){l.error(new Error("@lunora/storage: stream chunk is not a byte chunk; cannot enforce maxSize"));return}if(i+=h,i>n){l.error(new Error(`@lunora/storage: stream body exceeds maxSize (> ${String(n)} bytes)`));return}l.enqueue(s)}});return e.pipeThrough(u)},c=e=>{if(typeof e!="string"||e.length===0)throw new a("VALIDATION_ERROR","@lunora/storage: key must be a non-empty string");if(e.length>m)throw new a("VALIDATION_ERROR",`@lunora/storage: key exceeds ${String(m)}-byte limit`);if(e.includes("\0"))throw new a("VALIDATION_ERROR","@lunora/storage: key contains NUL byte");if(T(e))throw new a("VALIDATION_ERROR","@lunora/storage: key contains a control character (including CR/LF)");if(e.startsWith("/"))throw new a("VALIDATION_ERROR","@lunora/storage: key must not start with `/`");const n=e.split("/");for(const i of n)if(i==="..")throw new a("VALIDATION_ERROR","@lunora/storage: key contains a `..` path component")},v=(e,n)=>{c(e),c(n);const i=`${e.endsWith("/")?e.slice(0,-1):e}/${n}`;if(i.length>m)throw new a("VALIDATION_ERROR",`@lunora/storage: scoped key exceeds ${String(m)}-byte limit`);return i},B=e=>{if(!e.bucket)throw new a("INTERNAL","@lunora/storage: `bucket` is required");const n=async(r,t,o={})=>{if(c(r),o.allowedContentTypes!==void 0){if(o.contentType===void 0)throw new a("VALIDATION_ERROR","@lunora/storage: contentType is required when allowedContentTypes is set");if(!o.allowedContentTypes.includes(o.contentType))throw new a("VALIDATION_ERROR",`@lunora/storage: contentType "${o.contentType}" not in allowedContentTypes`)}let y=t;if(typeof o.maxSize=="number"){let g;if(t instanceof ArrayBuffer?g=t.byteLength:t instanceof Blob&&(g=t.size),g!==void 0&&g>o.maxSize)throw new a("PAYLOAD_TOO_LARGE",`@lunora/storage: body exceeds maxSize (${String(g)} > ${String(o.maxSize)})`);t instanceof ReadableStream&&(y=N(t,o.maxSize))}const d=await e.bucket.put(r,y,{customMetadata:o.customMetadata,httpMetadata:o.contentType?{contentType:o.contentType}:void 0});return{etag:d.etag,httpEtag:d.httpEtag??`"${d.etag}"`,key:d.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)},p=async r=>{c(r),await e.bucket.delete(r)},u=async r=>{if(c(r),e.bucket.head){const o=await e.bucket.head(r);return o&&w(o)}const t=await e.bucket.get(r,{range:{length:0}});return t&&w(t)},s=async(r,t={})=>{if(r?.includes("\0"))throw new a("VALIDATION_ERROR","@lunora/storage: prefix contains NUL byte");const o=t.limit??U,y=Math.min(Math.max(1,Math.floor(o)),A),d=await e.bucket.list({cursor:t.cursor,delimiter:t.delimiter,limit:y,prefix:r});return{cursor:d.cursor,objects:d.objects.map(g=>M(g)),truncated:d.truncated}},l=r=>{if(!e.publicBaseUrl)throw new a("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getUrl()");c(r);const t=r.split("/").map(o=>encodeURIComponent(o)).join("/");return`${k(e.publicBaseUrl)}/${t}`},h=async(r,t={})=>{if(!e.publicBaseUrl)throw new a("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getSignedUrl()");if(!e.signingSecret)throw new a("INTERNAL","@lunora/storage: `signingSecret` is required for getSignedUrl()");return c(r),R({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 a("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:p,download:i,generateUploadUrl:async(r,t={})=>h(r,{contentType:t.contentType,expiresInSeconds:t.expiresInSeconds,method:"PUT"}),getMetadata:u,getPresignedUrl:async(r,t={})=>{if(!e.s3)throw new a("INTERNAL","@lunora/storage: `s3` credentials are required for getPresignedUrl() — pass { accountId, accessKeyId, secretAccessKey, bucket }");return c(r),I({credentials:e.s3,expiresInSeconds:t.expiresInSeconds,key:r,method:t.method})},getSignedUrl:h,getUrl:l,list:s,resumeMultipartUpload:(r,t)=>{if(c(r),typeof t!="string"||t.length===0)throw new a("VALIDATION_ERROR","@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");if(!e.bucket.resumeMultipartUpload)throw new a("INTERNAL","@lunora/storage: bucket binding does not support multipart uploads (resumeMultipartUpload)");return e.bucket.resumeMultipartUpload(r,t)},store:async(r,t,o={})=>n(r,t,o),upload:n}};export{B as createStorage,v as scopeKey};
@@ -1 +0,0 @@
1
- const o=e=>{const t=new Uint8Array(e);let n="";for(const r of t)n+=r.toString(16).padStart(2,"0");return n},a=e=>{let t=e.length;for(;t>0&&e[t-1]==="/";)t-=1;return e.slice(0,t)};export{o as a,a as t};
@@ -1,5 +0,0 @@
1
- import{LunoraError as p}from"@lunora/errors";import{t as b}from"./internal-DpjlEJPE.mjs";const R=(e,r)=>{if(e.size<r)return;const t=e.keys().next().value;t!==void 0&&e.delete(t)},u=new TextEncoder,f=10080*60,U=/^[a-z][a-z0-9+\-.]*:\/\//i,x=Array.from({length:32},(e,r)=>r),g=new RegExp(`[${x.map(e=>String.fromCodePoint(e)).join("")}]`,"u"),T=e=>{const r=String.fromCodePoint(...e);return btoa(r).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")},S=e=>{const r=e.replaceAll("-","+").replaceAll("_","/")+"===".slice((e.length+3)%4),t=atob(r),n=new Uint8Array(t.length);for(let o=0;o<t.length;o+=1)n[o]=t.codePointAt(o)??0;return n},C=64,m=new Map,h=async e=>{const r=m.get(e);if(r)return r;R(m,C);const t=crypto.subtle.importKey("raw",u.encode(e),{hash:"SHA-256",name:"HMAC"},!1,["sign","verify"]);return m.set(e,t),t},y=e=>{try{return new URL(e).host}catch{return e.replace(U,"").split("/")[0]??""}},w=e=>{if(g.test(e))throw new TypeError("hmac-url: value must not contain control characters (including CR/LF)")},H=e=>g.test(e),I=async(e,r)=>{const t=await h(e),n=await crypto.subtle.sign("HMAC",t,u.encode(r));return T(new Uint8Array(n))},E=async(e,r,t)=>{const n=await h(e);return crypto.subtle.verify("HMAC",n,t,u.encode(r))},P=/^\//,v=(e,r,t,n,o)=>{const a=`${e}
2
- ${r.toLowerCase()}
3
- ${t}
4
- ${String(n)}`;return o===void 0?a:`${a}
5
- ${o}`},M=async e=>{const r=e.method??"GET",t=e.expiresInSeconds??3600;if(!Number.isFinite(t)||t<=0)throw new p("VALIDATION_ERROR","@lunora/storage: expiresInSeconds must be a positive finite number");if(t>f)throw new p("VALIDATION_ERROR",`@lunora/storage: expiresInSeconds must not exceed ${String(f)} (7 days)`);const n=r==="PUT"?e.contentType:void 0;w(e.key);const o=Math.floor(Date.now()/1e3)+t,a=y(e.baseUrl),c=await I(e.secret,v(r,a,e.key,o,n)),s=b(e.baseUrl),l=e.key.split("/").map(d=>encodeURIComponent(d)).join("/"),i=n===void 0?"":`&ct=${encodeURIComponent(n)}`;return`${s}/${l}?exp=${String(o)}&method=${r}&sig=${c}${i}`},k=async(e,r,t)=>{let n;try{n=e instanceof URL?e:new URL(e)}catch{return{reason:"malformed",valid:!1}}const o=n.searchParams.get("exp"),a=o===null?Number.NaN:Number(o),c=n.searchParams.get("sig"),s=n.searchParams.get("method")??"GET",l=n.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(s!=="GET"&&s!=="PUT")return{reason:"malformed",valid:!1};let i,d;try{i=n.pathname.replace(P,"").split("/").map($=>decodeURIComponent($)).join("/"),w(i),d=S(c)}catch{return{reason:"malformed",valid:!1}}const A=t?.expectedHost===void 0?n.host:y(t.expectedHost);return await E(r,v(s,A,i,a,l),d)?{contentType:l,key:i,method:s,valid:!0}:{reason:"bad_signature",valid:!1}};export{M as b,H as d,k as v};