@openparachute/hub 0.5.14-rc.9 → 0.6.0

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 (83) hide show
  1. package/README.md +23 -0
  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 +30 -21
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-users.test.ts +7 -2
  9. package/src/__tests__/auth.test.ts +157 -30
  10. package/src/__tests__/cli.test.ts +44 -5
  11. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  12. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  13. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  14. package/src/__tests__/expose.test.ts +52 -2
  15. package/src/__tests__/hub-server.test.ts +97 -0
  16. package/src/__tests__/hub.test.ts +85 -6
  17. package/src/__tests__/init.test.ts +102 -1
  18. package/src/__tests__/lifecycle.test.ts +464 -2
  19. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  20. package/src/__tests__/oauth-ui.test.ts +12 -1
  21. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  22. package/src/__tests__/resource-binding.test.ts +97 -0
  23. package/src/__tests__/scope-explanations.test.ts +41 -12
  24. package/src/__tests__/services-manifest.test.ts +122 -4
  25. package/src/__tests__/setup-wizard.test.ts +335 -15
  26. package/src/__tests__/status.test.ts +36 -0
  27. package/src/__tests__/two-factor-flow.test.ts +602 -0
  28. package/src/__tests__/two-factor.test.ts +183 -0
  29. package/src/__tests__/upgrade.test.ts +78 -1
  30. package/src/__tests__/users.test.ts +68 -0
  31. package/src/__tests__/vault-auth-status.test.ts +47 -6
  32. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  33. package/src/account-home-ui.ts +488 -38
  34. package/src/account-vault-token.ts +282 -0
  35. package/src/admin-handlers.ts +159 -4
  36. package/src/admin-login-ui.ts +49 -5
  37. package/src/admin-vaults.ts +48 -15
  38. package/src/api-account.ts +14 -0
  39. package/src/api-modules-ops.ts +49 -11
  40. package/src/api-users.ts +29 -3
  41. package/src/cli.ts +26 -21
  42. package/src/clients.ts +18 -6
  43. package/src/cloudflare/config.ts +10 -4
  44. package/src/cloudflare/detect.ts +39 -44
  45. package/src/commands/auth.ts +165 -24
  46. package/src/commands/expose-2fa-warning.ts +34 -32
  47. package/src/commands/expose-auth-preflight.ts +89 -78
  48. package/src/commands/expose-cloudflare.ts +370 -12
  49. package/src/commands/expose.ts +8 -0
  50. package/src/commands/init.ts +33 -2
  51. package/src/commands/lifecycle.ts +386 -17
  52. package/src/commands/status.ts +22 -0
  53. package/src/commands/upgrade.ts +55 -11
  54. package/src/commands/wizard.ts +8 -4
  55. package/src/env-file.ts +10 -0
  56. package/src/help.ts +3 -1
  57. package/src/hub-db.ts +39 -1
  58. package/src/hub-server.ts +52 -0
  59. package/src/hub.ts +82 -14
  60. package/src/oauth-handlers.ts +298 -21
  61. package/src/oauth-ui.ts +10 -0
  62. package/src/operator-token.ts +151 -0
  63. package/src/pending-login.ts +116 -0
  64. package/src/rate-limit.ts +51 -0
  65. package/src/resource-binding.ts +134 -0
  66. package/src/scope-explanations.ts +46 -18
  67. package/src/services-manifest.ts +112 -0
  68. package/src/setup-wizard.ts +77 -7
  69. package/src/tailscale/run.ts +28 -11
  70. package/src/totp.ts +201 -0
  71. package/src/two-factor-handlers.ts +287 -0
  72. package/src/two-factor-store.ts +181 -0
  73. package/src/two-factor-ui.ts +462 -0
  74. package/src/users.ts +58 -0
  75. package/src/vault/auth-status.ts +71 -19
  76. package/src/vault-hub-origin-env.ts +163 -0
  77. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  78. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  79. package/web/ui/dist/index.html +2 -2
  80. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  81. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  82. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  83. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Pending-login state for the two-step TOTP login (hub#473).
3
+ *
4
+ * When a user with 2FA enrolled posts a correct username+password to `/login`,
5
+ * we do NOT mint a session yet — the user still has to prove the second
6
+ * factor. We stash the half-authenticated state under an opaque token and hand
7
+ * the browser a short-lived `parachute_hub_pending_login` cookie. The user
8
+ * then posts their TOTP / backup code to `/login/2fa`, which looks up the
9
+ * pending state, verifies the factor, and only then mints the real session.
10
+ *
11
+ * Storage: a process-local Map with per-entry expiry — same posture as the
12
+ * rate-limiter (`rate-limit.ts`). Persistence isn't worth a DB write: the
13
+ * window is 5 minutes, and a process restart simply forces the user to
14
+ * re-enter their password (the password POST is cheap to repeat, and losing
15
+ * an in-flight half-login on restart is fine — no security regression). This
16
+ * also avoids a second schema migration for a 5-minute-lived ephemeral row.
17
+ *
18
+ * The token is a 32-byte base64url random (same shape as a session id), so it
19
+ * is unguessable and opaque to the client. It carries no claims — everything
20
+ * is server-side in the Map.
21
+ */
22
+ import { randomBytes } from "node:crypto";
23
+ import { isHttpsRequest } from "./request-protocol.ts";
24
+
25
+ export const PENDING_LOGIN_COOKIE_NAME = "parachute_hub_pending_login";
26
+ /** Pending logins are valid for 5 minutes — long enough to open an
27
+ * authenticator app, short enough to bound a half-authenticated window. */
28
+ export const PENDING_LOGIN_TTL_MS = 5 * 60 * 1000;
29
+
30
+ interface PendingLogin {
31
+ userId: string;
32
+ /** The post-2FA redirect target resolved at password-verify time. */
33
+ next: string;
34
+ /** Absolute expiry (ms epoch). */
35
+ expiresAtMs: number;
36
+ }
37
+
38
+ const pending = new Map<string, PendingLogin>();
39
+
40
+ function gc(nowMs: number): void {
41
+ for (const [token, p] of pending) {
42
+ if (p.expiresAtMs <= nowMs) pending.delete(token);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Create a pending-login entry and return its opaque token. The caller sets
48
+ * the token as a cookie on the "enter your code" response.
49
+ */
50
+ export function createPendingLogin(
51
+ userId: string,
52
+ next: string,
53
+ now: () => Date = () => new Date(),
54
+ ): string {
55
+ const nowMs = now().getTime();
56
+ gc(nowMs);
57
+ const token = randomBytes(32).toString("base64url");
58
+ pending.set(token, { userId, next, expiresAtMs: nowMs + PENDING_LOGIN_TTL_MS });
59
+ return token;
60
+ }
61
+
62
+ /**
63
+ * Resolve a pending-login token to its state, or null if absent/expired.
64
+ * Does NOT consume — the caller consumes only after the second factor
65
+ * verifies (so a failed 2FA attempt can retry against the same pending login
66
+ * without re-entering the password).
67
+ */
68
+ export function getPendingLogin(
69
+ token: string | null,
70
+ now: () => Date = () => new Date(),
71
+ ): { userId: string; next: string } | null {
72
+ if (!token) return null;
73
+ const nowMs = now().getTime();
74
+ gc(nowMs);
75
+ const p = pending.get(token);
76
+ if (!p) return null;
77
+ if (p.expiresAtMs <= nowMs) {
78
+ pending.delete(token);
79
+ return null;
80
+ }
81
+ return { userId: p.userId, next: p.next };
82
+ }
83
+
84
+ /** Delete a pending-login entry (after successful 2FA, or on cancel). Idempotent. */
85
+ export function consumePendingLogin(token: string | null): void {
86
+ if (token) pending.delete(token);
87
+ }
88
+
89
+ /** Test-only: clear all pending logins between cases. */
90
+ export function _resetPendingLogins(): void {
91
+ pending.clear();
92
+ }
93
+
94
+ export function buildPendingLoginCookie(token: string, req: Request): string {
95
+ const parts = [`${PENDING_LOGIN_COOKIE_NAME}=${token}`, "HttpOnly"];
96
+ if (isHttpsRequest(req)) parts.push("Secure");
97
+ // Path=/login so the cookie only rides /login and /login/2fa requests.
98
+ parts.push("SameSite=Lax", "Path=/login", `Max-Age=${Math.floor(PENDING_LOGIN_TTL_MS / 1000)}`);
99
+ return parts.join("; ");
100
+ }
101
+
102
+ export function buildPendingLoginClearCookie(req: Request): string {
103
+ const parts = [`${PENDING_LOGIN_COOKIE_NAME}=`, "HttpOnly"];
104
+ if (isHttpsRequest(req)) parts.push("Secure");
105
+ parts.push("SameSite=Lax", "Path=/login", "Max-Age=0");
106
+ return parts.join("; ");
107
+ }
108
+
109
+ export function parsePendingLoginCookie(cookieHeader: string | null): string | null {
110
+ if (!cookieHeader) return null;
111
+ for (const part of cookieHeader.split(";")) {
112
+ const [name, ...rest] = part.trim().split("=");
113
+ if (name === PENDING_LOGIN_COOKIE_NAME) return rest.join("=");
114
+ }
115
+ return null;
116
+ }
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
+ }
@@ -138,23 +138,39 @@ 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 via the
142
- * public OAuth flow: they let the holder mint, revoke, and rotate tokens
143
- * for a specific vault instance, which is operator-only territory.
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).
144
153
  *
145
- * They are mintable by operator-proving local paths, all of which require
146
- * already-established authority (never third-party consent):
154
+ * `vault:<name>:admin` also remains mintable by operator-proving local paths,
155
+ * all of which require already-established authority:
147
156
  * - the session-cookie-gated `/admin/vault-admin-token/:name` endpoint
148
157
  * (the vault SPA's Manage link + setup wizard); and
149
158
  * - `POST /api/auth/mint-token` under the capability-attenuation model —
150
- * a `parachute:host:admin` bearer (box-wide one-vault) or a
151
- * `vault:<name>:admin` bearer (same-vault subset). The same model governs
152
- * `POST /api/auth/revoke-token` (revoke what you could mint). See
153
- * `canGrant` in `scope-attenuation.ts` and the guards in
154
- * `api-mint-token.ts` / `api-revoke-token.ts`.
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`.
155
166
  *
156
- * Pattern-based because the set is open-ended — every vault instance the
157
- * operator creates implies a new scope, and we don't want to enumerate them.
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).
158
174
  */
159
175
  const VAULT_ADMIN_RE = /^vault:[a-zA-Z0-9_-]+:admin$/;
160
176
 
@@ -240,7 +256,12 @@ export function isWellFormedOrNonVaultScope(scope: string): boolean {
240
256
 
241
257
  /** True when the scope is non-requestable via the public OAuth flow. */
242
258
  export function isNonRequestableScope(scope: string): boolean {
243
- 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);
244
265
  }
245
266
 
246
267
  /** True when the scope can appear in a public `/oauth/authorize` request. */
@@ -261,17 +282,24 @@ export function isRequestableScope(scope: string): boolean {
261
282
  * shape we use on the operator approval page where no per-user vault has
262
283
  * been selected yet (a specific vault is chosen during sign-in).
263
284
  *
264
- * Verb-only admin verbs on a per-vault basis (`vault:<name>:admin`) are
265
- * `NON_REQUESTABLE_SCOPES` by policy and never reach the consent screen, so
266
- * 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.
267
295
  */
268
- const VAULT_VERB_RE = /^vault:[a-zA-Z0-9_*-]+:(read|write)$/;
296
+ const VAULT_VERB_RE = /^vault:[a-zA-Z0-9_*-]+:(read|write|admin)$/;
269
297
 
270
298
  export function explainScope(scope: string): ScopeExplanation | null {
271
299
  const direct = SCOPE_EXPLANATIONS[scope];
272
300
  if (direct) return direct;
273
301
  if (VAULT_VERB_RE.test(scope)) {
274
- const verb = scope.split(":")[2] as "read" | "write";
302
+ const verb = scope.split(":")[2] as "read" | "write" | "admin";
275
303
  return SCOPE_EXPLANATIONS[`vault:${verb}`] ?? null;
276
304
  }
277
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
+ }