@openparachute/hub 0.7.4-rc.2 → 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.
Files changed (60) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-clients.test.ts +103 -1
  3. package/src/__tests__/admin-lock.test.ts +7 -1
  4. package/src/__tests__/admin-vaults.test.ts +216 -10
  5. package/src/__tests__/api-account-2fa.test.ts +453 -0
  6. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  7. package/src/__tests__/api-modules.test.ts +143 -0
  8. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  9. package/src/__tests__/auth.test.ts +336 -0
  10. package/src/__tests__/clients.test.ts +326 -8
  11. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  12. package/src/__tests__/cors.test.ts +138 -1
  13. package/src/__tests__/doctor.test.ts +755 -0
  14. package/src/__tests__/hub-command.test.ts +69 -2
  15. package/src/__tests__/hub-server.test.ts +127 -5
  16. package/src/__tests__/hub-settings.test.ts +188 -0
  17. package/src/__tests__/init.test.ts +153 -0
  18. package/src/__tests__/managed-unit.test.ts +62 -0
  19. package/src/__tests__/oauth-handlers.test.ts +626 -0
  20. package/src/__tests__/oauth-ui.test.ts +107 -1
  21. package/src/__tests__/scope-explanations.test.ts +19 -0
  22. package/src/__tests__/setup-gate.test.ts +111 -3
  23. package/src/__tests__/setup-wizard.test.ts +124 -7
  24. package/src/__tests__/supervisor.test.ts +25 -0
  25. package/src/__tests__/vault-names.test.ts +32 -3
  26. package/src/__tests__/vault-remove.test.ts +40 -19
  27. package/src/__tests__/well-known.test.ts +37 -2
  28. package/src/admin-clients.ts +55 -3
  29. package/src/admin-vaults.ts +52 -25
  30. package/src/api-account-2fa.ts +395 -0
  31. package/src/api-admin-lock.ts +7 -0
  32. package/src/api-hub-upgrade.ts +38 -3
  33. package/src/api-me.ts +11 -2
  34. package/src/api-modules.ts +105 -0
  35. package/src/api-settings-root-redirect.ts +188 -0
  36. package/src/cli.ts +56 -5
  37. package/src/clients.ts +178 -0
  38. package/src/commands/auth.ts +263 -1
  39. package/src/commands/doctor.ts +1250 -0
  40. package/src/commands/hub.ts +102 -1
  41. package/src/commands/init.ts +108 -0
  42. package/src/commands/vault-remove.ts +16 -24
  43. package/src/cors.ts +7 -3
  44. package/src/help.ts +65 -1
  45. package/src/hub-db.ts +14 -0
  46. package/src/hub-server.ts +139 -24
  47. package/src/hub-settings.ts +163 -1
  48. package/src/managed-unit.ts +30 -1
  49. package/src/oauth-handlers.ts +103 -6
  50. package/src/oauth-ui.ts +174 -0
  51. package/src/rate-limit.ts +28 -0
  52. package/src/scope-explanations.ts +2 -1
  53. package/src/setup-wizard.ts +40 -21
  54. package/src/supervisor.ts +46 -2
  55. package/src/vault-names.ts +15 -4
  56. package/src/well-known.ts +10 -1
  57. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  58. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  59. package/web/ui/dist/index.html +2 -2
  60. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -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
+ });
@@ -3,10 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import {
7
- _resetBootstrapTokenForTests,
8
- generateBootstrapToken,
9
- } from "../bootstrap-token.ts";
6
+ import { _resetBootstrapTokenForTests, generateBootstrapToken } from "../bootstrap-token.ts";
10
7
  import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
11
8
  import { HUB_SVC, hubPortPath } from "../hub-control.ts";
12
9
  import { createDbHolder } from "../hub-db-liveness.ts";
@@ -3715,7 +3712,9 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
3715
3712
  // flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
3716
3713
  test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
3717
3714
  expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
3718
- expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe("public");
3715
+ expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe(
3716
+ "public",
3717
+ );
3719
3718
  });
3720
3719
 
3721
3720
  // The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
@@ -6090,3 +6089,126 @@ describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + C
6090
6089
  }
6091
6090
  });
6092
6091
  });
6092
+
6093
+ // hub#643 (Tier-1): non-script security headers on proxied module/surface
6094
+ // text/html pages. The vault content proxy and the generic services-mount
6095
+ // proxy both flow through `decorateWithChrome`, so the headers land on both.
6096
+ // DELIBERATELY no `script-src` — a strict script-src would white-screen
6097
+ // self-built GitHub-hosted surfaces + inline-script module pages (that's the
6098
+ // deferred Tier-2). Header-only: non-HTML proxied responses are NOT decorated.
6099
+ describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
6100
+ const TIER1_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
6101
+
6102
+ // Live upstream that echoes a fixed content-type + body so the test can
6103
+ // exercise both the text/html (decorated) and JSON (untouched) branches.
6104
+ function startUpstream(contentType: string, body: string): { port: number; stop: () => void } {
6105
+ const server = Bun.serve({
6106
+ port: 0,
6107
+ hostname: "127.0.0.1",
6108
+ fetch: () => new Response(body, { status: 200, headers: { "content-type": contentType } }),
6109
+ });
6110
+ return { port: server.port as number, stop: () => server.stop(true) };
6111
+ }
6112
+
6113
+ test("decorates a proxied text/html generic-mount page with nosniff + the Tier-1 CSP", async () => {
6114
+ const h = makeHarness();
6115
+ const upstream = startUpstream(
6116
+ "text/html; charset=utf-8",
6117
+ "<html><body><h1>my surface</h1></body></html>",
6118
+ );
6119
+ try {
6120
+ writeManifest(
6121
+ {
6122
+ services: [
6123
+ {
6124
+ name: "parachute-surface",
6125
+ port: upstream.port,
6126
+ paths: ["/surface"],
6127
+ health: "/surface/health",
6128
+ version: "0.2.0",
6129
+ },
6130
+ ],
6131
+ },
6132
+ h.manifestPath,
6133
+ );
6134
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/foo"));
6135
+ expect(res.status).toBe(200);
6136
+ expect(res.headers.get("content-type")).toContain("text/html");
6137
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
6138
+ const csp = res.headers.get("content-security-policy");
6139
+ expect(csp).toBe(TIER1_CSP);
6140
+ // The critical Tier-1/Tier-2 boundary: NO script-src — self-built
6141
+ // GitHub-hosted surfaces + inline-script module pages must stay
6142
+ // unrestricted. A strict script-src is the deferred Tier-2.
6143
+ expect(csp).not.toContain("script-src");
6144
+ } finally {
6145
+ upstream.stop();
6146
+ h.cleanup();
6147
+ }
6148
+ });
6149
+
6150
+ test("decorates a proxied text/html per-vault page (the Notes-PWA path) with the same headers", async () => {
6151
+ const h = makeHarness();
6152
+ const upstream = startUpstream("text/html; charset=utf-8", "<html><body>notes</body></html>");
6153
+ try {
6154
+ writeManifest(
6155
+ {
6156
+ services: [
6157
+ {
6158
+ name: "parachute-vault",
6159
+ port: upstream.port,
6160
+ paths: ["/vault/default"],
6161
+ health: "/vault/default/health",
6162
+ version: "0.4.0",
6163
+ },
6164
+ ],
6165
+ },
6166
+ h.manifestPath,
6167
+ );
6168
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
6169
+ req("/vault/default/some-page"),
6170
+ );
6171
+ expect(res.status).toBe(200);
6172
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
6173
+ expect(res.headers.get("content-security-policy")).toBe(TIER1_CSP);
6174
+ expect(res.headers.get("content-security-policy")).not.toContain("script-src");
6175
+ } finally {
6176
+ upstream.stop();
6177
+ h.cleanup();
6178
+ }
6179
+ });
6180
+
6181
+ test("leaves a proxied NON-HTML response (JSON) undecorated", async () => {
6182
+ const h = makeHarness();
6183
+ const upstream = startUpstream("application/json", JSON.stringify({ ok: true }));
6184
+ try {
6185
+ writeManifest(
6186
+ {
6187
+ services: [
6188
+ {
6189
+ name: "parachute-surface",
6190
+ port: upstream.port,
6191
+ paths: ["/surface"],
6192
+ health: "/surface/health",
6193
+ version: "0.2.0",
6194
+ },
6195
+ ],
6196
+ },
6197
+ h.manifestPath,
6198
+ );
6199
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/api/data"));
6200
+ expect(res.status).toBe(200);
6201
+ expect(res.headers.get("content-type")).toContain("application/json");
6202
+ // No HTML CSP on a JSON API response (proves the header is gated on
6203
+ // content-type, so a `.js` asset proxied through the same path is also
6204
+ // left alone).
6205
+ expect(res.headers.get("content-security-policy")).toBeNull();
6206
+ expect(res.headers.get("x-content-type-options")).toBeNull();
6207
+ const body = (await res.json()) as { ok: boolean };
6208
+ expect(body.ok).toBe(true);
6209
+ } finally {
6210
+ upstream.stop();
6211
+ h.cleanup();
6212
+ }
6213
+ });
6214
+ });
@@ -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
+ });
@@ -2218,5 +2218,158 @@ describe("resolveInitChannel (hub#694 bug 2)", () => {
2218
2218
  });
2219
2219
  });
2220
2220
 
2221
+ // ---------------------------------------------------------------------------
2222
+ // #478 Part 2 — `parachute init --vault-name <name>` creates the first vault
2223
+ // ---------------------------------------------------------------------------
2224
+
2225
+ describe("init --vault-name (#478 Part 2)", () => {
2226
+ /** Minimal stub that satisfies every init seam except the ones under test. */
2227
+ function baseOpts(
2228
+ h: Harness,
2229
+ overrides: Parameters<typeof init>[0] = {},
2230
+ ): Parameters<typeof init>[0] {
2231
+ return {
2232
+ configDir: h.configDir,
2233
+ manifestPath: h.manifestPath,
2234
+ log: () => {},
2235
+ alive: () => false,
2236
+ ensureHubVersion: async () => ({
2237
+ outcome: "match" as const,
2238
+ installedVersion: "test",
2239
+ messages: [],
2240
+ }),
2241
+ ensureHub: async () => {
2242
+ writeHubPort(1939, h.configDir);
2243
+ return { pid: 0, port: 1939, started: true };
2244
+ },
2245
+ readExposeStateFn: () => undefined,
2246
+ isTty: false,
2247
+ platform: "linux" as const,
2248
+ installVaultModuleImpl: noopVaultInstall,
2249
+ noBrowser: true,
2250
+ noExposePrompt: true,
2251
+ noWizardPrompt: true,
2252
+ ...overrides,
2253
+ };
2254
+ }
2255
+
2256
+ test("(a) with vaultName set: invokes createFirstVaultImpl with the name", async () => {
2257
+ const h = makeHarness();
2258
+ try {
2259
+ const createCalls: string[] = [];
2260
+ const logs: string[] = [];
2261
+ const code = await init(
2262
+ baseOpts(h, {
2263
+ vaultName: "myvault",
2264
+ log: (l) => logs.push(l),
2265
+ createFirstVaultImpl: async (name) => {
2266
+ createCalls.push(name);
2267
+ return 0;
2268
+ },
2269
+ }),
2270
+ );
2271
+ expect(code).toBe(0);
2272
+ expect(createCalls).toEqual(["myvault"]);
2273
+ expect(logs.join("\n")).toContain('Creating vault "myvault"');
2274
+ expect(logs.join("\n")).toContain('Vault "myvault" created');
2275
+ } finally {
2276
+ h.cleanup();
2277
+ }
2278
+ });
2279
+
2280
+ test("(b) without vaultName: does NOT invoke createFirstVaultImpl", async () => {
2281
+ const h = makeHarness();
2282
+ try {
2283
+ let createCalled = false;
2284
+ const code = await init(
2285
+ baseOpts(h, {
2286
+ // no vaultName set
2287
+ createFirstVaultImpl: async () => {
2288
+ createCalled = true;
2289
+ return 0;
2290
+ },
2291
+ }),
2292
+ );
2293
+ expect(code).toBe(0);
2294
+ expect(createCalled).toBe(false);
2295
+ } finally {
2296
+ h.cleanup();
2297
+ }
2298
+ });
2299
+
2300
+ test("(c) invalid vault name via the CLI seam: validateVaultName rejects it", () => {
2301
+ // The CLI validates before calling init; test the validator directly
2302
+ // so the unit test doesn't need to drive argv parsing. The validator
2303
+ // is the same one the CLI uses (imported in cli.ts).
2304
+ const { validateVaultName } = require("../vault-name.ts");
2305
+ const result = validateVaultName("My Vault!");
2306
+ expect(result.ok).toBe(false);
2307
+ expect(result.error).toMatch(/lowercase alphanumeric/);
2308
+ });
2309
+
2310
+ test("(d) a seeded services.json vault MODULE row does NOT suppress the create", async () => {
2311
+ // REGRESSION (the rc.7 verification bug): Step 0.5's `install("vault",
2312
+ // { noCreate: true })` seeds a `parachute-vault` services.json row via
2313
+ // `spec.seedEntry` on EVERY fresh install. The OLD Step 1.6 keyed
2314
+ // idempotency off that row, so on the exact fresh-box path this feature
2315
+ // targets it saw the row + silently no-op'd the create — the headline
2316
+ // feature never fired. The row marks "module installed", not "instance
2317
+ // exists". Idempotency must live in `parachute-vault create`'s own exit
2318
+ // (which errors "already exists" on a real re-run), NOT a row precheck.
2319
+ // So: a seeded module row must NOT prevent the create from being attempted.
2320
+ const h = makeHarness();
2321
+ try {
2322
+ // Seed services.json with the module row (as Step 0.5 always does).
2323
+ seedVault(h.manifestPath);
2324
+ const createCalls: string[] = [];
2325
+ const logs: string[] = [];
2326
+ const code = await init(
2327
+ baseOpts(h, {
2328
+ vaultName: "myvault",
2329
+ log: (l) => logs.push(l),
2330
+ createFirstVaultImpl: async (name) => {
2331
+ createCalls.push(name);
2332
+ return 0;
2333
+ },
2334
+ }),
2335
+ );
2336
+ expect(code).toBe(0);
2337
+ // The create WAS attempted despite the seeded module row.
2338
+ expect(createCalls).toEqual(["myvault"]);
2339
+ expect(logs.join("\n")).toContain('Creating vault "myvault"');
2340
+ expect(logs.join("\n")).toContain('Vault "myvault" created');
2341
+ // No "already configured / ignored" no-op message.
2342
+ expect(logs.join("\n")).not.toContain("already configured");
2343
+ } finally {
2344
+ h.cleanup();
2345
+ }
2346
+ });
2347
+
2348
+ test("non-zero exit from create (e.g. vault already exists): warns but init still exits 0", async () => {
2349
+ // A non-zero exit covers both "vault already exists" (a benign re-run, where
2350
+ // `parachute-vault create` errors + exits 1) and a genuine creation failure.
2351
+ // Either way init is non-fatal — the operator can re-run / check status.
2352
+ const h = makeHarness();
2353
+ try {
2354
+ const logs: string[] = [];
2355
+ const code = await init(
2356
+ baseOpts(h, {
2357
+ vaultName: "myvault",
2358
+ log: (l) => logs.push(l),
2359
+ createFirstVaultImpl: async () => 1,
2360
+ }),
2361
+ );
2362
+ // Init is non-fatal on create failure — operator can retry.
2363
+ expect(code).toBe(0);
2364
+ const joined = logs.join("\n");
2365
+ expect(joined).toContain("exited 1");
2366
+ expect(joined).toContain("may already exist, or creation failed");
2367
+ expect(joined).toContain("parachute vault create myvault");
2368
+ } finally {
2369
+ h.cleanup();
2370
+ }
2371
+ });
2372
+ });
2373
+
2221
2374
  // Type alias used only inside this test file for the heuristic test.
2222
2375
  type ExposeChoice = "none" | "tailnet" | "cloudflare";
@@ -398,6 +398,68 @@ describe("installManagedUnit — start:boolean (§7.1)", () => {
398
398
  expect(f.calls).toContainEqual(["systemctl", "--user", "daemon-reload"]);
399
399
  expect(f.calls.some((c) => c.includes("enable"))).toBe(false);
400
400
  });
401
+
402
+ // #528: a per-command fake `run` so the linger probe + enable-linger can return
403
+ // distinct results. Non-linger commands (systemctl daemon-reload / enable) all
404
+ // succeed; only the linger sequence is scripted via `linger`.
405
+ function lingerDeps(linger: {
406
+ probe?: ServiceCommandResult;
407
+ enable?: ServiceCommandResult;
408
+ }): FakeDepsState {
409
+ const ok: ServiceCommandResult = { code: 0, stdout: "", stderr: "" };
410
+ return fakeDeps({
411
+ platform: "linux",
412
+ getuid: () => 1000,
413
+ userName: () => "op",
414
+ run: ((cmd: readonly string[]) => {
415
+ // `calls` is recorded by the default run; here we record into a closure
416
+ // list returned alongside via the returned FakeDepsState — but fakeDeps
417
+ // only records in its OWN default run. So push into a shared array.
418
+ recorded.push([...cmd]);
419
+ if (cmd[0] === "loginctl" && cmd[1] === "show-user") return linger.probe ?? ok;
420
+ if (cmd[0] === "loginctl" && cmd[1] === "enable-linger") return linger.enable ?? ok;
421
+ return ok;
422
+ }) as ManagedUnitDeps["run"],
423
+ });
424
+ }
425
+ // Shared recorder for the per-command run above (fakeDeps's own `calls` array
426
+ // isn't populated when we override `run`).
427
+ let recorded: string[][] = [];
428
+
429
+ test("#528: linger ALREADY on → no enable attempt, no warning (false-alarm fix)", () => {
430
+ recorded = [];
431
+ const f = lingerDeps({ probe: { code: 0, stdout: "Linger=yes\n", stderr: "" } });
432
+ const result = installManagedUnit({
433
+ unit: hubUnit(f.deps),
434
+ deps: f.deps,
435
+ messages: HUB_MESSAGES,
436
+ start: false,
437
+ });
438
+ // Probed current state...
439
+ expect(recorded).toContainEqual(["loginctl", "show-user", "op", "--property=Linger"]);
440
+ // ...and because it's already on, did NOT try to enable it.
441
+ expect(recorded.some((c) => c[0] === "loginctl" && c[1] === "enable-linger")).toBe(false);
442
+ // ...and emitted NO scary linger warning.
443
+ expect(result.messages).not.toContain(HUB_MESSAGES.lingerWarning);
444
+ });
445
+
446
+ test("#528: linger OFF + enable-linger fails → warning surfaces", () => {
447
+ recorded = [];
448
+ const f = lingerDeps({
449
+ probe: { code: 0, stdout: "Linger=no\n", stderr: "" },
450
+ enable: { code: 1, stdout: "", stderr: "operation not permitted" },
451
+ });
452
+ const result = installManagedUnit({
453
+ unit: hubUnit(f.deps),
454
+ deps: f.deps,
455
+ messages: HUB_MESSAGES,
456
+ start: false,
457
+ });
458
+ // Off → did attempt to enable...
459
+ expect(recorded).toContainEqual(["loginctl", "enable-linger", "op"]);
460
+ // ...and the genuine failure warns.
461
+ expect(result.messages).toContain(HUB_MESSAGES.lingerWarning);
462
+ });
401
463
  });
402
464
 
403
465
  // ---------------------------------------------------------------------------