@aglyn/plugins-video-delivery 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +51 -0
- package/package.json +35 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/constants.d.ts +18 -0
- package/src/lib/constants.js +18 -0
- package/src/lib/constants.js.map +1 -0
- package/src/lib/declarations.server.d.ts +33 -0
- package/src/lib/declarations.server.js +41 -0
- package/src/lib/declarations.server.js.map +1 -0
- package/src/lib/delivery-token.d.ts +116 -0
- package/src/lib/delivery-token.js +196 -0
- package/src/lib/delivery-token.js.map +1 -0
- package/src/lib/r2-object-store.d.ts +67 -0
- package/src/lib/r2-object-store.js +167 -0
- package/src/lib/r2-object-store.js.map +1 -0
- package/src/lib/sigv4.d.ts +57 -0
- package/src/lib/sigv4.js +104 -0
- package/src/lib/sigv4.js.map +1 -0
- package/src/lib/video-delivery-provider.d.ts +59 -0
- package/src/lib/video-delivery-provider.js +107 -0
- package/src/lib/video-delivery-provider.js.map +1 -0
- package/src/lib/worker/r2-binding.d.ts +48 -0
- package/src/lib/worker/r2-binding.js +27 -0
- package/src/lib/worker/r2-binding.js.map +1 -0
- package/src/lib/worker/video-worker.d.ts +74 -0
- package/src/lib/worker/video-worker.js +174 -0
- package/src/lib/worker/video-worker.js.map +1 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* The delivery token (AGL-2824): the ONE format both halves share.
|
|
19
|
+
*
|
|
20
|
+
* The platform mints it when it redirects a video request, and the Worker
|
|
21
|
+
* verifies it before it serves a byte. Both import this module, so the two
|
|
22
|
+
* can only disagree about the format by disagreeing about the secret. It uses
|
|
23
|
+
* nothing but Web Crypto and the Web encoding globals, which both runtimes
|
|
24
|
+
* have, and imports nothing, so the Worker bundles it as it stands.
|
|
25
|
+
*
|
|
26
|
+
* ## Shape
|
|
27
|
+
*
|
|
28
|
+
* `{payload}.{signature}`, both base64url. The payload is JSON:
|
|
29
|
+
*
|
|
30
|
+
* | field | meaning |
|
|
31
|
+
* | -- | -- |
|
|
32
|
+
* | `v` | format version, `1` |
|
|
33
|
+
* | `k` | the object key the token is for — the only object it opens |
|
|
34
|
+
* | `e` | expiry, epoch milliseconds |
|
|
35
|
+
* | `o`, `h`, `m`, `s` | org, host, media id and CDN scope, when known |
|
|
36
|
+
*
|
|
37
|
+
* The ids open nothing. They ride inside the signature so that delivered
|
|
38
|
+
* bytes can later be attributed to an organization without a lookup, and so
|
|
39
|
+
* that nobody can reattribute a URL they were handed.
|
|
40
|
+
*
|
|
41
|
+
* The signature is HMAC-SHA256 over a domain-separated string, keyed by the
|
|
42
|
+
* dedicated delivery secret. That secret is never the platform's
|
|
43
|
+
* `TOKEN_SIGNING_SECRET`: it lives in a second runtime, and a leak there must
|
|
44
|
+
* not be able to forge a commerce download or a gated-video link.
|
|
45
|
+
*
|
|
46
|
+
* ## What the verifier refuses
|
|
47
|
+
*
|
|
48
|
+
* A token that does not parse; one whose signature does not match (a changed
|
|
49
|
+
* byte, or another secret); one that has expired; one that claims a lifetime
|
|
50
|
+
* past {@link DELIVERY_TOKEN_MAX_TTL_MS} — no minter issues one, so a token
|
|
51
|
+
* that claims one was never meant to exist; and one minted for a different
|
|
52
|
+
* key than the one requested. The last is reported apart from the others,
|
|
53
|
+
* because the Worker answers it as a missing object rather than a bad token.
|
|
54
|
+
*/ /** Format version, in the payload. */ export const DELIVERY_TOKEN_VERSION = 1;
|
|
55
|
+
/** The query parameter that carries a token on a delivery URL. */ export const DELIVERY_TOKEN_PARAM = 'token';
|
|
56
|
+
/**
|
|
57
|
+
* The longest lifetime a token may claim: the same four hours as a gated
|
|
58
|
+
* video's viewing session, which is the longest link the platform issues.
|
|
59
|
+
*/ export const DELIVERY_TOKEN_MAX_TTL_MS = 4 * 60 * 60 * 1000;
|
|
60
|
+
/**
|
|
61
|
+
* Allowance for the minting and verifying clocks disagreeing: the platform
|
|
62
|
+
* mints on one provider's machines and the Worker verifies on another's.
|
|
63
|
+
*/ export const DELIVERY_TOKEN_CLOCK_SKEW_MS = 60 * 1000;
|
|
64
|
+
/**
|
|
65
|
+
* The shortest secret either side accepts. `openssl rand -hex 32` makes 64
|
|
66
|
+
* characters; anything under 32 is a placeholder that was never replaced.
|
|
67
|
+
*/ export const DELIVERY_SECRET_MIN_LENGTH = 32;
|
|
68
|
+
/** Separates this signature from any other HMAC over the same secret. */ const SIGNING_CONTEXT = 'aglyn-media-delivery:v1:';
|
|
69
|
+
const encoder = new TextEncoder();
|
|
70
|
+
const decoder = new TextDecoder();
|
|
71
|
+
function toBase64Url(bytes) {
|
|
72
|
+
let binary = '';
|
|
73
|
+
for (const byte of bytes)binary += String.fromCharCode(byte);
|
|
74
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
75
|
+
}
|
|
76
|
+
function fromBase64Url(text) {
|
|
77
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
|
|
78
|
+
const padded = text.replace(/-/g, '+').replace(/_/g, '/');
|
|
79
|
+
try {
|
|
80
|
+
const binary = atob(padded + '='.repeat((4 - padded.length % 4) % 4));
|
|
81
|
+
const bytes = new Uint8Array(binary.length);
|
|
82
|
+
for(let index = 0; index < binary.length; index += 1){
|
|
83
|
+
bytes[index] = binary.charCodeAt(index);
|
|
84
|
+
}
|
|
85
|
+
return bytes;
|
|
86
|
+
} catch (unused) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function usableSecret(secret) {
|
|
91
|
+
return typeof secret === 'string' && secret.length >= DELIVERY_SECRET_MIN_LENGTH;
|
|
92
|
+
}
|
|
93
|
+
function hmacKey(secret, usage) {
|
|
94
|
+
return crypto.subtle.importKey('raw', encoder.encode(secret), {
|
|
95
|
+
name: 'HMAC',
|
|
96
|
+
hash: 'SHA-256'
|
|
97
|
+
}, false, [
|
|
98
|
+
usage
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
function optionalId(value) {
|
|
102
|
+
return typeof value === 'string' && value ? value : null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Mints a token for `claims`. Throws on a secret that is missing or too
|
|
106
|
+
* short, on an empty key, and on a lifetime that has run out or is longer
|
|
107
|
+
* than {@link DELIVERY_TOKEN_MAX_TTL_MS} — the verifier would refuse each of
|
|
108
|
+
* those, so a token minted for one would be dead on arrival.
|
|
109
|
+
*/ export async function mintDeliveryToken(claims, secret, nowMs = Date.now()) {
|
|
110
|
+
if (!usableSecret(secret)) {
|
|
111
|
+
throw new Error('The media delivery secret is missing or too short');
|
|
112
|
+
}
|
|
113
|
+
if (!claims.key) throw new Error('A delivery token needs an object key');
|
|
114
|
+
const lifetime = claims.expiresAtMs - nowMs;
|
|
115
|
+
if (!Number.isFinite(lifetime) || lifetime <= 0) {
|
|
116
|
+
throw new RangeError('A delivery token must expire in the future');
|
|
117
|
+
}
|
|
118
|
+
if (lifetime > DELIVERY_TOKEN_MAX_TTL_MS) {
|
|
119
|
+
throw new RangeError(`A delivery token may live at most ${DELIVERY_TOKEN_MAX_TTL_MS} ms`);
|
|
120
|
+
}
|
|
121
|
+
const payload = toBase64Url(encoder.encode(JSON.stringify(_extends({
|
|
122
|
+
v: DELIVERY_TOKEN_VERSION,
|
|
123
|
+
k: claims.key,
|
|
124
|
+
e: Math.floor(claims.expiresAtMs)
|
|
125
|
+
}, claims.orgId ? {
|
|
126
|
+
o: claims.orgId
|
|
127
|
+
} : {}, claims.hostId ? {
|
|
128
|
+
h: claims.hostId
|
|
129
|
+
} : {}, claims.mediaId ? {
|
|
130
|
+
m: claims.mediaId
|
|
131
|
+
} : {}, claims.scope ? {
|
|
132
|
+
s: claims.scope
|
|
133
|
+
} : {}))));
|
|
134
|
+
const signature = await crypto.subtle.sign('HMAC', await hmacKey(secret, 'sign'), encoder.encode(`${SIGNING_CONTEXT}${payload}`));
|
|
135
|
+
return `${payload}.${toBase64Url(new Uint8Array(signature))}`;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Verifies a token for the object `key`. The signature is checked with Web
|
|
139
|
+
* Crypto's own `verify`, which compares in constant time, before anything in
|
|
140
|
+
* the payload is believed.
|
|
141
|
+
*/ export async function verifyDeliveryToken(token, secret, expected) {
|
|
142
|
+
var _expected_nowMs;
|
|
143
|
+
const refuse = (refusal)=>({
|
|
144
|
+
ok: false,
|
|
145
|
+
refusal
|
|
146
|
+
});
|
|
147
|
+
if (!usableSecret(secret)) return refuse('secret');
|
|
148
|
+
if (typeof token !== 'string' || token.length > 4096) return refuse('malformed');
|
|
149
|
+
const parts = token.split('.');
|
|
150
|
+
if (parts.length !== 2) return refuse('malformed');
|
|
151
|
+
const [payload, signature] = parts;
|
|
152
|
+
const signatureBytes = fromBase64Url(signature);
|
|
153
|
+
if (!payload || !signatureBytes || signatureBytes.length !== 32) {
|
|
154
|
+
return refuse('malformed');
|
|
155
|
+
}
|
|
156
|
+
const genuine = await crypto.subtle.verify('HMAC', await hmacKey(secret, 'verify'), signatureBytes, encoder.encode(`${SIGNING_CONTEXT}${payload}`));
|
|
157
|
+
if (!genuine) return refuse('signature');
|
|
158
|
+
const payloadBytes = fromBase64Url(payload);
|
|
159
|
+
if (!payloadBytes) return refuse('malformed');
|
|
160
|
+
let body;
|
|
161
|
+
try {
|
|
162
|
+
const parsed = JSON.parse(decoder.decode(payloadBytes));
|
|
163
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
164
|
+
return refuse('malformed');
|
|
165
|
+
}
|
|
166
|
+
body = parsed;
|
|
167
|
+
} catch (unused) {
|
|
168
|
+
return refuse('malformed');
|
|
169
|
+
}
|
|
170
|
+
if (body['v'] !== DELIVERY_TOKEN_VERSION) return refuse('malformed');
|
|
171
|
+
const key = body['k'];
|
|
172
|
+
const expiresAtMs = body['e'];
|
|
173
|
+
if (typeof key !== 'string' || !key) return refuse('malformed');
|
|
174
|
+
if (typeof expiresAtMs !== 'number' || !Number.isFinite(expiresAtMs)) {
|
|
175
|
+
return refuse('malformed');
|
|
176
|
+
}
|
|
177
|
+
const nowMs = (_expected_nowMs = expected.nowMs) != null ? _expected_nowMs : Date.now();
|
|
178
|
+
if (expiresAtMs <= nowMs) return refuse('expired');
|
|
179
|
+
if (expiresAtMs - nowMs > DELIVERY_TOKEN_MAX_TTL_MS + DELIVERY_TOKEN_CLOCK_SKEW_MS) {
|
|
180
|
+
return refuse('too-long');
|
|
181
|
+
}
|
|
182
|
+
if (key !== expected.key) return refuse('key');
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
claims: {
|
|
186
|
+
key,
|
|
187
|
+
expiresAtMs,
|
|
188
|
+
orgId: optionalId(body['o']),
|
|
189
|
+
hostId: optionalId(body['h']),
|
|
190
|
+
mediaId: optionalId(body['m']),
|
|
191
|
+
scope: optionalId(body['s'])
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
//# sourceMappingURL=delivery-token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/video-delivery/src/lib/delivery-token.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The delivery token (AGL-2824): the ONE format both halves share.\n *\n * The platform mints it when it redirects a video request, and the Worker\n * verifies it before it serves a byte. Both import this module, so the two\n * can only disagree about the format by disagreeing about the secret. It uses\n * nothing but Web Crypto and the Web encoding globals, which both runtimes\n * have, and imports nothing, so the Worker bundles it as it stands.\n *\n * ## Shape\n *\n * `{payload}.{signature}`, both base64url. The payload is JSON:\n *\n * | field | meaning |\n * | -- | -- |\n * | `v` | format version, `1` |\n * | `k` | the object key the token is for — the only object it opens |\n * | `e` | expiry, epoch milliseconds |\n * | `o`, `h`, `m`, `s` | org, host, media id and CDN scope, when known |\n *\n * The ids open nothing. They ride inside the signature so that delivered\n * bytes can later be attributed to an organization without a lookup, and so\n * that nobody can reattribute a URL they were handed.\n *\n * The signature is HMAC-SHA256 over a domain-separated string, keyed by the\n * dedicated delivery secret. That secret is never the platform's\n * `TOKEN_SIGNING_SECRET`: it lives in a second runtime, and a leak there must\n * not be able to forge a commerce download or a gated-video link.\n *\n * ## What the verifier refuses\n *\n * A token that does not parse; one whose signature does not match (a changed\n * byte, or another secret); one that has expired; one that claims a lifetime\n * past {@link DELIVERY_TOKEN_MAX_TTL_MS} — no minter issues one, so a token\n * that claims one was never meant to exist; and one minted for a different\n * key than the one requested. The last is reported apart from the others,\n * because the Worker answers it as a missing object rather than a bad token.\n */\n\n/** Format version, in the payload. */\nexport const DELIVERY_TOKEN_VERSION = 1\n\n/** The query parameter that carries a token on a delivery URL. */\nexport const DELIVERY_TOKEN_PARAM = 'token'\n\n/**\n * The longest lifetime a token may claim: the same four hours as a gated\n * video's viewing session, which is the longest link the platform issues.\n */\nexport const DELIVERY_TOKEN_MAX_TTL_MS = 4 * 60 * 60 * 1000\n\n/**\n * Allowance for the minting and verifying clocks disagreeing: the platform\n * mints on one provider's machines and the Worker verifies on another's.\n */\nexport const DELIVERY_TOKEN_CLOCK_SKEW_MS = 60 * 1000\n\n/**\n * The shortest secret either side accepts. `openssl rand -hex 32` makes 64\n * characters; anything under 32 is a placeholder that was never replaced.\n */\nexport const DELIVERY_SECRET_MIN_LENGTH = 32\n\n/** Separates this signature from any other HMAC over the same secret. */\nconst SIGNING_CONTEXT = 'aglyn-media-delivery:v1:'\n\nexport interface DeliveryTokenClaims {\n /** The object key the token opens. */\n key: string\n expiresAtMs: number\n orgId: string | null\n hostId: string | null\n mediaId: string | null\n scope: string | null\n}\n\nexport type DeliveryTokenRefusal =\n /** No secret, or one too short to trust. */\n | 'secret'\n /** Not a token at all. */\n | 'malformed'\n /** Altered, or signed with another secret. */\n | 'signature'\n | 'expired'\n /** Claims a lifetime no minter issues. */\n | 'too-long'\n /** A genuine token for another object. */\n | 'key'\n\nexport type DeliveryTokenVerdict =\n | { ok: true; claims: DeliveryTokenClaims }\n | { ok: false; refusal: DeliveryTokenRefusal }\n\nconst encoder = new TextEncoder()\nconst decoder = new TextDecoder()\n\nfunction toBase64Url(bytes: Uint8Array): string {\n let binary = ''\n for (const byte of bytes) binary += String.fromCharCode(byte)\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction fromBase64Url(text: string): Uint8Array<ArrayBuffer> | null {\n if (!/^[A-Za-z0-9_-]*$/.test(text)) return null\n const padded = text.replace(/-/g, '+').replace(/_/g, '/')\n try {\n const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))\n const bytes = new Uint8Array(binary.length)\n for (let index = 0; index < binary.length; index += 1) {\n bytes[index] = binary.charCodeAt(index)\n }\n return bytes\n } catch {\n return null\n }\n}\n\nfunction usableSecret(secret: unknown): secret is string {\n return typeof secret === 'string' && secret.length >= DELIVERY_SECRET_MIN_LENGTH\n}\n\nfunction hmacKey(secret: string, usage: 'sign' | 'verify'): Promise<CryptoKey> {\n return crypto.subtle.importKey(\n 'raw',\n encoder.encode(secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n [usage],\n )\n}\n\nfunction optionalId(value: unknown): string | null {\n return typeof value === 'string' && value ? value : null\n}\n\n/**\n * Mints a token for `claims`. Throws on a secret that is missing or too\n * short, on an empty key, and on a lifetime that has run out or is longer\n * than {@link DELIVERY_TOKEN_MAX_TTL_MS} — the verifier would refuse each of\n * those, so a token minted for one would be dead on arrival.\n */\nexport async function mintDeliveryToken(\n claims: DeliveryTokenClaims,\n secret: string,\n nowMs: number = Date.now(),\n): Promise<string> {\n if (!usableSecret(secret)) {\n throw new Error('The media delivery secret is missing or too short')\n }\n if (!claims.key) throw new Error('A delivery token needs an object key')\n const lifetime = claims.expiresAtMs - nowMs\n if (!Number.isFinite(lifetime) || lifetime <= 0) {\n throw new RangeError('A delivery token must expire in the future')\n }\n if (lifetime > DELIVERY_TOKEN_MAX_TTL_MS) {\n throw new RangeError(\n `A delivery token may live at most ${DELIVERY_TOKEN_MAX_TTL_MS} ms`,\n )\n }\n const payload = toBase64Url(\n encoder.encode(\n JSON.stringify({\n v: DELIVERY_TOKEN_VERSION,\n k: claims.key,\n e: Math.floor(claims.expiresAtMs),\n ...(claims.orgId ? { o: claims.orgId } : {}),\n ...(claims.hostId ? { h: claims.hostId } : {}),\n ...(claims.mediaId ? { m: claims.mediaId } : {}),\n ...(claims.scope ? { s: claims.scope } : {}),\n }),\n ),\n )\n const signature = await crypto.subtle.sign(\n 'HMAC',\n await hmacKey(secret, 'sign'),\n encoder.encode(`${SIGNING_CONTEXT}${payload}`),\n )\n return `${payload}.${toBase64Url(new Uint8Array(signature))}`\n}\n\n/**\n * Verifies a token for the object `key`. The signature is checked with Web\n * Crypto's own `verify`, which compares in constant time, before anything in\n * the payload is believed.\n */\nexport async function verifyDeliveryToken(\n token: unknown,\n secret: unknown,\n expected: { key: string; nowMs?: number },\n): Promise<DeliveryTokenVerdict> {\n const refuse = (refusal: DeliveryTokenRefusal): DeliveryTokenVerdict => ({\n ok: false,\n refusal,\n })\n if (!usableSecret(secret)) return refuse('secret')\n if (typeof token !== 'string' || token.length > 4096) return refuse('malformed')\n const parts = token.split('.')\n if (parts.length !== 2) return refuse('malformed')\n const [payload, signature] = parts as [string, string]\n const signatureBytes = fromBase64Url(signature)\n if (!payload || !signatureBytes || signatureBytes.length !== 32) {\n return refuse('malformed')\n }\n const genuine = await crypto.subtle.verify(\n 'HMAC',\n await hmacKey(secret, 'verify'),\n signatureBytes,\n encoder.encode(`${SIGNING_CONTEXT}${payload}`),\n )\n if (!genuine) return refuse('signature')\n\n const payloadBytes = fromBase64Url(payload)\n if (!payloadBytes) return refuse('malformed')\n let body: Record<string, unknown>\n try {\n const parsed = JSON.parse(decoder.decode(payloadBytes))\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n return refuse('malformed')\n }\n body = parsed as Record<string, unknown>\n } catch {\n return refuse('malformed')\n }\n if (body['v'] !== DELIVERY_TOKEN_VERSION) return refuse('malformed')\n const key = body['k']\n const expiresAtMs = body['e']\n if (typeof key !== 'string' || !key) return refuse('malformed')\n if (typeof expiresAtMs !== 'number' || !Number.isFinite(expiresAtMs)) {\n return refuse('malformed')\n }\n const nowMs = expected.nowMs ?? Date.now()\n if (expiresAtMs <= nowMs) return refuse('expired')\n if (expiresAtMs - nowMs > DELIVERY_TOKEN_MAX_TTL_MS + DELIVERY_TOKEN_CLOCK_SKEW_MS) {\n return refuse('too-long')\n }\n if (key !== expected.key) return refuse('key')\n return {\n ok: true,\n claims: {\n key,\n expiresAtMs,\n orgId: optionalId(body['o']),\n hostId: optionalId(body['h']),\n mediaId: optionalId(body['m']),\n scope: optionalId(body['s']),\n },\n }\n}\n"],"names":["DELIVERY_TOKEN_VERSION","DELIVERY_TOKEN_PARAM","DELIVERY_TOKEN_MAX_TTL_MS","DELIVERY_TOKEN_CLOCK_SKEW_MS","DELIVERY_SECRET_MIN_LENGTH","SIGNING_CONTEXT","encoder","TextEncoder","decoder","TextDecoder","toBase64Url","bytes","binary","byte","String","fromCharCode","btoa","replace","fromBase64Url","text","test","padded","atob","repeat","length","Uint8Array","index","charCodeAt","usableSecret","secret","hmacKey","usage","crypto","subtle","importKey","encode","name","hash","optionalId","value","mintDeliveryToken","claims","nowMs","Date","now","Error","key","lifetime","expiresAtMs","Number","isFinite","RangeError","payload","JSON","stringify","v","k","e","Math","floor","orgId","o","hostId","h","mediaId","m","scope","s","signature","sign","verifyDeliveryToken","token","expected","refuse","refusal","ok","parts","split","signatureBytes","genuine","verify","payloadBytes","body","parsed","parse","decode","Array","isArray"],"mappings":"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"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type SigV4Credentials } from './sigv4';
|
|
18
|
+
/**
|
|
19
|
+
* The four S3 calls the copy flow makes against a Cloudflare R2 bucket, over
|
|
20
|
+
* `fetch` with SigV4 (AGL-2824).
|
|
21
|
+
*
|
|
22
|
+
* R2's S3 endpoint is per account, `{accountId}.r2.cloudflarestorage.com`,
|
|
23
|
+
* and its region is `auto`. Requests are path-style: the bucket is the first
|
|
24
|
+
* path segment. A put streams its body with `UNSIGNED-PAYLOAD` and an exact
|
|
25
|
+
* `content-length`, so a 200 MB film is never held in memory; every other
|
|
26
|
+
* call has no body.
|
|
27
|
+
*
|
|
28
|
+
* `fetch` is injectable, which is how the specs exercise every call with no
|
|
29
|
+
* network at all.
|
|
30
|
+
*/
|
|
31
|
+
export interface R2Credentials extends SigV4Credentials {
|
|
32
|
+
accountId: string;
|
|
33
|
+
bucket: string;
|
|
34
|
+
}
|
|
35
|
+
export interface R2PutRequest {
|
|
36
|
+
key: string;
|
|
37
|
+
body: ReadableStream<Uint8Array> | Uint8Array;
|
|
38
|
+
contentLength: number;
|
|
39
|
+
contentType: string;
|
|
40
|
+
}
|
|
41
|
+
export interface R2ObjectStore {
|
|
42
|
+
putObject(request: R2PutRequest): Promise<void>;
|
|
43
|
+
/** Resolves whether or not the key existed. */
|
|
44
|
+
deleteObject(key: string): Promise<void>;
|
|
45
|
+
/** Null when the key does not exist. */
|
|
46
|
+
headObject(key: string): Promise<{
|
|
47
|
+
size: number;
|
|
48
|
+
contentType: string | null;
|
|
49
|
+
} | null>;
|
|
50
|
+
/** One page of keys under `prefix`, and the token for the next page. */
|
|
51
|
+
listKeys(prefix: string, continuationToken?: string): Promise<{
|
|
52
|
+
keys: string[];
|
|
53
|
+
next: string | null;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
/** Whether `credentials` could address a bucket at all. */
|
|
57
|
+
export declare function r2CredentialsUsable(credentials: Partial<R2Credentials>): boolean;
|
|
58
|
+
export declare function createR2ObjectStore(credentials: R2Credentials, options?: {
|
|
59
|
+
fetch?: typeof fetch;
|
|
60
|
+
now?: () => Date;
|
|
61
|
+
}): R2ObjectStore;
|
|
62
|
+
/**
|
|
63
|
+
* Deletes every key under `prefix`, page by page, and answers how many it
|
|
64
|
+
* deleted. The prefix is required to end in `/` and hold at least two
|
|
65
|
+
* segments, so no caller can empty the bucket by passing `''`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function deleteR2Prefix(store: R2ObjectStore, prefix: string): Promise<number>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { EMPTY_PAYLOAD_SHA256, encodeRfc3986, signSigV4, UNSIGNED_PAYLOAD } from "./sigv4.js";
|
|
18
|
+
/** A Cloudflare account id: 32 hex characters. It becomes part of a hostname. */ const ACCOUNT_ID = /^[a-f0-9]{32}$/i;
|
|
19
|
+
/** An R2 bucket name. */ const BUCKET = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
|
|
20
|
+
/** Whether `credentials` could address a bucket at all. */ export function r2CredentialsUsable(credentials) {
|
|
21
|
+
var _credentials_accountId, _credentials_bucket;
|
|
22
|
+
return ACCOUNT_ID.test(String((_credentials_accountId = credentials.accountId) != null ? _credentials_accountId : '')) && BUCKET.test(String((_credentials_bucket = credentials.bucket) != null ? _credentials_bucket : '')) && Boolean(credentials.accessKeyId) && Boolean(credentials.secretAccessKey);
|
|
23
|
+
}
|
|
24
|
+
/** An object key as a path: each segment encoded, the separators kept. */ function encodeKeyPath(key) {
|
|
25
|
+
return key.split('/').map(encodeRfc3986).join('/');
|
|
26
|
+
}
|
|
27
|
+
/** The text between `<Tag>` and `</Tag>` for every occurrence, XML-unescaped. */ function xmlValues(xml, tag) {
|
|
28
|
+
const values = [];
|
|
29
|
+
const pattern = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g');
|
|
30
|
+
for (const match of xml.matchAll(pattern)){
|
|
31
|
+
var _match_;
|
|
32
|
+
values.push(((_match_ = match[1]) != null ? _match_ : '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&'));
|
|
33
|
+
}
|
|
34
|
+
return values;
|
|
35
|
+
}
|
|
36
|
+
export function createR2ObjectStore(credentials, options = {}) {
|
|
37
|
+
var _options_fetch, _options_now;
|
|
38
|
+
if (!r2CredentialsUsable(credentials)) {
|
|
39
|
+
throw new Error('The R2 account id, bucket or access key is missing or malformed');
|
|
40
|
+
}
|
|
41
|
+
const send = (_options_fetch = options.fetch) != null ? _options_fetch : fetch;
|
|
42
|
+
const now = (_options_now = options.now) != null ? _options_now : ()=>new Date();
|
|
43
|
+
const origin = `https://${credentials.accountId.toLowerCase()}.r2.cloudflarestorage.com`;
|
|
44
|
+
const request = async (method, path, query, init = {})=>{
|
|
45
|
+
var _init_payloadHash, _init_headers, _init_headers1;
|
|
46
|
+
const search = query.map(([name, value])=>`${encodeRfc3986(name)}=${encodeRfc3986(value)}`).join('&');
|
|
47
|
+
const url = new URL(`${origin}/${credentials.bucket}${path}${search ? `?${search}` : ''}`);
|
|
48
|
+
const payloadHash = (_init_payloadHash = init.payloadHash) != null ? _init_payloadHash : EMPTY_PAYLOAD_SHA256;
|
|
49
|
+
const authorization = await signSigV4({
|
|
50
|
+
method,
|
|
51
|
+
url,
|
|
52
|
+
headers: (_init_headers = init.headers) != null ? _init_headers : {},
|
|
53
|
+
payloadHash,
|
|
54
|
+
region: 'auto',
|
|
55
|
+
service: 's3',
|
|
56
|
+
now: now()
|
|
57
|
+
}, credentials);
|
|
58
|
+
return send(url.toString(), _extends({
|
|
59
|
+
method,
|
|
60
|
+
headers: _extends({}, (_init_headers1 = init.headers) != null ? _init_headers1 : {}, authorization)
|
|
61
|
+
}, init.body !== undefined ? _extends({
|
|
62
|
+
body: init.body
|
|
63
|
+
}, init.body instanceof Uint8Array ? {} : {
|
|
64
|
+
duplex: 'half'
|
|
65
|
+
}) : {}));
|
|
66
|
+
};
|
|
67
|
+
const fail = async (what, response)=>{
|
|
68
|
+
const detail = await response.text().catch(()=>'');
|
|
69
|
+
const code = xmlValues(detail, 'Code')[0];
|
|
70
|
+
throw new Error(`R2 ${what} failed: ${response.status}${code ? ` ${code}` : ''}`);
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
async putObject ({ key, body, contentLength, contentType }) {
|
|
74
|
+
var _response_body;
|
|
75
|
+
if (!key) throw new Error('An R2 put needs a key');
|
|
76
|
+
if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
|
|
77
|
+
throw new Error('An R2 put needs an exact content length');
|
|
78
|
+
}
|
|
79
|
+
const response = await request('PUT', `/${encodeKeyPath(key)}`, [], {
|
|
80
|
+
headers: {
|
|
81
|
+
'content-length': String(contentLength),
|
|
82
|
+
'content-type': contentType
|
|
83
|
+
},
|
|
84
|
+
body,
|
|
85
|
+
payloadHash: UNSIGNED_PAYLOAD
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) await fail('put', response);
|
|
88
|
+
await ((_response_body = response.body) == null ? void 0 : _response_body.cancel().catch(()=>undefined));
|
|
89
|
+
},
|
|
90
|
+
async deleteObject (key) {
|
|
91
|
+
var _response_body;
|
|
92
|
+
if (!key) throw new Error('An R2 delete needs a key');
|
|
93
|
+
const response = await request('DELETE', `/${encodeKeyPath(key)}`, []);
|
|
94
|
+
// S3 answers 204 for a delete whether or not the key existed.
|
|
95
|
+
if (!response.ok && response.status !== 404) await fail('delete', response);
|
|
96
|
+
await ((_response_body = response.body) == null ? void 0 : _response_body.cancel().catch(()=>undefined));
|
|
97
|
+
},
|
|
98
|
+
async headObject (key) {
|
|
99
|
+
var _response_headers_get;
|
|
100
|
+
if (!key) throw new Error('An R2 head needs a key');
|
|
101
|
+
const response = await request('HEAD', `/${encodeKeyPath(key)}`, []);
|
|
102
|
+
if (response.status === 404) return null;
|
|
103
|
+
if (!response.ok) await fail('head', response);
|
|
104
|
+
return {
|
|
105
|
+
size: Number((_response_headers_get = response.headers.get('content-length')) != null ? _response_headers_get : 0),
|
|
106
|
+
contentType: response.headers.get('content-type')
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
async listKeys (prefix, continuationToken) {
|
|
110
|
+
var _xmlValues_;
|
|
111
|
+
const query = [
|
|
112
|
+
[
|
|
113
|
+
'list-type',
|
|
114
|
+
'2'
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
'prefix',
|
|
118
|
+
prefix
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
'max-keys',
|
|
122
|
+
'1000'
|
|
123
|
+
]
|
|
124
|
+
];
|
|
125
|
+
if (continuationToken) query.push([
|
|
126
|
+
'continuation-token',
|
|
127
|
+
continuationToken
|
|
128
|
+
]);
|
|
129
|
+
const response = await request('GET', '', query);
|
|
130
|
+
if (!response.ok) await fail('list', response);
|
|
131
|
+
const xml = await response.text();
|
|
132
|
+
const truncated = xmlValues(xml, 'IsTruncated')[0] === 'true';
|
|
133
|
+
const next = (_xmlValues_ = xmlValues(xml, 'NextContinuationToken')[0]) != null ? _xmlValues_ : null;
|
|
134
|
+
return {
|
|
135
|
+
keys: xmlValues(xml, 'Key'),
|
|
136
|
+
next: truncated && next ? next : null
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Deletes every key under `prefix`, page by page, and answers how many it
|
|
143
|
+
* deleted. The prefix is required to end in `/` and hold at least two
|
|
144
|
+
* segments, so no caller can empty the bucket by passing `''`.
|
|
145
|
+
*/ export async function deleteR2Prefix(store, prefix) {
|
|
146
|
+
if (!/^[^/]+\/[^/]+\/(?:.+\/)?$/.test(prefix)) {
|
|
147
|
+
throw new Error(`Refusing to delete under the prefix "${prefix}"`);
|
|
148
|
+
}
|
|
149
|
+
let deleted = 0;
|
|
150
|
+
let token;
|
|
151
|
+
// The listing is read to its end before anything is deleted from a page,
|
|
152
|
+
// so a deletion never shifts a page still to be read.
|
|
153
|
+
const keys = [];
|
|
154
|
+
do {
|
|
155
|
+
var _page_next;
|
|
156
|
+
const page = await store.listKeys(prefix, token);
|
|
157
|
+
keys.push(...page.keys.filter((key)=>key.startsWith(prefix)));
|
|
158
|
+
token = (_page_next = page.next) != null ? _page_next : undefined;
|
|
159
|
+
}while (token)
|
|
160
|
+
for (const key of keys){
|
|
161
|
+
await store.deleteObject(key);
|
|
162
|
+
deleted += 1;
|
|
163
|
+
}
|
|
164
|
+
return deleted;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
//# sourceMappingURL=r2-object-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/video-delivery/src/lib/r2-object-store.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n EMPTY_PAYLOAD_SHA256,\n encodeRfc3986,\n type SigV4Credentials,\n signSigV4,\n UNSIGNED_PAYLOAD,\n} from './sigv4'\n\n/**\n * The four S3 calls the copy flow makes against a Cloudflare R2 bucket, over\n * `fetch` with SigV4 (AGL-2824).\n *\n * R2's S3 endpoint is per account, `{accountId}.r2.cloudflarestorage.com`,\n * and its region is `auto`. Requests are path-style: the bucket is the first\n * path segment. A put streams its body with `UNSIGNED-PAYLOAD` and an exact\n * `content-length`, so a 200 MB film is never held in memory; every other\n * call has no body.\n *\n * `fetch` is injectable, which is how the specs exercise every call with no\n * network at all.\n */\n\nexport interface R2Credentials extends SigV4Credentials {\n accountId: string\n bucket: string\n}\n\nexport interface R2PutRequest {\n key: string\n body: ReadableStream<Uint8Array> | Uint8Array\n contentLength: number\n contentType: string\n}\n\nexport interface R2ObjectStore {\n putObject(request: R2PutRequest): Promise<void>\n /** Resolves whether or not the key existed. */\n deleteObject(key: string): Promise<void>\n /** Null when the key does not exist. */\n headObject(key: string): Promise<{ size: number; contentType: string | null } | null>\n /** One page of keys under `prefix`, and the token for the next page. */\n listKeys(\n prefix: string,\n continuationToken?: string,\n ): Promise<{ keys: string[]; next: string | null }>\n}\n\n/** A Cloudflare account id: 32 hex characters. It becomes part of a hostname. */\nconst ACCOUNT_ID = /^[a-f0-9]{32}$/i\n/** An R2 bucket name. */\nconst BUCKET = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/\n\n/** Whether `credentials` could address a bucket at all. */\nexport function r2CredentialsUsable(credentials: Partial<R2Credentials>): boolean {\n return (\n ACCOUNT_ID.test(String(credentials.accountId ?? '')) &&\n BUCKET.test(String(credentials.bucket ?? '')) &&\n Boolean(credentials.accessKeyId) &&\n Boolean(credentials.secretAccessKey)\n )\n}\n\n/** An object key as a path: each segment encoded, the separators kept. */\nfunction encodeKeyPath(key: string): string {\n return key.split('/').map(encodeRfc3986).join('/')\n}\n\n/** The text between `<Tag>` and `</Tag>` for every occurrence, XML-unescaped. */\nfunction xmlValues(xml: string, tag: string): string[] {\n const values: string[] = []\n const pattern = new RegExp(`<${tag}>([\\\\s\\\\S]*?)</${tag}>`, 'g')\n for (const match of xml.matchAll(pattern)) {\n values.push(\n (match[1] ?? '')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/&/g, '&'),\n )\n }\n return values\n}\n\nexport function createR2ObjectStore(\n credentials: R2Credentials,\n options: { fetch?: typeof fetch; now?: () => Date } = {},\n): R2ObjectStore {\n if (!r2CredentialsUsable(credentials)) {\n throw new Error('The R2 account id, bucket or access key is missing or malformed')\n }\n const send = options.fetch ?? fetch\n const now = options.now ?? (() => new Date())\n const origin = `https://${credentials.accountId.toLowerCase()}.r2.cloudflarestorage.com`\n\n const request = async (\n method: string,\n path: string,\n query: Array<[string, string]>,\n init: {\n headers?: Record<string, string>\n body?: ReadableStream<Uint8Array> | Uint8Array\n payloadHash?: string\n } = {},\n ): Promise<Response> => {\n const search = query\n .map(([name, value]) => `${encodeRfc3986(name)}=${encodeRfc3986(value)}`)\n .join('&')\n const url = new URL(`${origin}/${credentials.bucket}${path}${search ? `?${search}` : ''}`)\n const payloadHash = init.payloadHash ?? EMPTY_PAYLOAD_SHA256\n const authorization = await signSigV4(\n {\n method,\n url,\n headers: init.headers ?? {},\n payloadHash,\n region: 'auto',\n service: 's3',\n now: now(),\n },\n credentials,\n )\n return send(url.toString(), {\n method,\n headers: { ...(init.headers ?? {}), ...authorization },\n ...(init.body !== undefined\n ? {\n body: init.body,\n // Node's fetch sends a stream body only in half-duplex mode.\n ...(init.body instanceof Uint8Array ? {} : { duplex: 'half' }),\n }\n : {}),\n } as RequestInit)\n }\n\n const fail = async (what: string, response: Response): Promise<never> => {\n const detail = await response.text().catch(() => '')\n const code = xmlValues(detail, 'Code')[0]\n throw new Error(`R2 ${what} failed: ${response.status}${code ? ` ${code}` : ''}`)\n }\n\n return {\n async putObject({ key, body, contentLength, contentType }) {\n if (!key) throw new Error('An R2 put needs a key')\n if (!Number.isSafeInteger(contentLength) || contentLength < 0) {\n throw new Error('An R2 put needs an exact content length')\n }\n const response = await request('PUT', `/${encodeKeyPath(key)}`, [], {\n headers: {\n 'content-length': String(contentLength),\n 'content-type': contentType,\n },\n body,\n payloadHash: UNSIGNED_PAYLOAD,\n })\n if (!response.ok) await fail('put', response)\n await response.body?.cancel().catch(() => undefined)\n },\n\n async deleteObject(key) {\n if (!key) throw new Error('An R2 delete needs a key')\n const response = await request('DELETE', `/${encodeKeyPath(key)}`, [])\n // S3 answers 204 for a delete whether or not the key existed.\n if (!response.ok && response.status !== 404) await fail('delete', response)\n await response.body?.cancel().catch(() => undefined)\n },\n\n async headObject(key) {\n if (!key) throw new Error('An R2 head needs a key')\n const response = await request('HEAD', `/${encodeKeyPath(key)}`, [])\n if (response.status === 404) return null\n if (!response.ok) await fail('head', response)\n return {\n size: Number(response.headers.get('content-length') ?? 0),\n contentType: response.headers.get('content-type'),\n }\n },\n\n async listKeys(prefix, continuationToken) {\n const query: Array<[string, string]> = [\n ['list-type', '2'],\n ['prefix', prefix],\n ['max-keys', '1000'],\n ]\n if (continuationToken) query.push(['continuation-token', continuationToken])\n const response = await request('GET', '', query)\n if (!response.ok) await fail('list', response)\n const xml = await response.text()\n const truncated = xmlValues(xml, 'IsTruncated')[0] === 'true'\n const next = xmlValues(xml, 'NextContinuationToken')[0] ?? null\n return { keys: xmlValues(xml, 'Key'), next: truncated && next ? next : null }\n },\n }\n}\n\n/**\n * Deletes every key under `prefix`, page by page, and answers how many it\n * deleted. The prefix is required to end in `/` and hold at least two\n * segments, so no caller can empty the bucket by passing `''`.\n */\nexport async function deleteR2Prefix(\n store: R2ObjectStore,\n prefix: string,\n): Promise<number> {\n if (!/^[^/]+\\/[^/]+\\/(?:.+\\/)?$/.test(prefix)) {\n throw new Error(`Refusing to delete under the prefix \"${prefix}\"`)\n }\n let deleted = 0\n let token: string | undefined\n // The listing is read to its end before anything is deleted from a page,\n // so a deletion never shifts a page still to be read.\n const keys: string[] = []\n do {\n const page = await store.listKeys(prefix, token)\n keys.push(...page.keys.filter((key) => key.startsWith(prefix)))\n token = page.next ?? undefined\n } while (token)\n for (const key of keys) {\n await store.deleteObject(key)\n deleted += 1\n }\n return deleted\n}\n"],"names":["EMPTY_PAYLOAD_SHA256","encodeRfc3986","signSigV4","UNSIGNED_PAYLOAD","ACCOUNT_ID","BUCKET","r2CredentialsUsable","credentials","test","String","accountId","bucket","Boolean","accessKeyId","secretAccessKey","encodeKeyPath","key","split","map","join","xmlValues","xml","tag","values","pattern","RegExp","match","matchAll","push","replace","createR2ObjectStore","options","Error","send","fetch","now","Date","origin","toLowerCase","request","method","path","query","init","search","name","value","url","URL","payloadHash","authorization","headers","region","service","toString","body","undefined","Uint8Array","duplex","fail","what","response","detail","text","catch","code","status","putObject","contentLength","contentType","Number","isSafeInteger","ok","cancel","deleteObject","headObject","size","get","listKeys","prefix","continuationToken","truncated","next","keys","deleteR2Prefix","store","deleted","token","page","filter","startsWith"],"mappings":"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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* AWS Signature Version 4, header-signed, on Web Crypto.
|
|
19
|
+
*
|
|
20
|
+
* R2 speaks the S3 API, and the copy flow needs four of its calls: put,
|
|
21
|
+
* delete, head and list. An S3 SDK would bring a large dependency tree for
|
|
22
|
+
* those four, so this signs them directly, following the published algorithm
|
|
23
|
+
* (the canonical request, the string to sign, the derived signing key). The
|
|
24
|
+
* spec checks it against the worked examples in the S3 documentation.
|
|
25
|
+
*
|
|
26
|
+
* The caller builds the URL with its path already encoded; S3 signs the path
|
|
27
|
+
* as sent and does not encode it a second time. Query parameters are encoded
|
|
28
|
+
* here, strictly, in the order the algorithm requires.
|
|
29
|
+
*/
|
|
30
|
+
/** The SHA-256 of an empty body, which every bodiless request signs. */
|
|
31
|
+
export declare const EMPTY_PAYLOAD_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
32
|
+
/** Signs a streamed body without hashing it first; the transport is TLS. */
|
|
33
|
+
export declare const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
|
|
34
|
+
export interface SigV4Credentials {
|
|
35
|
+
accessKeyId: string;
|
|
36
|
+
secretAccessKey: string;
|
|
37
|
+
}
|
|
38
|
+
export interface SigV4Request {
|
|
39
|
+
method: string;
|
|
40
|
+
url: URL;
|
|
41
|
+
/** Headers to sign besides `host`, `x-amz-date` and `x-amz-content-sha256`. */
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
/** Hex SHA-256 of the body, or {@link UNSIGNED_PAYLOAD}. */
|
|
44
|
+
payloadHash: string;
|
|
45
|
+
region: string;
|
|
46
|
+
service: string;
|
|
47
|
+
now: Date;
|
|
48
|
+
}
|
|
49
|
+
/** RFC 3986 encoding: `encodeURIComponent` plus the four it leaves alone. */
|
|
50
|
+
export declare function encodeRfc3986(value: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* The headers that authorize `request`: `authorization`, `x-amz-date` and
|
|
53
|
+
* `x-amz-content-sha256`. Send them with the headers named in
|
|
54
|
+
* `request.headers`, exactly as signed. `host` is signed from the URL, which
|
|
55
|
+
* is what `fetch` sends.
|
|
56
|
+
*/
|
|
57
|
+
export declare function signSigV4(request: SigV4Request, credentials: SigV4Credentials): Promise<Record<string, string>>;
|