@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.
@@ -0,0 +1,104 @@
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
+ * AWS Signature Version 4, header-signed, on Web Crypto.
18
+ *
19
+ * R2 speaks the S3 API, and the copy flow needs four of its calls: put,
20
+ * delete, head and list. An S3 SDK would bring a large dependency tree for
21
+ * those four, so this signs them directly, following the published algorithm
22
+ * (the canonical request, the string to sign, the derived signing key). The
23
+ * spec checks it against the worked examples in the S3 documentation.
24
+ *
25
+ * The caller builds the URL with its path already encoded; S3 signs the path
26
+ * as sent and does not encode it a second time. Query parameters are encoded
27
+ * here, strictly, in the order the algorithm requires.
28
+ */ /** The SHA-256 of an empty body, which every bodiless request signs. */ export const EMPTY_PAYLOAD_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
29
+ /** Signs a streamed body without hashing it first; the transport is TLS. */ export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
30
+ const encoder = new TextEncoder();
31
+ function hex(bytes) {
32
+ return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (byte)=>byte.toString(16).padStart(2, '0')).join('');
33
+ }
34
+ async function sha256Hex(text) {
35
+ return hex(await crypto.subtle.digest('SHA-256', encoder.encode(text)));
36
+ }
37
+ async function hmac(key, text) {
38
+ const imported = await crypto.subtle.importKey('raw', typeof key === 'string' ? encoder.encode(key) : key, {
39
+ name: 'HMAC',
40
+ hash: 'SHA-256'
41
+ }, false, [
42
+ 'sign'
43
+ ]);
44
+ return new Uint8Array(await crypto.subtle.sign('HMAC', imported, encoder.encode(text)));
45
+ }
46
+ /** RFC 3986 encoding: `encodeURIComponent` plus the four it leaves alone. */ export function encodeRfc3986(value) {
47
+ return encodeURIComponent(value).replace(/[!'()*]/g, (character)=>`%${character.charCodeAt(0).toString(16).toUpperCase()}`);
48
+ }
49
+ /** `20130524T000000Z`. */ function amzDate(now) {
50
+ return now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
51
+ }
52
+ /**
53
+ * The headers that authorize `request`: `authorization`, `x-amz-date` and
54
+ * `x-amz-content-sha256`. Send them with the headers named in
55
+ * `request.headers`, exactly as signed. `host` is signed from the URL, which
56
+ * is what `fetch` sends.
57
+ */ export async function signSigV4(request, credentials) {
58
+ var _request_headers;
59
+ const date = amzDate(request.now);
60
+ const day = date.slice(0, 8);
61
+ const signed = {};
62
+ for (const [name, value] of Object.entries((_request_headers = request.headers) != null ? _request_headers : {})){
63
+ signed[name.toLowerCase()] = String(value).trim().replace(/\s+/g, ' ');
64
+ }
65
+ signed['host'] = request.url.host;
66
+ signed['x-amz-date'] = date;
67
+ signed['x-amz-content-sha256'] = request.payloadHash;
68
+ const names = Object.keys(signed).sort();
69
+ const canonicalHeaders = names.map((name)=>`${name}:${signed[name]}\n`).join('');
70
+ const signedHeaders = names.join(';');
71
+ const query = [
72
+ ...request.url.searchParams.entries()
73
+ ].map(([name, value])=>[
74
+ encodeRfc3986(name),
75
+ encodeRfc3986(value)
76
+ ]).sort(([a, x], [b, y])=>a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0).map(([name, value])=>`${name}=${value}`).join('&');
77
+ const canonicalRequest = [
78
+ request.method.toUpperCase(),
79
+ request.url.pathname || '/',
80
+ query,
81
+ canonicalHeaders,
82
+ signedHeaders,
83
+ request.payloadHash
84
+ ].join('\n');
85
+ const scope = `${day}/${request.region}/${request.service}/aws4_request`;
86
+ const stringToSign = [
87
+ 'AWS4-HMAC-SHA256',
88
+ date,
89
+ scope,
90
+ await sha256Hex(canonicalRequest)
91
+ ].join('\n');
92
+ let key = await hmac(`AWS4${credentials.secretAccessKey}`, day);
93
+ key = await hmac(key, request.region);
94
+ key = await hmac(key, request.service);
95
+ key = await hmac(key, 'aws4_request');
96
+ const signature = hex(await hmac(key, stringToSign));
97
+ return {
98
+ authorization: `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}`,
99
+ 'x-amz-date': date,
100
+ 'x-amz-content-sha256': request.payloadHash
101
+ };
102
+ }
103
+
104
+ //# sourceMappingURL=sigv4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/video-delivery/src/lib/sigv4.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 * AWS Signature Version 4, header-signed, on Web Crypto.\n *\n * R2 speaks the S3 API, and the copy flow needs four of its calls: put,\n * delete, head and list. An S3 SDK would bring a large dependency tree for\n * those four, so this signs them directly, following the published algorithm\n * (the canonical request, the string to sign, the derived signing key). The\n * spec checks it against the worked examples in the S3 documentation.\n *\n * The caller builds the URL with its path already encoded; S3 signs the path\n * as sent and does not encode it a second time. Query parameters are encoded\n * here, strictly, in the order the algorithm requires.\n */\n\n/** The SHA-256 of an empty body, which every bodiless request signs. */\nexport const EMPTY_PAYLOAD_SHA256 =\n 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'\n\n/** Signs a streamed body without hashing it first; the transport is TLS. */\nexport const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'\n\nexport interface SigV4Credentials {\n accessKeyId: string\n secretAccessKey: string\n}\n\nexport interface SigV4Request {\n method: string\n url: URL\n /** Headers to sign besides `host`, `x-amz-date` and `x-amz-content-sha256`. */\n headers?: Record<string, string>\n /** Hex SHA-256 of the body, or {@link UNSIGNED_PAYLOAD}. */\n payloadHash: string\n region: string\n service: string\n now: Date\n}\n\nconst encoder = new TextEncoder()\n\nfunction hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (byte) =>\n byte.toString(16).padStart(2, '0'),\n ).join('')\n}\n\nasync function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest('SHA-256', encoder.encode(text)))\n}\n\nasync function hmac(\n key: Uint8Array<ArrayBuffer> | string,\n text: string,\n): Promise<Uint8Array<ArrayBuffer>> {\n const imported = await crypto.subtle.importKey(\n 'raw',\n typeof key === 'string' ? encoder.encode(key) : key,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n return new Uint8Array(await crypto.subtle.sign('HMAC', imported, encoder.encode(text)))\n}\n\n/** RFC 3986 encoding: `encodeURIComponent` plus the four it leaves alone. */\nexport function encodeRfc3986(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,\n )\n}\n\n/** `20130524T000000Z`. */\nfunction amzDate(now: Date): string {\n return now.toISOString().replace(/[-:]/g, '').replace(/\\.\\d{3}/, '')\n}\n\n/**\n * The headers that authorize `request`: `authorization`, `x-amz-date` and\n * `x-amz-content-sha256`. Send them with the headers named in\n * `request.headers`, exactly as signed. `host` is signed from the URL, which\n * is what `fetch` sends.\n */\nexport async function signSigV4(\n request: SigV4Request,\n credentials: SigV4Credentials,\n): Promise<Record<string, string>> {\n const date = amzDate(request.now)\n const day = date.slice(0, 8)\n const signed: Record<string, string> = {}\n for (const [name, value] of Object.entries(request.headers ?? {})) {\n signed[name.toLowerCase()] = String(value).trim().replace(/\\s+/g, ' ')\n }\n signed['host'] = request.url.host\n signed['x-amz-date'] = date\n signed['x-amz-content-sha256'] = request.payloadHash\n const names = Object.keys(signed).sort()\n const canonicalHeaders = names.map((name) => `${name}:${signed[name]}\\n`).join('')\n const signedHeaders = names.join(';')\n const query = [...request.url.searchParams.entries()]\n .map(([name, value]) => [encodeRfc3986(name), encodeRfc3986(value)] as const)\n .sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0))\n .map(([name, value]) => `${name}=${value}`)\n .join('&')\n const canonicalRequest = [\n request.method.toUpperCase(),\n request.url.pathname || '/',\n query,\n canonicalHeaders,\n signedHeaders,\n request.payloadHash,\n ].join('\\n')\n const scope = `${day}/${request.region}/${request.service}/aws4_request`\n const stringToSign = [\n 'AWS4-HMAC-SHA256',\n date,\n scope,\n await sha256Hex(canonicalRequest),\n ].join('\\n')\n let key = await hmac(`AWS4${credentials.secretAccessKey}`, day)\n key = await hmac(key, request.region)\n key = await hmac(key, request.service)\n key = await hmac(key, 'aws4_request')\n const signature = hex(await hmac(key, stringToSign))\n return {\n authorization:\n `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, ` +\n `SignedHeaders=${signedHeaders}, Signature=${signature}`,\n 'x-amz-date': date,\n 'x-amz-content-sha256': request.payloadHash,\n }\n}\n"],"names":["EMPTY_PAYLOAD_SHA256","UNSIGNED_PAYLOAD","encoder","TextEncoder","hex","bytes","Array","from","Uint8Array","byte","toString","padStart","join","sha256Hex","text","crypto","subtle","digest","encode","hmac","key","imported","importKey","name","hash","sign","encodeRfc3986","value","encodeURIComponent","replace","character","charCodeAt","toUpperCase","amzDate","now","toISOString","signSigV4","request","credentials","date","day","slice","signed","Object","entries","headers","toLowerCase","String","trim","url","host","payloadHash","names","keys","sort","canonicalHeaders","map","signedHeaders","query","searchParams","a","x","b","y","canonicalRequest","method","pathname","scope","region","service","stringToSign","secretAccessKey","signature","authorization","accessKeyId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,sEAAsE,GACtE,OAAO,MAAMA,uBACX,mEAAkE;AAEpE,0EAA0E,GAC1E,OAAO,MAAMC,mBAAmB,mBAAkB;AAmBlD,MAAMC,UAAU,IAAIC;AAEpB,SAASC,IAAIC,KAA+B;IAC1C,OAAOC,MAAMC,IAAI,CAACF,iBAAiBG,aAAaH,QAAQ,IAAIG,WAAWH,QAAQ,CAACI,OAC9EA,KAAKC,QAAQ,CAAC,IAAIC,QAAQ,CAAC,GAAG,MAC9BC,IAAI,CAAC;AACT;AAEA,eAAeC,UAAUC,IAAY;IACnC,OAAOV,IAAI,MAAMW,OAAOC,MAAM,CAACC,MAAM,CAAC,WAAWf,QAAQgB,MAAM,CAACJ;AAClE;AAEA,eAAeK,KACbC,GAAqC,EACrCN,IAAY;IAEZ,MAAMO,WAAW,MAAMN,OAAOC,MAAM,CAACM,SAAS,CAC5C,OACA,OAAOF,QAAQ,WAAWlB,QAAQgB,MAAM,CAACE,OAAOA,KAChD;QAAEG,MAAM;QAAQC,MAAM;IAAU,GAChC,OACA;QAAC;KAAO;IAEV,OAAO,IAAIhB,WAAW,MAAMO,OAAOC,MAAM,CAACS,IAAI,CAAC,QAAQJ,UAAUnB,QAAQgB,MAAM,CAACJ;AAClF;AAEA,2EAA2E,GAC3E,OAAO,SAASY,cAAcC,KAAa;IACzC,OAAOC,mBAAmBD,OAAOE,OAAO,CACtC,YACA,CAACC,YAAc,CAAC,CAAC,EAAEA,UAAUC,UAAU,CAAC,GAAGrB,QAAQ,CAAC,IAAIsB,WAAW,IAAI;AAE3E;AAEA,wBAAwB,GACxB,SAASC,QAAQC,GAAS;IACxB,OAAOA,IAAIC,WAAW,GAAGN,OAAO,CAAC,SAAS,IAAIA,OAAO,CAAC,WAAW;AACnE;AAEA;;;;;CAKC,GACD,OAAO,eAAeO,UACpBC,OAAqB,EACrBC,WAA6B;QAKcD;IAH3C,MAAME,OAAON,QAAQI,QAAQH,GAAG;IAChC,MAAMM,MAAMD,KAAKE,KAAK,CAAC,GAAG;IAC1B,MAAMC,SAAiC,CAAC;IACxC,KAAK,MAAM,CAACnB,MAAMI,MAAM,IAAIgB,OAAOC,OAAO,EAACP,mBAAAA,QAAQQ,OAAO,YAAfR,mBAAmB,CAAC,GAAI;QACjEK,MAAM,CAACnB,KAAKuB,WAAW,GAAG,GAAGC,OAAOpB,OAAOqB,IAAI,GAAGnB,OAAO,CAAC,QAAQ;IACpE;IACAa,MAAM,CAAC,OAAO,GAAGL,QAAQY,GAAG,CAACC,IAAI;IACjCR,MAAM,CAAC,aAAa,GAAGH;IACvBG,MAAM,CAAC,uBAAuB,GAAGL,QAAQc,WAAW;IACpD,MAAMC,QAAQT,OAAOU,IAAI,CAACX,QAAQY,IAAI;IACtC,MAAMC,mBAAmBH,MAAMI,GAAG,CAAC,CAACjC,OAAS,GAAGA,KAAK,CAAC,EAAEmB,MAAM,CAACnB,KAAK,CAAC,EAAE,CAAC,EAAEX,IAAI,CAAC;IAC/E,MAAM6C,gBAAgBL,MAAMxC,IAAI,CAAC;IACjC,MAAM8C,QAAQ;WAAIrB,QAAQY,GAAG,CAACU,YAAY,CAACf,OAAO;KAAG,CAClDY,GAAG,CAAC,CAAC,CAACjC,MAAMI,MAAM,GAAK;YAACD,cAAcH;YAAOG,cAAcC;SAAO,EAClE2B,IAAI,CAAC,CAAC,CAACM,GAAGC,EAAE,EAAE,CAACC,GAAGC,EAAE,GAAMH,IAAIE,IAAI,CAAC,IAAIF,IAAIE,IAAI,IAAID,IAAIE,IAAI,CAAC,IAAIF,IAAIE,IAAI,IAAI,GAC5EP,GAAG,CAAC,CAAC,CAACjC,MAAMI,MAAM,GAAK,GAAGJ,KAAK,CAAC,EAAEI,OAAO,EACzCf,IAAI,CAAC;IACR,MAAMoD,mBAAmB;QACvB3B,QAAQ4B,MAAM,CAACjC,WAAW;QAC1BK,QAAQY,GAAG,CAACiB,QAAQ,IAAI;QACxBR;QACAH;QACAE;QACApB,QAAQc,WAAW;KACpB,CAACvC,IAAI,CAAC;IACP,MAAMuD,QAAQ,GAAG3B,IAAI,CAAC,EAAEH,QAAQ+B,MAAM,CAAC,CAAC,EAAE/B,QAAQgC,OAAO,CAAC,aAAa,CAAC;IACxE,MAAMC,eAAe;QACnB;QACA/B;QACA4B;QACA,MAAMtD,UAAUmD;KACjB,CAACpD,IAAI,CAAC;IACP,IAAIQ,MAAM,MAAMD,KAAK,CAAC,IAAI,EAAEmB,YAAYiC,eAAe,EAAE,EAAE/B;IAC3DpB,MAAM,MAAMD,KAAKC,KAAKiB,QAAQ+B,MAAM;IACpChD,MAAM,MAAMD,KAAKC,KAAKiB,QAAQgC,OAAO;IACrCjD,MAAM,MAAMD,KAAKC,KAAK;IACtB,MAAMoD,YAAYpE,IAAI,MAAMe,KAAKC,KAAKkD;IACtC,OAAO;QACLG,eACE,CAAC,4BAA4B,EAAEnC,YAAYoC,WAAW,CAAC,CAAC,EAAEP,MAAM,EAAE,CAAC,GACnE,CAAC,cAAc,EAAEV,cAAc,YAAY,EAAEe,WAAW;QAC1D,cAAcjC;QACd,wBAAwBF,QAAQc,WAAW;IAC7C;AACF"}
@@ -0,0 +1,59 @@
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 { MediaDeliveryCapability, MediaDeliveryProvider } from '@aglyn/aglyn/plugin-manager/media-delivery-provider';
18
+ /**
19
+ * Core's media delivery provider, on Cloudflare R2 and a Worker (AGL-2824).
20
+ *
21
+ * `store` is R2's S3 API with a bucket-scoped key pair; `deliver` is a token
22
+ * the Worker on the delivery host verifies before it serves the object from
23
+ * its bucket binding. The two capabilities read different settings, so a
24
+ * deployment can hold the R2 key pair only where copies are written — the
25
+ * console — and the delivery secret wherever a URL is minted.
26
+ *
27
+ * Settings are read from the environment on every call rather than once, so
28
+ * a deployment that gains or loses them behaves accordingly without a
29
+ * restart, and each read is a handful of property lookups.
30
+ */
31
+ export interface VideoDeliverySettings {
32
+ accountId: string;
33
+ accessKeyId: string;
34
+ secretAccessKey: string;
35
+ bucket: string;
36
+ /** A bare hostname, or an origin; see {@link videoDeliveryOrigin}. */
37
+ deliveryHost: string;
38
+ deliverySecret: string;
39
+ }
40
+ /** The provider's settings, from this process's environment. */
41
+ export declare function videoDeliverySettingsFromEnv(): VideoDeliverySettings;
42
+ /**
43
+ * The origin delivery URLs are minted on, or null for a setting that is not
44
+ * one.
45
+ *
46
+ * A bare hostname (`video.example.workers.dev`) means HTTPS. A full origin is
47
+ * accepted too, which is what a local `wrangler dev` needs: HTTPS anywhere,
48
+ * or HTTP on the loopback host only. Anything with a path, a query or
49
+ * credentials is refused rather than trimmed, because a URL minted on a
50
+ * guess is a URL that points somewhere nobody chose.
51
+ */
52
+ export declare function videoDeliveryOrigin(host: string): string | null;
53
+ /** Whether `settings` hold everything `capability` needs. */
54
+ export declare function videoDeliveryConfigured(settings: VideoDeliverySettings, capability: MediaDeliveryCapability): boolean;
55
+ export declare function createVideoDeliveryProvider(options?: {
56
+ settings?: () => VideoDeliverySettings;
57
+ fetch?: typeof fetch;
58
+ now?: () => number;
59
+ }): MediaDeliveryProvider;
@@ -0,0 +1,107 @@
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
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { DELIVERY_SECRET_MIN_LENGTH, DELIVERY_TOKEN_PARAM, mintDeliveryToken } from "./delivery-token.js";
18
+ import { createR2ObjectStore, deleteR2Prefix, r2CredentialsUsable } from "./r2-object-store.js";
19
+ import { encodeRfc3986 } from "./sigv4.js";
20
+ /** The provider's settings, from this process's environment. */ export function videoDeliverySettingsFromEnv() {
21
+ var _process_env_R2_ACCOUNT_ID, _process_env_R2_ACCESS_KEY_ID, _process_env_R2_SECRET_ACCESS_KEY, _process_env_R2_VIDEO_BUCKET, _process_env_MEDIA_VIDEO_DELIVERY_HOST, _process_env_MEDIA_VIDEO_DELIVERY_SECRET;
22
+ return {
23
+ accountId: ((_process_env_R2_ACCOUNT_ID = process.env['R2_ACCOUNT_ID']) != null ? _process_env_R2_ACCOUNT_ID : '').trim(),
24
+ accessKeyId: ((_process_env_R2_ACCESS_KEY_ID = process.env['R2_ACCESS_KEY_ID']) != null ? _process_env_R2_ACCESS_KEY_ID : '').trim(),
25
+ secretAccessKey: ((_process_env_R2_SECRET_ACCESS_KEY = process.env['R2_SECRET_ACCESS_KEY']) != null ? _process_env_R2_SECRET_ACCESS_KEY : '').trim(),
26
+ bucket: ((_process_env_R2_VIDEO_BUCKET = process.env['R2_VIDEO_BUCKET']) != null ? _process_env_R2_VIDEO_BUCKET : '').trim(),
27
+ deliveryHost: ((_process_env_MEDIA_VIDEO_DELIVERY_HOST = process.env['MEDIA_VIDEO_DELIVERY_HOST']) != null ? _process_env_MEDIA_VIDEO_DELIVERY_HOST : '').trim(),
28
+ deliverySecret: ((_process_env_MEDIA_VIDEO_DELIVERY_SECRET = process.env['MEDIA_VIDEO_DELIVERY_SECRET']) != null ? _process_env_MEDIA_VIDEO_DELIVERY_SECRET : '').trim()
29
+ };
30
+ }
31
+ /**
32
+ * The origin delivery URLs are minted on, or null for a setting that is not
33
+ * one.
34
+ *
35
+ * A bare hostname (`video.example.workers.dev`) means HTTPS. A full origin is
36
+ * accepted too, which is what a local `wrangler dev` needs: HTTPS anywhere,
37
+ * or HTTP on the loopback host only. Anything with a path, a query or
38
+ * credentials is refused rather than trimmed, because a URL minted on a
39
+ * guess is a URL that points somewhere nobody chose.
40
+ */ export function videoDeliveryOrigin(host) {
41
+ const value = host.trim();
42
+ if (!value) return null;
43
+ if (value.includes('://')) {
44
+ let url;
45
+ try {
46
+ url = new URL(value);
47
+ } catch (unused) {
48
+ return null;
49
+ }
50
+ if (url.username || url.password || url.search || url.hash) return null;
51
+ if (url.pathname !== '/' && url.pathname !== '') return null;
52
+ const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
53
+ if (url.protocol === 'https:' || url.protocol === 'http:' && loopback) {
54
+ return url.origin;
55
+ }
56
+ return null;
57
+ }
58
+ if (!/^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?(?::\d{1,5})?$/i.test(value)) return null;
59
+ return `https://${value.toLowerCase()}`;
60
+ }
61
+ /** Whether `settings` hold everything `capability` needs. */ export function videoDeliveryConfigured(settings, capability) {
62
+ if (capability === 'deliver') {
63
+ return videoDeliveryOrigin(settings.deliveryHost) !== null && settings.deliverySecret.length >= DELIVERY_SECRET_MIN_LENGTH;
64
+ }
65
+ return r2CredentialsUsable(settings);
66
+ }
67
+ export function createVideoDeliveryProvider(options = {}) {
68
+ var _options_settings, _options_now;
69
+ const settings = (_options_settings = options.settings) != null ? _options_settings : videoDeliverySettingsFromEnv;
70
+ const now = (_options_now = options.now) != null ? _options_now : Date.now;
71
+ /** The object store for the current settings; throws when unconfigured. */ const store = ()=>{
72
+ const current = settings();
73
+ if (!videoDeliveryConfigured(current, 'store')) {
74
+ throw new Error('Video delivery storage is not configured');
75
+ }
76
+ return createR2ObjectStore(current, _extends({}, options.fetch ? {
77
+ fetch: options.fetch
78
+ } : {}, {
79
+ now: ()=>new Date(now())
80
+ }));
81
+ };
82
+ return {
83
+ isConfigured: (capability)=>videoDeliveryConfigured(settings(), capability),
84
+ putObject: (request)=>store().putObject(request),
85
+ deleteObject: (key)=>store().deleteObject(key),
86
+ deleteObjectsWithPrefix: (prefix)=>deleteR2Prefix(store(), prefix),
87
+ async deliveryUrl ({ key, expiresAtMs, claims }) {
88
+ const current = settings();
89
+ const origin = videoDeliveryOrigin(current.deliveryHost);
90
+ if (!origin || !videoDeliveryConfigured(current, 'deliver')) {
91
+ throw new Error('Video delivery is not configured');
92
+ }
93
+ const token = await mintDeliveryToken({
94
+ key,
95
+ expiresAtMs,
96
+ orgId: claims.orgId,
97
+ hostId: claims.hostId,
98
+ mediaId: claims.mediaId,
99
+ scope: claims.scope
100
+ }, current.deliverySecret, now());
101
+ const path = key.split('/').map(encodeRfc3986).join('/');
102
+ return `${origin}/${path}?${DELIVERY_TOKEN_PARAM}=${token}`;
103
+ }
104
+ };
105
+ }
106
+
107
+ //# sourceMappingURL=video-delivery-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/video-delivery/src/lib/video-delivery-provider.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 type {\n MediaDeliveryCapability,\n MediaDeliveryProvider,\n} from '@aglyn/aglyn/plugin-manager/media-delivery-provider'\nimport {\n DELIVERY_SECRET_MIN_LENGTH,\n DELIVERY_TOKEN_PARAM,\n mintDeliveryToken,\n} from './delivery-token'\nimport {\n createR2ObjectStore,\n deleteR2Prefix,\n r2CredentialsUsable,\n type R2ObjectStore,\n} from './r2-object-store'\nimport { encodeRfc3986 } from './sigv4'\n\n/**\n * Core's media delivery provider, on Cloudflare R2 and a Worker (AGL-2824).\n *\n * `store` is R2's S3 API with a bucket-scoped key pair; `deliver` is a token\n * the Worker on the delivery host verifies before it serves the object from\n * its bucket binding. The two capabilities read different settings, so a\n * deployment can hold the R2 key pair only where copies are written — the\n * console — and the delivery secret wherever a URL is minted.\n *\n * Settings are read from the environment on every call rather than once, so\n * a deployment that gains or loses them behaves accordingly without a\n * restart, and each read is a handful of property lookups.\n */\n\nexport interface VideoDeliverySettings {\n accountId: string\n accessKeyId: string\n secretAccessKey: string\n bucket: string\n /** A bare hostname, or an origin; see {@link videoDeliveryOrigin}. */\n deliveryHost: string\n deliverySecret: string\n}\n\n/** The provider's settings, from this process's environment. */\nexport function videoDeliverySettingsFromEnv(): VideoDeliverySettings {\n return {\n accountId: (process.env['R2_ACCOUNT_ID'] ?? '').trim(),\n accessKeyId: (process.env['R2_ACCESS_KEY_ID'] ?? '').trim(),\n secretAccessKey: (process.env['R2_SECRET_ACCESS_KEY'] ?? '').trim(),\n bucket: (process.env['R2_VIDEO_BUCKET'] ?? '').trim(),\n deliveryHost: (process.env['MEDIA_VIDEO_DELIVERY_HOST'] ?? '').trim(),\n deliverySecret: (process.env['MEDIA_VIDEO_DELIVERY_SECRET'] ?? '').trim(),\n }\n}\n\n/**\n * The origin delivery URLs are minted on, or null for a setting that is not\n * one.\n *\n * A bare hostname (`video.example.workers.dev`) means HTTPS. A full origin is\n * accepted too, which is what a local `wrangler dev` needs: HTTPS anywhere,\n * or HTTP on the loopback host only. Anything with a path, a query or\n * credentials is refused rather than trimmed, because a URL minted on a\n * guess is a URL that points somewhere nobody chose.\n */\nexport function videoDeliveryOrigin(host: string): string | null {\n const value = host.trim()\n if (!value) return null\n if (value.includes('://')) {\n let url: URL\n try {\n url = new URL(value)\n } catch {\n return null\n }\n if (url.username || url.password || url.search || url.hash) return null\n if (url.pathname !== '/' && url.pathname !== '') return null\n const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1'\n if (url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) {\n return url.origin\n }\n return null\n }\n if (!/^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?(?::\\d{1,5})?$/i.test(value)) return null\n return `https://${value.toLowerCase()}`\n}\n\n/** Whether `settings` hold everything `capability` needs. */\nexport function videoDeliveryConfigured(\n settings: VideoDeliverySettings,\n capability: MediaDeliveryCapability,\n): boolean {\n if (capability === 'deliver') {\n return (\n videoDeliveryOrigin(settings.deliveryHost) !== null &&\n settings.deliverySecret.length >= DELIVERY_SECRET_MIN_LENGTH\n )\n }\n return r2CredentialsUsable(settings)\n}\n\nexport function createVideoDeliveryProvider(\n options: {\n settings?: () => VideoDeliverySettings\n fetch?: typeof fetch\n now?: () => number\n } = {},\n): MediaDeliveryProvider {\n const settings = options.settings ?? videoDeliverySettingsFromEnv\n const now = options.now ?? Date.now\n\n /** The object store for the current settings; throws when unconfigured. */\n const store = (): R2ObjectStore => {\n const current = settings()\n if (!videoDeliveryConfigured(current, 'store')) {\n throw new Error('Video delivery storage is not configured')\n }\n return createR2ObjectStore(current, {\n ...(options.fetch ? { fetch: options.fetch } : {}),\n now: () => new Date(now()),\n })\n }\n\n return {\n isConfigured: (capability) => videoDeliveryConfigured(settings(), capability),\n\n putObject: (request) => store().putObject(request),\n\n deleteObject: (key) => store().deleteObject(key),\n\n deleteObjectsWithPrefix: (prefix) => deleteR2Prefix(store(), prefix),\n\n async deliveryUrl({ key, expiresAtMs, claims }) {\n const current = settings()\n const origin = videoDeliveryOrigin(current.deliveryHost)\n if (!origin || !videoDeliveryConfigured(current, 'deliver')) {\n throw new Error('Video delivery is not configured')\n }\n const token = await mintDeliveryToken(\n {\n key,\n expiresAtMs,\n orgId: claims.orgId,\n hostId: claims.hostId,\n mediaId: claims.mediaId,\n scope: claims.scope,\n },\n current.deliverySecret,\n now(),\n )\n const path = key.split('/').map(encodeRfc3986).join('/')\n return `${origin}/${path}?${DELIVERY_TOKEN_PARAM}=${token}`\n },\n }\n}\n"],"names":["DELIVERY_SECRET_MIN_LENGTH","DELIVERY_TOKEN_PARAM","mintDeliveryToken","createR2ObjectStore","deleteR2Prefix","r2CredentialsUsable","encodeRfc3986","videoDeliverySettingsFromEnv","process","accountId","env","trim","accessKeyId","secretAccessKey","bucket","deliveryHost","deliverySecret","videoDeliveryOrigin","host","value","includes","url","URL","username","password","search","hash","pathname","loopback","hostname","protocol","origin","test","toLowerCase","videoDeliveryConfigured","settings","capability","length","createVideoDeliveryProvider","options","now","Date","store","current","Error","fetch","isConfigured","putObject","request","deleteObject","key","deleteObjectsWithPrefix","prefix","deliveryUrl","expiresAtMs","claims","token","orgId","hostId","mediaId","scope","path","split","map","join"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAMD,SACEA,0BAA0B,EAC1BC,oBAAoB,EACpBC,iBAAiB,QACZ,sBAAkB;AACzB,SACEC,mBAAmB,EACnBC,cAAc,EACdC,mBAAmB,QAEd,uBAAmB;AAC1B,SAASC,aAAa,QAAQ,aAAS;AA0BvC,8DAA8D,GAC9D,OAAO,SAASC;QAEAC,4BACEA,+BACIA,mCACTA,8BACMA,wCACEA;IANnB,OAAO;QACLC,WAAW,EAACD,6BAAAA,QAAQE,GAAG,CAAC,gBAAgB,YAA5BF,6BAAgC,IAAIG,IAAI;QACpDC,aAAa,EAACJ,gCAAAA,QAAQE,GAAG,CAAC,mBAAmB,YAA/BF,gCAAmC,IAAIG,IAAI;QACzDE,iBAAiB,EAACL,oCAAAA,QAAQE,GAAG,CAAC,uBAAuB,YAAnCF,oCAAuC,IAAIG,IAAI;QACjEG,QAAQ,EAACN,+BAAAA,QAAQE,GAAG,CAAC,kBAAkB,YAA9BF,+BAAkC,IAAIG,IAAI;QACnDI,cAAc,EAACP,yCAAAA,QAAQE,GAAG,CAAC,4BAA4B,YAAxCF,yCAA4C,IAAIG,IAAI;QACnEK,gBAAgB,EAACR,2CAAAA,QAAQE,GAAG,CAAC,8BAA8B,YAA1CF,2CAA8C,IAAIG,IAAI;IACzE;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASM,oBAAoBC,IAAY;IAC9C,MAAMC,QAAQD,KAAKP,IAAI;IACvB,IAAI,CAACQ,OAAO,OAAO;IACnB,IAAIA,MAAMC,QAAQ,CAAC,QAAQ;QACzB,IAAIC;QACJ,IAAI;YACFA,MAAM,IAAIC,IAAIH;QAChB,EAAE,eAAM;YACN,OAAO;QACT;QACA,IAAIE,IAAIE,QAAQ,IAAIF,IAAIG,QAAQ,IAAIH,IAAII,MAAM,IAAIJ,IAAIK,IAAI,EAAE,OAAO;QACnE,IAAIL,IAAIM,QAAQ,KAAK,OAAON,IAAIM,QAAQ,KAAK,IAAI,OAAO;QACxD,MAAMC,WAAWP,IAAIQ,QAAQ,KAAK,eAAeR,IAAIQ,QAAQ,KAAK;QAClE,IAAIR,IAAIS,QAAQ,KAAK,YAAaT,IAAIS,QAAQ,KAAK,WAAWF,UAAW;YACvE,OAAOP,IAAIU,MAAM;QACnB;QACA,OAAO;IACT;IACA,IAAI,CAAC,yDAAyDC,IAAI,CAACb,QAAQ,OAAO;IAClF,OAAO,CAAC,QAAQ,EAAEA,MAAMc,WAAW,IAAI;AACzC;AAEA,2DAA2D,GAC3D,OAAO,SAASC,wBACdC,QAA+B,EAC/BC,UAAmC;IAEnC,IAAIA,eAAe,WAAW;QAC5B,OACEnB,oBAAoBkB,SAASpB,YAAY,MAAM,QAC/CoB,SAASnB,cAAc,CAACqB,MAAM,IAAIrC;IAEtC;IACA,OAAOK,oBAAoB8B;AAC7B;AAEA,OAAO,SAASG,4BACdC,UAII,CAAC,CAAC;QAEWA,mBACLA;IADZ,MAAMJ,YAAWI,oBAAAA,QAAQJ,QAAQ,YAAhBI,oBAAoBhC;IACrC,MAAMiC,OAAMD,eAAAA,QAAQC,GAAG,YAAXD,eAAeE,KAAKD,GAAG;IAEnC,yEAAyE,GACzE,MAAME,QAAQ;QACZ,MAAMC,UAAUR;QAChB,IAAI,CAACD,wBAAwBS,SAAS,UAAU;YAC9C,MAAM,IAAIC,MAAM;QAClB;QACA,OAAOzC,oBAAoBwC,SAAS,aAC9BJ,QAAQM,KAAK,GAAG;YAAEA,OAAON,QAAQM,KAAK;QAAC,IAAI,CAAC;YAChDL,KAAK,IAAM,IAAIC,KAAKD;;IAExB;IAEA,OAAO;QACLM,cAAc,CAACV,aAAeF,wBAAwBC,YAAYC;QAElEW,WAAW,CAACC,UAAYN,QAAQK,SAAS,CAACC;QAE1CC,cAAc,CAACC,MAAQR,QAAQO,YAAY,CAACC;QAE5CC,yBAAyB,CAACC,SAAWhD,eAAesC,SAASU;QAE7D,MAAMC,aAAY,EAAEH,GAAG,EAAEI,WAAW,EAAEC,MAAM,EAAE;YAC5C,MAAMZ,UAAUR;YAChB,MAAMJ,SAASd,oBAAoB0B,QAAQ5B,YAAY;YACvD,IAAI,CAACgB,UAAU,CAACG,wBAAwBS,SAAS,YAAY;gBAC3D,MAAM,IAAIC,MAAM;YAClB;YACA,MAAMY,QAAQ,MAAMtD,kBAClB;gBACEgD;gBACAI;gBACAG,OAAOF,OAAOE,KAAK;gBACnBC,QAAQH,OAAOG,MAAM;gBACrBC,SAASJ,OAAOI,OAAO;gBACvBC,OAAOL,OAAOK,KAAK;YACrB,GACAjB,QAAQ3B,cAAc,EACtBwB;YAEF,MAAMqB,OAAOX,IAAIY,KAAK,CAAC,KAAKC,GAAG,CAACzD,eAAe0D,IAAI,CAAC;YACpD,OAAO,GAAGjC,OAAO,CAAC,EAAE8B,KAAK,CAAC,EAAE5D,qBAAqB,CAAC,EAAEuD,OAAO;QAC7D;IACF;AACF"}
@@ -0,0 +1,48 @@
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
+ * The slice of the Workers R2 binding the delivery Worker calls, typed here
19
+ * rather than taken from `@cloudflare/workers-types`: the Worker needs two
20
+ * methods, and a spec satisfies these with an in-memory bucket.
21
+ *
22
+ * The shapes follow the binding's documented `R2Bucket.head` and
23
+ * `R2Bucket.get`: `head` answers the object's metadata or null, and `get`
24
+ * answers the object with a body, or null when the key does not exist. A
25
+ * ranged `get` returns only the bytes in `range`.
26
+ */
27
+ export interface R2ObjectMetadata {
28
+ key: string;
29
+ /** The whole object's size, whatever range was read. */
30
+ size: number;
31
+ /** The object's ETag, quoted, ready for an `ETag` header. */
32
+ httpEtag: string;
33
+ httpMetadata?: {
34
+ contentType?: string;
35
+ };
36
+ }
37
+ export interface R2ObjectWithBody extends R2ObjectMetadata {
38
+ body: ReadableStream<Uint8Array>;
39
+ }
40
+ export interface R2BucketBinding {
41
+ head(key: string): Promise<R2ObjectMetadata | null>;
42
+ get(key: string, options?: {
43
+ range?: {
44
+ offset: number;
45
+ length?: number;
46
+ };
47
+ }): Promise<R2ObjectWithBody | null>;
48
+ }
@@ -0,0 +1,27 @@
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
+ * The slice of the Workers R2 binding the delivery Worker calls, typed here
18
+ * rather than taken from `@cloudflare/workers-types`: the Worker needs two
19
+ * methods, and a spec satisfies these with an in-memory bucket.
20
+ *
21
+ * The shapes follow the binding's documented `R2Bucket.head` and
22
+ * `R2Bucket.get`: `head` answers the object's metadata or null, and `get`
23
+ * answers the object with a body, or null when the key does not exist. A
24
+ * ranged `get` returns only the bytes in `range`.
25
+ */ export { };
26
+
27
+ //# sourceMappingURL=r2-binding.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/video-delivery/src/lib/worker/r2-binding.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 slice of the Workers R2 binding the delivery Worker calls, typed here\n * rather than taken from `@cloudflare/workers-types`: the Worker needs two\n * methods, and a spec satisfies these with an in-memory bucket.\n *\n * The shapes follow the binding's documented `R2Bucket.head` and\n * `R2Bucket.get`: `head` answers the object's metadata or null, and `get`\n * answers the object with a body, or null when the key does not exist. A\n * ranged `get` returns only the bytes in `range`.\n */\n\nexport interface R2ObjectMetadata {\n key: string\n /** The whole object's size, whatever range was read. */\n size: number\n /** The object's ETag, quoted, ready for an `ETag` header. */\n httpEtag: string\n httpMetadata?: { contentType?: string }\n}\n\nexport interface R2ObjectWithBody extends R2ObjectMetadata {\n body: ReadableStream<Uint8Array>\n}\n\nexport interface R2BucketBinding {\n head(key: string): Promise<R2ObjectMetadata | null>\n get(\n key: string,\n options?: { range?: { offset: number; length?: number } },\n ): Promise<R2ObjectWithBody | null>\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;CASC,GAeD,WAMC"}
@@ -0,0 +1,74 @@
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 { R2BucketBinding } from './r2-binding';
18
+ /**
19
+ * The delivery Worker (AGL-2824): serves one R2 object to the holder of a
20
+ * token minted for it.
21
+ *
22
+ * `wrangler.jsonc` at the plugin's root deploys this file as the Worker named
23
+ * `video`, with the `aglyn-video` bucket bound as `VIDEO_BUCKET` and the
24
+ * delivery secret set as the Worker secret `MEDIA_VIDEO_DELIVERY_SECRET` —
25
+ * the same value the platform mints with.
26
+ *
27
+ * ## One request
28
+ *
29
+ * `GET|HEAD /{key}?token={token}`. The token is verified before the bucket is
30
+ * touched, and it must name exactly the key in the path:
31
+ *
32
+ * - no secret configured: `503`;
33
+ * - a token that does not verify — malformed, altered, expired, signed with
34
+ * another secret, or claiming a lifetime no minter issues: `403`;
35
+ * - a genuine token for another object, or an object the bucket does not
36
+ * hold: `404`.
37
+ *
38
+ * A refusal has no body, so it says nothing about which of those it was
39
+ * beyond its status.
40
+ *
41
+ * ## Ranges
42
+ *
43
+ * A single `bytes=` range is answered `206` with `Content-Range`, the same
44
+ * three forms the platform's CDN route honors (`a-b`, `a-`, `-n`); a range
45
+ * past the end is `416`; anything else — several ranges, another unit,
46
+ * malformed syntax, an `If-Range` that does not match — is ignored and the
47
+ * whole object is sent, which RFC 9110 allows and no client can misread.
48
+ * Every response says `Accept-Ranges: bytes`.
49
+ *
50
+ * ## Caching
51
+ *
52
+ * `private`, for at most as long as the token has left: a viewer's browser
53
+ * may keep the ranges it fetched for the life of the URL that fetched them,
54
+ * and no shared cache — Cloudflare's included — holds anything. So a
55
+ * takedown that removes the object leaves nothing to purge.
56
+ */
57
+ export interface VideoWorkerEnv {
58
+ VIDEO_BUCKET: R2BucketBinding;
59
+ MEDIA_VIDEO_DELIVERY_SECRET?: string;
60
+ }
61
+ type ParsedRange = {
62
+ start: number;
63
+ end: number;
64
+ } | 'unsatisfiable' | null;
65
+ /**
66
+ * One `bytes=` range against an object of `size` bytes, both ends inclusive,
67
+ * with the platform CDN's semantics: `null` means serve the whole object.
68
+ */
69
+ export declare function parseByteRange(header: string | null, size: number): ParsedRange;
70
+ export declare function handleVideoRequest(request: Request, env: VideoWorkerEnv, nowMs?: number): Promise<Response>;
71
+ declare const _default: {
72
+ fetch(request: Request, env: VideoWorkerEnv): Promise<Response>;
73
+ };
74
+ export default _default;
@@ -0,0 +1,174 @@
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
+ */ import { DELIVERY_TOKEN_PARAM, verifyDeliveryToken } from "../delivery-token.js";
17
+ /**
18
+ * The same base policy the platform's media CDN sets on every response
19
+ * (`MEDIA_CDN_BASE_CSP`), restated because the Worker bundles nothing from
20
+ * the platform: a film opened as a top-level document can run nothing.
21
+ */ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'none'; object-src 'none'; " + "base-uri 'none'; form-action 'none'";
22
+ /** The longest a browser may keep a response, whatever the token has left. */ const MAX_BROWSER_CACHE_SECONDS = 60 * 60;
23
+ /** Headers every answer carries, refusals included. */ function baseHeaders() {
24
+ return new Headers({
25
+ 'access-control-allow-origin': '*',
26
+ 'x-content-type-options': 'nosniff',
27
+ 'content-security-policy': CONTENT_SECURITY_POLICY
28
+ });
29
+ }
30
+ function refusal(status, extra = {}) {
31
+ const headers = baseHeaders();
32
+ headers.set('cache-control', 'no-store');
33
+ for (const [name, value] of Object.entries(extra))headers.set(name, value);
34
+ return new Response(null, {
35
+ status,
36
+ headers
37
+ });
38
+ }
39
+ /**
40
+ * One `bytes=` range against an object of `size` bytes, both ends inclusive,
41
+ * with the platform CDN's semantics: `null` means serve the whole object.
42
+ */ export function parseByteRange(header, size) {
43
+ var _unit_, _specs_;
44
+ if (!header) return null;
45
+ const unit = /^bytes=(.*)$/i.exec(header.trim());
46
+ if (!unit) return null;
47
+ const specs = ((_unit_ = unit[1]) != null ? _unit_ : '').split(',');
48
+ if (specs.length !== 1) return null;
49
+ const spec = /^(\d*)-(\d*)$/.exec(((_specs_ = specs[0]) != null ? _specs_ : '').trim());
50
+ if (!spec) return null;
51
+ const [, startRaw = '', endRaw = ''] = spec;
52
+ if (!startRaw && !endRaw) return null;
53
+ if (!startRaw) {
54
+ const suffix = Number(endRaw);
55
+ if (!Number.isSafeInteger(suffix)) return null;
56
+ if (suffix === 0 || size === 0) return 'unsatisfiable';
57
+ return {
58
+ start: Math.max(0, size - suffix),
59
+ end: size - 1
60
+ };
61
+ }
62
+ const start = Number(startRaw);
63
+ if (!Number.isSafeInteger(start)) return null;
64
+ const end = endRaw ? Number(endRaw) : size - 1;
65
+ if (!Number.isSafeInteger(end)) return null;
66
+ if (endRaw && end < start) return null;
67
+ if (start >= size) return 'unsatisfiable';
68
+ return {
69
+ start,
70
+ end: Math.min(end, size - 1)
71
+ };
72
+ }
73
+ /** The object key a path names, or null for one that cannot be decoded. */ function keyFromPath(pathname) {
74
+ try {
75
+ const key = pathname.replace(/^\/+/, '').split('/').map(decodeURIComponent).join('/');
76
+ return key && !key.split('/').includes('..') ? key : null;
77
+ } catch (unused) {
78
+ return null;
79
+ }
80
+ }
81
+ function objectHeaders(object, maxAgeSeconds) {
82
+ var _object_httpMetadata;
83
+ const headers = baseHeaders();
84
+ headers.set('content-type', ((_object_httpMetadata = object.httpMetadata) == null ? void 0 : _object_httpMetadata.contentType) || 'application/octet-stream');
85
+ headers.set('accept-ranges', 'bytes');
86
+ headers.set('etag', object.httpEtag);
87
+ headers.set('cache-control', `private, max-age=${maxAgeSeconds}`);
88
+ headers.set('access-control-expose-headers', 'accept-ranges, content-length, content-range, etag');
89
+ return headers;
90
+ }
91
+ export async function handleVideoRequest(request, env, nowMs = Date.now()) {
92
+ if (request.method === 'OPTIONS') {
93
+ const headers = baseHeaders();
94
+ headers.set('access-control-allow-methods', 'GET, HEAD, OPTIONS');
95
+ headers.set('access-control-allow-headers', 'range, if-range');
96
+ headers.set('access-control-max-age', '86400');
97
+ return new Response(null, {
98
+ status: 204,
99
+ headers
100
+ });
101
+ }
102
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
103
+ return refusal(405, {
104
+ allow: 'GET, HEAD, OPTIONS'
105
+ });
106
+ }
107
+ if (!env.MEDIA_VIDEO_DELIVERY_SECRET) return refusal(503);
108
+ const url = new URL(request.url);
109
+ const key = keyFromPath(url.pathname);
110
+ if (!key) return refusal(404);
111
+ const verdict = await verifyDeliveryToken(url.searchParams.get(DELIVERY_TOKEN_PARAM), env.MEDIA_VIDEO_DELIVERY_SECRET, {
112
+ key,
113
+ nowMs
114
+ });
115
+ if (verdict.ok === false) {
116
+ if (verdict.refusal === 'secret') return refusal(503);
117
+ return refusal(verdict.refusal === 'key' ? 404 : 403);
118
+ }
119
+ const maxAgeSeconds = Math.max(0, Math.min(MAX_BROWSER_CACHE_SECONDS, Math.floor((verdict.claims.expiresAtMs - nowMs) / 1000)));
120
+ if (request.method === 'HEAD') {
121
+ const object = await env.VIDEO_BUCKET.head(key);
122
+ if (!object) return refusal(404);
123
+ const headers = objectHeaders(object, maxAgeSeconds);
124
+ headers.set('content-length', String(object.size));
125
+ return new Response(null, {
126
+ status: 200,
127
+ headers
128
+ });
129
+ }
130
+ const rangeHeader = request.headers.get('range');
131
+ if (rangeHeader) {
132
+ const object = await env.VIDEO_BUCKET.head(key);
133
+ if (!object) return refusal(404);
134
+ const ifRange = request.headers.get('if-range');
135
+ const parsed = ifRange && ifRange !== object.httpEtag ? null : parseByteRange(rangeHeader, object.size);
136
+ if (parsed === 'unsatisfiable') {
137
+ return refusal(416, {
138
+ 'content-range': `bytes */${object.size}`
139
+ });
140
+ }
141
+ if (parsed) {
142
+ const length = parsed.end - parsed.start + 1;
143
+ const ranged = await env.VIDEO_BUCKET.get(key, {
144
+ range: {
145
+ offset: parsed.start,
146
+ length
147
+ }
148
+ });
149
+ if (!ranged) return refusal(404);
150
+ const headers = objectHeaders(ranged, maxAgeSeconds);
151
+ headers.set('content-range', `bytes ${parsed.start}-${parsed.end}/${ranged.size}`);
152
+ headers.set('content-length', String(length));
153
+ return new Response(ranged.body, {
154
+ status: 206,
155
+ headers
156
+ });
157
+ }
158
+ }
159
+ const object = await env.VIDEO_BUCKET.get(key);
160
+ if (!object) return refusal(404);
161
+ const headers = objectHeaders(object, maxAgeSeconds);
162
+ headers.set('content-length', String(object.size));
163
+ return new Response(object.body, {
164
+ status: 200,
165
+ headers
166
+ });
167
+ }
168
+ export default {
169
+ fetch (request, env) {
170
+ return handleVideoRequest(request, env);
171
+ }
172
+ };
173
+
174
+ //# sourceMappingURL=video-worker.js.map