@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.
- package/README.md +23 -0
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +30 -21
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +41 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +335 -15
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-modules-ops.ts +49 -11
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-explanations.ts +46 -18
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +77 -7
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +71 -19
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
-
*
|
|
146
|
-
* already-established authority
|
|
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:
|
|
151
|
-
* `vault:<name>:admin`
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
*
|
|
157
|
-
* operator creates implies a new scope, and we don't want to
|
|
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
|
-
|
|
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
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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;
|
package/src/services-manifest.ts
CHANGED
|
@@ -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
|
+
}
|