@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
@@ -0,0 +1,192 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { writePid } from "../process-state.ts";
6
+ import { type ClassifyOpts, classifyUpstream } from "../proxy-state.ts";
7
+ import type { ModuleState, Supervisor } from "../supervisor.ts";
8
+
9
+ /**
10
+ * Stub supervisor — only `get(short)` is exercised by `classifyUpstream`.
11
+ * We construct it directly instead of standing up a real `Supervisor`
12
+ * + driving the spawn lifecycle, so test cases stay focused on the
13
+ * classifier's per-status branching.
14
+ */
15
+ function stubSupervisor(states: Record<string, ModuleState>): Supervisor {
16
+ return {
17
+ get: (short: string) => states[short],
18
+ list: () => Object.values(states),
19
+ // Unused by classifyUpstream — present to satisfy the Supervisor type.
20
+ start: async () => {
21
+ throw new Error("not implemented");
22
+ },
23
+ stop: async () => undefined,
24
+ restart: async () => undefined,
25
+ } as unknown as Supervisor;
26
+ }
27
+
28
+ function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
29
+ return {
30
+ status: "running",
31
+ restartsInWindow: 0,
32
+ ...partial,
33
+ };
34
+ }
35
+
36
+ describe("classifyUpstream — supervisor mode", () => {
37
+ test("status=starting → transient", () => {
38
+ const sup = stubSupervisor({
39
+ vault: moduleState({ short: "vault", status: "starting" }),
40
+ });
41
+ expect(classifyUpstream("vault", { supervisor: sup })).toBe("transient");
42
+ });
43
+
44
+ test("status=restarting → transient", () => {
45
+ const sup = stubSupervisor({
46
+ vault: moduleState({ short: "vault", status: "restarting" }),
47
+ });
48
+ expect(classifyUpstream("vault", { supervisor: sup })).toBe("transient");
49
+ });
50
+
51
+ test("status=crashed → persistent", () => {
52
+ const sup = stubSupervisor({
53
+ vault: moduleState({ short: "vault", status: "crashed" }),
54
+ });
55
+ expect(classifyUpstream("vault", { supervisor: sup })).toBe("persistent");
56
+ });
57
+
58
+ test("status=stopped → persistent", () => {
59
+ const sup = stubSupervisor({
60
+ vault: moduleState({ short: "vault", status: "stopped" }),
61
+ });
62
+ expect(classifyUpstream("vault", { supervisor: sup })).toBe("persistent");
63
+ });
64
+
65
+ test("status=running, inside boot window → transient", () => {
66
+ const now = 1_700_000_000_000;
67
+ const startedAt = new Date(now - 10_000).toISOString(); // 10s ago
68
+ const sup = stubSupervisor({
69
+ vault: moduleState({ short: "vault", status: "running", startedAt }),
70
+ });
71
+ expect(classifyUpstream("vault", { supervisor: sup, now: () => now })).toBe("transient");
72
+ });
73
+
74
+ test("status=running, outside boot window → persistent", () => {
75
+ const now = 1_700_000_000_000;
76
+ const startedAt = new Date(now - 60_000).toISOString(); // 60s ago
77
+ const sup = stubSupervisor({
78
+ vault: moduleState({ short: "vault", status: "running", startedAt }),
79
+ });
80
+ expect(classifyUpstream("vault", { supervisor: sup, now: () => now })).toBe("persistent");
81
+ });
82
+
83
+ test("status=running, exactly at boot-window boundary → persistent", () => {
84
+ // The check is strict-less-than, so exactly 30s falls into persistent.
85
+ const now = 1_700_000_000_000;
86
+ const startedAt = new Date(now - 30_000).toISOString();
87
+ const sup = stubSupervisor({
88
+ vault: moduleState({ short: "vault", status: "running", startedAt }),
89
+ });
90
+ expect(classifyUpstream("vault", { supervisor: sup, now: () => now })).toBe("persistent");
91
+ });
92
+
93
+ test("status=running, missing startedAt → persistent", () => {
94
+ // Can't classify a running module without a start time; safer to call
95
+ // persistent and let the operator hit refresh than to lie that it's
96
+ // booting.
97
+ const sup = stubSupervisor({
98
+ vault: moduleState({ short: "vault", status: "running" }),
99
+ });
100
+ expect(classifyUpstream("vault", { supervisor: sup })).toBe("persistent");
101
+ });
102
+
103
+ test("custom boot window honored", () => {
104
+ const now = 1_700_000_000_000;
105
+ const startedAt = new Date(now - 5_000).toISOString(); // 5s ago
106
+ const sup = stubSupervisor({
107
+ vault: moduleState({ short: "vault", status: "running", startedAt }),
108
+ });
109
+ expect(
110
+ classifyUpstream("vault", { supervisor: sup, now: () => now, bootWindowMs: 2_000 }),
111
+ ).toBe("persistent");
112
+ expect(
113
+ classifyUpstream("vault", { supervisor: sup, now: () => now, bootWindowMs: 10_000 }),
114
+ ).toBe("transient");
115
+ });
116
+
117
+ test("module not tracked → falls back to pidfile path", () => {
118
+ // Empty supervisor map. Classifier must call through to processState;
119
+ // we inject a stub via readProcessState.
120
+ const sup = stubSupervisor({});
121
+ const opts: ClassifyOpts = {
122
+ supervisor: sup,
123
+ readProcessState: () => ({ status: "unknown" }),
124
+ };
125
+ expect(classifyUpstream("vault", opts)).toBe("persistent");
126
+ });
127
+ });
128
+
129
+ describe("classifyUpstream — on-box CLI mode (no supervisor)", () => {
130
+ test("running pidfile inside boot window → transient", () => {
131
+ const now = 1_700_000_000_000;
132
+ const opts: ClassifyOpts = {
133
+ now: () => now,
134
+ readProcessState: () => ({
135
+ status: "running",
136
+ pid: 12345,
137
+ startedAt: new Date(now - 5_000), // 5s old pidfile
138
+ }),
139
+ };
140
+ expect(classifyUpstream("vault", opts)).toBe("transient");
141
+ });
142
+
143
+ test("running pidfile outside boot window → persistent", () => {
144
+ const now = 1_700_000_000_000;
145
+ const opts: ClassifyOpts = {
146
+ now: () => now,
147
+ readProcessState: () => ({
148
+ status: "running",
149
+ pid: 12345,
150
+ startedAt: new Date(now - 60_000),
151
+ }),
152
+ };
153
+ expect(classifyUpstream("vault", opts)).toBe("persistent");
154
+ });
155
+
156
+ test("stopped pidfile (stale) → persistent", () => {
157
+ const opts: ClassifyOpts = {
158
+ readProcessState: () => ({ status: "stopped", pid: 12345 }),
159
+ };
160
+ expect(classifyUpstream("vault", opts)).toBe("persistent");
161
+ });
162
+
163
+ test("no pidfile (unknown) → persistent", () => {
164
+ const opts: ClassifyOpts = {
165
+ readProcessState: () => ({ status: "unknown" }),
166
+ };
167
+ expect(classifyUpstream("vault", opts)).toBe("persistent");
168
+ });
169
+
170
+ test("readProcessState throws → persistent (defensive)", () => {
171
+ // pidfile read can race with cleanup. Don't blow up the proxy.
172
+ const opts: ClassifyOpts = {
173
+ readProcessState: () => {
174
+ throw new Error("ENOENT");
175
+ },
176
+ };
177
+ expect(classifyUpstream("vault", opts)).toBe("persistent");
178
+ });
179
+
180
+ test("integration with real processState — running pid is alive + fresh mtime", () => {
181
+ // Write a real pidfile pointing at this test process (always alive),
182
+ // so `defaultAlive` returns true. Pidfile mtime will be ~now, so it
183
+ // falls inside the boot window.
184
+ const dir = mkdtempSync(join(tmpdir(), "proxy-state-"));
185
+ try {
186
+ writePid("vault", process.pid, dir);
187
+ expect(classifyUpstream("vault", { configDir: dir })).toBe("transient");
188
+ } finally {
189
+ rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ });
192
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { narrowResourceVaultScopes, resolveResourceVault } from "../resource-binding.ts";
3
+
4
+ const ORIGIN = "https://hub.example";
5
+ const BOUND = [ORIGIN, "http://127.0.0.1:1939"];
6
+
7
+ describe("resolveResourceVault", () => {
8
+ test("resolves a per-vault MCP resource to the vault name", () => {
9
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp`, BOUND)).toBe("jon");
10
+ });
11
+
12
+ test("tolerates a trailing slash on the MCP path", () => {
13
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp/`, BOUND)).toBe("jon");
14
+ });
15
+
16
+ test("ignores query string + fragment", () => {
17
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp?x=1#y`, BOUND)).toBe("jon");
18
+ });
19
+
20
+ test("resolves the PRM document URL to the vault name", () => {
21
+ expect(
22
+ resolveResourceVault(`${ORIGIN}/vault/jon/.well-known/oauth-protected-resource`, BOUND),
23
+ ).toBe("jon");
24
+ });
25
+
26
+ test("resolves against a non-issuer bound origin (loopback)", () => {
27
+ expect(resolveResourceVault("http://127.0.0.1:1939/vault/work/mcp", BOUND)).toBe("work");
28
+ });
29
+
30
+ test("returns null for an off-origin resource (not one we front)", () => {
31
+ expect(resolveResourceVault("https://evil.example/vault/jon/mcp", BOUND)).toBeNull();
32
+ });
33
+
34
+ test("returns null for a non-vault path", () => {
35
+ expect(resolveResourceVault(`${ORIGIN}/scribe/mcp`, BOUND)).toBeNull();
36
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon`, BOUND)).toBeNull();
37
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/notes`, BOUND)).toBeNull();
38
+ });
39
+
40
+ test("returns null for absent / empty / malformed resource", () => {
41
+ expect(resolveResourceVault(null, BOUND)).toBeNull();
42
+ expect(resolveResourceVault(undefined, BOUND)).toBeNull();
43
+ expect(resolveResourceVault("", BOUND)).toBeNull();
44
+ expect(resolveResourceVault("not a url", BOUND)).toBeNull();
45
+ });
46
+
47
+ test("does not collapse a deeper vault sub-path into the MCP shape", () => {
48
+ // `/vault/jon/mcp/extra` is not the canonical MCP endpoint.
49
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp/extra`, BOUND)).toBeNull();
50
+ });
51
+
52
+ test("rejects a vault segment that isn't a well-formed vault name (no junk mint)", () => {
53
+ // A crafted `resource=…/vault/%2F..%2Fadmin/mcp` decodes to `/../admin`,
54
+ // which is not `[a-zA-Z0-9_-]+`. Returning null falls through to the
55
+ // unbound flow — no narrowing, no token stamped `aud=vault./../admin`.
56
+ expect(resolveResourceVault(`${ORIGIN}/vault/%2F..%2Fadmin/mcp`, BOUND)).toBeNull();
57
+ // Spaces / dots / slashes in the decoded name are all out of shape.
58
+ expect(resolveResourceVault(`${ORIGIN}/vault/a.b/mcp`, BOUND)).toBeNull();
59
+ });
60
+
61
+ test("returns null for a malformed percent-escape in the vault segment (safeDecode catch path)", () => {
62
+ // `%GG` is not a valid percent-escape — `decodeURIComponent` throws; the
63
+ // helper must degrade to null rather than 500 the authorize handler.
64
+ expect(resolveResourceVault(`${ORIGIN}/vault/%GG/mcp`, BOUND)).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe("narrowResourceVaultScopes", () => {
69
+ test("narrows unnamed vault verbs to the named form", () => {
70
+ expect(narrowResourceVaultScopes(["vault:read", "vault:write"], "jon")).toEqual([
71
+ "vault:jon:read",
72
+ "vault:jon:write",
73
+ ]);
74
+ });
75
+
76
+ test("leaves already-named scopes for other vaults untouched", () => {
77
+ expect(narrowResourceVaultScopes(["vault:other:read"], "jon")).toEqual(["vault:other:read"]);
78
+ });
79
+
80
+ test("passes non-vault scopes through unchanged", () => {
81
+ expect(narrowResourceVaultScopes(["scribe:transcribe", "vault:read"], "jon")).toEqual([
82
+ "scribe:transcribe",
83
+ "vault:jon:read",
84
+ ]);
85
+ });
86
+
87
+ test("narrows the admin verb too (gate happens downstream)", () => {
88
+ // narrowResourceVaultScopes only rewrites shape; the non-requestable gate
89
+ // (`vault:<name>:admin`) blocks it afterward.
90
+ expect(narrowResourceVaultScopes(["vault:admin"], "jon")).toEqual(["vault:jon:admin"]);
91
+ });
92
+
93
+ test("is idempotent over an already-narrowed list", () => {
94
+ const once = narrowResourceVaultScopes(["vault:read"], "jon");
95
+ expect(narrowResourceVaultScopes(once, "jon")).toEqual(once);
96
+ });
97
+ });
@@ -5,6 +5,7 @@ import {
5
5
  SCOPE_EXPLANATIONS,
6
6
  explainScope,
7
7
  isRequestableScope,
8
+ isWellFormedOrNonVaultScope,
8
9
  scopeIsAdmin,
9
10
  } from "../scope-explanations.ts";
10
11
 
@@ -59,10 +60,18 @@ describe("explainScope", () => {
59
60
  expect(explainScope("vault:*:write")?.level).toBe("write");
60
61
  });
61
62
 
62
- test("doesn't promote a per-vault admin (vault:<name>:admin) into an explained scope", () => {
63
- // vault:<name>:admin is NON_REQUESTABLE never appears on the consent
64
- // screen. Explicitly not in the verb-pattern, so explainScope returns null.
65
- expect(explainScope("vault:default:admin")).toBeNull();
63
+ // Single-consent change (2026-05-29): vault:<name>:admin is now REQUESTABLE
64
+ // and reaches the consent screen, so explainScope MUST resolve it to the
65
+ // vault:admin explanation (level "admin"). This is load-bearing: it makes
66
+ // scopeIsAdmin("vault:<name>:admin") return true, which the same-hub and
67
+ // trust-by-name auto-mint gates rely on to keep admin consent-gated.
68
+ test("resolves a per-vault admin (vault:<name>:admin) to the vault:admin explanation", () => {
69
+ expect(explainScope("vault:default:admin")?.label).toBe(
70
+ SCOPE_EXPLANATIONS["vault:admin"]?.label,
71
+ );
72
+ expect(explainScope("vault:default:admin")?.level).toBe("admin");
73
+ expect(explainScope("vault:my-techne_2:admin")?.level).toBe("admin");
74
+ expect(explainScope("vault:*:admin")?.level).toBe("admin");
66
75
  });
67
76
  });
68
77
 
@@ -73,6 +82,17 @@ describe("scopeIsAdmin", () => {
73
82
  expect(scopeIsAdmin("parachute:host:admin")).toBe(true);
74
83
  });
75
84
 
85
+ // Single-consent change (2026-05-29): the named per-vault admin form must
86
+ // be recognized as admin. LOAD-BEARING — the same-hub auto-trust gate
87
+ // (`!hasAdminScope`) and the trust-by-client_name gate
88
+ // (`!requestedScopes.some(scopeIsAdmin)`) rely on this to keep a named admin
89
+ // grant consent-gated instead of silently auto-minting it.
90
+ test("true for named per-vault admin (vault:<name>:admin)", () => {
91
+ expect(scopeIsAdmin("vault:work:admin")).toBe(true);
92
+ expect(scopeIsAdmin("vault:default:admin")).toBe(true);
93
+ expect(scopeIsAdmin("vault:my-techne_2:admin")).toBe(true);
94
+ });
95
+
76
96
  test("false for non-admin and unknown scopes", () => {
77
97
  expect(scopeIsAdmin("vault:read")).toBe(false);
78
98
  expect(scopeIsAdmin("channel:send")).toBe(false);
@@ -119,17 +139,62 @@ describe("isRequestableScope", () => {
119
139
  expect(isRequestableScope("notes:something-new")).toBe(true);
120
140
  });
121
141
 
122
- // Per-vault admin scopes are pattern-matched as non-requestable so the
123
- // public OAuth flow can never mint vault:<name>:admin — only the local
124
- // session-cookie endpoint at /admin/vault-admin-token/<name> can.
125
- test("false for any vault:<name>:admin scope", () => {
126
- expect(isRequestableScope("vault:default:admin")).toBe(false);
127
- expect(isRequestableScope("vault:work:admin")).toBe(false);
128
- expect(isRequestableScope("vault:my-techne_2:admin")).toBe(false);
142
+ // Single-consent change (2026-05-29): per-vault admin scopes are now
143
+ // requestable via the public OAuth flow. The anti-privesc cap at the mint
144
+ // choke-point (`capScopesToUserAuthority`) keeps a non-owner from actually
145
+ // being granted admin — but the scope is no longer rejected up front, so
146
+ // Claude MCP (consenting as the owner) can mint a vault admin token.
147
+ test("true for any vault:<name>:admin scope (single-consent change)", () => {
148
+ expect(isRequestableScope("vault:default:admin")).toBe(true);
149
+ expect(isRequestableScope("vault:work:admin")).toBe(true);
150
+ expect(isRequestableScope("vault:my-techne_2:admin")).toBe(true);
151
+ });
152
+
153
+ test("host-level operator scopes stay non-requestable", () => {
154
+ // The asymmetry the single-consent change preserved: per-vault admin is
155
+ // now requestable (capped at mint), but host-wide operator authority is
156
+ // still operator-only-mintable.
157
+ expect(isRequestableScope("parachute:host:admin")).toBe(false);
158
+ expect(isRequestableScope("parachute:host:auth")).toBe(false);
129
159
  });
130
160
 
131
- test("vault:<name>:read|write stays requestable (only :admin is locked down)", () => {
161
+ test("vault:<name>:read|write stays requestable", () => {
132
162
  expect(isRequestableScope("vault:default:read")).toBe(true);
133
163
  expect(isRequestableScope("vault:work:write")).toBe(true);
134
164
  });
135
165
  });
166
+
167
+ // Mint-time shape guard (defensive hygiene, audit 2026-05-28). Rejects only the
168
+ // *named* three-segment vault shape when malformed; leaves the unnamed two-
169
+ // segment forms and all non-vault scopes alone.
170
+ describe("isWellFormedOrNonVaultScope", () => {
171
+ test("rejects the four audited malformed named-vault forms", () => {
172
+ expect(isWellFormedOrNonVaultScope("vault:work:ADMIN")).toBe(false); // uppercase verb
173
+ expect(isWellFormedOrNonVaultScope("vault::admin")).toBe(false); // empty name
174
+ expect(isWellFormedOrNonVaultScope("vault:work:read:admin")).toBe(false); // extra segment
175
+ expect(isWellFormedOrNonVaultScope("VAULT:work:admin")).toBe(false); // uppercase resource
176
+ });
177
+
178
+ test("admits well-formed named-vault scopes (all three verbs)", () => {
179
+ expect(isWellFormedOrNonVaultScope("vault:work:read")).toBe(true);
180
+ expect(isWellFormedOrNonVaultScope("vault:work:write")).toBe(true);
181
+ expect(isWellFormedOrNonVaultScope("vault:work:admin")).toBe(true);
182
+ expect(isWellFormedOrNonVaultScope("vault:my-techne_2:admin")).toBe(true);
183
+ });
184
+
185
+ test("admits the unnamed two-segment vault forms (out of remit)", () => {
186
+ expect(isWellFormedOrNonVaultScope("vault:read")).toBe(true);
187
+ expect(isWellFormedOrNonVaultScope("vault:write")).toBe(true);
188
+ expect(isWellFormedOrNonVaultScope("vault:admin")).toBe(true);
189
+ expect(isWellFormedOrNonVaultScope("vault")).toBe(true); // bare, no colon
190
+ });
191
+
192
+ test("admits all non-vault scopes unconditionally", () => {
193
+ expect(isWellFormedOrNonVaultScope("scribe:transcribe")).toBe(true);
194
+ expect(isWellFormedOrNonVaultScope("parachute:host:auth")).toBe(true);
195
+ expect(isWellFormedOrNonVaultScope("parachute:host:admin")).toBe(true);
196
+ expect(isWellFormedOrNonVaultScope("hub:admin")).toBe(true);
197
+ // A three-segment non-vault scope is not constrained even if malformed-looking.
198
+ expect(isWellFormedOrNonVaultScope("scribe:work:ADMIN")).toBe(true);
199
+ });
200
+ });
@@ -6,9 +6,11 @@ import {
6
6
  type ServiceEntry,
7
7
  ServicesManifestError,
8
8
  type UiSubUnit,
9
+ clearStartError,
9
10
  findService,
10
11
  readManifest,
11
12
  readManifestLenient,
13
+ recordStartError,
12
14
  removeService,
13
15
  upsertService,
14
16
  writeManifest,
@@ -1410,9 +1412,27 @@ describe("readManifestLenient — skips bad entries instead of throwing (hub#406
1410
1412
  path,
1411
1413
  JSON.stringify({
1412
1414
  services: [
1413
- { name: "parachute-vault", port: 1940, paths: ["/vault/default"], health: "/vault/default/health", version: "0.4.8-rc.10" },
1414
- { name: "parachute-surface", port: 1946, paths: ["/surface"], health: "/surface/healthz", version: "0.2.0-rc.13" },
1415
- { name: "widget", port: 0, paths: ["/widget"], health: "/widget/health", version: "0.0.1" },
1415
+ {
1416
+ name: "parachute-vault",
1417
+ port: 1940,
1418
+ paths: ["/vault/default"],
1419
+ health: "/vault/default/health",
1420
+ version: "0.4.8-rc.10",
1421
+ },
1422
+ {
1423
+ name: "parachute-surface",
1424
+ port: 1946,
1425
+ paths: ["/surface"],
1426
+ health: "/surface/healthz",
1427
+ version: "0.2.0-rc.13",
1428
+ },
1429
+ {
1430
+ name: "widget",
1431
+ port: 0,
1432
+ paths: ["/widget"],
1433
+ health: "/widget/health",
1434
+ version: "0.0.1",
1435
+ },
1416
1436
  ],
1417
1437
  }),
1418
1438
  );
@@ -1479,7 +1499,15 @@ describe("readManifestLenient — skips bad entries instead of throwing (hub#406
1479
1499
  writeFileSync(
1480
1500
  path,
1481
1501
  JSON.stringify({
1482
- services: [{ name: "widget", port: 0, paths: ["/widget"], health: "/widget/health", version: "0.0.1" }],
1502
+ services: [
1503
+ {
1504
+ name: "widget",
1505
+ port: 0,
1506
+ paths: ["/widget"],
1507
+ health: "/widget/health",
1508
+ version: "0.0.1",
1509
+ },
1510
+ ],
1483
1511
  }),
1484
1512
  );
1485
1513
  expect(() => readManifest(path)).toThrow(ServicesManifestError);
@@ -1487,4 +1515,94 @@ describe("readManifestLenient — skips bad entries instead of throwing (hub#406
1487
1515
  cleanup();
1488
1516
  }
1489
1517
  });
1518
+
1519
+ describe("lastStartError", () => {
1520
+ const wire = {
1521
+ error_type: "missing_dependency",
1522
+ error_description: "parachute-vault is required ...",
1523
+ binary: "parachute-vault",
1524
+ why: "run the Vault module Hub supervises",
1525
+ docs_url: "https://parachute.computer",
1526
+ install: { generic: "parachute install vault" },
1527
+ sysadmin_hint: "Or ask your system administrator to install it for you.",
1528
+ };
1529
+
1530
+ test("recordStartError persists the wire + stamps `at`", () => {
1531
+ const { path, cleanup } = makeTempPath();
1532
+ try {
1533
+ upsertService(vault, path);
1534
+ recordStartError("parachute-vault", wire, path);
1535
+ const entry = readManifest(path).services.find((s) => s.name === "parachute-vault");
1536
+ expect(entry?.lastStartError?.error_type).toBe("missing_dependency");
1537
+ expect(entry?.lastStartError?.binary).toBe("parachute-vault");
1538
+ expect(entry?.lastStartError?.install?.generic).toBe("parachute install vault");
1539
+ expect(entry?.lastStartError?.at).toBeDefined();
1540
+ } finally {
1541
+ cleanup();
1542
+ }
1543
+ });
1544
+
1545
+ test("recordStartError is a no-op when the row is absent", () => {
1546
+ const { path, cleanup } = makeTempPath();
1547
+ try {
1548
+ upsertService(vault, path);
1549
+ recordStartError("parachute-scribe", wire, path);
1550
+ const scribe = readManifest(path).services.find((s) => s.name === "parachute-scribe");
1551
+ expect(scribe).toBeUndefined();
1552
+ } finally {
1553
+ cleanup();
1554
+ }
1555
+ });
1556
+
1557
+ test("clearStartError removes a recorded error", () => {
1558
+ const { path, cleanup } = makeTempPath();
1559
+ try {
1560
+ upsertService(vault, path);
1561
+ recordStartError("parachute-vault", wire, path);
1562
+ clearStartError("parachute-vault", path);
1563
+ const entry = readManifest(path).services.find((s) => s.name === "parachute-vault");
1564
+ expect(entry?.lastStartError).toBeUndefined();
1565
+ } finally {
1566
+ cleanup();
1567
+ }
1568
+ });
1569
+
1570
+ test("lastStartError round-trips through validation", () => {
1571
+ const { path, cleanup } = makeTempPath();
1572
+ try {
1573
+ const withErr: ServiceEntry = {
1574
+ ...vault,
1575
+ lastStartError: { ...wire, at: "2026-05-29T00:00:00Z" },
1576
+ };
1577
+ upsertService(withErr, path);
1578
+ const entry = readManifest(path).services.find((s) => s.name === "parachute-vault");
1579
+ expect(entry?.lastStartError).toEqual({ ...wire, at: "2026-05-29T00:00:00Z" });
1580
+ } finally {
1581
+ cleanup();
1582
+ }
1583
+ });
1584
+
1585
+ test("a malformed lastStartError is dropped, not thrown (diagnostic field)", () => {
1586
+ const { path, cleanup } = makeTempPath();
1587
+ try {
1588
+ writeFileSync(
1589
+ path,
1590
+ JSON.stringify({
1591
+ services: [
1592
+ {
1593
+ ...vault,
1594
+ // missing error_description → invalid shape → dropped
1595
+ lastStartError: { error_type: "missing_dependency" },
1596
+ },
1597
+ ],
1598
+ }),
1599
+ );
1600
+ const entry = readManifest(path).services.find((s) => s.name === "parachute-vault");
1601
+ expect(entry).toBeDefined();
1602
+ expect(entry?.lastStartError).toBeUndefined();
1603
+ } finally {
1604
+ cleanup();
1605
+ }
1606
+ });
1607
+ });
1490
1608
  });