@openparachute/hub 0.7.4-rc.19 → 0.7.4-rc.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.19",
3
+ "version": "0.7.4-rc.20",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -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
+ });
@@ -12,9 +12,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
12
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
- import { hub, hubSetOrigin, rewriteCaddyfileHost } from "../commands/hub.ts";
15
+ import { hub, hubSetOrigin, hubSetRootRedirect, rewriteCaddyfileHost } from "../commands/hub.ts";
16
16
  import { hubDbPath, openHubDb } from "../hub-db.ts";
17
- import { getHubOrigin } from "../hub-settings.ts";
17
+ import { getHubOrigin, getRootRedirect } from "../hub-settings.ts";
18
18
  import type { CommandResult } from "../tailscale/run.ts";
19
19
 
20
20
  describe("parachute hub set-origin", () => {
@@ -431,3 +431,70 @@ describe("parachute hub set-origin — Caddy automation", () => {
431
431
  expect(log.join("\n")).toContain("already points at old.example.com");
432
432
  });
433
433
  });
434
+
435
+ describe("parachute hub set-root-redirect", () => {
436
+ let dir: string;
437
+ let log: string[];
438
+ const collect = (line: string) => log.push(line);
439
+
440
+ beforeEach(() => {
441
+ dir = mkdtempSync(join(tmpdir(), "hub-set-root-redirect-"));
442
+ log = [];
443
+ });
444
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
445
+
446
+ /** Open the configDir's hub.db and read the persisted root_redirect. */
447
+ function persisted(): string | null {
448
+ const db = openHubDb(hubDbPath(dir));
449
+ try {
450
+ return getRootRedirect(db);
451
+ } finally {
452
+ db.close();
453
+ }
454
+ }
455
+
456
+ test("persists a safe same-origin path to hub_settings.root_redirect", async () => {
457
+ const code = await hubSetRootRedirect(["/surface/reading-room"], {
458
+ configDir: dir,
459
+ log: collect,
460
+ });
461
+ expect(code).toBe(0);
462
+ expect(persisted()).toBe("/surface/reading-room");
463
+ });
464
+
465
+ test("--clear deletes the row", async () => {
466
+ await hubSetRootRedirect(["/surface/x"], { configDir: dir, log: collect });
467
+ const code = await hubSetRootRedirect(["--clear"], { configDir: dir, log: collect });
468
+ expect(code).toBe(0);
469
+ expect(persisted()).toBeNull();
470
+ });
471
+
472
+ test("rejects an open-redirect path without writing", async () => {
473
+ for (const bad of ["//evil.com", "https://evil.com", "/\\evil.com", "/"]) {
474
+ const code = await hubSetRootRedirect([bad], { configDir: dir, log: collect });
475
+ expect(code).toBe(1);
476
+ expect(persisted()).toBeNull();
477
+ }
478
+ });
479
+
480
+ test("rejects a path with no leading slash without writing", async () => {
481
+ const code = await hubSetRootRedirect(["surface/x"], { configDir: dir, log: collect });
482
+ expect(code).toBe(1);
483
+ expect(persisted()).toBeNull();
484
+ });
485
+
486
+ test("usage error (exit 1) when no path + no --clear", async () => {
487
+ const code = await hubSetRootRedirect([], { configDir: dir, log: collect });
488
+ expect(code).toBe(1);
489
+ expect(persisted()).toBeNull();
490
+ });
491
+
492
+ test("routed through the `hub` dispatcher", async () => {
493
+ const code = await hub(["set-root-redirect", "/surface/via-dispatcher"], {
494
+ configDir: dir,
495
+ log: collect,
496
+ });
497
+ expect(code).toBe(0);
498
+ expect(persisted()).toBe("/surface/via-dispatcher");
499
+ });
500
+ });
@@ -12,8 +12,10 @@ import { join } from "node:path";
12
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
13
  import {
14
14
  DEFAULT_MODULE_INSTALL_CHANNEL,
15
+ DEFAULT_ROOT_REDIRECT,
15
16
  FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
16
17
  MODULE_INSTALL_CHANNELS,
18
+ PARACHUTE_HUB_ROOT_REDIRECT_ENV,
17
19
  PARACHUTE_INSTALL_CHANNEL_ENV,
18
20
  PARACHUTE_MODULE_CHANNEL_ENV,
19
21
  SETUP_EXPOSE_MODES,
@@ -21,15 +23,20 @@ import {
21
23
  deleteSetting,
22
24
  getHubOrigin,
23
25
  getModuleInstallChannel,
26
+ getRootRedirect,
24
27
  getSetting,
25
28
  isFirstClientAutoApproveWindowOpen,
26
29
  isModuleInstallChannel,
27
30
  isNotesRedirectDisabled,
31
+ isSafeRedirectPath,
28
32
  isSetupExposeMode,
29
33
  openFirstClientAutoApproveWindow,
34
+ resolveRootRedirect,
35
+ resolveRootRedirectDetailed,
30
36
  setHubOrigin,
31
37
  setModuleInstallChannel,
32
38
  setNotesRedirectDisabled,
39
+ setRootRedirect,
33
40
  setSetting,
34
41
  } from "../hub-settings.ts";
35
42
 
@@ -613,3 +620,184 @@ describe("hub-settings — notes_redirect_disabled (parachute-app §16)", () =>
613
620
  }
614
621
  });
615
622
  });
623
+
624
+ describe("hub-settings — isSafeRedirectPath (open-redirect guard)", () => {
625
+ test("accepts plain same-origin relative paths", () => {
626
+ expect(isSafeRedirectPath("/admin")).toBe(true);
627
+ expect(isSafeRedirectPath("/surface/reading-room")).toBe(true);
628
+ expect(isSafeRedirectPath("/vault/default/")).toBe(true);
629
+ // Query + fragment stay same-origin → allowed.
630
+ expect(isSafeRedirectPath("/surface/x?view=reading#top")).toBe(true);
631
+ // Deep paths with hyphens/dots/underscores (regression: a botched
632
+ // whitespace regex once rejected `-`).
633
+ expect(isSafeRedirectPath("/a-b_c.d/e")).toBe(true);
634
+ });
635
+
636
+ test("rejects protocol-relative + backslash authority tricks", () => {
637
+ expect(isSafeRedirectPath("//evil.com")).toBe(false);
638
+ expect(isSafeRedirectPath("//evil.com/path")).toBe(false);
639
+ expect(isSafeRedirectPath("/\\evil.com")).toBe(false);
640
+ expect(isSafeRedirectPath("/\\\\evil.com")).toBe(false);
641
+ });
642
+
643
+ test("rejects absolute URLs + scheme payloads", () => {
644
+ expect(isSafeRedirectPath("https://evil.com")).toBe(false);
645
+ expect(isSafeRedirectPath("http://evil.com/x")).toBe(false);
646
+ // biome-ignore lint/suspicious/noExplicitAny: testing the runtime guard with a hostile string
647
+ expect(isSafeRedirectPath("javascript:alert(1)" as any)).toBe(false);
648
+ expect(isSafeRedirectPath("data:text/html,<script>1</script>")).toBe(false);
649
+ });
650
+
651
+ test("rejects values missing a leading slash", () => {
652
+ expect(isSafeRedirectPath("admin")).toBe(false);
653
+ expect(isSafeRedirectPath("evil.com")).toBe(false);
654
+ expect(isSafeRedirectPath("")).toBe(false);
655
+ });
656
+
657
+ test("rejects pathname-`/` targets (would 302-loop the bare-`/` route)", () => {
658
+ expect(isSafeRedirectPath("/")).toBe(false);
659
+ expect(isSafeRedirectPath("/?next=x")).toBe(false);
660
+ expect(isSafeRedirectPath("/#frag")).toBe(false);
661
+ });
662
+
663
+ test("rejects whitespace + control chars (header-injection / normalization)", () => {
664
+ expect(isSafeRedirectPath("/admin\r\nSet-Cookie: x=1")).toBe(false);
665
+ expect(isSafeRedirectPath("/ad min")).toBe(false);
666
+ expect(isSafeRedirectPath("/admin\t")).toBe(false);
667
+ expect(isSafeRedirectPath("/\tadmin")).toBe(false);
668
+ expect(isSafeRedirectPath("/admin ")).toBe(false);
669
+ // U+2028 line separator (stripped by some parsers) — built via charCode so
670
+ // the source file carries no irregular-whitespace literal.
671
+ expect(isSafeRedirectPath(`/admin${String.fromCharCode(0x2028)}x`)).toBe(false);
672
+ });
673
+
674
+ test("rejects non-string inputs", () => {
675
+ // biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
676
+ expect(isSafeRedirectPath(null as any)).toBe(false);
677
+ // biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
678
+ expect(isSafeRedirectPath(undefined as any)).toBe(false);
679
+ // biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
680
+ expect(isSafeRedirectPath(42 as any)).toBe(false);
681
+ // biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
682
+ expect(isSafeRedirectPath({} as any)).toBe(false);
683
+ });
684
+ });
685
+
686
+ describe("hub-settings — root_redirect storage + resolution", () => {
687
+ let dir: string;
688
+ beforeEach(() => {
689
+ dir = mkdtempSync(join(tmpdir(), "hub-settings-root-redirect-"));
690
+ });
691
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
692
+
693
+ // An empty env so the resolver's env layer is deterministic (the host's real
694
+ // PARACHUTE_HUB_ROOT_REDIRECT, if any, must not leak in).
695
+ const noEnv: NodeJS.ProcessEnv = {};
696
+ const silent = () => {};
697
+
698
+ test("getRootRedirect round-trips via setRootRedirect", () => {
699
+ const db = openHubDb(hubDbPath(dir));
700
+ try {
701
+ expect(getRootRedirect(db)).toBeNull();
702
+ setRootRedirect(db, "/surface/reading-room");
703
+ expect(getRootRedirect(db)).toBe("/surface/reading-room");
704
+ } finally {
705
+ db.close();
706
+ }
707
+ });
708
+
709
+ test("setRootRedirect(null) / empty clears the row", () => {
710
+ const db = openHubDb(hubDbPath(dir));
711
+ try {
712
+ setRootRedirect(db, "/surface/x");
713
+ setRootRedirect(db, null);
714
+ expect(getRootRedirect(db)).toBeNull();
715
+ setRootRedirect(db, "/surface/x");
716
+ setRootRedirect(db, "");
717
+ expect(getRootRedirect(db)).toBeNull();
718
+ } finally {
719
+ db.close();
720
+ }
721
+ });
722
+
723
+ test("resolves to /admin default when neither DB nor env is set", () => {
724
+ const db = openHubDb(hubDbPath(dir));
725
+ try {
726
+ expect(resolveRootRedirect(db, { env: noEnv })).toBe(DEFAULT_ROOT_REDIRECT);
727
+ expect(resolveRootRedirectDetailed(db, { env: noEnv })).toEqual({
728
+ value: "/admin",
729
+ source: "default",
730
+ });
731
+ } finally {
732
+ db.close();
733
+ }
734
+ });
735
+
736
+ test("env override applies when no DB row", () => {
737
+ const db = openHubDb(hubDbPath(dir));
738
+ try {
739
+ const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
740
+ expect(resolveRootRedirectDetailed(db, { env })).toEqual({
741
+ value: "/surface/from-env",
742
+ source: "env",
743
+ });
744
+ } finally {
745
+ db.close();
746
+ }
747
+ });
748
+
749
+ test("DB row overrides env (DB is tier-1)", () => {
750
+ const db = openHubDb(hubDbPath(dir));
751
+ try {
752
+ setRootRedirect(db, "/surface/from-db");
753
+ const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
754
+ expect(resolveRootRedirectDetailed(db, { env })).toEqual({
755
+ value: "/surface/from-db",
756
+ source: "db",
757
+ });
758
+ } finally {
759
+ db.close();
760
+ }
761
+ });
762
+
763
+ test("an unsafe DB row is ignored → falls through to env", () => {
764
+ const db = openHubDb(hubDbPath(dir));
765
+ try {
766
+ // Simulate a hand-edited sqlite row that bypassed write-side validation.
767
+ setSetting(db, "root_redirect", "//evil.com");
768
+ const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
769
+ expect(resolveRootRedirect(db, { env, warn: silent })).toBe("/surface/from-env");
770
+ } finally {
771
+ db.close();
772
+ }
773
+ });
774
+
775
+ test("an unsafe DB row with no env → falls all the way back to /admin", () => {
776
+ const db = openHubDb(hubDbPath(dir));
777
+ try {
778
+ setSetting(db, "root_redirect", "https://evil.com");
779
+ expect(resolveRootRedirect(db, { env: noEnv, warn: silent })).toBe("/admin");
780
+ } finally {
781
+ db.close();
782
+ }
783
+ });
784
+
785
+ test("an unsafe env value is ignored → falls back to /admin", () => {
786
+ const db = openHubDb(hubDbPath(dir));
787
+ try {
788
+ const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "//evil.com" };
789
+ expect(resolveRootRedirectDetailed(db, { env, warn: silent })).toEqual({
790
+ value: "/admin",
791
+ source: "default",
792
+ });
793
+ } finally {
794
+ db.close();
795
+ }
796
+ });
797
+
798
+ test("a null db (no state) resolves from env / default only", () => {
799
+ expect(resolveRootRedirect(null, { env: noEnv })).toBe("/admin");
800
+ const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
801
+ expect(resolveRootRedirect(null, { env })).toBe("/surface/from-env");
802
+ });
803
+ });
@@ -31,6 +31,7 @@ import { tmpdir } from "node:os";
31
31
  import { join } from "node:path";
32
32
  import { hubDbPath, openHubDb } from "../hub-db.ts";
33
33
  import { hubFetch } from "../hub-server.ts";
34
+ import { setRootRedirect, setSetting } from "../hub-settings.ts";
34
35
  import { writeManifest } from "../services-manifest.ts";
35
36
  import { createUser } from "../users.ts";
36
37
 
@@ -101,9 +102,7 @@ describe("setup gate (no admin yet)", () => {
101
102
  test("/login POST still 503s setup_required when no admin exists (hub#644)", async () => {
102
103
  const db = openHubDb(hubDbPath(h.dir));
103
104
  try {
104
- const res = await hubFetch(h.dir, { getDb: () => db })(
105
- req("/login", { method: "POST" }),
106
- );
105
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/login", { method: "POST" }));
107
106
  expect(res.status).toBe(503);
108
107
  const body = (await res.json()) as Record<string, unknown>;
109
108
  expect(body.error).toBe("setup_required");
@@ -368,3 +367,112 @@ describe("setup gate (admin exists)", () => {
368
367
  }
369
368
  });
370
369
  });
370
+
371
+ describe("configurable bare-`/` redirect target", () => {
372
+ let h: Harness;
373
+ beforeEach(() => {
374
+ h = makeHarness();
375
+ });
376
+ afterEach(() => h.cleanup());
377
+
378
+ /** A set-up hub (admin + vault) so the bare-`/` redirect is reached. */
379
+ function setUpHub(db: ReturnType<typeof openHubDb>): void {
380
+ writeManifest(
381
+ {
382
+ services: [
383
+ {
384
+ name: "parachute-vault",
385
+ version: "0.1.0",
386
+ port: 1940,
387
+ paths: ["/vault/default"],
388
+ health: "/health",
389
+ },
390
+ ],
391
+ },
392
+ join(h.dir, "services.json"),
393
+ );
394
+ }
395
+
396
+ function handler(db: ReturnType<typeof openHubDb>) {
397
+ return hubFetch(h.dir, { getDb: () => db, manifestPath: join(h.dir, "services.json") });
398
+ }
399
+
400
+ test("a configured root_redirect retargets the bare-`/` 302", async () => {
401
+ const db = openHubDb(hubDbPath(h.dir));
402
+ try {
403
+ await createUser(db, "owner", "pw");
404
+ setUpHub(db);
405
+ setRootRedirect(db, "/surface/reading-room");
406
+ const res = await handler(db)(req("/"));
407
+ expect(res.status).toBe(302);
408
+ expect(res.headers.get("location")).toBe("/surface/reading-room");
409
+ } finally {
410
+ db.close();
411
+ }
412
+ });
413
+
414
+ test("an unsafe stored root_redirect falls back to /admin (never an open redirect)", async () => {
415
+ const db = openHubDb(hubDbPath(h.dir));
416
+ try {
417
+ await createUser(db, "owner", "pw");
418
+ setUpHub(db);
419
+ // A hand-edited sqlite row that bypassed write-side validation.
420
+ setSetting(db, "root_redirect", "//evil.com");
421
+ const res = await handler(db)(req("/"));
422
+ expect(res.status).toBe(302);
423
+ expect(res.headers.get("location")).toBe("/admin");
424
+ } finally {
425
+ db.close();
426
+ }
427
+ });
428
+
429
+ test("PARACHUTE_HUB_ROOT_REDIRECT env retargets the bare-`/` 302 (no DB row)", async () => {
430
+ const db = openHubDb(hubDbPath(h.dir));
431
+ const prev = process.env.PARACHUTE_HUB_ROOT_REDIRECT;
432
+ process.env.PARACHUTE_HUB_ROOT_REDIRECT = "/surface/from-env";
433
+ try {
434
+ await createUser(db, "owner", "pw");
435
+ setUpHub(db);
436
+ const res = await handler(db)(req("/"));
437
+ expect(res.status).toBe(302);
438
+ expect(res.headers.get("location")).toBe("/surface/from-env");
439
+ } finally {
440
+ // Restore process.env to its pre-test state. `delete` (not assign-undefined,
441
+ // which would coerce to the string "undefined") removes a key we added.
442
+ if (prev === undefined) {
443
+ // biome-ignore lint/performance/noDelete: env-key cleanup, not a hot path
444
+ delete process.env.PARACHUTE_HUB_ROOT_REDIRECT;
445
+ } else {
446
+ process.env.PARACHUTE_HUB_ROOT_REDIRECT = prev;
447
+ }
448
+ db.close();
449
+ }
450
+ });
451
+
452
+ test("wizard funnel WINS: a configured root_redirect does NOT bypass setup on a fresh hub", async () => {
453
+ const db = openHubDb(hubDbPath(h.dir));
454
+ try {
455
+ // No admin yet → not-set-up hub. Even with a surface configured, the
456
+ // bare-`/` must funnel to the wizard, not a surface that can't work yet.
457
+ setRootRedirect(db, "/surface/reading-room");
458
+ const res = await handler(db)(req("/"));
459
+ expect(res.status).toBe(302);
460
+ expect(res.headers.get("location")).toBe("/admin/setup");
461
+ } finally {
462
+ db.close();
463
+ }
464
+ });
465
+
466
+ test("default is unchanged: bare-`/` → /admin when nothing is configured", async () => {
467
+ const db = openHubDb(hubDbPath(h.dir));
468
+ try {
469
+ await createUser(db, "owner", "pw");
470
+ setUpHub(db);
471
+ const res = await handler(db)(req("/"));
472
+ expect(res.status).toBe(302);
473
+ expect(res.headers.get("location")).toBe("/admin");
474
+ } finally {
475
+ db.close();
476
+ }
477
+ });
478
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `GET|PUT /api/settings/root-redirect` — operator-settable target for the
3
+ * bare-`/` 302.
4
+ *
5
+ * The hub's root (`/`) redirects to `/admin` by default. This endpoint lets an
6
+ * operator point it at a surface instead (e.g. a custom-domain hub fronting a
7
+ * team reading-room surface) without redeploying. The stored value resolves
8
+ * tier-1 in `resolveRootRedirect` (hub-settings.ts):
9
+ *
10
+ * 1. hub_settings.root_redirect (this endpoint writes here)
11
+ * 2. PARACHUTE_HUB_ROOT_REDIRECT env
12
+ * 3. `/admin` default (unchanged behavior)
13
+ *
14
+ * The endpoint surfaces both the stored value *and* the resolved value + source
15
+ * so the SPA can render "current: /surface/x (from env)" while the input shows
16
+ * the empty stored row — same separation rationale as `/api/settings/hub-origin`.
17
+ *
18
+ * OPEN-REDIRECT SAFETY is the highest-stakes part: the resolved value lands in a
19
+ * `Location:` header, so an off-origin value would be a textbook open redirect.
20
+ * PUT validation (and the read-time resolver) require a SAME-ORIGIN relative
21
+ * path via `isSafeRedirectPath` — must start with a single `/`, never `//` /
22
+ * `/\` / a scheme, no control chars / whitespace, and must not resolve back to
23
+ * `/` (redirect loop). Anything else is rejected (PUT 400 / resolver fallback to
24
+ * `/admin`).
25
+ *
26
+ * Bearer-gated on `parachute:host:admin`, mirroring `handleApiSettingsHubOrigin`
27
+ * — same Bearer parsing, scope-check posture, and error vocabulary.
28
+ */
29
+
30
+ import type { Database } from "bun:sqlite";
31
+ import {
32
+ type RootRedirectSource,
33
+ getRootRedirect,
34
+ isSafeRedirectPath,
35
+ resolveRootRedirectDetailed,
36
+ setRootRedirect,
37
+ } from "./hub-settings.ts";
38
+ import { validateAccessToken } from "./jwt-sign.ts";
39
+
40
+ /** Scope required on the bearer token to call either endpoint. */
41
+ export const API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE = "parachute:host:admin";
42
+
43
+ export interface ApiSettingsRootRedirectDeps {
44
+ db: Database;
45
+ /** Issuer the bearer token must validate against (the hub's resolved issuer). */
46
+ issuer: string;
47
+ /**
48
+ * Env seam for the resolver's env layer. Defaults to `process.env`. Threaded
49
+ * so the dispatcher (and tests) can resolve `PARACHUTE_HUB_ROOT_REDIRECT`
50
+ * deterministically.
51
+ */
52
+ env?: NodeJS.ProcessEnv;
53
+ }
54
+
55
+ interface GetResponseBody {
56
+ /** Raw stored value from hub_settings.root_redirect, or null. */
57
+ root_redirect: string | null;
58
+ /** Resolved target applied to the bare-`/` 302 (precedence-aware, guarded). */
59
+ resolved: string;
60
+ /** Which precedence layer the resolved value came from. */
61
+ source: RootRedirectSource;
62
+ }
63
+
64
+ interface PutResponseBody {
65
+ /** Echo of the now-stored value (null if cleared). */
66
+ root_redirect: string | null;
67
+ }
68
+
69
+ /**
70
+ * Validation outcome. The "normalized" branch is what gets passed to
71
+ * setRootRedirect — string (a safe path) or null (clear the row).
72
+ */
73
+ type ValidateOutcome = { ok: true; normalized: string | null } | { ok: false; description: string };
74
+
75
+ /**
76
+ * Validate the body's `root_redirect` field. Accepts:
77
+ * - `null` (or empty string) → clear the stored value, revert to env/default.
78
+ * - A safe SAME-ORIGIN relative path per `isSafeRedirectPath`.
79
+ * Everything else → 400 with an operator-friendly description.
80
+ */
81
+ export function validateRootRedirect(value: unknown): ValidateOutcome {
82
+ if (value === null) return { ok: true, normalized: null };
83
+ if (typeof value !== "string") {
84
+ return {
85
+ ok: false,
86
+ description: `root_redirect must be a string or null (got ${typeof value})`,
87
+ };
88
+ }
89
+ // Empty string is the canonical "clear" shape — store as null (mirrors
90
+ // setHubOrigin's footgun guard; an empty Location would be meaningless).
91
+ if (value.length === 0) return { ok: true, normalized: null };
92
+ if (!isSafeRedirectPath(value)) {
93
+ return {
94
+ ok: false,
95
+ description:
96
+ "root_redirect must be a same-origin relative path (start with a single `/`, no `//`/`/\\`/scheme, no whitespace, and not `/` itself)",
97
+ };
98
+ }
99
+ return { ok: true, normalized: value };
100
+ }
101
+
102
+ export async function handleApiSettingsRootRedirect(
103
+ req: Request,
104
+ deps: ApiSettingsRootRedirectDeps,
105
+ ): Promise<Response> {
106
+ if (req.method !== "GET" && req.method !== "PUT") {
107
+ return jsonError(405, "method_not_allowed", "use GET or PUT");
108
+ }
109
+
110
+ // Bearer presence + parsing — identical shape to api-settings-hub-origin
111
+ // for consistency across hub-internal admin endpoints.
112
+ const auth = req.headers.get("authorization");
113
+ if (!auth || !auth.startsWith("Bearer ")) {
114
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
115
+ }
116
+ const bearer = auth.slice("Bearer ".length).trim();
117
+ if (!bearer) {
118
+ return jsonError(401, "unauthenticated", "empty bearer token");
119
+ }
120
+
121
+ // Bearer validation + scope check.
122
+ try {
123
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
124
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
125
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
126
+ }
127
+ const scopes =
128
+ typeof validated.payload.scope === "string"
129
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
130
+ : [];
131
+ if (!scopes.includes(API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE)) {
132
+ return jsonError(
133
+ 403,
134
+ "insufficient_scope",
135
+ `bearer token lacks ${API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE}`,
136
+ );
137
+ }
138
+ } catch (err) {
139
+ const msg = err instanceof Error ? err.message : String(err);
140
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
141
+ }
142
+
143
+ if (req.method === "GET") {
144
+ const resolved = resolveRootRedirectDetailed(deps.db, { env: deps.env });
145
+ const body: GetResponseBody = {
146
+ root_redirect: getRootRedirect(deps.db),
147
+ resolved: resolved.value,
148
+ source: resolved.source,
149
+ };
150
+ return new Response(JSON.stringify(body), {
151
+ status: 200,
152
+ headers: { "content-type": "application/json" },
153
+ });
154
+ }
155
+
156
+ // PUT — parse + validate body.
157
+ let parsed: unknown;
158
+ try {
159
+ parsed = await req.json();
160
+ } catch {
161
+ return jsonError(400, "invalid_request", "request body must be JSON");
162
+ }
163
+ if (typeof parsed !== "object" || parsed === null) {
164
+ return jsonError(400, "invalid_request", "request body must be a JSON object");
165
+ }
166
+ if (!("root_redirect" in parsed)) {
167
+ return jsonError(400, "invalid_request", "request body must include a `root_redirect` field");
168
+ }
169
+ const result = validateRootRedirect((parsed as { root_redirect: unknown }).root_redirect);
170
+ if (!result.ok) {
171
+ return jsonError(400, "invalid_root_redirect", result.description);
172
+ }
173
+
174
+ setRootRedirect(deps.db, result.normalized);
175
+
176
+ const body: PutResponseBody = { root_redirect: result.normalized };
177
+ return new Response(JSON.stringify(body), {
178
+ status: 200,
179
+ headers: { "content-type": "application/json" },
180
+ });
181
+ }
182
+
183
+ function jsonError(status: number, code: string, description: string): Response {
184
+ return new Response(JSON.stringify({ error: code, error_description: description }), {
185
+ status,
186
+ headers: { "content-type": "application/json" },
187
+ });
188
+ }
@@ -32,7 +32,12 @@ import { validateHubOrigin } from "../api-settings-hub-origin.ts";
32
32
  import { restart } from "../commands/lifecycle.ts";
33
33
  import { CONFIG_DIR } from "../config.ts";
34
34
  import { hubDbPath, openHubDb } from "../hub-db.ts";
35
- import { setHubOrigin } from "../hub-settings.ts";
35
+ import {
36
+ DEFAULT_ROOT_REDIRECT,
37
+ isSafeRedirectPath,
38
+ setHubOrigin,
39
+ setRootRedirect,
40
+ } from "../hub-settings.ts";
36
41
  import { type CommandResult, type Runner, defaultRunner } from "../tailscale/run.ts";
37
42
  import { isLoopbackOrigin } from "../vault-hub-origin-env.ts";
38
43
 
@@ -347,6 +352,81 @@ async function runCaddyReload(run: Runner): Promise<CommandResult> {
347
352
  return run(["systemctl", "reload", "caddy"]);
348
353
  }
349
354
 
355
+ /**
356
+ * `parachute hub set-root-redirect <path>` — persist the operator's bare-`/`
357
+ * redirect target into `hub_settings.root_redirect` (tier-1 in
358
+ * `resolveRootRedirect`). Lets a headless box (the canonical use case is a
359
+ * custom-domain hub fronting a team surface) flip its landing page from `/admin`
360
+ * to a surface without a browser session OR a redeploy.
361
+ *
362
+ * `--clear` deletes the row, reverting to the env / `/admin` default.
363
+ *
364
+ * The path is validated through `isSafeRedirectPath` — the SAME open-redirect
365
+ * guard the admin PUT enforces — so the CLI can never plant an off-origin
366
+ * `Location` target either. Returns 0 on success, 1 on a usage / validation /
367
+ * DB-write failure.
368
+ */
369
+ export async function hubSetRootRedirect(
370
+ args: readonly string[],
371
+ deps: HubCommandDeps = {},
372
+ ): Promise<number> {
373
+ const configDir = deps.configDir ?? CONFIG_DIR;
374
+ const log = deps.log ?? ((line) => console.log(line));
375
+ const err = (line: string) => console.error(line);
376
+ const openDb = deps.openDb ?? ((dir: string) => openHubDb(hubDbPath(dir)));
377
+
378
+ const clear = args.includes("--clear");
379
+ const positional = args.filter((a) => !a.startsWith("-"));
380
+
381
+ if (clear) {
382
+ if (positional.length > 0) {
383
+ err("parachute hub set-root-redirect: --clear takes no path argument");
384
+ return 1;
385
+ }
386
+ const db = openDb(configDir);
387
+ try {
388
+ setRootRedirect(db, null);
389
+ } finally {
390
+ db.close();
391
+ }
392
+ log(
393
+ `✓ Cleared the root redirect — \`/\` reverts to env / the ${DEFAULT_ROOT_REDIRECT} default.`,
394
+ );
395
+ return 0;
396
+ }
397
+
398
+ const raw = positional[0];
399
+ if (raw === undefined) {
400
+ err("usage: parachute hub set-root-redirect <path> (or --clear)");
401
+ err("example: parachute hub set-root-redirect /surface/reading-room");
402
+ return 1;
403
+ }
404
+ if (positional.length > 1) {
405
+ err(`parachute hub set-root-redirect: unexpected argument "${positional[1]}"`);
406
+ err("usage: parachute hub set-root-redirect <path> (or --clear)");
407
+ return 1;
408
+ }
409
+
410
+ if (!isSafeRedirectPath(raw)) {
411
+ err(`parachute hub set-root-redirect: "${raw}" is not a safe same-origin path`);
412
+ err(" It must start with a single `/` (no `//`, `/\\`, scheme, or whitespace) and");
413
+ err(" not be `/` itself. Example: /surface/reading-room");
414
+ return 1;
415
+ }
416
+
417
+ const db = openDb(configDir);
418
+ try {
419
+ setRootRedirect(db, raw);
420
+ } finally {
421
+ db.close();
422
+ }
423
+
424
+ log(`✓ Bare \`/\` now redirects to ${raw}.`);
425
+ log(" Stored in hub_settings.root_redirect — takes effect on the next request,");
426
+ log(" no restart needed. Clear it with: parachute hub set-root-redirect --clear");
427
+ return 0;
428
+ }
429
+
350
430
  /**
351
431
  * `parachute hub <subcommand>` dispatcher. Mirrors `auth`'s shape (a thin
352
432
  * router over subcommand handlers, each catching its own errors).
@@ -367,6 +447,16 @@ export async function hub(args: readonly string[], deps: HubCommandDeps = {}): P
367
447
  return 1;
368
448
  }
369
449
  }
450
+ if (sub === "set-root-redirect") {
451
+ try {
452
+ return await hubSetRootRedirect(args.slice(1), deps);
453
+ } catch (err) {
454
+ console.error(
455
+ `parachute hub set-root-redirect: ${err instanceof Error ? err.message : String(err)}`,
456
+ );
457
+ return 1;
458
+ }
459
+ }
370
460
  console.error(`parachute hub: unknown subcommand "${sub}"`);
371
461
  console.error("");
372
462
  console.error(hubHelp());
@@ -378,6 +468,7 @@ export function hubHelp(): string {
378
468
 
379
469
  Usage:
380
470
  parachute hub set-origin <url> [--no-caddy] [--no-restart]
471
+ parachute hub set-root-redirect <path> | --clear
381
472
 
382
473
  Subcommands:
383
474
  set-origin <url> Persist the canonical public origin (OAuth issuer) to the
@@ -401,9 +492,19 @@ Subcommands:
401
492
  Caddyfile rewrite + reload, or --no-restart to skip the
402
493
  module restart.
403
494
 
495
+ set-root-redirect <path>
496
+ Point the bare \`/\` 302 at a same-origin path instead of the
497
+ default /admin (e.g. a team surface). Stored in
498
+ hub_settings.root_redirect; takes effect on the next request,
499
+ no restart. The path must start with a single \`/\` (no \`//\`,
500
+ \`/\\\`, scheme, or whitespace). Pass --clear to revert to the
501
+ env / /admin default. (Env equivalent: PARACHUTE_HUB_ROOT_REDIRECT.)
502
+
404
503
  Examples:
405
504
  parachute hub set-origin https://box.sslip.io
406
505
  parachute hub set-origin https://parachute.example.com
407
506
  parachute hub set-origin https://parachute.example.com --no-caddy
507
+ parachute hub set-root-redirect /surface/reading-room
508
+ parachute hub set-root-redirect --clear
408
509
  `;
409
510
  }
package/src/hub-server.ts CHANGED
@@ -87,6 +87,7 @@
87
87
  * /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
88
88
  * /api/modules/operations/:id (GET) → poll async op status
89
89
  * /api/settings/hub-origin (GET + PUT) → canonical hub URL (host:admin)
90
+ * /api/settings/root-redirect (GET + PUT) → bare-`/` redirect target (host:admin)
90
91
  * /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
91
92
  * /api/auth/revoke-token (POST) → revoke registry-row token by jti
92
93
  * /api/auth/tokens (GET) → paginated registry list
@@ -217,6 +218,7 @@ import { handleApiReady } from "./api-ready.ts";
217
218
  import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
218
219
  import { handleApiRevokeToken } from "./api-revoke-token.ts";
219
220
  import { handleApiSettingsHubOrigin } from "./api-settings-hub-origin.ts";
221
+ import { handleApiSettingsRootRedirect } from "./api-settings-root-redirect.ts";
220
222
  import { handleApiTokens } from "./api-tokens.ts";
221
223
  import {
222
224
  handleCreateUser,
@@ -246,7 +248,7 @@ import {
246
248
  startDbPathLivenessTimer,
247
249
  } from "./hub-db-liveness.ts";
248
250
  import { hubDbPath, openHubDb } from "./hub-db.ts";
249
- import { getHubOrigin } from "./hub-settings.ts";
251
+ import { getHubOrigin, resolveRootRedirect } from "./hub-settings.ts";
250
252
  import { type RenderHubOpts, renderHub } from "./hub.ts";
251
253
  import { pemToJwk } from "./jwks.ts";
252
254
  import {
@@ -2479,23 +2481,32 @@ export function hubFetch(
2479
2481
  );
2480
2482
  }
2481
2483
 
2482
- // Bare `/` → `/admin` (admin-shell IA, R1). The home page and the admin
2483
- // SPA used to be two disconnected surfaces; `/` now funnels straight into
2484
- // the single coherent admin shell, whose Home/Overview carries the
2485
- // discovery content (hub-native sections, modules, user surfaces) that
2486
- // used to live here.
2484
+ // Bare `/` → configurable target (default `/admin`, the admin-shell IA).
2485
+ // The home page and the admin SPA used to be two disconnected surfaces;
2486
+ // `/` funnels straight into the single coherent admin shell, whose
2487
+ // Home/Overview carries the discovery content (hub-native sections,
2488
+ // modules, user surfaces) that used to live here.
2489
+ //
2490
+ // The target is operator-configurable (resolveRootRedirect): a hub_settings
2491
+ // `root_redirect` row → `PARACHUTE_HUB_ROOT_REDIRECT` env → `/admin`
2492
+ // default. Lets an operator point their hub's root at a surface (e.g. a
2493
+ // team reading-room) instead of the admin shell, without redeploying. The
2494
+ // resolver re-validates every layer through the same-origin guard
2495
+ // (`isSafeRedirectPath`) so a stored/env value can NEVER produce an open
2496
+ // redirect — an unsafe value is ignored and falls back to `/admin`.
2487
2497
  //
2488
2498
  // Ordering matters: this sits AFTER the fresh-hub wizard funnel above
2489
- // (so a brand-new operator still lands on `/admin/setup`, not a 404 inside
2490
- // the shell) and AFTER the pre-admin lockout (so an admin-less hub still
2491
- // 503s API callers correctly). 302 (not 301) — `/` is reclaimed for
2492
- // future use, but a permanent redirect would get cached and we may want
2493
- // `/` back later.
2499
+ // (so a brand-new operator still lands on `/admin/setup`, not a surface
2500
+ // that can't work yet) and AFTER the pre-admin lockout (so an admin-less
2501
+ // hub still 503s API callers correctly). 302 (not 301) — the target is
2502
+ // operator-mutable, so a permanent/cached redirect would strand visitors
2503
+ // on a stale destination after the operator flips it.
2494
2504
  //
2495
- // The signed-out path is preserved: a signed-out visitor lands on
2496
- // `/admin`, where the SPA's AuthIndicator shows a "Sign in" link that
2497
- // round-trips through `/login?next=/admin/...` and back. We don't pin the
2498
- // redirect on session state — the shell handles both auth states itself.
2505
+ // The signed-out path is preserved when the target is `/admin`: a
2506
+ // signed-out visitor lands on `/admin`, where the SPA's AuthIndicator
2507
+ // shows a "Sign in" link that round-trips through `/login?next=/admin/...`
2508
+ // and back. We don't pin the redirect on session state — the shell
2509
+ // handles both auth states itself.
2499
2510
  //
2500
2511
  // `/hub.html` is INTENTIONALLY excluded: it still renders the discovery
2501
2512
  // page (used by the static `parachute expose --set-path=/` disk file and
@@ -2503,7 +2514,7 @@ export function hubFetch(
2503
2514
  if (pathname === "/") {
2504
2515
  return new Response(null, {
2505
2516
  status: 302,
2506
- headers: { location: "/admin" },
2517
+ headers: { location: resolveRootRedirect(getDb ? getDb() : null) },
2507
2518
  });
2508
2519
  }
2509
2520
 
@@ -3178,6 +3189,18 @@ export function hubFetch(
3178
3189
  });
3179
3190
  }
3180
3191
 
3192
+ // Bare-`/` redirect target (configurable; default `/admin`). Admin SPA /
3193
+ // CLI reads + writes the operator-set landing page. Same Bearer/scope
3194
+ // posture as hub-origin; the open-redirect guard lives in the handler +
3195
+ // resolver.
3196
+ if (pathname === "/api/settings/root-redirect") {
3197
+ if (!getDb) return dbNotConfigured();
3198
+ return handleApiSettingsRootRedirect(req, {
3199
+ db: getDb(),
3200
+ issuer: oauthDeps(req).issuer,
3201
+ });
3202
+ }
3203
+
3181
3204
  // Module operation poll surface — pre-empts the /api/modules/:short/*
3182
3205
  // routes below so `/api/modules/operations/<uuid>` doesn't accidentally
3183
3206
  // match a parseModulesPath("/operations") and fall through.
@@ -106,7 +106,23 @@ export type HubSettingKey =
106
106
  // Idle timeout for the admin screen-lock, in seconds. Optional override of
107
107
  // the built-in default (DEFAULT_ADMIN_LOCK_IDLE_SECONDS). Stored as a
108
108
  // stringified integer; absent / unparseable falls back to the default.
109
- | "admin_lock_idle_seconds";
109
+ | "admin_lock_idle_seconds"
110
+ // hub: operator-settable target for the bare-`/` 302. Lets an operator
111
+ // point their hub's root at a surface (e.g. a team reading-room surface)
112
+ // instead of the default `/admin`. Stored as a SAME-ORIGIN relative path
113
+ // (must start with a single `/`, never `//` / `/\` / a scheme — see
114
+ // `isSafeRedirectPath`); validated on write (admin PUT + CLI) AND re-checked
115
+ // on read so a hand-edited sqlite row can never produce an open redirect.
116
+ //
117
+ // Precedence on each request (resolveRootRedirect): this row, then
118
+ // `PARACHUTE_HUB_ROOT_REDIRECT` env, then the `/admin` default. DB-first
119
+ // (unlike `module_install_channel`'s env-first) so an operator can flip the
120
+ // landing page from the admin SPA / CLI without a redeploy — the headline
121
+ // use case (custom-domain hub fronting a team surface). The fresh-hub
122
+ // wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
123
+ // not-yet-set-up hub still lands on setup, not a surface that can't work
124
+ // yet.
125
+ | "root_redirect";
110
126
 
111
127
  export type SetupExposeMode = "localhost" | "tailnet" | "public";
112
128
 
@@ -431,3 +447,149 @@ export function setNotesRedirectDisabled(db: Database, value: boolean): void {
431
447
  deleteSetting(db, "notes_redirect_disabled");
432
448
  }
433
449
  }
450
+
451
+ // --- domain helpers: configurable bare-`/` redirect target ----------------
452
+
453
+ /** Env override for the bare-`/` redirect target. Below the DB row, above the default. */
454
+ export const PARACHUTE_HUB_ROOT_REDIRECT_ENV = "PARACHUTE_HUB_ROOT_REDIRECT";
455
+
456
+ /** Fallback when neither DB row nor env is set — the admin shell (unchanged behavior). */
457
+ export const DEFAULT_ROOT_REDIRECT = "/admin";
458
+
459
+ /**
460
+ * Open-redirect guard for the configurable bare-`/` redirect target.
461
+ *
462
+ * The resolved value lands verbatim in a `Location:` header on the `/` 302,
463
+ * so an off-origin value would be a textbook open redirect. To be accepted it
464
+ * must be a SAME-ORIGIN relative path:
465
+ *
466
+ * - starts with a single `/` (a site-relative path). This alone rejects
467
+ * `https://evil.com`, `javascript:…`, and bare hostnames.
468
+ * - second char is NOT `/` (a protocol-relative `//evil.com` sends the
469
+ * browser to another origin) and NOT `\` (browsers normalize the
470
+ * backslash, so `/\evil.com` resolves like `//evil.com`).
471
+ * - contains no ASCII control chars or whitespace — a CR/LF would enable
472
+ * header injection, and tab/newline are stripped by some browsers which
473
+ * could re-expose a hidden `//` authority.
474
+ * - resolves same-origin against a placeholder base (belt-and-suspenders:
475
+ * `new URL(value, base).origin === base`) — catches any scheme/authority
476
+ * shape the prefix checks missed.
477
+ * - does NOT resolve to pathname `/` — that would re-enter this very route
478
+ * and 302-loop forever (`/`, `/?x`, `/#y` all rejected).
479
+ *
480
+ * A query string / fragment on a real path is allowed (stays same-origin).
481
+ * Returns false for non-strings, empty, and every off-origin shape.
482
+ */
483
+ export function isSafeRedirectPath(value: unknown): value is string {
484
+ if (typeof value !== "string" || value.length === 0) return false;
485
+ if (value[0] !== "/") return false;
486
+ if (value[1] === "/" || value[1] === "\\") return false;
487
+ // Reject whitespace (\t \n \r space + Unicode separators U+2028/U+2029) and
488
+ // ASCII control chars. A CR/LF would enable header injection; stripped
489
+ // whitespace could re-expose a hidden `//` authority. `\s` covers the
490
+ // whitespace family (incl. Unicode); the charCode scan covers the remaining
491
+ // non-whitespace control chars (0x00-0x1f, 0x7f) without a control-char
492
+ // regex literal.
493
+ if (/\s/u.test(value)) return false;
494
+ for (let i = 0; i < value.length; i++) {
495
+ const c = value.charCodeAt(i);
496
+ if (c < 0x20 || c === 0x7f) return false;
497
+ }
498
+ try {
499
+ const base = "http://parachute.invalid";
500
+ const resolved = new URL(value, base);
501
+ if (resolved.origin !== base) return false;
502
+ // pathname "/" would match the bare-`/` route again -> infinite redirect.
503
+ if (resolved.pathname === "/") return false;
504
+ } catch {
505
+ return false;
506
+ }
507
+ return true;
508
+ }
509
+
510
+ /**
511
+ * Read the operator-set bare-`/` redirect target from hub_settings. Returns
512
+ * the raw stored value (or `null` when absent) WITHOUT re-validating — callers
513
+ * that need a safe value go through `resolveRootRedirect`, which re-checks the
514
+ * guard. The raw read is what the admin GET surfaces so the operator sees
515
+ * exactly what's stored (even if a hand-edit made it unsafe → ignored on use).
516
+ */
517
+ export function getRootRedirect(db: Database): string | null {
518
+ return getSetting(db, "root_redirect") ?? null;
519
+ }
520
+
521
+ /**
522
+ * Write or clear the bare-`/` redirect target. Passing `null`/empty deletes
523
+ * the row, reverting to env / default precedence (mirrors `setHubOrigin`).
524
+ * The caller MUST have validated via `isSafeRedirectPath` — this trusts the
525
+ * input (typed-callsite contract); `resolveRootRedirect` re-guards on read as
526
+ * defense-in-depth regardless.
527
+ */
528
+ export function setRootRedirect(db: Database, value: string | null): void {
529
+ if (value === null || value === "") {
530
+ deleteSetting(db, "root_redirect");
531
+ return;
532
+ }
533
+ setSetting(db, "root_redirect", value);
534
+ }
535
+
536
+ /** Which precedence layer the resolved redirect came from. */
537
+ export type RootRedirectSource = "db" | "env" | "default";
538
+
539
+ export interface ResolvedRootRedirect {
540
+ /** The safe same-origin path the `/` 302 should target. */
541
+ value: string;
542
+ /** Which layer it came from (for admin-UI attribution). */
543
+ source: RootRedirectSource;
544
+ }
545
+
546
+ /**
547
+ * Resolve the bare-`/` redirect target with source attribution.
548
+ *
549
+ * Precedence: hub_settings.root_redirect → `PARACHUTE_HUB_ROOT_REDIRECT` env
550
+ * → `/admin` default. Every layer is re-validated through `isSafeRedirectPath`;
551
+ * an unsafe value at any layer is warned + skipped so the chain can never
552
+ * produce an open redirect (worst case falls all the way to `/admin`).
553
+ *
554
+ * `db` may be `null` (hub-server running without state) — the DB layer is then
555
+ * skipped and resolution starts from env. The `env` / `warn` knobs are test
556
+ * seams (production uses `process.env` + `console.warn`).
557
+ */
558
+ export function resolveRootRedirectDetailed(
559
+ db: Database | null,
560
+ opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
561
+ ): ResolvedRootRedirect {
562
+ const env = opts.env ?? process.env;
563
+ const warn = opts.warn ?? ((msg: string) => console.warn(msg));
564
+
565
+ // 1. DB row (operator-set via the admin PUT / `parachute hub set-root-redirect`).
566
+ if (db) {
567
+ const fromDb = getSetting(db, "root_redirect");
568
+ if (fromDb !== undefined) {
569
+ if (isSafeRedirectPath(fromDb)) return { value: fromDb, source: "db" };
570
+ warn(
571
+ `[hub-settings] root_redirect="${fromDb}" in hub_settings is not a safe same-origin path — ignoring (falling through to env/default).`,
572
+ );
573
+ }
574
+ }
575
+
576
+ // 2. Env override.
577
+ const fromEnv = env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
578
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
579
+ if (isSafeRedirectPath(fromEnv)) return { value: fromEnv, source: "env" };
580
+ warn(
581
+ `[hub-settings] ${PARACHUTE_HUB_ROOT_REDIRECT_ENV}="${fromEnv}" is not a safe same-origin path — falling back to "${DEFAULT_ROOT_REDIRECT}".`,
582
+ );
583
+ }
584
+
585
+ // 3. Default — unchanged behavior.
586
+ return { value: DEFAULT_ROOT_REDIRECT, source: "default" };
587
+ }
588
+
589
+ /** Convenience: just the resolved path (see `resolveRootRedirectDetailed`). */
590
+ export function resolveRootRedirect(
591
+ db: Database | null,
592
+ opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
593
+ ): string {
594
+ return resolveRootRedirectDetailed(db, opts).value;
595
+ }