@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.21

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.
Files changed (75) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-lock.test.ts +7 -1
  5. package/src/__tests__/admin-vaults.test.ts +216 -10
  6. package/src/__tests__/api-account-2fa.test.ts +453 -0
  7. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  8. package/src/__tests__/api-mint-token.test.ts +75 -0
  9. package/src/__tests__/api-modules.test.ts +143 -0
  10. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  11. package/src/__tests__/auth.test.ts +336 -0
  12. package/src/__tests__/clients.test.ts +326 -8
  13. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-server.test.ts +127 -5
  18. package/src/__tests__/hub-settings.test.ts +188 -0
  19. package/src/__tests__/init.test.ts +153 -0
  20. package/src/__tests__/jwt-sign.test.ts +27 -0
  21. package/src/__tests__/managed-unit.test.ts +62 -0
  22. package/src/__tests__/oauth-handlers.test.ts +626 -0
  23. package/src/__tests__/oauth-ui.test.ts +107 -1
  24. package/src/__tests__/scope-explanations.test.ts +19 -0
  25. package/src/__tests__/setup-gate.test.ts +111 -3
  26. package/src/__tests__/setup-wizard.test.ts +124 -7
  27. package/src/__tests__/supervisor.test.ts +25 -0
  28. package/src/__tests__/vault-names.test.ts +32 -3
  29. package/src/__tests__/vault-remove.test.ts +40 -19
  30. package/src/__tests__/well-known.test.ts +37 -2
  31. package/src/admin-agent-grants.ts +16 -1
  32. package/src/admin-auth.ts +13 -4
  33. package/src/admin-clients.ts +66 -5
  34. package/src/admin-grants.ts +11 -2
  35. package/src/admin-vaults.ts +77 -27
  36. package/src/api-account-2fa.ts +395 -0
  37. package/src/api-admin-lock.ts +7 -0
  38. package/src/api-hub-upgrade.ts +52 -4
  39. package/src/api-hub.ts +10 -1
  40. package/src/api-invites.ts +18 -3
  41. package/src/api-me.ts +11 -2
  42. package/src/api-mint-token.ts +16 -1
  43. package/src/api-modules.ts +119 -1
  44. package/src/api-revoke-token.ts +14 -1
  45. package/src/api-settings-hub-origin.ts +14 -1
  46. package/src/api-settings-root-redirect.ts +201 -0
  47. package/src/api-tokens.ts +14 -1
  48. package/src/api-users.ts +15 -6
  49. package/src/api-vault-caps.ts +11 -2
  50. package/src/cli.ts +56 -5
  51. package/src/clients.ts +178 -0
  52. package/src/commands/auth.ts +263 -1
  53. package/src/commands/doctor.ts +1250 -0
  54. package/src/commands/hub.ts +102 -1
  55. package/src/commands/init.ts +108 -0
  56. package/src/commands/vault-remove.ts +16 -24
  57. package/src/cors.ts +7 -3
  58. package/src/help.ts +65 -1
  59. package/src/hub-db.ts +14 -0
  60. package/src/hub-server.ts +173 -25
  61. package/src/hub-settings.ts +163 -1
  62. package/src/jwt-sign.ts +25 -6
  63. package/src/managed-unit.ts +30 -1
  64. package/src/oauth-handlers.ts +110 -7
  65. package/src/oauth-ui.ts +174 -0
  66. package/src/rate-limit.ts +28 -0
  67. package/src/scope-explanations.ts +2 -1
  68. package/src/setup-wizard.ts +40 -21
  69. package/src/supervisor.ts +46 -2
  70. package/src/vault-names.ts +15 -4
  71. package/src/well-known.ts +10 -1
  72. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  73. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  74. package/web/ui/dist/index.html +2 -2
  75. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -6,8 +6,10 @@ import {
6
6
  API_MODULES_CHANNEL_REQUIRED_SCOPE,
7
7
  API_MODULES_REQUIRED_SCOPE,
8
8
  _clearLatestVersionCacheForTests,
9
+ defaultReadInstalledVersion,
9
10
  handleApiModules,
10
11
  handleApiModulesChannel,
12
+ isUpgradeAvailable,
11
13
  } from "../api-modules.ts";
12
14
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
15
  import { getSetting, setModuleInstallChannel } from "../hub-settings.ts";
@@ -491,6 +493,147 @@ describe("GET /api/modules", () => {
491
493
  expect(scribe?.installed_version).toBeNull();
492
494
  });
493
495
 
496
+ // ── hub#243: upgrade-offer must be semver-aware + installed-version must be live ──
497
+
498
+ type UpgradeWire = {
499
+ short: string;
500
+ installed_version: string | null;
501
+ latest_version: string | null;
502
+ upgrade_available: boolean;
503
+ };
504
+
505
+ async function modulesWith(opts: {
506
+ installedVersion: string;
507
+ latest: string | null;
508
+ readInstalledVersion?: (installDir: string) => string | null;
509
+ }): Promise<UpgradeWire[]> {
510
+ writeManifest(h.manifestPath, [
511
+ {
512
+ name: "parachute-vault",
513
+ port: 1940,
514
+ paths: ["/vault/default"],
515
+ health: "/vault/default/health",
516
+ version: opts.installedVersion,
517
+ installDir: "/install/dir/vault",
518
+ },
519
+ ]);
520
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
521
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
522
+ db: h.db,
523
+ issuer: ISSUER,
524
+ manifestPath: h.manifestPath,
525
+ fetchLatestVersion: async () => opts.latest,
526
+ // Default: no live read (synthetic install dir has no package.json), so
527
+ // the services.json cache is used — matching the prior behavior.
528
+ readInstalledVersion: opts.readInstalledVersion ?? (() => null),
529
+ });
530
+ const body = (await res.json()) as { modules: UpgradeWire[] };
531
+ return body.modules;
532
+ }
533
+
534
+ test("does NOT offer an upgrade when the channel target is OLDER than installed (the live downgrade bug)", async () => {
535
+ // The exact live shape: rc operator installed 0.6.4-rc.15; channel resolved
536
+ // latest_version to the OLDER @latest 0.6.3. Strings differ, but it's a
537
+ // downgrade — upgrade_available MUST be false.
538
+ const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.3" });
539
+ const vault = mods.find((m) => m.short === "vault");
540
+ expect(vault?.installed_version).toBe("0.6.4-rc.15");
541
+ expect(vault?.latest_version).toBe("0.6.3");
542
+ expect(vault?.upgrade_available).toBe(false);
543
+ });
544
+
545
+ test("offers an upgrade for a real rc → newer-rc move", async () => {
546
+ const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.4-rc.16" });
547
+ const vault = mods.find((m) => m.short === "vault");
548
+ expect(vault?.upgrade_available).toBe(true);
549
+ });
550
+
551
+ test("offers an upgrade for rc → its own stable (stable > its rc per semver)", async () => {
552
+ const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.4" });
553
+ const vault = mods.find((m) => m.short === "vault");
554
+ expect(vault?.upgrade_available).toBe(true);
555
+ });
556
+
557
+ test("offers an upgrade for a plain stable → newer stable", async () => {
558
+ const mods = await modulesWith({ installedVersion: "0.4.5", latest: "0.5.0" });
559
+ const vault = mods.find((m) => m.short === "vault");
560
+ expect(vault?.upgrade_available).toBe(true);
561
+ });
562
+
563
+ test("no upgrade when installed === latest", async () => {
564
+ const mods = await modulesWith({ installedVersion: "0.5.0", latest: "0.5.0" });
565
+ const vault = mods.find((m) => m.short === "vault");
566
+ expect(vault?.upgrade_available).toBe(false);
567
+ });
568
+
569
+ test("no upgrade when the npm probe failed (latest_version null)", async () => {
570
+ const mods = await modulesWith({ installedVersion: "0.5.0", latest: null });
571
+ const vault = mods.find((m) => m.short === "vault");
572
+ expect(vault?.latest_version).toBeNull();
573
+ expect(vault?.upgrade_available).toBe(false);
574
+ });
575
+
576
+ test("installed_version reflects the LIVE on-disk version, not a stale services.json cache (hub#243)", async () => {
577
+ // services.json cache lags the bun-linked checkout: cache says 0.5.4-rc.15
578
+ // (the live symptom) while package.json on disk is already 0.6.4-rc.15.
579
+ // The admin view must show what's actually installed.
580
+ const mods = await modulesWith({
581
+ installedVersion: "0.5.4-rc.15",
582
+ latest: "0.6.3",
583
+ readInstalledVersion: (dir) => (dir === "/install/dir/vault" ? "0.6.4-rc.15" : null),
584
+ });
585
+ const vault = mods.find((m) => m.short === "vault");
586
+ expect(vault?.installed_version).toBe("0.6.4-rc.15");
587
+ // And with the corrected current, @latest 0.6.3 is still a downgrade → no offer.
588
+ expect(vault?.upgrade_available).toBe(false);
589
+ });
590
+
591
+ test("falls back to the services.json version when the live read returns null", async () => {
592
+ const mods = await modulesWith({
593
+ installedVersion: "0.6.4-rc.15",
594
+ latest: "0.6.4-rc.16",
595
+ readInstalledVersion: () => null,
596
+ });
597
+ const vault = mods.find((m) => m.short === "vault");
598
+ expect(vault?.installed_version).toBe("0.6.4-rc.15");
599
+ expect(vault?.upgrade_available).toBe(true);
600
+ });
601
+
602
+ test("isUpgradeAvailable: semver-aware, fail-closed on unparseable + nulls", () => {
603
+ // strictly-newer → true
604
+ expect(isUpgradeAvailable("0.4.5", "0.5.0")).toBe(true);
605
+ expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.4-rc.16")).toBe(true);
606
+ expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.4")).toBe(true); // stable > its rc
607
+ // same / older → false
608
+ expect(isUpgradeAvailable("0.5.0", "0.5.0")).toBe(false);
609
+ expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.3")).toBe(false); // the live downgrade
610
+ expect(isUpgradeAvailable("0.6.4", "0.6.4-rc.15")).toBe(false); // stable → its rc
611
+ // nulls → false (not installed / probe failed)
612
+ expect(isUpgradeAvailable(null, "0.5.0")).toBe(false);
613
+ expect(isUpgradeAvailable("0.5.0", null)).toBe(false);
614
+ // unparseable → false (fail-closed: never offer a move we can't verify)
615
+ expect(isUpgradeAvailable("not-a-version", "0.5.0")).toBe(false);
616
+ expect(isUpgradeAvailable("0.5.0", "garbage")).toBe(false);
617
+ });
618
+
619
+ test("defaultReadInstalledVersion reads package.json version + tolerates missing/bad files", () => {
620
+ const tmp = mkdtempSync(join(tmpdir(), "phub-live-ver-"));
621
+ try {
622
+ writeFileSync(join(tmp, "package.json"), JSON.stringify({ version: "0.6.4-rc.15" }));
623
+ expect(defaultReadInstalledVersion(tmp)).toBe("0.6.4-rc.15");
624
+ // Missing dir / no package.json → null.
625
+ expect(defaultReadInstalledVersion(join(tmp, "does-not-exist"))).toBeNull();
626
+ // Malformed JSON → null (no throw).
627
+ writeFileSync(join(tmp, "package.json"), "{ not json");
628
+ expect(defaultReadInstalledVersion(tmp)).toBeNull();
629
+ // No version field → null.
630
+ writeFileSync(join(tmp, "package.json"), JSON.stringify({ name: "x" }));
631
+ expect(defaultReadInstalledVersion(tmp)).toBeNull();
632
+ } finally {
633
+ rmSync(tmp, { recursive: true, force: true });
634
+ }
635
+ });
636
+
494
637
  test("includes supervisor status + pid when a supervisor is injected", async () => {
495
638
  writeManifest(h.manifestPath, [
496
639
  {
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Tests for `/api/settings/root-redirect`.
3
+ *
4
+ * Covers:
5
+ * - `validateRootRedirect` pure validator (null/empty clear, safe path,
6
+ * open-redirect rejection).
7
+ * - GET response shape (root_redirect + resolved + source).
8
+ * - PUT happy path + open-redirect rejection (the highest-stakes part).
9
+ * - PUT clear (null) reverts to env/default precedence.
10
+ * - Auth gating: 401 missing/empty bearer, 403 wrong scope.
11
+ * - "Change takes effect on the next request" — the GET resolved value
12
+ * reflects the value just written, without restarting.
13
+ */
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import {
19
+ API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE,
20
+ handleApiSettingsRootRedirect,
21
+ validateRootRedirect,
22
+ } from "../api-settings-root-redirect.ts";
23
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
24
+ import { getRootRedirect, setRootRedirect } from "../hub-settings.ts";
25
+ import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
26
+ import { rotateSigningKey } from "../signing-keys.ts";
27
+ import { createUser } from "../users.ts";
28
+
29
+ const ISSUER = "http://127.0.0.1:1939";
30
+
31
+ interface Harness {
32
+ dir: string;
33
+ db: ReturnType<typeof openHubDb>;
34
+ userId: string;
35
+ cleanup: () => void;
36
+ }
37
+
38
+ async function makeHarness(): Promise<Harness> {
39
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-settings-root-redirect-"));
40
+ const db = openHubDb(hubDbPath(dir));
41
+ rotateSigningKey(db);
42
+ const user = await createUser(db, "owner", "pw");
43
+ return {
44
+ dir,
45
+ db,
46
+ userId: user.id,
47
+ cleanup: () => {
48
+ db.close();
49
+ rmSync(dir, { recursive: true, force: true });
50
+ },
51
+ };
52
+ }
53
+
54
+ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
55
+ const signed = await signAccessToken(h.db, {
56
+ sub: h.userId,
57
+ scopes,
58
+ audience: "parachute-hub",
59
+ clientId: "parachute-hub",
60
+ issuer: ISSUER,
61
+ ttlSeconds: 3600,
62
+ });
63
+ recordTokenMint(h.db, {
64
+ jti: signed.jti,
65
+ createdVia: "operator_mint",
66
+ subject: h.userId,
67
+ clientId: "parachute-hub",
68
+ scopes,
69
+ expiresAt: signed.expiresAt,
70
+ });
71
+ return signed.token;
72
+ }
73
+
74
+ function getReq(headers: Record<string, string> = {}): Request {
75
+ return new Request("http://localhost/api/settings/root-redirect", { method: "GET", headers });
76
+ }
77
+
78
+ function putReq(body: unknown, headers: Record<string, string> = {}): Request {
79
+ return new Request("http://localhost/api/settings/root-redirect", {
80
+ method: "PUT",
81
+ headers: { "content-type": "application/json", ...headers },
82
+ body: typeof body === "string" ? body : JSON.stringify(body),
83
+ });
84
+ }
85
+
86
+ // Empty env so the resolver's env layer is deterministic (the host's real
87
+ // PARACHUTE_HUB_ROOT_REDIRECT must not leak into GET's resolved/source).
88
+ const noEnv: NodeJS.ProcessEnv = {};
89
+
90
+ function deps(
91
+ h: Harness,
92
+ overrides: Partial<Parameters<typeof handleApiSettingsRootRedirect>[1]> = {},
93
+ ) {
94
+ return {
95
+ db: h.db,
96
+ issuer: ISSUER,
97
+ env: noEnv,
98
+ ...overrides,
99
+ };
100
+ }
101
+
102
+ describe("validateRootRedirect — pure validator", () => {
103
+ test("null → normalized null (clear)", () => {
104
+ expect(validateRootRedirect(null)).toEqual({ ok: true, normalized: null });
105
+ });
106
+
107
+ test("empty string → normalized null (clear footgun guard)", () => {
108
+ expect(validateRootRedirect("")).toEqual({ ok: true, normalized: null });
109
+ });
110
+
111
+ test("safe same-origin path → normalized verbatim", () => {
112
+ expect(validateRootRedirect("/surface/reading-room")).toEqual({
113
+ ok: true,
114
+ normalized: "/surface/reading-room",
115
+ });
116
+ });
117
+
118
+ test("rejects off-origin + scheme shapes", () => {
119
+ for (const bad of [
120
+ "//evil.com",
121
+ "/\\evil.com",
122
+ "https://evil.com",
123
+ "javascript:alert(1)",
124
+ "admin",
125
+ "/",
126
+ ]) {
127
+ const r = validateRootRedirect(bad);
128
+ expect(r.ok).toBe(false);
129
+ }
130
+ });
131
+
132
+ test("rejects non-string non-null", () => {
133
+ expect(validateRootRedirect(42).ok).toBe(false);
134
+ expect(validateRootRedirect({}).ok).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe("auth gating", () => {
139
+ let h: Harness;
140
+ beforeEach(async () => {
141
+ h = await makeHarness();
142
+ });
143
+ afterEach(() => h.cleanup());
144
+
145
+ test("405 on non-GET/PUT", async () => {
146
+ const res = await handleApiSettingsRootRedirect(
147
+ new Request("http://localhost/api/settings/root-redirect", { method: "POST" }),
148
+ deps(h),
149
+ );
150
+ expect(res.status).toBe(405);
151
+ });
152
+
153
+ test("401 when Authorization header is missing", async () => {
154
+ const res = await handleApiSettingsRootRedirect(getReq(), deps(h));
155
+ expect(res.status).toBe(401);
156
+ });
157
+
158
+ test("401 on empty bearer", async () => {
159
+ const res = await handleApiSettingsRootRedirect(getReq({ authorization: "Bearer " }), deps(h));
160
+ expect(res.status).toBe(401);
161
+ });
162
+
163
+ test("403 when the bearer lacks the required scope", async () => {
164
+ const bearer = await mintBearer(h, ["parachute:host:auth"]);
165
+ const resGet = await handleApiSettingsRootRedirect(
166
+ getReq({ authorization: `Bearer ${bearer}` }),
167
+ deps(h),
168
+ );
169
+ expect(resGet.status).toBe(403);
170
+ const resPut = await handleApiSettingsRootRedirect(
171
+ putReq({ root_redirect: "/surface/x" }, { authorization: `Bearer ${bearer}` }),
172
+ deps(h),
173
+ );
174
+ expect(resPut.status).toBe(403);
175
+ // Nothing was written.
176
+ expect(getRootRedirect(h.db)).toBeNull();
177
+ });
178
+ });
179
+
180
+ describe("GET /api/settings/root-redirect", () => {
181
+ let h: Harness;
182
+ beforeEach(async () => {
183
+ h = await makeHarness();
184
+ });
185
+ afterEach(() => h.cleanup());
186
+
187
+ test("default shape when unset: /admin from the default layer", async () => {
188
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
189
+ const res = await handleApiSettingsRootRedirect(
190
+ getReq({ authorization: `Bearer ${bearer}` }),
191
+ deps(h),
192
+ );
193
+ expect(res.status).toBe(200);
194
+ const body = (await res.json()) as Record<string, unknown>;
195
+ expect(body).toEqual({ root_redirect: null, resolved: "/admin", source: "default" });
196
+ });
197
+
198
+ test("reflects a stored value with source=db", async () => {
199
+ setRootRedirect(h.db, "/surface/reading-room");
200
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
201
+ const res = await handleApiSettingsRootRedirect(
202
+ getReq({ authorization: `Bearer ${bearer}` }),
203
+ deps(h),
204
+ );
205
+ const body = (await res.json()) as Record<string, unknown>;
206
+ expect(body).toEqual({
207
+ root_redirect: "/surface/reading-room",
208
+ resolved: "/surface/reading-room",
209
+ source: "db",
210
+ });
211
+ });
212
+
213
+ test("surfaces an env-sourced resolved value while the stored row is null", async () => {
214
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
215
+ const res = await handleApiSettingsRootRedirect(
216
+ getReq({ authorization: `Bearer ${bearer}` }),
217
+ deps(h, { env: { PARACHUTE_HUB_ROOT_REDIRECT: "/surface/from-env" } }),
218
+ );
219
+ const body = (await res.json()) as Record<string, unknown>;
220
+ expect(body).toEqual({
221
+ root_redirect: null,
222
+ resolved: "/surface/from-env",
223
+ source: "env",
224
+ });
225
+ });
226
+ });
227
+
228
+ describe("PUT /api/settings/root-redirect", () => {
229
+ let h: Harness;
230
+ beforeEach(async () => {
231
+ h = await makeHarness();
232
+ });
233
+ afterEach(() => h.cleanup());
234
+
235
+ test("stores a safe path + GET reflects it on the next request (no restart)", async () => {
236
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
237
+ const put = await handleApiSettingsRootRedirect(
238
+ putReq({ root_redirect: "/surface/reading-room" }, { authorization: `Bearer ${bearer}` }),
239
+ deps(h),
240
+ );
241
+ expect(put.status).toBe(200);
242
+ expect((await put.json()) as unknown).toEqual({ root_redirect: "/surface/reading-room" });
243
+ expect(getRootRedirect(h.db)).toBe("/surface/reading-room");
244
+
245
+ const get = await handleApiSettingsRootRedirect(
246
+ getReq({ authorization: `Bearer ${bearer}` }),
247
+ deps(h),
248
+ );
249
+ const body = (await get.json()) as Record<string, unknown>;
250
+ expect(body.resolved).toBe("/surface/reading-room");
251
+ expect(body.source).toBe("db");
252
+ });
253
+
254
+ test("null clears the row", async () => {
255
+ setRootRedirect(h.db, "/surface/x");
256
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
257
+ const res = await handleApiSettingsRootRedirect(
258
+ putReq({ root_redirect: null }, { authorization: `Bearer ${bearer}` }),
259
+ deps(h),
260
+ );
261
+ expect(res.status).toBe(200);
262
+ expect(getRootRedirect(h.db)).toBeNull();
263
+ });
264
+
265
+ test("rejects open-redirect payloads with 400 and writes nothing", async () => {
266
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
267
+ for (const bad of [
268
+ "//evil.com",
269
+ "https://evil.com",
270
+ "javascript:alert(1)",
271
+ "/\\evil.com",
272
+ "/",
273
+ ]) {
274
+ const res = await handleApiSettingsRootRedirect(
275
+ putReq({ root_redirect: bad }, { authorization: `Bearer ${bearer}` }),
276
+ deps(h),
277
+ );
278
+ expect(res.status).toBe(400);
279
+ const body = (await res.json()) as Record<string, unknown>;
280
+ expect(body.error).toBe("invalid_root_redirect");
281
+ expect(getRootRedirect(h.db)).toBeNull();
282
+ }
283
+ });
284
+
285
+ test("400 on a body without a root_redirect field", async () => {
286
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
287
+ const res = await handleApiSettingsRootRedirect(
288
+ putReq({ wrong: "x" }, { authorization: `Bearer ${bearer}` }),
289
+ deps(h),
290
+ );
291
+ expect(res.status).toBe(400);
292
+ });
293
+
294
+ test("400 on non-JSON body", async () => {
295
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
296
+ const res = await handleApiSettingsRootRedirect(
297
+ putReq("not json{", { authorization: `Bearer ${bearer}` }),
298
+ deps(h),
299
+ );
300
+ expect(res.status).toBe(400);
301
+ });
302
+ });