@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.
- package/README.md +109 -15
- 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 +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -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__/cloudflare-detect.test.ts +60 -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 +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- 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__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- 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 +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -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-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- 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 +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -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/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- 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 +200 -25
- 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
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
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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;
|
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
|
+
}
|