@bytescale/sdk 3.57.0 → 3.58.0

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.
@@ -3513,9 +3513,15 @@ var AuthManagerImpl = /*#__PURE__*/function () {
3513
3513
  }))) {
3514
3514
  throw new Error("The 'sourceUrlPrefixes' field returned by 'serviceWorkerConfig' must be an array of strings.");
3515
3515
  }
3516
+ if (result.urlRewriteRules !== undefined && (!Array.isArray(result.urlRewriteRules) || !result.urlRewriteRules.every(function (rule) {
3517
+ return rule !== null && AuthManagerBrowser_typeof(rule) === "object" && typeof rule.fromUrlPrefix === "string" && typeof rule.toUrlPrefix === "string";
3518
+ }))) {
3519
+ throw new Error("The 'urlRewriteRules' field returned by 'serviceWorkerConfig' must be an array of URL rewrite rules.");
3520
+ }
3516
3521
  var config = {
3517
3522
  additionalConfig: result.additionalConfig,
3518
- sourceUrlPrefixes: result.sourceUrlPrefixes
3523
+ sourceUrlPrefixes: result.sourceUrlPrefixes,
3524
+ urlRewriteRules: result.urlRewriteRules
3519
3525
  };
3520
3526
  return AuthManagerBrowser_invoke(function () {
3521
3527
  if (session.primaryAuthSwConfig !== undefined) {
@@ -3555,7 +3561,7 @@ var AuthManagerImpl = /*#__PURE__*/function () {
3555
3561
  var _a;
3556
3562
  return AuthManagerBrowser_await(AuthManagerBrowser_awaitIgnored(_this5.sendServiceWorkerConfig(session, [Object.assign(Object.assign({}, primaryConfig), {
3557
3563
  sourceUrlPrefixes: serviceWorkerConfig === null || serviceWorkerConfig === void 0 ? void 0 : serviceWorkerConfig.sourceUrlPrefixes
3558
- })].concat(AuthManagerBrowser_toConsumableArray((_a = serviceWorkerConfig === null || serviceWorkerConfig === void 0 ? void 0 : serviceWorkerConfig.additionalConfig) !== null && _a !== void 0 ? _a : [])))));
3564
+ })].concat(AuthManagerBrowser_toConsumableArray((_a = serviceWorkerConfig === null || serviceWorkerConfig === void 0 ? void 0 : serviceWorkerConfig.additionalConfig) !== null && _a !== void 0 ? _a : [])), serviceWorkerConfig === null || serviceWorkerConfig === void 0 ? void 0 : serviceWorkerConfig.urlRewriteRules)));
3559
3565
  } catch (e) {
3560
3566
  return Promise.reject(e);
3561
3567
  }
@@ -3582,20 +3588,22 @@ var AuthManagerImpl = /*#__PURE__*/function () {
3582
3588
  }
3583
3589
  }, {
3584
3590
  key: "sendServiceWorkerConfig",
3585
- value: function sendServiceWorkerConfig(session, config) {
3591
+ value: function sendServiceWorkerConfig(session, config, urlRewriteRules) {
3586
3592
  try {
3587
3593
  var _this6 = this;
3588
3594
  if (session.authServiceWorker === undefined) {
3589
3595
  throw new Error("Service worker configuration cannot be applied because service workers are unavailable.");
3590
3596
  }
3591
- return AuthManagerBrowser_await(_this6.serviceWorkerUtils.sendMessage({
3597
+ return AuthManagerBrowser_await(_this6.serviceWorkerUtils.sendMessage(Object.assign({
3592
3598
  type: "SET_BYTESCALE_AUTH_CONFIG",
3593
3599
  config: config.map(function (entry) {
3594
3600
  return Object.assign(Object.assign({}, entry), {
3595
3601
  urlPrefix: "".concat(entry.sourceUrlPrefixes === undefined ? "" : _this6.sourceScopedUrlPrefixMarker).concat(entry.urlPrefix)
3596
3602
  });
3597
3603
  })
3598
- }, session.authServiceWorker, _this6.serviceWorkerScriptFieldName), function (_this6$serviceWorkerU) {
3604
+ }, urlRewriteRules === undefined ? {} : {
3605
+ urlRewriteRules: urlRewriteRules
3606
+ }), session.authServiceWorker, _this6.serviceWorkerScriptFieldName), function (_this6$serviceWorkerU) {
3599
3607
  session.authServiceWorker = _this6$serviceWorkerU;
3600
3608
  });
3601
3609
  } catch (e) {
@@ -3473,9 +3473,15 @@ var AuthManagerImpl = /*#__PURE__*/function () {
3473
3473
  }))) {
3474
3474
  throw new Error("The 'sourceUrlPrefixes' field returned by 'serviceWorkerConfig' must be an array of strings.");
3475
3475
  }
3476
+ if (result.urlRewriteRules !== undefined && (!Array.isArray(result.urlRewriteRules) || !result.urlRewriteRules.every(function (rule) {
3477
+ return rule !== null && AuthManagerBrowser_typeof(rule) === "object" && typeof rule.fromUrlPrefix === "string" && typeof rule.toUrlPrefix === "string";
3478
+ }))) {
3479
+ throw new Error("The 'urlRewriteRules' field returned by 'serviceWorkerConfig' must be an array of URL rewrite rules.");
3480
+ }
3476
3481
  var config = {
3477
3482
  additionalConfig: result.additionalConfig,
3478
- sourceUrlPrefixes: result.sourceUrlPrefixes
3483
+ sourceUrlPrefixes: result.sourceUrlPrefixes,
3484
+ urlRewriteRules: result.urlRewriteRules
3479
3485
  };
3480
3486
  return AuthManagerBrowser_invoke(function () {
3481
3487
  if (session.primaryAuthSwConfig !== undefined) {
@@ -3515,7 +3521,7 @@ var AuthManagerImpl = /*#__PURE__*/function () {
3515
3521
  var _a;
3516
3522
  return AuthManagerBrowser_await(AuthManagerBrowser_awaitIgnored(_this5.sendServiceWorkerConfig(session, [Object.assign(Object.assign({}, primaryConfig), {
3517
3523
  sourceUrlPrefixes: serviceWorkerConfig === null || serviceWorkerConfig === void 0 ? void 0 : serviceWorkerConfig.sourceUrlPrefixes
3518
- })].concat(AuthManagerBrowser_toConsumableArray((_a = serviceWorkerConfig === null || serviceWorkerConfig === void 0 ? void 0 : serviceWorkerConfig.additionalConfig) !== null && _a !== void 0 ? _a : [])))));
3524
+ })].concat(AuthManagerBrowser_toConsumableArray((_a = serviceWorkerConfig === null || serviceWorkerConfig === void 0 ? void 0 : serviceWorkerConfig.additionalConfig) !== null && _a !== void 0 ? _a : [])), serviceWorkerConfig === null || serviceWorkerConfig === void 0 ? void 0 : serviceWorkerConfig.urlRewriteRules)));
3519
3525
  } catch (e) {
3520
3526
  return Promise.reject(e);
3521
3527
  }
@@ -3542,20 +3548,22 @@ var AuthManagerImpl = /*#__PURE__*/function () {
3542
3548
  }
3543
3549
  }, {
3544
3550
  key: "sendServiceWorkerConfig",
3545
- value: function sendServiceWorkerConfig(session, config) {
3551
+ value: function sendServiceWorkerConfig(session, config, urlRewriteRules) {
3546
3552
  try {
3547
3553
  var _this6 = this;
3548
3554
  if (session.authServiceWorker === undefined) {
3549
3555
  throw new Error("Service worker configuration cannot be applied because service workers are unavailable.");
3550
3556
  }
3551
- return AuthManagerBrowser_await(_this6.serviceWorkerUtils.sendMessage({
3557
+ return AuthManagerBrowser_await(_this6.serviceWorkerUtils.sendMessage(Object.assign({
3552
3558
  type: "SET_BYTESCALE_AUTH_CONFIG",
3553
3559
  config: config.map(function (entry) {
3554
3560
  return Object.assign(Object.assign({}, entry), {
3555
3561
  urlPrefix: "".concat(entry.sourceUrlPrefixes === undefined ? "" : _this6.sourceScopedUrlPrefixMarker).concat(entry.urlPrefix)
3556
3562
  });
3557
3563
  })
3558
- }, session.authServiceWorker, _this6.serviceWorkerScriptFieldName), function (_this6$serviceWorkerU) {
3564
+ }, urlRewriteRules === undefined ? {} : {
3565
+ urlRewriteRules: urlRewriteRules
3566
+ }), session.authServiceWorker, _this6.serviceWorkerScriptFieldName), function (_this6$serviceWorkerU) {
3559
3567
  session.authServiceWorker = _this6$serviceWorkerU;
3560
3568
  });
3561
3569
  } catch (e) {
@@ -1,4 +1,5 @@
1
1
  import { AuthSwConfigDto } from "./AuthSwConfigDto";
2
+ import { UrlRewriteRule } from "../model/AuthManagerInterface";
2
3
  export interface AuthSwSetConfigDto {
3
4
  config: AuthSwConfigDto;
4
5
  /**
@@ -8,4 +9,5 @@ export interface AuthSwSetConfigDto {
8
9
  * with the user's events.
9
10
  */
10
11
  type: "SET_BYTESCALE_AUTH_CONFIG";
12
+ urlRewriteRules?: UrlRewriteRule[];
11
13
  }
@@ -1,5 +1,11 @@
1
1
  import { BytescaleApiClientConfig } from "../../public/shared";
2
2
  import { AuthSwConfigEntryDto } from "../dtos/AuthSwConfigEntryDto";
3
+ export interface UrlRewriteRule {
4
+ /** URL prefix to replace. This URL must be covered by the service worker's origin and scope. */
5
+ fromUrlPrefix: string;
6
+ /** Replacement URL prefix. The remainder of the original URL is appended unchanged. */
7
+ toUrlPrefix: string;
8
+ }
3
9
  export interface AuthManagerServiceWorkerConfig {
4
10
  /**
5
11
  * Additional download-only service-worker authorization rules.
@@ -12,6 +18,11 @@ export interface AuthManagerServiceWorkerConfig {
12
18
  * History API changes do not update the matched client URL.
13
19
  */
14
20
  sourceUrlPrefixes?: string[];
21
+ /**
22
+ * Rewrites matching service-worker requests before applying authorization rules. The first matching rule wins and
23
+ * rewritten URLs are not processed recursively.
24
+ */
25
+ urlRewriteRules?: UrlRewriteRule[];
15
26
  }
16
27
  export interface BeginAuthSessionParams {
17
28
  /**
@@ -34,7 +45,8 @@ export interface BeginAuthSessionParams {
34
45
  options?: Pick<BytescaleApiClientConfig, "fetchApi" | "cdnUrl">;
35
46
  /**
36
47
  * Returns configuration for service-worker authorization. The primary JWT returned by `authUrl` is restricted using
37
- * `sourceUrlPrefixes`; `additionalConfig` contains download-only rules and never authenticates SDK API operations.
48
+ * `sourceUrlPrefixes`; `additionalConfig` contains download-only rules and never authenticates SDK API operations;
49
+ * `urlRewriteRules` rewrites in-scope URLs before download authorization is evaluated.
38
50
  * Called when the session begins and again 20 seconds before the earliest additional entry expires. Entries without
39
51
  * an expiry do not trigger a refresh.
40
52
  *
@@ -3,7 +3,7 @@ import { AuthSwSetConfigDto } from "../../private/dtos/AuthSwSetConfigDto";
3
3
  import { ServiceWorkerUtils } from "../../private/ServiceWorkerUtils";
4
4
  export type { AuthSwConfigEntryDto } from "../../private/dtos/AuthSwConfigEntryDto";
5
5
  export type { AuthSwHeaderDto } from "../../private/dtos/AuthSwHeaderDto";
6
- export type { AuthManagerServiceWorkerConfig, BeginAuthSessionParams } from "../../private/model/AuthManagerInterface";
6
+ export type { AuthManagerServiceWorkerConfig, BeginAuthSessionParams, UrlRewriteRule } from "../../private/model/AuthManagerInterface";
7
7
  declare class AuthManagerImpl implements AuthManagerInterface {
8
8
  private readonly serviceWorkerUtils;
9
9
  private readonly authSessionMutex;
@@ -1,7 +1,7 @@
1
1
  import { AuthManagerInterface, BeginAuthSessionParams } from "../../private/model/AuthManagerInterface";
2
2
  export type { AuthSwConfigEntryDto } from "../../private/dtos/AuthSwConfigEntryDto";
3
3
  export type { AuthSwHeaderDto } from "../../private/dtos/AuthSwHeaderDto";
4
- export type { AuthManagerServiceWorkerConfig, BeginAuthSessionParams } from "../../private/model/AuthManagerInterface";
4
+ export type { AuthManagerServiceWorkerConfig, BeginAuthSessionParams, UrlRewriteRule } from "../../private/model/AuthManagerInterface";
5
5
  declare class AuthManagerImpl implements AuthManagerInterface {
6
6
  beginAuthSession(_params: BeginAuthSessionParams): Promise<void>;
7
7
  endAuthSession(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytescale/sdk",
3
- "version": "3.57.0",
3
+ "version": "3.58.0",
4
4
  "description": "Bytescale JavaScript SDK",
5
5
  "author": "Bytescale <hello@bytescale.com> (https://www.bytescale.com)",
6
6
  "license": "MIT",
@@ -4,7 +4,8 @@ import { AuthSessionState } from "../src/private/AuthSessionState";
4
4
  import type {
5
5
  AuthManagerServiceWorkerConfig,
6
6
  AuthSwConfigEntryDto,
7
- BeginAuthSessionParams
7
+ BeginAuthSessionParams,
8
+ UrlRewriteRule
8
9
  } from "../src/index.browser";
9
10
 
10
11
  type FetchApi = NonNullable<NonNullable<BeginAuthSessionParams["options"]>["fetchApi"]>;
@@ -130,10 +131,17 @@ describe("AuthManager browser service-worker config", () => {
130
131
  urlPrefix: "https://upcdn.io/account-c/"
131
132
  }
132
133
  ];
134
+ const urlRewriteRules: UrlRewriteRule[] = [
135
+ {
136
+ fromUrlPrefix: "https://app.example.com/__authenticated-download/",
137
+ toUrlPrefix: "https://upcdn.io/account-b/"
138
+ }
139
+ ];
133
140
  const serviceWorkerConfig = jest.fn(
134
141
  async (): Promise<AuthManagerServiceWorkerConfig> => ({
135
142
  additionalConfig,
136
- sourceUrlPrefixes: ["https://app.example.com/"]
143
+ sourceUrlPrefixes: ["https://app.example.com/"],
144
+ urlRewriteRules
137
145
  })
138
146
  );
139
147
 
@@ -159,7 +167,8 @@ describe("AuthManager browser service-worker config", () => {
159
167
  },
160
168
  additionalConfig[1]
161
169
  ],
162
- type: "SET_BYTESCALE_AUTH_CONFIG"
170
+ type: "SET_BYTESCALE_AUTH_CONFIG",
171
+ urlRewriteRules
163
172
  });
164
173
  expect(additionalConfig[0].urlPrefix).toBe("https://upcdn.io/account-b/");
165
174
  expect(AuthSessionState.getSession()?.accessToken).toBe("access-a");
@@ -184,11 +193,23 @@ describe("AuthManager browser service-worker config", () => {
184
193
  .fn<() => Promise<AuthManagerServiceWorkerConfig>>()
185
194
  .mockResolvedValueOnce({
186
195
  additionalConfig: initialAdditionalConfig,
187
- sourceUrlPrefixes: ["https://app.example.com/initial/"]
196
+ sourceUrlPrefixes: ["https://app.example.com/initial/"],
197
+ urlRewriteRules: [
198
+ {
199
+ fromUrlPrefix: "https://app.example.com/download/",
200
+ toUrlPrefix: "https://upcdn.io/account-b/"
201
+ }
202
+ ]
188
203
  })
189
204
  .mockResolvedValueOnce({
190
205
  additionalConfig: [],
191
- sourceUrlPrefixes: ["https://app.example.com/refreshed/"]
206
+ sourceUrlPrefixes: ["https://app.example.com/refreshed/"],
207
+ urlRewriteRules: [
208
+ {
209
+ fromUrlPrefix: "https://app.example.com/download/",
210
+ toUrlPrefix: "https://upcdn.io/account-c/"
211
+ }
212
+ ]
192
213
  });
193
214
 
194
215
  await AuthManager.beginAuthSession({
@@ -208,7 +229,13 @@ describe("AuthManager browser service-worker config", () => {
208
229
  urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-a/"
209
230
  }
210
231
  ],
211
- type: "SET_BYTESCALE_AUTH_CONFIG"
232
+ type: "SET_BYTESCALE_AUTH_CONFIG",
233
+ urlRewriteRules: [
234
+ {
235
+ fromUrlPrefix: "https://app.example.com/download/",
236
+ toUrlPrefix: "https://upcdn.io/account-c/"
237
+ }
238
+ ]
212
239
  });
213
240
  expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
214
241
  expect(AuthManager.isAuthSessionReady()).toBe(true);
@@ -225,7 +252,13 @@ describe("AuthManager browser service-worker config", () => {
225
252
  ];
226
253
  const serviceWorkerConfig = jest.fn(
227
254
  async (): Promise<AuthManagerServiceWorkerConfig> => ({
228
- additionalConfig
255
+ additionalConfig,
256
+ urlRewriteRules: [
257
+ {
258
+ fromUrlPrefix: "https://app.example.com/download/",
259
+ toUrlPrefix: "https://upcdn.io/account-b/"
260
+ }
261
+ ]
229
262
  })
230
263
  );
231
264
 
@@ -254,8 +287,31 @@ describe("AuthManager browser service-worker config", () => {
254
287
  },
255
288
  additionalConfig[0]
256
289
  ],
257
- type: "SET_BYTESCALE_AUTH_CONFIG"
290
+ type: "SET_BYTESCALE_AUTH_CONFIG",
291
+ urlRewriteRules: [
292
+ {
293
+ fromUrlPrefix: "https://app.example.com/download/",
294
+ toUrlPrefix: "https://upcdn.io/account-b/"
295
+ }
296
+ ]
297
+ });
298
+ });
299
+
300
+ test("rejects malformed URL rewrite rules", async () => {
301
+ jest.spyOn(console, "warn").mockImplementation(() => {});
302
+ const fetchApi = createPrimaryFetchApi();
303
+
304
+ await AuthManager.beginAuthSession({
305
+ ...createParams(fetchApi),
306
+ serviceWorkerConfig: async (): Promise<AuthManagerServiceWorkerConfig> => ({
307
+ additionalConfig: [],
308
+ urlRewriteRules: [{ fromUrlPrefix: "https://app.example.com/download/" }] as UrlRewriteRule[]
309
+ }),
310
+ serviceWorkerScript: "/bytescale-auth-sw.js"
258
311
  });
312
+
313
+ expect(postMessage).not.toHaveBeenCalled();
314
+ expect(AuthManager.isAuthSessionReady()).toBe(false);
259
315
  });
260
316
 
261
317
  test("fails closed until the initial service-worker config callback succeeds", async () => {
@@ -0,0 +1,276 @@
1
+ import { jest } from "@jest/globals";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { runInNewContext } from "node:vm";
5
+ import { Headers as NodeFetchHeaders, Request as NodeFetchRequest, Response as NodeFetchResponse } from "node-fetch";
6
+ import type { RequestInit as NodeFetchRequestInit } from "node-fetch";
7
+ import type { AuthSwConfigEntryDto, UrlRewriteRule } from "../src/index.browser";
8
+
9
+ const workerSource = readFileSync(resolve(process.cwd(), "src/index.auth-sw.js"), "utf8");
10
+
11
+ describe("Auth service-worker URL rewriting", () => {
12
+ test("preserves the remaining path, query string, and fragment", async () => {
13
+ const harness = new AuthServiceWorkerHarness();
14
+ const rules = [rewriteRule("download/", "account-a/")];
15
+ await harness.setConfig([], rules);
16
+
17
+ const result = await harness.dispatchFetch(
18
+ "https://dashboard.example.com/download/path/to/file.pdf?download=true&version=2"
19
+ );
20
+
21
+ expect(result.outboundRequest?.url).toBe("https://upcdn.io/account-a/path/to/file.pdf?download=true&version=2");
22
+ expect(harness.getRewrittenUrl("https://dashboard.example.com/download/file.pdf?download=true#page=2", rules)).toBe(
23
+ "https://upcdn.io/account-a/file.pdf?download=true#page=2"
24
+ );
25
+ });
26
+
27
+ test("matches authentication against the rewritten URL and supports navigation requests", async () => {
28
+ const upstreamResponse = new NodeFetchResponse("streamed-body", {
29
+ headers: {
30
+ "Accept-Ranges": "bytes",
31
+ "Content-Disposition": 'attachment; filename="file.pdf"',
32
+ "Content-Length": "13",
33
+ "Content-Range": "bytes 0-12/13",
34
+ "Content-Type": "application/pdf"
35
+ },
36
+ status: 206
37
+ });
38
+ const blob = jest.spyOn(upstreamResponse, "blob");
39
+ const arrayBuffer = jest.spyOn(upstreamResponse, "arrayBuffer");
40
+ const harness = new AuthServiceWorkerHarness(upstreamResponse);
41
+ await harness.setConfig([authConfig("account-a/", "token-a")], [rewriteRule("download/", "account-a/")]);
42
+
43
+ const result = await harness.dispatchFetch("https://dashboard.example.com/download/file.pdf", {
44
+ headers: {
45
+ "If-Modified-Since": "Wed, 21 Oct 2015 07:28:00 GMT",
46
+ "If-None-Match": '"etag"',
47
+ "If-Range": '"range-etag"',
48
+ "Range": "bytes=0-12"
49
+ },
50
+ navigation: true
51
+ });
52
+
53
+ expect(result.outboundRequest?.url).toBe("https://upcdn.io/account-a/file.pdf");
54
+ expect(result.outboundRequest?.mode).toBe("cors");
55
+ expect(result.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-a");
56
+ expect(result.outboundRequest?.headers.get("Range")).toBe("bytes=0-12");
57
+ expect(result.outboundRequest?.headers.get("If-Range")).toBe('"range-etag"');
58
+ expect(result.outboundRequest?.headers.get("If-None-Match")).toBe('"etag"');
59
+ expect(result.outboundRequest?.headers.get("If-Modified-Since")).toBe("Wed, 21 Oct 2015 07:28:00 GMT");
60
+ expect(result.response).toBe(upstreamResponse);
61
+ expect(result.response?.status).toBe(206);
62
+ expect(result.response?.headers.get("Content-Disposition")).toBe('attachment; filename="file.pdf"');
63
+ expect(result.response?.headers.get("Accept-Ranges")).toBe("bytes");
64
+ expect(blob).not.toHaveBeenCalled();
65
+ expect(arrayBuffer).not.toHaveBeenCalled();
66
+ });
67
+
68
+ test("selects different authentication configurations for different rewrite destinations", async () => {
69
+ const harness = new AuthServiceWorkerHarness();
70
+ await harness.setConfig(
71
+ [authConfig("account-a/", "token-a"), authConfig("account-b/", "token-b")],
72
+ [rewriteRule("download-a/", "account-a/"), rewriteRule("download-b/", "account-b/")]
73
+ );
74
+
75
+ const first = await harness.dispatchFetch("https://dashboard.example.com/download-a/file.pdf");
76
+ const second = await harness.dispatchFetch("https://dashboard.example.com/download-b/file.pdf");
77
+
78
+ expect(first.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-a");
79
+ expect(second.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-b");
80
+ });
81
+
82
+ test("never selects a token from the original URL", async () => {
83
+ const harness = new AuthServiceWorkerHarness();
84
+ await harness.setConfig(
85
+ [authConfigForUrl("https://dashboard.example.com/download/", "original-token")],
86
+ [rewriteRule("download/", "unconfigured-account/")]
87
+ );
88
+
89
+ const result = await harness.dispatchFetch("https://dashboard.example.com/download/file.pdf");
90
+
91
+ expect(result.outboundRequest?.url).toBe("https://upcdn.io/unconfigured-account/file.pdf");
92
+ expect(result.outboundRequest?.headers.has("Authorization")).toBe(false);
93
+ });
94
+
95
+ test("fetches rewritten requests even when no authentication configuration matches", async () => {
96
+ const harness = new AuthServiceWorkerHarness();
97
+ await harness.setConfig([], [rewriteRule("download/", "public-account/")]);
98
+
99
+ const result = await harness.dispatchFetch("https://dashboard.example.com/download/public.pdf");
100
+
101
+ expect(result.responded).toBe(true);
102
+ expect(result.outboundRequest?.url).toBe("https://upcdn.io/public-account/public.pdf");
103
+ expect(result.outboundRequest?.headers.has("Authorization")).toBe(false);
104
+ });
105
+
106
+ test("uses only the first matching rewrite rule", async () => {
107
+ const harness = new AuthServiceWorkerHarness();
108
+ await harness.setConfig(
109
+ [authConfig("account-a/", "token-a"), authConfig("account-b/", "token-b")],
110
+ [rewriteRule("download/", "account-a/"), rewriteRule("download/", "account-b/")]
111
+ );
112
+
113
+ const result = await harness.dispatchFetch("https://dashboard.example.com/download/file.pdf");
114
+
115
+ expect(result.outboundRequest?.url).toBe("https://upcdn.io/account-a/file.pdf");
116
+ expect(result.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-a");
117
+ });
118
+
119
+ test("does not recursively rewrite the rewritten URL", async () => {
120
+ const harness = new AuthServiceWorkerHarness();
121
+ await harness.setConfig(
122
+ [authConfig("account-a/", "token-a")],
123
+ [
124
+ {
125
+ fromUrlPrefix: "https://dashboard.example.com/download/",
126
+ toUrlPrefix: "https://proxy.example.com/download/"
127
+ },
128
+ {
129
+ fromUrlPrefix: "https://proxy.example.com/download/",
130
+ toUrlPrefix: "https://upcdn.io/account-a/"
131
+ }
132
+ ]
133
+ );
134
+
135
+ const result = await harness.dispatchFetch("https://dashboard.example.com/download/file.pdf");
136
+
137
+ expect(result.outboundRequest?.url).toBe("https://proxy.example.com/download/file.pdf");
138
+ expect(result.outboundRequest?.headers.has("Authorization")).toBe(false);
139
+ });
140
+
141
+ test("retains existing behavior when no rewrite rule matches", async () => {
142
+ const harness = new AuthServiceWorkerHarness();
143
+ await harness.setConfig([authConfig("account-a/", "token-a")], [rewriteRule("download/", "account-a/")]);
144
+
145
+ const authenticated = await harness.dispatchFetch("https://upcdn.io/account-a/file.pdf");
146
+ const untouched = await harness.dispatchFetch("https://dashboard.example.com/ordinary-page");
147
+
148
+ expect(authenticated.outboundRequest?.url).toBe("https://upcdn.io/account-a/file.pdf");
149
+ expect(authenticated.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-a");
150
+ expect(untouched.responded).toBe(false);
151
+ });
152
+ });
153
+
154
+ interface FetchOptions {
155
+ headers?: HeadersInit;
156
+ navigation?: boolean;
157
+ }
158
+
159
+ interface FetchResult {
160
+ outboundRequest: TestRequest | undefined;
161
+ responded: boolean;
162
+ response: NodeFetchResponse | undefined;
163
+ }
164
+
165
+ type WorkerEventListener = (event: unknown) => void;
166
+
167
+ class AuthServiceWorkerHarness {
168
+ private readonly context: {
169
+ getRewrittenUrl: (url: string, rules: UrlRewriteRule[]) => string | undefined;
170
+ setConfig: (config: AuthSwConfigEntryDto[], rules?: UrlRewriteRule[]) => Promise<void>;
171
+ };
172
+
173
+ private readonly fetchListener: WorkerEventListener;
174
+ private readonly fetchMock: jest.MockedFunction<(request: TestRequest) => Promise<NodeFetchResponse>>;
175
+
176
+ constructor(private readonly upstreamResponse: NodeFetchResponse = new NodeFetchResponse("ok")) {
177
+ const listeners = new Map<string, WorkerEventListener>();
178
+ const cacheEntries = new Map<string, NodeFetchResponse>();
179
+ this.fetchMock = jest.fn(async (_request: TestRequest): Promise<NodeFetchResponse> => this.upstreamResponse);
180
+
181
+ const self = {
182
+ addEventListener: (type: string, listener: WorkerEventListener): void => {
183
+ listeners.set(type, listener);
184
+ },
185
+ clients: {
186
+ claim: async (): Promise<void> => {},
187
+ get: async (): Promise<undefined> => undefined
188
+ },
189
+ skipWaiting: async (): Promise<void> => {}
190
+ };
191
+ const cache = {
192
+ match: async (key: string): Promise<NodeFetchResponse | undefined> => cacheEntries.get(key)?.clone(),
193
+ put: async (key: string, value: NodeFetchResponse): Promise<void> => {
194
+ cacheEntries.set(key, value.clone());
195
+ }
196
+ };
197
+ const sandbox = {
198
+ caches: { open: async (): Promise<typeof cache> => cache },
199
+ console: { error: jest.fn(), log: jest.fn() },
200
+ fetch: this.fetchMock,
201
+ Headers: NodeFetchHeaders,
202
+ Promise,
203
+ Request: TestRequest,
204
+ Response: NodeFetchResponse,
205
+ self,
206
+ setTimeout
207
+ };
208
+ runInNewContext(workerSource, sandbox);
209
+
210
+ this.context = sandbox as typeof sandbox & AuthServiceWorkerHarness["context"];
211
+ const fetchListener = listeners.get("fetch");
212
+ if (fetchListener === undefined) {
213
+ throw new Error("Auth service worker did not register a fetch listener.");
214
+ }
215
+ this.fetchListener = fetchListener;
216
+ }
217
+
218
+ async setConfig(config: AuthSwConfigEntryDto[], rules?: UrlRewriteRule[]): Promise<void> {
219
+ await this.context.setConfig(config, rules);
220
+ }
221
+
222
+ getRewrittenUrl(url: string, rules: UrlRewriteRule[]): string | undefined {
223
+ return this.context.getRewrittenUrl(url, rules);
224
+ }
225
+
226
+ async dispatchFetch(url: string, options: FetchOptions = {}): Promise<FetchResult> {
227
+ const request = new TestRequest(url, { headers: options.headers as NodeFetchRequestInit["headers"] });
228
+ if (options.navigation === true) {
229
+ Object.defineProperty(request, "mode", { configurable: true, value: "navigate" });
230
+ }
231
+
232
+ let responsePromise: Promise<NodeFetchResponse> | undefined;
233
+ this.fetchListener({
234
+ clientId: "",
235
+ request,
236
+ respondWith: (response: NodeFetchResponse | Promise<NodeFetchResponse>): void => {
237
+ responsePromise = Promise.resolve(response);
238
+ }
239
+ });
240
+
241
+ const response = await responsePromise;
242
+ return {
243
+ outboundRequest: this.fetchMock.mock.calls.at(-1)?.[0],
244
+ responded: responsePromise !== undefined,
245
+ response
246
+ };
247
+ }
248
+ }
249
+
250
+ class TestRequest extends NodeFetchRequest {
251
+ readonly mode: RequestMode;
252
+
253
+ constructor(input: string | NodeFetchRequest, init: NodeFetchRequestInit & { mode?: RequestMode } = {}) {
254
+ super(input, init);
255
+ this.mode = init.mode ?? (input instanceof TestRequest ? input.mode : "cors");
256
+ }
257
+ }
258
+
259
+ function authConfig(accountPath: string, token: string): AuthSwConfigEntryDto {
260
+ return authConfigForUrl(`https://upcdn.io/${accountPath}`, token);
261
+ }
262
+
263
+ function authConfigForUrl(urlPrefix: string, token: string): AuthSwConfigEntryDto {
264
+ return {
265
+ expires: undefined,
266
+ headers: [{ key: "Authorization", value: `Bearer ${token}` }],
267
+ urlPrefix
268
+ };
269
+ }
270
+
271
+ function rewriteRule(fromPath: string, toPath: string): UrlRewriteRule {
272
+ return {
273
+ fromUrlPrefix: `https://dashboard.example.com/${fromPath}`,
274
+ toUrlPrefix: `https://upcdn.io/${toPath}`
275
+ };
276
+ }