@lunora/storage 1.0.0-alpha.6 → 1.0.0-alpha.8
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 +3 -3
- package/dist/packem_shared/{buildPresignedUrl-DzPwi1bY.mjs → buildPresignedUrl-B5f1wvz6.mjs} +2 -8
- package/dist/packem_shared/{buildSignedUrl-DX535yKv.mjs → buildSignedUrl-rt-6azia.mjs} +38 -30
- package/dist/packem_shared/{createStorage-fwSr3UdU.mjs → createStorage-Bu6GJB25.mjs} +37 -31
- package/dist/packem_shared/internal-Dqk0MrAj.mjs +17 -0
- package/package.json +2 -2
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-
|
|
3
|
-
export { buildPresignedUrl } from './packem_shared/buildPresignedUrl-
|
|
4
|
-
export { buildSignedUrl, verifySignedUrl } from './packem_shared/buildSignedUrl-
|
|
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';
|
package/dist/packem_shared/{buildPresignedUrl-DzPwi1bY.mjs → buildPresignedUrl-B5f1wvz6.mjs}
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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("
|
|
73
|
+
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: expiresInSeconds must be a positive finite number");
|
|
58
74
|
}
|
|
59
|
-
if (expiresInSeconds >
|
|
60
|
-
throw new LunoraError("
|
|
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
|
|
66
|
-
const
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
99
|
+
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key contains NUL byte");
|
|
94
100
|
}
|
|
95
101
|
if (key.startsWith("/")) {
|
|
96
|
-
throw new LunoraError("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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) =>
|
|
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("
|
|
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.
|
|
3
|
+
"version": "1.0.0-alpha.8",
|
|
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.
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.4"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
52
|
"node": "^22.15.0 || >=24.11.0"
|