@openparachute/hub 0.5.13 → 0.5.14-rc.10

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 (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -17,6 +17,7 @@ import { clearNotesRedirectLogState } from "../notes-redirect.ts";
17
17
  import { pidPath } from "../process-state.ts";
18
18
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
19
19
  import { rotateSigningKey } from "../signing-keys.ts";
20
+ import type { ModuleState, Supervisor } from "../supervisor.ts";
20
21
  import { createUser } from "../users.ts";
21
22
 
22
23
  interface Harness {
@@ -42,6 +43,33 @@ function mkdirIfMissing(dir: string): void {
42
43
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
43
44
  }
44
45
 
46
+ /**
47
+ * Minimal stub of the Supervisor surface that proxyRequest's classifier
48
+ * reads from. We don't drive real Bun.spawn — just hand back hand-crafted
49
+ * ModuleState values so the test can pin "vault is starting" / "vault has
50
+ * been running for a minute" exactly. See `supervisor.test.ts` for the
51
+ * real lifecycle tests.
52
+ */
53
+ function stubSupervisor(states: ModuleState[]): Supervisor {
54
+ return {
55
+ list: () => states,
56
+ get: (short: string) => states.find((s) => s.short === short),
57
+ start: async () => {
58
+ throw new Error("not implemented");
59
+ },
60
+ stop: async () => undefined,
61
+ restart: async () => undefined,
62
+ } as unknown as Supervisor;
63
+ }
64
+
65
+ function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
66
+ return {
67
+ status: "running",
68
+ restartsInWindow: 0,
69
+ ...partial,
70
+ };
71
+ }
72
+
45
73
  function vaultEntry(name: string): ServiceEntry {
46
74
  return {
47
75
  name: `parachute-vault-${name}`,
@@ -995,22 +1023,22 @@ describe("hubFetch routing", () => {
995
1023
  });
996
1024
 
997
1025
  // Notes-as-app migration Phase 2 (parachute-app design doc §16).
998
- // `/notes/*` 301-redirects to `/app/notes/*` so legacy bookmarks land
1026
+ // `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land
999
1027
  // on the apps-hosted Notes. Tested with no DB (the migration-default
1000
1028
  // path — absent DB or absent row means redirect-on).
1001
- test("301: /notes/ → /app/notes/", async () => {
1029
+ test("301: /notes/ → /surface/notes/", async () => {
1002
1030
  clearNotesRedirectLogState();
1003
1031
  const h = makeHarness();
1004
1032
  try {
1005
1033
  const res = await hubFetch(h.dir)(req("/notes/"));
1006
1034
  expect(res.status).toBe(301);
1007
- expect(res.headers.get("location")).toBe("/app/notes/");
1035
+ expect(res.headers.get("location")).toBe("/surface/notes/");
1008
1036
  } finally {
1009
1037
  h.cleanup();
1010
1038
  }
1011
1039
  });
1012
1040
 
1013
- test("301: bare /notes → /app/notes", async () => {
1041
+ test("301: bare /notes → /surface/notes", async () => {
1014
1042
  // The bare-prefix form (no trailing slash) is the path browsers land
1015
1043
  // on when an operator types `https://hub.example/notes` directly.
1016
1044
  clearNotesRedirectLogState();
@@ -1018,7 +1046,7 @@ describe("hubFetch routing", () => {
1018
1046
  try {
1019
1047
  const res = await hubFetch(h.dir)(req("/notes"));
1020
1048
  expect(res.status).toBe(301);
1021
- expect(res.headers.get("location")).toBe("/app/notes");
1049
+ expect(res.headers.get("location")).toBe("/surface/notes");
1022
1050
  } finally {
1023
1051
  h.cleanup();
1024
1052
  }
@@ -1030,7 +1058,7 @@ describe("hubFetch routing", () => {
1030
1058
  try {
1031
1059
  const res = await hubFetch(h.dir)(req("/notes/some/path?q=1&n=2"));
1032
1060
  expect(res.status).toBe(301);
1033
- expect(res.headers.get("location")).toBe("/app/notes/some/path?q=1&n=2");
1061
+ expect(res.headers.get("location")).toBe("/surface/notes/some/path?q=1&n=2");
1034
1062
  } finally {
1035
1063
  h.cleanup();
1036
1064
  }
@@ -1912,10 +1940,12 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1912
1940
  }
1913
1941
  });
1914
1942
 
1915
- test("returns 502 when the matching vault upstream is unreachable", async () => {
1943
+ test("returns 502 with persistent-state JSON when the matching vault upstream is unreachable", async () => {
1916
1944
  // Vault is in services.json but the port has nothing listening — vault
1917
1945
  // 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.
1946
+ // useful error instead of a hang or a silent 404. No supervisor +
1947
+ // no pidfile → classifier returns "persistent" → 502 + admin_url
1948
+ // (hub#443 boot-readiness gating).
1919
1949
  const h = makeHarness();
1920
1950
  try {
1921
1951
  writeManifest(
@@ -1934,10 +1964,261 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1934
1964
  h.manifestPath,
1935
1965
  );
1936
1966
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1937
- const res = await fetcher(req("/vault/default/health"));
1967
+ const res = await fetcher(
1968
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
1969
+ );
1970
+ expect(res.status).toBe(502);
1971
+ const body = (await res.json()) as {
1972
+ error: string;
1973
+ error_type: string;
1974
+ admin_url?: string;
1975
+ service: string;
1976
+ };
1977
+ expect(body.error_type).toBe("upstream_unreachable");
1978
+ expect(body.error).toBe("upstream_unreachable");
1979
+ expect(body.admin_url).toBe("/admin/modules");
1980
+ expect(body.service).toBe("vault");
1981
+ } finally {
1982
+ h.cleanup();
1983
+ }
1984
+ });
1985
+
1986
+ test("transient state (supervisor reports starting) → 503 + Retry-After when fetch fails", async () => {
1987
+ // Supervisor says vault is `starting` — the loopback port hasn't bound
1988
+ // yet, fetch fails with ECONNREFUSED. Classifier returns "transient",
1989
+ // proxy responds 503 with a Retry-After hint instead of 502.
1990
+ const h = makeHarness();
1991
+ try {
1992
+ writeManifest(
1993
+ {
1994
+ services: [
1995
+ {
1996
+ name: "parachute-vault",
1997
+ port: await pickClosedPort(),
1998
+ paths: ["/vault/default"],
1999
+ health: "/vault/default/health",
2000
+ version: "0.4.0",
2001
+ },
2002
+ ],
2003
+ },
2004
+ h.manifestPath,
2005
+ );
2006
+ const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
2007
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2008
+ const res = await fetcher(
2009
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
2010
+ );
2011
+ expect(res.status).toBe(503);
2012
+ expect(res.headers.get("retry-after")).toBe("2");
2013
+ const body = (await res.json()) as {
2014
+ error_type: string;
2015
+ retry_after_ms: number;
2016
+ max_attempts: number;
2017
+ admin_url?: string;
2018
+ };
2019
+ expect(body.error_type).toBe("upstream_starting");
2020
+ expect(body.retry_after_ms).toBe(2000);
2021
+ expect(body.max_attempts).toBe(5);
2022
+ // Transient JSON MUST NOT carry an admin link.
2023
+ expect(body.admin_url).toBeUndefined();
2024
+ } finally {
2025
+ h.cleanup();
2026
+ }
2027
+ });
2028
+
2029
+ test("transient state + Accept: text/html → 503 HTML page with auto-refresh + poll, no admin link", async () => {
2030
+ const h = makeHarness();
2031
+ try {
2032
+ writeManifest(
2033
+ {
2034
+ services: [
2035
+ {
2036
+ name: "parachute-vault",
2037
+ port: await pickClosedPort(),
2038
+ paths: ["/vault/default"],
2039
+ health: "/vault/default/health",
2040
+ version: "0.4.0",
2041
+ },
2042
+ ],
2043
+ },
2044
+ h.manifestPath,
2045
+ );
2046
+ const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
2047
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2048
+ const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
2049
+ expect(res.status).toBe(503);
2050
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
2051
+ const text = await res.text();
2052
+ expect(text).toContain(`<meta http-equiv="refresh"`);
2053
+ expect(text).toContain("/api/ready");
2054
+ expect(text).toContain("Just a moment");
2055
+ // Aaron design (d): transient page has no admin link.
2056
+ expect(text).not.toContain("/admin/modules");
2057
+ } finally {
2058
+ h.cleanup();
2059
+ }
2060
+ });
2061
+
2062
+ test("persistent state + Accept: text/html → 502 HTML page with admin link, no auto-refresh", async () => {
2063
+ const h = makeHarness();
2064
+ try {
2065
+ writeManifest(
2066
+ {
2067
+ services: [
2068
+ {
2069
+ name: "parachute-vault",
2070
+ port: await pickClosedPort(),
2071
+ paths: ["/vault/default"],
2072
+ health: "/vault/default/health",
2073
+ version: "0.4.0",
2074
+ },
2075
+ ],
2076
+ },
2077
+ h.manifestPath,
2078
+ );
2079
+ const supervisor = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
2080
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2081
+ const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
1938
2082
  expect(res.status).toBe(502);
1939
- const body = (await res.json()) as { error: string };
1940
- expect(body.error).toContain("vault upstream unreachable");
2083
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
2084
+ expect(res.headers.get("retry-after")).toBeNull();
2085
+ const text = await res.text();
2086
+ expect(text).toContain("Module unreachable");
2087
+ expect(text).toContain("/admin/modules");
2088
+ expect(text).not.toContain(`http-equiv="refresh"`);
2089
+ } finally {
2090
+ h.cleanup();
2091
+ }
2092
+ });
2093
+
2094
+ test("supervisor running-but-fresh-startedAt → transient classification", async () => {
2095
+ // Supervisor says vault is running, but startedAt is recent enough that
2096
+ // we trust the boot-window heuristic over the failed fetch.
2097
+ const h = makeHarness();
2098
+ try {
2099
+ writeManifest(
2100
+ {
2101
+ services: [
2102
+ {
2103
+ name: "parachute-vault",
2104
+ port: await pickClosedPort(),
2105
+ paths: ["/vault/default"],
2106
+ health: "/vault/default/health",
2107
+ version: "0.4.0",
2108
+ },
2109
+ ],
2110
+ },
2111
+ h.manifestPath,
2112
+ );
2113
+ const supervisor = stubSupervisor([
2114
+ moduleState({
2115
+ short: "vault",
2116
+ status: "running",
2117
+ startedAt: new Date(Date.now() - 5_000).toISOString(),
2118
+ }),
2119
+ ]);
2120
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2121
+ const res = await fetcher(
2122
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
2123
+ );
2124
+ expect(res.status).toBe(503);
2125
+ const body = (await res.json()) as { error_type: string };
2126
+ expect(body.error_type).toBe("upstream_starting");
2127
+ } finally {
2128
+ h.cleanup();
2129
+ }
2130
+ });
2131
+
2132
+ test("supervisor running-but-old-startedAt → persistent (boot window expired)", async () => {
2133
+ const h = makeHarness();
2134
+ try {
2135
+ writeManifest(
2136
+ {
2137
+ services: [
2138
+ {
2139
+ name: "parachute-vault",
2140
+ port: await pickClosedPort(),
2141
+ paths: ["/vault/default"],
2142
+ health: "/vault/default/health",
2143
+ version: "0.4.0",
2144
+ },
2145
+ ],
2146
+ },
2147
+ h.manifestPath,
2148
+ );
2149
+ const supervisor = stubSupervisor([
2150
+ moduleState({
2151
+ short: "vault",
2152
+ status: "running",
2153
+ startedAt: new Date(Date.now() - 120_000).toISOString(),
2154
+ }),
2155
+ ]);
2156
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2157
+ const res = await fetcher(
2158
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
2159
+ );
2160
+ expect(res.status).toBe(502);
2161
+ const body = (await res.json()) as { error_type: string };
2162
+ expect(body.error_type).toBe("upstream_unreachable");
2163
+ } finally {
2164
+ h.cleanup();
2165
+ }
2166
+ });
2167
+
2168
+ test("non-vault upstream (scribe) classified through supervisor when starting", async () => {
2169
+ // Same logic as vault, but exercising the generic /<svc>/* dispatch path.
2170
+ const h = makeHarness();
2171
+ try {
2172
+ writeManifest(
2173
+ {
2174
+ services: [
2175
+ {
2176
+ name: "scribe",
2177
+ port: await pickClosedPort(),
2178
+ paths: ["/scribe"],
2179
+ health: "/scribe/health",
2180
+ version: "0.1.0",
2181
+ },
2182
+ ],
2183
+ },
2184
+ h.manifestPath,
2185
+ );
2186
+ const supervisor = stubSupervisor([moduleState({ short: "scribe", status: "starting" })]);
2187
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2188
+ const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
2189
+ expect(res.status).toBe(503);
2190
+ const body = (await res.json()) as { error_type: string; service: string };
2191
+ expect(body.error_type).toBe("upstream_starting");
2192
+ expect(body.service).toBe("scribe");
2193
+ } finally {
2194
+ h.cleanup();
2195
+ }
2196
+ });
2197
+
2198
+ test("/api/ready returns supervisor view + is reachable pre-admin", async () => {
2199
+ // hub#443 endpoint is public + pre-admin (it has to be — the page that
2200
+ // polls it is itself served pre-auth when modules are still booting).
2201
+ const h = makeHarness();
2202
+ try {
2203
+ const supervisor = stubSupervisor([
2204
+ moduleState({ short: "vault", status: "starting" }),
2205
+ moduleState({
2206
+ short: "scribe",
2207
+ status: "running",
2208
+ startedAt: new Date(Date.now() - 60_000).toISOString(),
2209
+ }),
2210
+ ]);
2211
+ const fetcher = hubFetch(h.dir, { supervisor });
2212
+ const res = await fetcher(req("/api/ready"));
2213
+ expect(res.status).toBe(200);
2214
+ const body = (await res.json()) as {
2215
+ ready: boolean;
2216
+ ready_modules: string[];
2217
+ transient_modules: string[];
2218
+ };
2219
+ expect(body.ready).toBe(false);
2220
+ expect(body.transient_modules).toEqual(["vault"]);
2221
+ expect(body.ready_modules).toEqual(["scribe"]);
1941
2222
  } finally {
1942
2223
  h.cleanup();
1943
2224
  }
@@ -2240,7 +2521,7 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
2240
2521
  // motivator for the `--mount` strip in notes-serve.ts).
2241
2522
  //
2242
2523
  // Post-parachute-app §16 Phase 2 the `/notes/*` path 301-redirects to
2243
- // `/app/notes/*` by default. This test pins the notes-as-module legacy
2524
+ // `/surface/notes/*` by default. This test pins the notes-as-module legacy
2244
2525
  // path (notes-daemon still serving its own mount); set the opt-out
2245
2526
  // flag so the dispatch falls through to the generic proxy.
2246
2527
  const h = makeHarness();
@@ -2562,9 +2843,10 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
2562
2843
  }
2563
2844
  });
2564
2845
 
2565
- test("returns 502 when the matching upstream is unreachable", async () => {
2846
+ test("returns 502 with persistent-state JSON when the matching upstream is unreachable", async () => {
2566
2847
  // 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`.
2848
+ // shape as the vault-unreachable test (hub#443 boot-readiness gating).
2849
+ // No supervisor + no pidfile → persistent → 502 with admin_url.
2568
2850
  const h = makeHarness();
2569
2851
  try {
2570
2852
  writeManifest(
@@ -2582,10 +2864,17 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
2582
2864
  h.manifestPath,
2583
2865
  );
2584
2866
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
2585
- const res = await fetcher(req("/scribe/health"));
2867
+ const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
2586
2868
  expect(res.status).toBe(502);
2587
- const body = (await res.json()) as { error: string };
2588
- expect(body.error).toContain("scribe upstream unreachable");
2869
+ const body = (await res.json()) as {
2870
+ error: string;
2871
+ error_type: string;
2872
+ admin_url?: string;
2873
+ service: string;
2874
+ };
2875
+ expect(body.error_type).toBe("upstream_unreachable");
2876
+ expect(body.admin_url).toBe("/admin/modules");
2877
+ expect(body.service).toBe("scribe");
2589
2878
  } finally {
2590
2879
  h.cleanup();
2591
2880
  }
@@ -3453,7 +3742,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3453
3742
  // Pins the proxy-side wiring of the chrome strip from
3454
3743
  // `parachute-patterns/patterns/design-system.md` §7 — every proxied
3455
3744
  // text/html response gets the strip injected after the first `<body>`,
3456
- // with opt-outs for `/app/notes/*` (the Notes PWA owns its own chrome).
3745
+ // with opt-outs for `/surface/notes/*` (the Notes PWA owns its own chrome).
3457
3746
  // The pure rewrite + opt-out logic is covered in chrome-strip.test.ts;
3458
3747
  // here we exercise the dispatch integration end-to-end through hubFetch.
3459
3748
 
@@ -3608,7 +3897,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3608
3897
  }
3609
3898
  });
3610
3899
 
3611
- test("does NOT inject chrome on /app/notes/* (Notes PWA owns its own chrome)", async () => {
3900
+ test("does NOT inject chrome on /surface/notes/* (Notes PWA owns its own chrome)", async () => {
3612
3901
  const h = makeHarness();
3613
3902
  const upstream = startHtmlUpstream("<html><body><h1>Notes</h1></body></html>");
3614
3903
  try {
@@ -3616,10 +3905,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3616
3905
  {
3617
3906
  services: [
3618
3907
  {
3619
- name: "parachute-app",
3908
+ name: "parachute-surface",
3620
3909
  port: upstream.port,
3621
- paths: ["/app"],
3622
- health: "/app/health",
3910
+ paths: ["/surface"],
3911
+ health: "/surface/health",
3623
3912
  version: "0.1.0",
3624
3913
  },
3625
3914
  ],
@@ -3627,7 +3916,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3627
3916
  h.manifestPath,
3628
3917
  );
3629
3918
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3630
- const res = await fetcher(req("/app/notes/"));
3919
+ const res = await fetcher(req("/surface/notes/"));
3631
3920
  expect(res.status).toBe(200);
3632
3921
  const body = await res.text();
3633
3922
  expect(body).toBe("<html><body><h1>Notes</h1></body></html>");
@@ -3638,7 +3927,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3638
3927
  }
3639
3928
  });
3640
3929
 
3641
- test("DOES inject chrome on /app/admin/* (parachute-app admin, not Notes)", async () => {
3930
+ test("DOES inject chrome on /surface/admin/* (parachute-app admin, not Notes)", async () => {
3642
3931
  const h = makeHarness();
3643
3932
  const upstream = startHtmlUpstream("<html><body>app admin</body></html>");
3644
3933
  try {
@@ -3646,10 +3935,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3646
3935
  {
3647
3936
  services: [
3648
3937
  {
3649
- name: "parachute-app",
3938
+ name: "parachute-surface",
3650
3939
  port: upstream.port,
3651
- paths: ["/app"],
3652
- health: "/app/health",
3940
+ paths: ["/surface"],
3941
+ health: "/surface/health",
3653
3942
  version: "0.1.0",
3654
3943
  },
3655
3944
  ],
@@ -3657,7 +3946,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3657
3946
  h.manifestPath,
3658
3947
  );
3659
3948
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3660
- const res = await fetcher(req("/app/admin/"));
3949
+ const res = await fetcher(req("/surface/admin/"));
3661
3950
  expect(res.status).toBe(200);
3662
3951
  const body = await res.text();
3663
3952
  expect(body).toContain("pc-chrome");
@@ -3668,7 +3957,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3668
3957
  }
3669
3958
  });
3670
3959
 
3671
- test("does NOT inject on /app/notes/ sub-paths (asset requests)", async () => {
3960
+ test("does NOT inject on /surface/notes/ sub-paths (asset requests)", async () => {
3672
3961
  const h = makeHarness();
3673
3962
  const upstream = startHtmlUpstream("<html><body>asset shell</body></html>");
3674
3963
  try {
@@ -3676,10 +3965,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3676
3965
  {
3677
3966
  services: [
3678
3967
  {
3679
- name: "parachute-app",
3968
+ name: "parachute-surface",
3680
3969
  port: upstream.port,
3681
- paths: ["/app"],
3682
- health: "/app/health",
3970
+ paths: ["/surface"],
3971
+ health: "/surface/health",
3683
3972
  version: "0.1.0",
3684
3973
  },
3685
3974
  ],
@@ -3687,7 +3976,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3687
3976
  h.manifestPath,
3688
3977
  );
3689
3978
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3690
- const res = await fetcher(req("/app/notes/index.html"));
3979
+ const res = await fetcher(req("/surface/notes/index.html"));
3691
3980
  expect(res.status).toBe(200);
3692
3981
  const body = await res.text();
3693
3982
  expect(body).not.toContain("pc-chrome");
@@ -189,6 +189,17 @@ describe("renderHub — signed-in indicator (rc.13)", () => {
189
189
  expect(html).not.toContain('href="/login?next=/"');
190
190
  });
191
191
 
192
+ test("signed-in indicator carries an Account breadcrumb to /account/", () => {
193
+ // Onboarding discoverability: a signed-in friend needs a single link
194
+ // to the self-service /account/ home (change password, their vault).
195
+ // Applies to admins too — harmless for them.
196
+ const html = renderHub({
197
+ session: { displayName: "aaron", csrfToken: "csrf-token-xyz" },
198
+ });
199
+ expect(html).toContain('href="/account/"');
200
+ expect(html).toContain("Account");
201
+ });
202
+
192
203
  test("displayName with HTML special chars is escaped", () => {
193
204
  // Username field allows alphanumerics historically, but the
194
205
  // displayName field on the wire is forward-compatible with profile