@openparachute/hub 0.5.14-rc.2 → 0.5.14-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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -469,10 +469,16 @@ describe("exposePublicInteractive — neither ready", () => {
469
469
  expect(interactiveCalled).toBe(false);
470
470
  expect(cloudflareCalled).toBe(false);
471
471
  const joined = logs.join("\n");
472
- expect(joined).toMatch(/apt-get|dnf/);
473
- expect(joined).toContain(
474
- "developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads",
475
- );
472
+ // Post 2026-05-27 cloudflared-URL refresh: the install hint moved
473
+ // off apt-get / dnf / developers.cloudflare.com (all unreliable —
474
+ // Aaron hit `No match for argument: cloudflared` on AL2023 and
475
+ // 404s from the docs URL on the same box) onto the static binary
476
+ // from GitHub releases.
477
+ expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
478
+ expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
479
+ expect(joined).not.toContain("developers.cloudflare.com");
480
+ expect(joined).not.toContain("pkg.cloudflare.com");
481
+ expect(joined).not.toContain("sudo dnf install cloudflared");
476
482
  } finally {
477
483
  env.cleanup();
478
484
  }
@@ -126,7 +126,11 @@ describe("exposePublicAutoPick — neither ready", () => {
126
126
  expect(code).toBe(1);
127
127
  const joined = logs.join("\n");
128
128
  expect(joined).toContain("tailscale.com/download");
129
- expect(joined).toContain("developers.cloudflare.com");
129
+ // Post 2026-05-27 cloudflared-URL refresh: the install hint now points
130
+ // at GitHub releases (developers.cloudflare.com / pkg.cloudflare.com
131
+ // both returned HTML/404 on Aaron's fresh AL2023 EC2 box).
132
+ expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
133
+ expect(joined).not.toContain("developers.cloudflare.com");
130
134
  expect(joined).toContain("--skip-provider-check");
131
135
  });
132
136
 
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { exposePublic, exposeTailnet } from "../commands/expose.ts";
6
+ import { readEnvFileValues } from "../env-file.ts";
6
7
  import { readExposeState, writeExposeState } from "../expose-state.ts";
7
8
  import type { EnsureHubOpts, HubSpawner, StopHubOpts } from "../hub-control.ts";
8
9
  import { writePid } from "../process-state.ts";
@@ -696,6 +697,53 @@ describe("expose tailnet off", () => {
696
697
  }
697
698
  });
698
699
 
700
+ test("clears the persisted PARACHUTE_HUB_ORIGIN from vault/.env on teardown", async () => {
701
+ // OAuth issuer-mismatch fix: `expose up` persisted the public origin into
702
+ // vault/.env so the daemon validates `iss` against it. With exposure gone,
703
+ // a local-only hub mints loopback-`iss` tokens, so a stale public origin
704
+ // left in `.env` would itself cause the mismatch on the next daemon boot.
705
+ // Tearing it down reverts vault to its loopback default.
706
+ const h = makeHarness();
707
+ try {
708
+ writeExposeState(
709
+ {
710
+ version: 1,
711
+ layer: "tailnet",
712
+ mode: "path",
713
+ canonicalFqdn: "parachute.taildf9ce2.ts.net",
714
+ port: 443,
715
+ funnel: false,
716
+ entries: [{ kind: "proxy", mount: "/", target: "http://127.0.0.1:1939", service: "hub" }],
717
+ hubOrigin: "https://parachute.taildf9ce2.ts.net",
718
+ },
719
+ h.statePath,
720
+ );
721
+ mkdirSync(join(h.configDir, "vault"), { recursive: true });
722
+ writeFileSync(
723
+ join(h.configDir, "vault", ".env"),
724
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://parachute.taildf9ce2.ts.net\n",
725
+ );
726
+ const { runner } = makeRunner();
727
+ const code = await exposeTailnet("off", {
728
+ runner,
729
+ statePath: h.statePath,
730
+ wellKnownPath: h.wellKnownPath,
731
+ hubPath: h.hubPath,
732
+ wellKnownDir: h.wellKnownDir,
733
+ configDir: h.configDir,
734
+ skipHub: true,
735
+ log: () => {},
736
+ });
737
+ expect(code).toBe(0);
738
+ const values = readEnvFileValues(join(h.configDir, "vault", ".env"));
739
+ expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
740
+ // Sibling keys preserved.
741
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
742
+ } finally {
743
+ h.cleanup();
744
+ }
745
+ });
746
+
699
747
  test("leaves state in place on teardown failure", async () => {
700
748
  const h = makeHarness();
701
749
  try {
@@ -961,7 +1009,9 @@ describe("expose public up", () => {
961
1009
  });
962
1010
  expect(code).toBe(0);
963
1011
  const joined = logs.join("\n");
964
- expect(joined).toContain("2FA is not enrolled");
1012
+ // hub#473: real hub-login 2FA. The warning now recommends the real
1013
+ // `parachute auth 2fa enroll` path.
1014
+ expect(joined).toContain("/login is now reachable on the public internet");
965
1015
  expect(joined).toContain("parachute auth 2fa enroll");
966
1016
  // /login pointer uses the canonical https://<fqdn> origin.
967
1017
  expect(joined).toContain("https://parachute.taildf9ce2.ts.net/login");
@@ -3,6 +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 { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
6
7
  import { HUB_SVC, hubPortPath } from "../hub-control.ts";
7
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
8
9
  import {
@@ -16,7 +17,9 @@ import { setNotesRedirectDisabled } from "../hub-settings.ts";
16
17
  import { clearNotesRedirectLogState } from "../notes-redirect.ts";
17
18
  import { pidPath } from "../process-state.ts";
18
19
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
20
+ import { buildSessionCookie, createSession } from "../sessions.ts";
19
21
  import { rotateSigningKey } from "../signing-keys.ts";
22
+ import type { ModuleState, Supervisor } from "../supervisor.ts";
20
23
  import { createUser } from "../users.ts";
21
24
 
22
25
  interface Harness {
@@ -42,6 +45,33 @@ function mkdirIfMissing(dir: string): void {
42
45
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
43
46
  }
44
47
 
48
+ /**
49
+ * Minimal stub of the Supervisor surface that proxyRequest's classifier
50
+ * reads from. We don't drive real Bun.spawn — just hand back hand-crafted
51
+ * ModuleState values so the test can pin "vault is starting" / "vault has
52
+ * been running for a minute" exactly. See `supervisor.test.ts` for the
53
+ * real lifecycle tests.
54
+ */
55
+ function stubSupervisor(states: ModuleState[]): Supervisor {
56
+ return {
57
+ list: () => states,
58
+ get: (short: string) => states.find((s) => s.short === short),
59
+ start: async () => {
60
+ throw new Error("not implemented");
61
+ },
62
+ stop: async () => undefined,
63
+ restart: async () => undefined,
64
+ } as unknown as Supervisor;
65
+ }
66
+
67
+ function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
68
+ return {
69
+ status: "running",
70
+ restartsInWindow: 0,
71
+ ...partial,
72
+ };
73
+ }
74
+
45
75
  function vaultEntry(name: string): ServiceEntry {
46
76
  return {
47
77
  name: `parachute-vault-${name}`,
@@ -1912,10 +1942,12 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1912
1942
  }
1913
1943
  });
1914
1944
 
1915
- test("returns 502 when the matching vault upstream is unreachable", async () => {
1945
+ test("returns 502 with persistent-state JSON when the matching vault upstream is unreachable", async () => {
1916
1946
  // Vault is in services.json but the port has nothing listening — vault
1917
1947
  // crashed, port shifted, or the user is mid-restart. We owe the caller a
1918
- // useful error instead of a hang or a silent 404.
1948
+ // useful error instead of a hang or a silent 404. No supervisor +
1949
+ // no pidfile → classifier returns "persistent" → 502 + admin_url
1950
+ // (hub#443 boot-readiness gating).
1919
1951
  const h = makeHarness();
1920
1952
  try {
1921
1953
  writeManifest(
@@ -1934,10 +1966,261 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1934
1966
  h.manifestPath,
1935
1967
  );
1936
1968
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1937
- const res = await fetcher(req("/vault/default/health"));
1969
+ const res = await fetcher(
1970
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
1971
+ );
1972
+ expect(res.status).toBe(502);
1973
+ const body = (await res.json()) as {
1974
+ error: string;
1975
+ error_type: string;
1976
+ admin_url?: string;
1977
+ service: string;
1978
+ };
1979
+ expect(body.error_type).toBe("upstream_unreachable");
1980
+ expect(body.error).toBe("upstream_unreachable");
1981
+ expect(body.admin_url).toBe("/admin/modules");
1982
+ expect(body.service).toBe("vault");
1983
+ } finally {
1984
+ h.cleanup();
1985
+ }
1986
+ });
1987
+
1988
+ test("transient state (supervisor reports starting) → 503 + Retry-After when fetch fails", async () => {
1989
+ // Supervisor says vault is `starting` — the loopback port hasn't bound
1990
+ // yet, fetch fails with ECONNREFUSED. Classifier returns "transient",
1991
+ // proxy responds 503 with a Retry-After hint instead of 502.
1992
+ const h = makeHarness();
1993
+ try {
1994
+ writeManifest(
1995
+ {
1996
+ services: [
1997
+ {
1998
+ name: "parachute-vault",
1999
+ port: await pickClosedPort(),
2000
+ paths: ["/vault/default"],
2001
+ health: "/vault/default/health",
2002
+ version: "0.4.0",
2003
+ },
2004
+ ],
2005
+ },
2006
+ h.manifestPath,
2007
+ );
2008
+ const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
2009
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2010
+ const res = await fetcher(
2011
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
2012
+ );
2013
+ expect(res.status).toBe(503);
2014
+ expect(res.headers.get("retry-after")).toBe("2");
2015
+ const body = (await res.json()) as {
2016
+ error_type: string;
2017
+ retry_after_ms: number;
2018
+ max_attempts: number;
2019
+ admin_url?: string;
2020
+ };
2021
+ expect(body.error_type).toBe("upstream_starting");
2022
+ expect(body.retry_after_ms).toBe(2000);
2023
+ expect(body.max_attempts).toBe(5);
2024
+ // Transient JSON MUST NOT carry an admin link.
2025
+ expect(body.admin_url).toBeUndefined();
2026
+ } finally {
2027
+ h.cleanup();
2028
+ }
2029
+ });
2030
+
2031
+ test("transient state + Accept: text/html → 503 HTML page with auto-refresh + poll, no admin link", async () => {
2032
+ const h = makeHarness();
2033
+ try {
2034
+ writeManifest(
2035
+ {
2036
+ services: [
2037
+ {
2038
+ name: "parachute-vault",
2039
+ port: await pickClosedPort(),
2040
+ paths: ["/vault/default"],
2041
+ health: "/vault/default/health",
2042
+ version: "0.4.0",
2043
+ },
2044
+ ],
2045
+ },
2046
+ h.manifestPath,
2047
+ );
2048
+ const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
2049
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2050
+ const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
2051
+ expect(res.status).toBe(503);
2052
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
2053
+ const text = await res.text();
2054
+ expect(text).toContain(`<meta http-equiv="refresh"`);
2055
+ expect(text).toContain("/api/ready");
2056
+ expect(text).toContain("Just a moment");
2057
+ // Aaron design (d): transient page has no admin link.
2058
+ expect(text).not.toContain("/admin/modules");
2059
+ } finally {
2060
+ h.cleanup();
2061
+ }
2062
+ });
2063
+
2064
+ test("persistent state + Accept: text/html → 502 HTML page with admin link, no auto-refresh", async () => {
2065
+ const h = makeHarness();
2066
+ try {
2067
+ writeManifest(
2068
+ {
2069
+ services: [
2070
+ {
2071
+ name: "parachute-vault",
2072
+ port: await pickClosedPort(),
2073
+ paths: ["/vault/default"],
2074
+ health: "/vault/default/health",
2075
+ version: "0.4.0",
2076
+ },
2077
+ ],
2078
+ },
2079
+ h.manifestPath,
2080
+ );
2081
+ const supervisor = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
2082
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2083
+ const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
2084
+ expect(res.status).toBe(502);
2085
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
2086
+ expect(res.headers.get("retry-after")).toBeNull();
2087
+ const text = await res.text();
2088
+ expect(text).toContain("Module unreachable");
2089
+ expect(text).toContain("/admin/modules");
2090
+ expect(text).not.toContain(`http-equiv="refresh"`);
2091
+ } finally {
2092
+ h.cleanup();
2093
+ }
2094
+ });
2095
+
2096
+ test("supervisor running-but-fresh-startedAt → transient classification", async () => {
2097
+ // Supervisor says vault is running, but startedAt is recent enough that
2098
+ // we trust the boot-window heuristic over the failed fetch.
2099
+ const h = makeHarness();
2100
+ try {
2101
+ writeManifest(
2102
+ {
2103
+ services: [
2104
+ {
2105
+ name: "parachute-vault",
2106
+ port: await pickClosedPort(),
2107
+ paths: ["/vault/default"],
2108
+ health: "/vault/default/health",
2109
+ version: "0.4.0",
2110
+ },
2111
+ ],
2112
+ },
2113
+ h.manifestPath,
2114
+ );
2115
+ const supervisor = stubSupervisor([
2116
+ moduleState({
2117
+ short: "vault",
2118
+ status: "running",
2119
+ startedAt: new Date(Date.now() - 5_000).toISOString(),
2120
+ }),
2121
+ ]);
2122
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2123
+ const res = await fetcher(
2124
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
2125
+ );
2126
+ expect(res.status).toBe(503);
2127
+ const body = (await res.json()) as { error_type: string };
2128
+ expect(body.error_type).toBe("upstream_starting");
2129
+ } finally {
2130
+ h.cleanup();
2131
+ }
2132
+ });
2133
+
2134
+ test("supervisor running-but-old-startedAt → persistent (boot window expired)", async () => {
2135
+ const h = makeHarness();
2136
+ try {
2137
+ writeManifest(
2138
+ {
2139
+ services: [
2140
+ {
2141
+ name: "parachute-vault",
2142
+ port: await pickClosedPort(),
2143
+ paths: ["/vault/default"],
2144
+ health: "/vault/default/health",
2145
+ version: "0.4.0",
2146
+ },
2147
+ ],
2148
+ },
2149
+ h.manifestPath,
2150
+ );
2151
+ const supervisor = stubSupervisor([
2152
+ moduleState({
2153
+ short: "vault",
2154
+ status: "running",
2155
+ startedAt: new Date(Date.now() - 120_000).toISOString(),
2156
+ }),
2157
+ ]);
2158
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2159
+ const res = await fetcher(
2160
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
2161
+ );
1938
2162
  expect(res.status).toBe(502);
1939
- const body = (await res.json()) as { error: string };
1940
- expect(body.error).toContain("vault upstream unreachable");
2163
+ const body = (await res.json()) as { error_type: string };
2164
+ expect(body.error_type).toBe("upstream_unreachable");
2165
+ } finally {
2166
+ h.cleanup();
2167
+ }
2168
+ });
2169
+
2170
+ test("non-vault upstream (scribe) classified through supervisor when starting", async () => {
2171
+ // Same logic as vault, but exercising the generic /<svc>/* dispatch path.
2172
+ const h = makeHarness();
2173
+ try {
2174
+ writeManifest(
2175
+ {
2176
+ services: [
2177
+ {
2178
+ name: "scribe",
2179
+ port: await pickClosedPort(),
2180
+ paths: ["/scribe"],
2181
+ health: "/scribe/health",
2182
+ version: "0.1.0",
2183
+ },
2184
+ ],
2185
+ },
2186
+ h.manifestPath,
2187
+ );
2188
+ const supervisor = stubSupervisor([moduleState({ short: "scribe", status: "starting" })]);
2189
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2190
+ const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
2191
+ expect(res.status).toBe(503);
2192
+ const body = (await res.json()) as { error_type: string; service: string };
2193
+ expect(body.error_type).toBe("upstream_starting");
2194
+ expect(body.service).toBe("scribe");
2195
+ } finally {
2196
+ h.cleanup();
2197
+ }
2198
+ });
2199
+
2200
+ test("/api/ready returns supervisor view + is reachable pre-admin", async () => {
2201
+ // hub#443 endpoint is public + pre-admin (it has to be — the page that
2202
+ // polls it is itself served pre-auth when modules are still booting).
2203
+ const h = makeHarness();
2204
+ try {
2205
+ const supervisor = stubSupervisor([
2206
+ moduleState({ short: "vault", status: "starting" }),
2207
+ moduleState({
2208
+ short: "scribe",
2209
+ status: "running",
2210
+ startedAt: new Date(Date.now() - 60_000).toISOString(),
2211
+ }),
2212
+ ]);
2213
+ const fetcher = hubFetch(h.dir, { supervisor });
2214
+ const res = await fetcher(req("/api/ready"));
2215
+ expect(res.status).toBe(200);
2216
+ const body = (await res.json()) as {
2217
+ ready: boolean;
2218
+ ready_modules: string[];
2219
+ transient_modules: string[];
2220
+ };
2221
+ expect(body.ready).toBe(false);
2222
+ expect(body.transient_modules).toEqual(["vault"]);
2223
+ expect(body.ready_modules).toEqual(["scribe"]);
1941
2224
  } finally {
1942
2225
  h.cleanup();
1943
2226
  }
@@ -2562,9 +2845,10 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
2562
2845
  }
2563
2846
  });
2564
2847
 
2565
- test("returns 502 when the matching upstream is unreachable", async () => {
2848
+ test("returns 502 with persistent-state JSON when the matching upstream is unreachable", async () => {
2566
2849
  // Service is in services.json but the port has nothing listening — same
2567
- // shape as the vault-unreachable test, label is the entry's `name`.
2850
+ // shape as the vault-unreachable test (hub#443 boot-readiness gating).
2851
+ // No supervisor + no pidfile → persistent → 502 with admin_url.
2568
2852
  const h = makeHarness();
2569
2853
  try {
2570
2854
  writeManifest(
@@ -2582,10 +2866,17 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
2582
2866
  h.manifestPath,
2583
2867
  );
2584
2868
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
2585
- const res = await fetcher(req("/scribe/health"));
2869
+ const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
2586
2870
  expect(res.status).toBe(502);
2587
- const body = (await res.json()) as { error: string };
2588
- expect(body.error).toContain("scribe upstream unreachable");
2871
+ const body = (await res.json()) as {
2872
+ error: string;
2873
+ error_type: string;
2874
+ admin_url?: string;
2875
+ service: string;
2876
+ };
2877
+ expect(body.error_type).toBe("upstream_unreachable");
2878
+ expect(body.admin_url).toBe("/admin/modules");
2879
+ expect(body.service).toBe("scribe");
2589
2880
  } finally {
2590
2881
  h.cleanup();
2591
2882
  }
@@ -3882,3 +4173,98 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3882
4173
  }
3883
4174
  });
3884
4175
  });
4176
+
4177
+ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to-end)", () => {
4178
+ // Drive the real dispatch (`hubFetch`) so the route wiring + precedence
4179
+ // (the `/account/vault-token/` prefix must win over `/account/` and the
4180
+ // SPA catch-all) is exercised, not just the handler in isolation.
4181
+ async function seed(h: Harness, assignedVaults: string[]) {
4182
+ const db = openHubDb(hubDbPath(h.dir));
4183
+ rotateSigningKey(db); // mint needs an active signing key
4184
+ await createUser(db, "operator", "operator-password-123");
4185
+ const friend = await createUser(db, "friend", "friend-password-123", {
4186
+ assignedVaults,
4187
+ allowMulti: true,
4188
+ });
4189
+ const session = createSession(db, { userId: friend.id });
4190
+ const csrf = generateCsrfToken();
4191
+ const cookie = `${buildSessionCookie(session.id, 3600, { secure: false })}; ${
4192
+ buildCsrfCookie(csrf, { secure: false }).split(";")[0]
4193
+ }`;
4194
+ return { db, friendId: friend.id, cookie, csrf };
4195
+ }
4196
+
4197
+ function postBody(csrf: string, verb: string): string {
4198
+ const b = new URLSearchParams();
4199
+ b.set("__csrf", csrf);
4200
+ b.set("verb", verb);
4201
+ return b.toString();
4202
+ }
4203
+
4204
+ test("assigned vault → 200 with a token banner, routed through hubFetch", async () => {
4205
+ const h = makeHarness();
4206
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4207
+ const { db, cookie, csrf } = await seed(h, ["work"]);
4208
+ try {
4209
+ const res = await hubFetch(h.dir, {
4210
+ getDb: () => db,
4211
+ manifestPath: h.manifestPath,
4212
+ issuer: "https://hub.test",
4213
+ })(
4214
+ req("/account/vault-token/work", {
4215
+ method: "POST",
4216
+ headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
4217
+ body: postBody(csrf, "read"),
4218
+ }),
4219
+ );
4220
+ expect(res.status).toBe(200);
4221
+ const html = await res.text();
4222
+ expect(html).toContain('data-testid="minted-token-banner"');
4223
+ expect(html).toContain("vault:work:read");
4224
+ } finally {
4225
+ db.close();
4226
+ h.cleanup();
4227
+ }
4228
+ });
4229
+
4230
+ test("unassigned vault → 403, no token, routed through hubFetch", async () => {
4231
+ const h = makeHarness();
4232
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4233
+ const { db, cookie, csrf } = await seed(h, ["work"]);
4234
+ try {
4235
+ const res = await hubFetch(h.dir, {
4236
+ getDb: () => db,
4237
+ manifestPath: h.manifestPath,
4238
+ issuer: "https://hub.test",
4239
+ })(
4240
+ req("/account/vault-token/other", {
4241
+ method: "POST",
4242
+ headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
4243
+ body: postBody(csrf, "read"),
4244
+ }),
4245
+ );
4246
+ expect(res.status).toBe(403);
4247
+ const html = await res.text();
4248
+ expect(html).not.toContain('data-testid="minted-token-banner"');
4249
+ } finally {
4250
+ db.close();
4251
+ h.cleanup();
4252
+ }
4253
+ });
4254
+
4255
+ test("GET on the mint path → 405 (POST-only)", async () => {
4256
+ const h = makeHarness();
4257
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4258
+ const { db } = await seed(h, ["work"]);
4259
+ try {
4260
+ const res = await hubFetch(h.dir, {
4261
+ getDb: () => db,
4262
+ manifestPath: h.manifestPath,
4263
+ })(req("/account/vault-token/work"));
4264
+ expect(res.status).toBe(405);
4265
+ } finally {
4266
+ db.close();
4267
+ h.cleanup();
4268
+ }
4269
+ });
4270
+ });