@lunora/storage 1.0.0-alpha.7 → 1.0.0-alpha.9

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.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  export { createBucketStorage } from './packem_shared/createBucketStorage-JYdrS4e5.mjs';
2
- export { createStorage, scopeKey } from './packem_shared/createStorage-fwSr3UdU.mjs';
3
- export { buildPresignedUrl } from './packem_shared/buildPresignedUrl-DzPwi1bY.mjs';
4
- export { buildSignedUrl, verifySignedUrl } from './packem_shared/buildSignedUrl-DX535yKv.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,9 +1,19 @@
1
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
+ };
2
13
 
3
14
  const textEncoder = new TextEncoder();
4
- const MAX_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60;
15
+ const MAX_SIGNED_URL_TTL_SECONDS = 7 * 24 * 60 * 60;
5
16
  const SCHEME_PREFIX_RE = /^[a-z][a-z0-9+\-.]*:\/\//i;
6
- const LEADING_SLASH_RE = /^\//;
7
17
  const toBase64Url = (bytes) => {
8
18
  const binary = String.fromCodePoint(...bytes);
9
19
  return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
@@ -24,24 +34,11 @@ const importHmacKey = async (secret) => {
24
34
  if (cached) {
25
35
  return cached;
26
36
  }
27
- if (keyCache.size >= KEY_CACHE_MAX) {
28
- const oldest = keyCache.keys().next().value;
29
- if (oldest !== void 0) {
30
- keyCache.delete(oldest);
31
- }
32
- }
37
+ evictOldestEntry(keyCache, KEY_CACHE_MAX);
33
38
  const keyPromise = crypto.subtle.importKey("raw", textEncoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign", "verify"]);
34
39
  keyCache.set(secret, keyPromise);
35
40
  return keyPromise;
36
41
  };
37
- const canonicalize = (method, host, key, exp, contentType) => {
38
- const base = `${method}
39
- ${host.toLowerCase()}
40
- ${key}
41
- ${String(exp)}`;
42
- return contentType === void 0 ? base : `${base}
43
- ${contentType}`;
44
- };
45
42
  const extractHost = (input) => {
46
43
  try {
47
44
  return new URL(input).host;
@@ -50,22 +47,39 @@ const extractHost = (input) => {
50
47
  return noScheme.split("/")[0] ?? "";
51
48
  }
52
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
+ };
53
69
  const buildSignedUrl = async (args) => {
54
70
  const method = args.method ?? "GET";
55
71
  const expiresInSeconds = args.expiresInSeconds ?? 60 * 60;
56
72
  if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
57
- throw new LunoraError("INTERNAL", "@lunora/storage: expiresInSeconds must be a positive finite number");
73
+ throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: expiresInSeconds must be a positive finite number");
58
74
  }
59
- if (expiresInSeconds > MAX_EXPIRES_IN_SECONDS) {
60
- throw new LunoraError("INTERNAL", `@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)`);
61
77
  }
62
78
  const contentType = method === "PUT" ? args.contentType : void 0;
63
79
  const exp = Math.floor(Date.now() / 1e3) + expiresInSeconds;
64
80
  const host = extractHost(args.baseUrl);
65
- const cryptoKey = await importHmacKey(args.secret);
66
- const signature = await crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(canonicalize(method, host, args.key, exp, contentType)));
67
- const sig = toBase64Url(new Uint8Array(signature));
68
- 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);
69
83
  const safeKey = args.key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
70
84
  const ctParameter = contentType === void 0 ? "" : `&ct=${encodeURIComponent(contentType)}`;
71
85
  return `${base}/${safeKey}?exp=${String(exp)}&method=${method}&sig=${sig}${ctParameter}`;
@@ -100,13 +114,7 @@ const verifySignedUrl = async (input, secret, options) => {
100
114
  return { reason: "malformed", valid: false };
101
115
  }
102
116
  const host = options?.expectedHost === void 0 ? url.host : extractHost(options.expectedHost);
103
- const cryptoKey = await importHmacKey(secret);
104
- const valid = await crypto.subtle.verify(
105
- "HMAC",
106
- cryptoKey,
107
- sigBytes,
108
- textEncoder.encode(canonicalize(method, host, key, exp, contentType))
109
- );
117
+ const valid = await verifyCanonical(secret, canonicalize(method, host, key, exp, contentType), sigBytes);
110
118
  if (!valid) {
111
119
  return { reason: "bad_signature", valid: false };
112
120
  }
@@ -1,18 +1,11 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
- import { buildPresignedUrl } from './buildPresignedUrl-DzPwi1bY.mjs';
3
- import { buildSignedUrl } from './buildSignedUrl-DX535yKv.mjs';
2
+ import { t as trimTrailingSlashes, a as toHex } from './internal-Dqk0MrAj.mjs';
3
+ import { buildPresignedUrl } from './buildPresignedUrl-B5f1wvz6.mjs';
4
+ import { buildSignedUrl } from './buildSignedUrl-rt-6azia.mjs';
4
5
 
5
6
  const MAX_KEY_LENGTH = 1024;
6
7
  const MAX_LIST_LIMIT = 1e3;
7
8
  const DEFAULT_LIST_LIMIT = 100;
8
- const toHex = (buffer) => {
9
- const bytes = new Uint8Array(buffer);
10
- let out = "";
11
- for (const byte of bytes) {
12
- out += byte.toString(16).padStart(2, "0");
13
- }
14
- return out;
15
- };
16
9
  const toBase64 = (buffer) => {
17
10
  const bytes = new Uint8Array(buffer);
18
11
  let binary = "";
@@ -55,17 +48,37 @@ const toMetadata = (object) => {
55
48
  uploaded: object.uploaded === void 0 ? void 0 : object.uploaded.getTime()
56
49
  };
57
50
  };
51
+ const toListObject = (object) => {
52
+ const raw = object.checksums?.sha256;
53
+ return {
54
+ checksums: object.checksums,
55
+ customMetadata: object.customMetadata,
56
+ etag: object.etag,
57
+ httpEtag: object.httpEtag,
58
+ httpMetadata: object.httpMetadata,
59
+ key: object.key,
60
+ sha256: raw === void 0 ? void 0 : toHex(raw),
61
+ sha256Base64: raw === void 0 ? void 0 : toBase64(raw),
62
+ size: object.size,
63
+ uploaded: object.uploaded
64
+ };
65
+ };
58
66
  const enforceStreamMaxSize = (stream, maxSize) => {
59
67
  let seen = 0;
60
68
  const byteLengthOf = (chunk) => {
61
69
  if (chunk instanceof ArrayBuffer) {
62
70
  return chunk.byteLength;
63
71
  }
64
- return ArrayBuffer.isView(chunk) ? chunk.byteLength : 0;
72
+ return ArrayBuffer.isView(chunk) ? chunk.byteLength : void 0;
65
73
  };
66
74
  const counter = new TransformStream({
67
75
  transform(chunk, controller) {
68
- seen += byteLengthOf(chunk);
76
+ const length = byteLengthOf(chunk);
77
+ if (length === void 0) {
78
+ controller.error(new Error("@lunora/storage: stream chunk is not a byte chunk; cannot enforce maxSize"));
79
+ return;
80
+ }
81
+ seen += length;
69
82
  if (seen > maxSize) {
70
83
  controller.error(new Error(`@lunora/storage: stream body exceeds maxSize (> ${String(maxSize)} bytes)`));
71
84
  return;
@@ -75,30 +88,23 @@ const enforceStreamMaxSize = (stream, maxSize) => {
75
88
  });
76
89
  return stream.pipeThrough(counter);
77
90
  };
78
- const trimTrailingSlashes = (value) => {
79
- let end = value.length;
80
- while (end > 0 && value[end - 1] === "/") {
81
- end -= 1;
82
- }
83
- return value.slice(0, end);
84
- };
85
91
  const validateKey = (key) => {
86
92
  if (typeof key !== "string" || key.length === 0) {
87
- throw new LunoraError("INTERNAL", "@lunora/storage: key must be a non-empty string");
93
+ throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key must be a non-empty string");
88
94
  }
89
95
  if (key.length > MAX_KEY_LENGTH) {
90
- throw new LunoraError("INTERNAL", `@lunora/storage: key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
96
+ throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
91
97
  }
92
98
  if (key.includes("\0")) {
93
- throw new LunoraError("INTERNAL", "@lunora/storage: key contains NUL byte");
99
+ throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key contains NUL byte");
94
100
  }
95
101
  if (key.startsWith("/")) {
96
- throw new LunoraError("INTERNAL", "@lunora/storage: key must not start with `/`");
102
+ throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key must not start with `/`");
97
103
  }
98
104
  const segments = key.split("/");
99
105
  for (const segment of segments) {
100
106
  if (segment === "..") {
101
- throw new LunoraError("INTERNAL", "@lunora/storage: key contains a `..` path component");
107
+ throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key contains a `..` path component");
102
108
  }
103
109
  }
104
110
  };
@@ -108,7 +114,7 @@ const scopeKey = (prefix, key) => {
108
114
  const trimmedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
109
115
  const composed = `${trimmedPrefix}/${key}`;
110
116
  if (composed.length > MAX_KEY_LENGTH) {
111
- throw new LunoraError("INTERNAL", `@lunora/storage: scoped key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
117
+ throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: scoped key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
112
118
  }
113
119
  return composed;
114
120
  };
@@ -120,10 +126,10 @@ const createStorage = (options) => {
120
126
  validateKey(key);
121
127
  if (uploadOptions.allowedContentTypes !== void 0) {
122
128
  if (uploadOptions.contentType === void 0) {
123
- throw new LunoraError("INTERNAL", "@lunora/storage: contentType is required when allowedContentTypes is set");
129
+ throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: contentType is required when allowedContentTypes is set");
124
130
  }
125
131
  if (!uploadOptions.allowedContentTypes.includes(uploadOptions.contentType)) {
126
- throw new LunoraError("INTERNAL", `@lunora/storage: contentType "${uploadOptions.contentType}" not in allowedContentTypes`);
132
+ throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: contentType "${uploadOptions.contentType}" not in allowedContentTypes`);
127
133
  }
128
134
  }
129
135
  let putBody = body;
@@ -135,7 +141,7 @@ const createStorage = (options) => {
135
141
  size = body.size;
136
142
  }
137
143
  if (size !== void 0 && size > uploadOptions.maxSize) {
138
- throw new LunoraError("INTERNAL", `@lunora/storage: body exceeds maxSize (${String(size)} > ${String(uploadOptions.maxSize)})`);
144
+ throw new LunoraError("PAYLOAD_TOO_LARGE", `@lunora/storage: body exceeds maxSize (${String(size)} > ${String(uploadOptions.maxSize)})`);
139
145
  }
140
146
  if (body instanceof ReadableStream) {
141
147
  putBody = enforceStreamMaxSize(body, uploadOptions.maxSize);
@@ -167,12 +173,12 @@ const createStorage = (options) => {
167
173
  };
168
174
  const list = async (prefix, listOptions = {}) => {
169
175
  if (prefix?.includes("\0")) {
170
- throw new LunoraError("INTERNAL", "@lunora/storage: prefix contains NUL byte");
176
+ throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: prefix contains NUL byte");
171
177
  }
172
178
  const requested = listOptions.limit ?? DEFAULT_LIST_LIMIT;
173
179
  const limit = Math.min(Math.max(1, Math.floor(requested)), MAX_LIST_LIMIT);
174
180
  const result = await options.bucket.list({ cursor: listOptions.cursor, delimiter: listOptions.delimiter, limit, prefix });
175
- return { cursor: result.cursor, objects: result.objects.map((object) => withSha256(object)), truncated: result.truncated };
181
+ return { cursor: result.cursor, objects: result.objects.map((object) => toListObject(object)), truncated: result.truncated };
176
182
  };
177
183
  const getUrl = (key) => {
178
184
  if (!options.publicBaseUrl) {
@@ -212,7 +218,7 @@ const createStorage = (options) => {
212
218
  const resumeMultipartUpload = (key, uploadId) => {
213
219
  validateKey(key);
214
220
  if (typeof uploadId !== "string" || uploadId.length === 0) {
215
- throw new LunoraError("INTERNAL", "@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");
221
+ throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");
216
222
  }
217
223
  if (!options.bucket.resumeMultipartUpload) {
218
224
  throw new LunoraError("INTERNAL", "@lunora/storage: bucket binding does not support multipart uploads (resumeMultipartUpload)");
@@ -0,0 +1,17 @@
1
+ const toHex = (buffer) => {
2
+ const bytes = new Uint8Array(buffer);
3
+ let out = "";
4
+ for (const byte of bytes) {
5
+ out += byte.toString(16).padStart(2, "0");
6
+ }
7
+ return out;
8
+ };
9
+ const trimTrailingSlashes = (value) => {
10
+ let end = value.length;
11
+ while (end > 0 && value[end - 1] === "/") {
12
+ end -= 1;
13
+ }
14
+ return value.slice(0, end);
15
+ };
16
+
17
+ export { toHex as a, trimTrailingSlashes as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/storage",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "R2-backed storage for Lunora: typed buckets and signed URLs",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -46,7 +46,7 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.3"
49
+ "@lunora/errors": "1.0.0-alpha.5"
50
50
  },
51
51
  "engines": {
52
52
  "node": "^22.15.0 || >=24.11.0"