@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
package/src/rate-limit.ts CHANGED
@@ -81,6 +81,34 @@ export const CHANGE_PASSWORD_WINDOW_MS = 5 * 60 * 1000;
81
81
  * cookie attacker shouldn't get a 5-shot grind window.
82
82
  */
83
83
  export const CHANGE_PASSWORD_MAX_ATTEMPTS = 3;
84
+ /**
85
+ * `/login/2fa` window length: 15 minutes — same as `/login`. The second-
86
+ * factor step (hub#473) sits behind a verified password + a short-lived
87
+ * pending-login token, so the threat model is "attacker who already has the
88
+ * password grinding 6-digit codes / backup codes." A 5-attempt / 15-min
89
+ * bucket per IP turns 10^6-space TOTP grinding into "rotate IPs," same floor
90
+ * as `/login`. Keyed by IP (the pending-login token is short-lived and an
91
+ * attacker could mint many, so IP is the stable actor key here).
92
+ */
93
+ export const TOTP_WINDOW_MS = 15 * 60 * 1000;
94
+ /** `/login/2fa` attempts allowed per window. 6th within the window is denied. */
95
+ export const TOTP_MAX_ATTEMPTS = 5;
96
+ /**
97
+ * `POST /account/vault-token/<name>` window length: 10 minutes. The endpoint
98
+ * is session-gated and assignment-capped (a friend can only mint
99
+ * `vault:<assigned>:read|write`), so this limiter isn't the primary defense —
100
+ * it's a floor that stops a compromised session (stolen cookie) from
101
+ * machine-gunning the registry with mint rows. Keyed by user-id (identity is
102
+ * established by the session before the limiter is reached), same posture as
103
+ * the change-password limiter.
104
+ */
105
+ export const VAULT_TOKEN_MINT_WINDOW_MS = 10 * 60 * 1000;
106
+ /**
107
+ * `POST /account/vault-token/<name>` attempts allowed per window. A friend
108
+ * minting a token for a script does it a handful of times at most; 10 per 10
109
+ * minutes is generous for a human and still chokes a stolen-cookie flood.
110
+ */
111
+ export const VAULT_TOKEN_MINT_MAX_ATTEMPTS = 10;
84
112
  /** Sentinel for the IP-extraction priority chain when nothing parsed. */
85
113
  export const UNKNOWN_IP_SENTINEL = "unknown";
86
114
 
@@ -197,6 +225,27 @@ export const changePasswordRateLimiter = new RateLimiter(
197
225
  CHANGE_PASSWORD_WINDOW_MS,
198
226
  );
199
227
 
228
+ /**
229
+ * `/login/2fa` rate limiter — per-IP, 5 attempts / 15 min (hub#473). Bounds
230
+ * second-factor grinding by an attacker who already has the password. Separate
231
+ * bucket from `/login` so a password failure and a TOTP failure don't share a
232
+ * window — but both are per-IP so rotating egress IPs is the only escape, same
233
+ * as the password floor.
234
+ */
235
+ export const totpRateLimiter = new RateLimiter(TOTP_MAX_ATTEMPTS, TOTP_WINDOW_MS);
236
+
237
+ /**
238
+ * `POST /account/vault-token/<name>` rate limiter — per-user, 10 attempts /
239
+ * 10 min (friend vault-token mint). Keyed by user-id (session-gated endpoint,
240
+ * identity established before this limiter is reached). Separate bucket from
241
+ * change-password so a token-mint flurry and a password-change flurry don't
242
+ * share a window.
243
+ */
244
+ export const vaultTokenMintRateLimiter = new RateLimiter(
245
+ VAULT_TOKEN_MINT_MAX_ATTEMPTS,
246
+ VAULT_TOKEN_MINT_WINDOW_MS,
247
+ );
248
+
200
249
  /**
201
250
  * Backwards-compat shim for hub#188's call sites: the original
202
251
  * top-level `checkAndRecord` was the login limiter. New code should
@@ -215,6 +264,8 @@ export function checkAndRecord(key: string, now: Date): RateLimitResult {
215
264
  export function __resetForTests(): void {
216
265
  loginRateLimiter.reset();
217
266
  changePasswordRateLimiter.reset();
267
+ totpRateLimiter.reset();
268
+ vaultTokenMintRateLimiter.reset();
218
269
  }
219
270
 
220
271
  /**
@@ -0,0 +1,134 @@
1
+ /**
2
+ * RFC 8707 (Resource Indicators for OAuth 2.0) resource-binding helpers.
3
+ *
4
+ * An MCP client connecting to a single vault (`<origin>/vault/<name>/mcp`)
5
+ * discovers the hub as its authorization server via the RFC 9728 challenge
6
+ * (`WWW-Authenticate: Bearer resource_metadata=…`) → per-vault Protected
7
+ * Resource Metadata. A spec-following client then sends the `resource`
8
+ * parameter on `/oauth/authorize` naming that exact MCP endpoint.
9
+ *
10
+ * Before this module the hub dropped `resource` on the floor: the consent
11
+ * screen advertised the ENTIRE scope catalog (every vault + hub:admin +
12
+ * scribe:admin …) and the minted token's audience was derived purely from
13
+ * the operator's manual vault-picker choice. Two failures fell out:
14
+ *
15
+ * 1. Scary consent — a friend connecting to ONE vault saw the whole hub's
16
+ * scope surface.
17
+ * 2. Broad-scope rejection — an unnamed `vault:read` token (the shape a
18
+ * client gets when it never picks a specific vault) is REJECTED by a
19
+ * current-line vault (`findBroadVaultScopes`), because hub-issued tokens
20
+ * must carry resource-narrowed `vault:<name>:<verb>` scopes + a matching
21
+ * `aud=vault.<name>`.
22
+ *
23
+ * This module consumes `resource` end-to-end: when it resolves to a per-vault
24
+ * MCP resource we derive `<name>`, narrow the consent scope list to that
25
+ * vault's named scopes, lock the picker to `<name>`, and mint named scopes so
26
+ * `inferAudience` stamps `aud=vault.<name>`.
27
+ *
28
+ * Source of truth for scope shape: `parachute-patterns/patterns/oauth-scopes.md`.
29
+ */
30
+
31
+ import { VAULT_VERBS } from "./jwt-audience.ts";
32
+
33
+ /**
34
+ * Two recognised per-vault resource shapes, both rooted at the hub origin:
35
+ *
36
+ * - the MCP endpoint: `<origin>/vault/<name>/mcp` (the canonical RFC 8707
37
+ * resource indicator the PRM `resource` field advertises and a spec-
38
+ * following client echoes back);
39
+ * - the PRM document: `<origin>/vault/<name>/.well-known/oauth-protected-resource`
40
+ * (some clients send the metadata URL itself as the resource).
41
+ *
42
+ * A trailing slash and any query/fragment are tolerated. Capture group 1 is
43
+ * the vault instance name (same `[^/]+` shape `vaultInstanceNameFor` derives
44
+ * from a `/vault/<name>` path).
45
+ */
46
+ const VAULT_MCP_PATH_RE = /^\/vault\/([^/]+)\/mcp\/?$/;
47
+ const VAULT_PRM_PATH_RE = /^\/vault\/([^/]+)\/\.well-known\/oauth-protected-resource\/?$/;
48
+
49
+ /**
50
+ * Resolve the RFC 8707 `resource` parameter to a vault instance name, or null
51
+ * when it isn't a per-vault MCP resource (absent, malformed, off-origin, or a
52
+ * non-vault path). Off-origin resources return null deliberately: we only
53
+ * narrow consent for resources the hub itself fronts, so a `resource` naming
54
+ * some third party's URL can't drive the vault-narrowing path.
55
+ *
56
+ * `boundOrigins` is the hub's own origin set (issuer + loopback + tailnet +
57
+ * funnel — same set the same-origin CSRF gate uses). A resource whose origin
58
+ * isn't in that set is treated as not-ours.
59
+ *
60
+ * The vault name is NOT validated against the live services.json here — that
61
+ * check stays where it already lives (the consent picker / submit defenses).
62
+ * This helper's only job is shape recognition + name extraction.
63
+ */
64
+ export function resolveResourceVault(
65
+ resource: string | null | undefined,
66
+ boundOrigins: readonly string[],
67
+ ): string | null {
68
+ if (!resource) return null;
69
+ let parsed: URL;
70
+ try {
71
+ parsed = new URL(resource);
72
+ } catch {
73
+ return null;
74
+ }
75
+ if (!boundOrigins.includes(parsed.origin)) return null;
76
+ const mcp = VAULT_MCP_PATH_RE.exec(parsed.pathname);
77
+ if (mcp?.[1]) return decodeVaultName(mcp[1]);
78
+ const prm = VAULT_PRM_PATH_RE.exec(parsed.pathname);
79
+ if (prm?.[1]) return decodeVaultName(prm[1]);
80
+ return null;
81
+ }
82
+
83
+ /** Canonical vault-name shape — mirrors `VAULT_SCOPED_RE`'s name group. */
84
+ const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
85
+
86
+ /**
87
+ * Decode a captured path segment into a vault name, returning null when it
88
+ * isn't a well-formed vault name. Two failure modes both fall through to the
89
+ * unbound flow (no narrowing, no junk mint):
90
+ *
91
+ * - malformed percent-escape (`%GG`) → `decodeURIComponent` throws → null.
92
+ * A bad `resource` must degrade gracefully, not 500 the authorize handler.
93
+ * - decoded value isn't `[a-zA-Z0-9_-]+` → null. The `[^/]+` path capture
94
+ * admits anything between slashes; a crafted `resource=…/vault/%2F..%2Fadmin/mcp`
95
+ * decodes to `/../admin`, which would otherwise mint a token stamped
96
+ * `aud=vault./../admin`. Harmless (the resource server rejects it) but it's
97
+ * audit-log noise + a minting path for non-vault names. Anchoring the name
98
+ * to the canonical shape closes it. Matches `VAULT_SCOPED_RE`'s name group
99
+ * so what we accept here is exactly what a vault scope can name.
100
+ */
101
+ function decodeVaultName(segment: string): string | null {
102
+ let decoded: string;
103
+ try {
104
+ decoded = decodeURIComponent(segment);
105
+ } catch {
106
+ return null;
107
+ }
108
+ return VAULT_NAME_RE.test(decoded) ? decoded : null;
109
+ }
110
+
111
+ /**
112
+ * Rewrite the requested scope list for a resource-bound vault flow.
113
+ *
114
+ * - unnamed `vault:<verb>` → `vault:<name>:<verb>` (the narrow,
115
+ * audience-correct shape vault accepts);
116
+ * - already-named `vault:<other>:<verb>` is LEFT UNTOUCHED — a client that
117
+ * explicitly named a different vault is not silently re-pointed; the
118
+ * downstream picker / assignment defenses decide whether that's allowed.
119
+ * - non-vault scopes (`scribe:transcribe`, `hub:admin`, …) pass through
120
+ * unchanged — resource-binding only narrows the vault verbs.
121
+ *
122
+ * Idempotent: a scope already shaped `vault:<name>:<verb>` for THIS name is
123
+ * returned as-is, so re-running over a narrowed list is a no-op.
124
+ */
125
+ export function narrowResourceVaultScopes(scopes: readonly string[], vaultName: string): string[] {
126
+ return scopes.map((s) => {
127
+ const parts = s.split(":");
128
+ const verb = parts[1];
129
+ if (parts.length === 2 && parts[0] === "vault" && verb && VAULT_VERBS.has(verb)) {
130
+ return `vault:${vaultName}:${verb}`;
131
+ }
132
+ return s;
133
+ });
134
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Capability attenuation — the shared authority model behind both the
3
+ * mint side (`/api/auth/mint-token`, hub#452) and the revoke side
4
+ * (`/api/auth/revoke-token`). The two endpoints are symmetric: you may
5
+ * revoke exactly what you could have minted.
6
+ *
7
+ * `canGrant(bearerScopes, scope)` answers: could a bearer holding
8
+ * `bearerScopes` mint a token carrying `scope`? It is the single source of
9
+ * truth for "is `scope` within this bearer's authority" — mint uses it to
10
+ * gate what a request may issue; revoke uses it to gate what a request may
11
+ * tear down (every recorded scope on the target jti must be `canGrant`-able).
12
+ *
13
+ * `hasMintingAuthority(bearerScopes)` is the cheap entry gate: does the
14
+ * bearer hold ANY authority at all (host:auth, host:admin, or some
15
+ * `vault:<*>:admin`)? A bearer with none can neither mint nor revoke via
16
+ * attenuation, so both endpoints 403 it before any per-scope work.
17
+ *
18
+ * Pure functions — no DB, no I/O — so they're trivially testable and both
19
+ * handlers stay thin.
20
+ */
21
+ import { isNonRequestableScope, isVaultAdminScope, vaultScopeName } from "./scope-explanations.ts";
22
+
23
+ /**
24
+ * Bearer scope that authorises minting any *requestable* scope (rule 1 of the
25
+ * attenuation model). The operator's admin scope-set carries this; a narrow
26
+ * `--scope-set=auth` operator token carries it too.
27
+ */
28
+ export const MINT_HOST_AUTH_SCOPE = "parachute:host:auth";
29
+ /**
30
+ * Bearer scope that authorises minting `vault:<name>:admin` (rule 2).
31
+ * `parachute:host:admin` already implies box-wide administration of every
32
+ * vault on the hub, so minting a vault-pinned admin from it is a privilege
33
+ * *reduction* (de-escalation), not an escalation — see the design doc
34
+ * `2026-05-28-operator-mintable-vault-admin.md`.
35
+ */
36
+ export const MINT_HOST_ADMIN_SCOPE = "parachute:host:admin";
37
+
38
+ /**
39
+ * Capability attenuation: can a bearer holding `bearerScopes` mint a token
40
+ * carrying `requestedScope`? True iff the requested scope is a subset of the
41
+ * bearer's own authority under one of three rules:
42
+ *
43
+ * 1. requestable + bearer has `parachute:host:auth`;
44
+ * 2. `vault:<N>:admin` + bearer has `parachute:host:admin`;
45
+ * 3. `vault:<N>:<verb>` + bearer has `vault:<N>:admin` (same `<N>`).
46
+ *
47
+ * Pure function — no DB, no I/O — so it's trivially testable and the guard in
48
+ * each handler is a single `scopes.filter((s) => !canGrant(bearerScopes, s))`.
49
+ *
50
+ * On the revoke side this is the symmetric rule: a target jti is revocable by
51
+ * a non-host:auth bearer iff EVERY one of its recorded scopes is `canGrant`-able
52
+ * — i.e. the bearer could have minted that token, so it may also tear it down.
53
+ * Cross-vault and host-authority targets are never `canGrant`-able by a mere
54
+ * `vault:<N>:admin` bearer, so it can neither mint nor revoke them.
55
+ */
56
+ export function canGrant(bearerScopes: string[], requestedScope: string): boolean {
57
+ // Rule 1 — host:auth mints any requestable scope.
58
+ if (!isNonRequestableScope(requestedScope) && bearerScopes.includes(MINT_HOST_AUTH_SCOPE)) {
59
+ return true;
60
+ }
61
+ // Rule 2 — host:admin attenuates to a named vault's admin.
62
+ if (isVaultAdminScope(requestedScope) && bearerScopes.includes(MINT_HOST_ADMIN_SCOPE)) {
63
+ return true;
64
+ }
65
+ // Rule 3 — vault:<N>:admin attenuates to any same-vault subset (incl. admin).
66
+ const requestedVault = vaultScopeName(requestedScope);
67
+ if (requestedVault !== null && bearerScopes.includes(`vault:${requestedVault}:admin`)) {
68
+ return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Does the bearer hold ANY minting authority? Entry gate before per-scope
75
+ * checks — a bearer with none (e.g. a read-only token) can mint (or revoke
76
+ * via attenuation) nothing, so both endpoints 403 it early rather than
77
+ * walking every scope to the same end.
78
+ */
79
+ export function hasMintingAuthority(bearerScopes: string[]): boolean {
80
+ return (
81
+ bearerScopes.includes(MINT_HOST_AUTH_SCOPE) ||
82
+ bearerScopes.includes(MINT_HOST_ADMIN_SCOPE) ||
83
+ bearerScopes.some((s) => isVaultAdminScope(s))
84
+ );
85
+ }
@@ -138,20 +138,130 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
138
138
  ]);
139
139
 
140
140
  /**
141
- * Per-vault `vault:<name>:admin` scopes are also non-requestable: they let
142
- * the holder mint, revoke, and rotate tokens for a specific vault instance,
143
- * which is operator-only territory. Like `parachute:host:admin`, these are
144
- * minted by a session-cookie-gated hub endpoint (`/admin/vault-admin-token/:name`),
145
- * never by the public OAuth flow.
146
- *
147
- * Pattern-based because the set is open-ended every vault instance the
148
- * operator creates implies a new scope, and we don't want to enumerate them.
141
+ * Per-vault `vault:<name>:admin` scopes ARE requestable via the public OAuth
142
+ * flow (single-consent change, 2026-05-29). A user may grant a named vault's
143
+ * admin scope to an OAuth client — e.g. Claude MCP minting an admin token for
144
+ * a vault but only within the one guardrail that survives the simplification:
145
+ * a user may only delegate authority they themselves hold. That guardrail is
146
+ * enforced at the shared mint choke-point (`capScopesToUserAuthority` applied
147
+ * inside `issueAuthCodeRedirect` in `oauth-handlers.ts`): an OAuth flow caps
148
+ * named vault verbs to those the consenting user actually holds on that vault.
149
+ * `vaultVerbsForRole` never returns `admin` for an assigned (read/write) user,
150
+ * so admin is dropped for everyone except the hub owner (isFirstAdmin) — who
151
+ * holds admin everywhere by construction. An admin-only request from a
152
+ * non-owner is refused outright (never minted as a zero-scope token).
153
+ *
154
+ * `vault:<name>:admin` also remains mintable by operator-proving local paths,
155
+ * all of which require already-established authority:
156
+ * - the session-cookie-gated `/admin/vault-admin-token/:name` endpoint
157
+ * (the vault SPA's Manage link + setup wizard); and
158
+ * - `POST /api/auth/mint-token` under the capability-attenuation model —
159
+ * a `parachute:host:auth` bearer (any requestable scope, now incl.
160
+ * `vault:<name>:admin` — an intentional de-escalation widening that
161
+ * landed with the single-consent change), a `parachute:host:admin`
162
+ * bearer (box-wide → one-vault), or a `vault:<name>:admin` bearer
163
+ * (same-vault subset). The same model governs `POST /api/auth/revoke-token`
164
+ * (revoke what you could mint). See `canGrant` in `scope-attenuation.ts`
165
+ * and the guards in `api-mint-token.ts` / `api-revoke-token.ts`.
166
+ *
167
+ * The matcher is pattern-based because the set is open-ended — every vault
168
+ * instance the operator creates implies a new scope, and we don't want to
169
+ * enumerate them. It is still used by `isVaultAdminScope` (the mint-token
170
+ * de-escalation recognizer) and `explainScope` / `VAULT_VERB_RE` (so the
171
+ * consent screen renders the admin badge and `scopeIsAdmin` recognizes the
172
+ * named admin form — load-bearing: the same-hub and trust-by-name auto-mint
173
+ * gates rely on `scopeIsAdmin` to keep admin consent-gated).
149
174
  */
150
175
  const VAULT_ADMIN_RE = /^vault:[a-zA-Z0-9_-]+:admin$/;
151
176
 
177
+ /**
178
+ * Any per-vault scope: `vault:<name>:<verb>` for verb ∈ {read, write, admin}.
179
+ * Captures the name in group 1 and the verb in group 2. Used by the
180
+ * mint-token capability-attenuation model to recognise the scopes a
181
+ * `vault:<name>:admin` bearer may attenuate to (same-vault subsets).
182
+ */
183
+ const VAULT_SCOPED_RE = /^vault:([a-zA-Z0-9_-]+):(read|write|admin)$/;
184
+
185
+ /**
186
+ * True when `scope` is a per-vault admin scope (`vault:<name>:admin`).
187
+ * Exported so the mint-token path can recognise the one non-requestable
188
+ * scope it conditionally admits for `parachute:host:admin` bearers.
189
+ */
190
+ export function isVaultAdminScope(scope: string): boolean {
191
+ return VAULT_ADMIN_RE.test(scope);
192
+ }
193
+
194
+ /**
195
+ * Extract the vault name from ANY per-vault scope (`vault:<name>:<verb>` for
196
+ * verb ∈ {read, write, admin}), or null if the scope isn't per-vault-scoped.
197
+ * Used by the mint-token attenuation model to (a) match a `vault:<name>:admin`
198
+ * bearer against same-vault requested scopes, and (b) derive the `vault_scope`
199
+ * pin for every vault-scoped mint regardless of verb.
200
+ */
201
+ export function vaultScopeName(scope: string): string | null {
202
+ const m = VAULT_SCOPED_RE.exec(scope);
203
+ return m ? (m[1] ?? null) : null;
204
+ }
205
+
206
+ /**
207
+ * Mint-time shape guard: reject scopes that LOOK like a *named* per-vault scope
208
+ * (`vault:<name>:<verb>`, three+ colon-segments, first segment `vault`
209
+ * case-insensitively to catch `VAULT:…`) but are malformed — i.e. they don't
210
+ * match the strict `vault:<name>:<read|write|admin>` shape (`VAULT_SCOPED_RE`).
211
+ *
212
+ * Returns true for (i.e. ADMITS):
213
+ * - well-formed named scopes `vault:<name>:<read|write|admin>`;
214
+ * - the canonical *unnamed* two-segment scopes `vault:read|write|admin`
215
+ * (legitimate OAuth/consent forms — keys in `SCOPE_EXPLANATIONS`, narrowed
216
+ * to a named vault at consent time) and any other non-three-segment
217
+ * `vault`-prefixed string — those aren't attempting the named shape, so
218
+ * they're out of this guard's remit and keep their existing behaviour;
219
+ * - every non-vault scope (`scribe:transcribe`, `parachute:host:*`, …).
220
+ *
221
+ * Returns false for (i.e. REJECTS) only a `vault`-prefixed string with three
222
+ * or more colon-segments that fails `VAULT_SCOPED_RE`:
223
+ * `vault:work:ADMIN` (uppercase verb), `vault::admin` (empty name),
224
+ * `vault:work:read:admin` (extra segment), `VAULT:work:admin` (uppercase
225
+ * resource).
226
+ *
227
+ * Why this exists (defensive hygiene — adversarial audit, 2026-05-28): a
228
+ * `parachute:host:auth` bearer can today mint those four malformed strings.
229
+ * `isNonRequestableScope`'s strict regexes don't match them, so `canGrant`
230
+ * rule 1 admits them as "requestable" — the mint succeeds (200) carrying the
231
+ * literal junk string and writes a registry row. They grant ZERO access today
232
+ * (the vault consumer's `decomposeVaultScope` is case-sensitive + anchored and
233
+ * rejects all four), so this is NOT exploitable now. The value is (a) registry
234
+ * hygiene (no junk rows) and (b) a backstop against a FUTURE consumer-
235
+ * normalization regression — if vault ever started case-folding scope verbs,
236
+ * those junk tokens could silently become live admin. A strict mint-time shape
237
+ * check closes that door now.
238
+ *
239
+ * Orthogonal to authority: this is an input-shape check applied to ALL mint
240
+ * callers (host:auth, host:admin, vault:<name>:admin) before any `canGrant`
241
+ * attenuation. It does not affect non-vault scopes or the unnamed `vault:<verb>`
242
+ * forms.
243
+ */
244
+ export function isWellFormedOrNonVaultScope(scope: string): boolean {
245
+ const segments = scope.split(":");
246
+ // Only constrain the *named* per-vault shape: first segment names the vault
247
+ // resource (case-insensitive, to catch `VAULT:`) AND there are three or more
248
+ // segments (an attempt at `vault:<name>:<verb>`). The unnamed two-segment
249
+ // forms (`vault:read|write|admin`) and a bare `vault` are out of remit.
250
+ const firstSegment = segments[0] ?? "";
251
+ if (firstSegment.toLowerCase() !== "vault" || segments.length < 3) {
252
+ return true;
253
+ }
254
+ return VAULT_SCOPED_RE.test(scope);
255
+ }
256
+
152
257
  /** True when the scope is non-requestable via the public OAuth flow. */
153
258
  export function isNonRequestableScope(scope: string): boolean {
154
- return NON_REQUESTABLE_SCOPES.has(scope) || VAULT_ADMIN_RE.test(scope);
259
+ // Per-vault `vault:<name>:admin` is NO LONGER globally non-requestable
260
+ // (single-consent change, 2026-05-29). It flows through the public OAuth
261
+ // consent path and through `canGrant` rule 1, capped to the consenting
262
+ // user's held authority at the `issueAuthCodeRedirect` choke-point. Only
263
+ // the host-level operator scopes stay non-requestable here.
264
+ return NON_REQUESTABLE_SCOPES.has(scope);
155
265
  }
156
266
 
157
267
  /** True when the scope can appear in a public `/oauth/authorize` request. */
@@ -172,17 +282,24 @@ export function isRequestableScope(scope: string): boolean {
172
282
  * shape we use on the operator approval page where no per-user vault has
173
283
  * been selected yet (a specific vault is chosen during sign-in).
174
284
  *
175
- * Verb-only admin verbs on a per-vault basis (`vault:<name>:admin`) are
176
- * `NON_REQUESTABLE_SCOPES` by policy and never reach the consent screen, so
177
- * we don't substitute for them here. Read / write get the matching label.
285
+ * Includes `admin` (single-consent change, 2026-05-29). `vault:<name>:admin`
286
+ * is now requestable via OAuth, so it reaches the consent screen and MUST get
287
+ * the `vault:admin` explanation (level `"admin"`) both so the consent UI
288
+ * renders the admin badge and so `scopeIsAdmin("vault:<name>:admin")` returns
289
+ * true. That second effect is LOAD-BEARING: the same-hub auto-trust gate
290
+ * (`!hasAdminScope`) and the trust-by-client_name gate
291
+ * (`!requestedScopes.some(scopeIsAdmin)`) rely on `scopeIsAdmin` recognizing
292
+ * the named admin form to keep admin grants consent-gated (never silently
293
+ * auto-minted). If this regex dropped `admin`, those gates would treat a
294
+ * named admin scope as non-admin and auto-mint it.
178
295
  */
179
- const VAULT_VERB_RE = /^vault:[a-zA-Z0-9_*-]+:(read|write)$/;
296
+ const VAULT_VERB_RE = /^vault:[a-zA-Z0-9_*-]+:(read|write|admin)$/;
180
297
 
181
298
  export function explainScope(scope: string): ScopeExplanation | null {
182
299
  const direct = SCOPE_EXPLANATIONS[scope];
183
300
  if (direct) return direct;
184
301
  if (VAULT_VERB_RE.test(scope)) {
185
- const verb = scope.split(":")[2] as "read" | "write";
302
+ const verb = scope.split(":")[2] as "read" | "write" | "admin";
186
303
  return SCOPE_EXPLANATIONS[`vault:${verb}`] ?? null;
187
304
  }
188
305
  return null;
@@ -185,6 +185,39 @@ export interface ServiceEntry {
185
185
  * with display metadata attached.
186
186
  */
187
187
  uis?: Record<string, UiSubUnit>;
188
+ /**
189
+ * Last `parachute start` failure that's still actionable, persisted so a
190
+ * *subsequent* `parachute status` (a separate invocation that only reads
191
+ * this manifest) + the admin SPA can surface it. Today the only writer is
192
+ * the lifecycle start preflight when a startCmd binary is missing from PATH
193
+ * (`@openparachute/depcheck` `MissingDependencyError.toWire()`): the row
194
+ * shows "failed to start — <binary> not installed" with the install info
195
+ * instead of a bare orphan-timeout. Cleared on the next successful start.
196
+ *
197
+ * Stored as the structured `missing_dependency` wire so the SPA can render
198
+ * the dedicated install card; `parachute status` reads `error_description`.
199
+ * Validated as a pass-through object (shape owned by depcheck) rather than
200
+ * re-typed field-by-field here.
201
+ */
202
+ lastStartError?: ServiceEntryStartError;
203
+ }
204
+
205
+ /**
206
+ * Persisted start-failure detail on a ServiceEntry. Mirrors depcheck's
207
+ * `MissingDependencyWire` for the missing-dependency case; `error_type` is
208
+ * left open so a future non-dependency start failure could reuse the field.
209
+ */
210
+ export interface ServiceEntryStartError {
211
+ error_type: string;
212
+ error_description: string;
213
+ /** Present for `error_type: "missing_dependency"`. */
214
+ binary?: string;
215
+ why?: string | null;
216
+ docs_url?: string | null;
217
+ install?: { darwin?: string; linux?: string; generic?: string };
218
+ sysadmin_hint?: string;
219
+ /** ISO timestamp of when the failure was recorded. */
220
+ at?: string;
188
221
  }
189
222
 
190
223
  export interface ServicesManifest {
@@ -251,6 +284,7 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
251
284
  }
252
285
  const uis = e.uis;
253
286
  const validatedUis = validateUis(uis, where);
287
+ const validatedStartError = validateStartError(e.lastStartError, where);
254
288
  const entry: ServiceEntry = { name, port, paths: paths as string[], health, version };
255
289
  if (displayName !== undefined) entry.displayName = displayName;
256
290
  if (tagline !== undefined) entry.tagline = tagline;
@@ -258,9 +292,48 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
258
292
  if (installDir !== undefined) entry.installDir = installDir;
259
293
  if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
260
294
  if (validatedUis !== undefined) entry.uis = validatedUis;
295
+ if (validatedStartError !== undefined) entry.lastStartError = validatedStartError;
261
296
  return entry;
262
297
  }
263
298
 
299
+ /**
300
+ * Validate the optional `lastStartError` field. `undefined` round-trips
301
+ * unchanged. A present value must be an object carrying the two required
302
+ * string fields (`error_type`, `error_description`); the remaining fields
303
+ * (the missing-dependency detail) pass through with light type-narrowing so
304
+ * the SPA install card has them, but we never hard-fail an otherwise-valid
305
+ * row on a malformed start-error — it's diagnostic metadata, not a contract
306
+ * field. A malformed value is dropped rather than thrown.
307
+ */
308
+ function validateStartError(raw: unknown, _where: string): ServiceEntryStartError | undefined {
309
+ if (raw === undefined || raw === null) return undefined;
310
+ if (typeof raw !== "object" || Array.isArray(raw)) return undefined;
311
+ const r = raw as Record<string, unknown>;
312
+ if (typeof r.error_type !== "string" || typeof r.error_description !== "string") {
313
+ return undefined;
314
+ }
315
+ const out: ServiceEntryStartError = {
316
+ error_type: r.error_type,
317
+ error_description: r.error_description,
318
+ };
319
+ if (typeof r.binary === "string") out.binary = r.binary;
320
+ if (typeof r.why === "string" || r.why === null) out.why = r.why as string | null;
321
+ if (typeof r.docs_url === "string" || r.docs_url === null) {
322
+ out.docs_url = r.docs_url as string | null;
323
+ }
324
+ if (r.install && typeof r.install === "object" && !Array.isArray(r.install)) {
325
+ const ins = r.install as Record<string, unknown>;
326
+ const install: { darwin?: string; linux?: string; generic?: string } = {};
327
+ if (typeof ins.darwin === "string") install.darwin = ins.darwin;
328
+ if (typeof ins.linux === "string") install.linux = ins.linux;
329
+ if (typeof ins.generic === "string") install.generic = ins.generic;
330
+ out.install = install;
331
+ }
332
+ if (typeof r.sysadmin_hint === "string") out.sysadmin_hint = r.sysadmin_hint;
333
+ if (typeof r.at === "string") out.at = r.at;
334
+ return out;
335
+ }
336
+
264
337
  /**
265
338
  * Validate the optional `uis` map on a ServiceEntry. `undefined` round-trips
266
339
  * unchanged (the field is optional); a present map must be a plain object
@@ -763,3 +836,42 @@ export function findService(
763
836
  // missed.
764
837
  return readManifestLenient(path).services.find((s) => s.name === name);
765
838
  }
839
+
840
+ /**
841
+ * Persist a `lastStartError` onto the named row so a later `parachute status`
842
+ * + the admin SPA surface it. No-op if the row isn't present (e.g. a service
843
+ * that failed to start before its first self-registration wrote a row — the
844
+ * inline `parachute start` message already told the operator). Lenient read
845
+ * so a malformed sibling row doesn't block recording an unrelated failure.
846
+ */
847
+ export function recordStartError(
848
+ name: string,
849
+ err: ServiceEntryStartError,
850
+ path: string = SERVICES_MANIFEST_PATH,
851
+ ): void {
852
+ if (!existsSync(path)) return;
853
+ const current = readManifestLenient(path);
854
+ const idx = current.services.findIndex((s) => s.name === name);
855
+ if (idx < 0) return;
856
+ const row = current.services[idx];
857
+ if (!row) return;
858
+ current.services[idx] = {
859
+ ...row,
860
+ lastStartError: { ...err, at: err.at ?? new Date().toISOString() },
861
+ };
862
+ writeManifest(current, path);
863
+ }
864
+
865
+ /** Clear a row's persisted `lastStartError` (called on a successful start).
866
+ * No-op when the row is absent or already clean. */
867
+ export function clearStartError(name: string, path: string = SERVICES_MANIFEST_PATH): void {
868
+ if (!existsSync(path)) return;
869
+ const current = readManifestLenient(path);
870
+ const idx = current.services.findIndex((s) => s.name === name);
871
+ if (idx < 0) return;
872
+ const row = current.services[idx];
873
+ if (!row || row.lastStartError === undefined) return;
874
+ const { lastStartError: _drop, ...rest } = row;
875
+ current.services[idx] = rest;
876
+ writeManifest(current, path);
877
+ }