@openparachute/hub 0.5.13 → 0.5.14-rc.10
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 +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- 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 +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- 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 +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- 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 +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- 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 +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -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-Dzrbe6EP.js +0 -61
|
@@ -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,17 +138,106 @@ 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
|
|
142
|
-
* the holder mint, revoke, and rotate tokens
|
|
143
|
-
* which is operator-only territory.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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.
|
|
144
|
+
*
|
|
145
|
+
* They are mintable by operator-proving local paths, all of which require
|
|
146
|
+
* already-established authority (never third-party consent):
|
|
147
|
+
* - the session-cookie-gated `/admin/vault-admin-token/:name` endpoint
|
|
148
|
+
* (the vault SPA's Manage link + setup wizard); and
|
|
149
|
+
* - `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`.
|
|
146
155
|
*
|
|
147
156
|
* Pattern-based because the set is open-ended — every vault instance the
|
|
148
157
|
* operator creates implies a new scope, and we don't want to enumerate them.
|
|
149
158
|
*/
|
|
150
159
|
const VAULT_ADMIN_RE = /^vault:[a-zA-Z0-9_-]+:admin$/;
|
|
151
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Any per-vault scope: `vault:<name>:<verb>` for verb ∈ {read, write, admin}.
|
|
163
|
+
* Captures the name in group 1 and the verb in group 2. Used by the
|
|
164
|
+
* mint-token capability-attenuation model to recognise the scopes a
|
|
165
|
+
* `vault:<name>:admin` bearer may attenuate to (same-vault subsets).
|
|
166
|
+
*/
|
|
167
|
+
const VAULT_SCOPED_RE = /^vault:([a-zA-Z0-9_-]+):(read|write|admin)$/;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* True when `scope` is a per-vault admin scope (`vault:<name>:admin`).
|
|
171
|
+
* Exported so the mint-token path can recognise the one non-requestable
|
|
172
|
+
* scope it conditionally admits for `parachute:host:admin` bearers.
|
|
173
|
+
*/
|
|
174
|
+
export function isVaultAdminScope(scope: string): boolean {
|
|
175
|
+
return VAULT_ADMIN_RE.test(scope);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Extract the vault name from ANY per-vault scope (`vault:<name>:<verb>` for
|
|
180
|
+
* verb ∈ {read, write, admin}), or null if the scope isn't per-vault-scoped.
|
|
181
|
+
* Used by the mint-token attenuation model to (a) match a `vault:<name>:admin`
|
|
182
|
+
* bearer against same-vault requested scopes, and (b) derive the `vault_scope`
|
|
183
|
+
* pin for every vault-scoped mint regardless of verb.
|
|
184
|
+
*/
|
|
185
|
+
export function vaultScopeName(scope: string): string | null {
|
|
186
|
+
const m = VAULT_SCOPED_RE.exec(scope);
|
|
187
|
+
return m ? (m[1] ?? null) : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Mint-time shape guard: reject scopes that LOOK like a *named* per-vault scope
|
|
192
|
+
* (`vault:<name>:<verb>`, three+ colon-segments, first segment `vault`
|
|
193
|
+
* case-insensitively to catch `VAULT:…`) but are malformed — i.e. they don't
|
|
194
|
+
* match the strict `vault:<name>:<read|write|admin>` shape (`VAULT_SCOPED_RE`).
|
|
195
|
+
*
|
|
196
|
+
* Returns true for (i.e. ADMITS):
|
|
197
|
+
* - well-formed named scopes `vault:<name>:<read|write|admin>`;
|
|
198
|
+
* - the canonical *unnamed* two-segment scopes `vault:read|write|admin`
|
|
199
|
+
* (legitimate OAuth/consent forms — keys in `SCOPE_EXPLANATIONS`, narrowed
|
|
200
|
+
* to a named vault at consent time) and any other non-three-segment
|
|
201
|
+
* `vault`-prefixed string — those aren't attempting the named shape, so
|
|
202
|
+
* they're out of this guard's remit and keep their existing behaviour;
|
|
203
|
+
* - every non-vault scope (`scribe:transcribe`, `parachute:host:*`, …).
|
|
204
|
+
*
|
|
205
|
+
* Returns false for (i.e. REJECTS) only a `vault`-prefixed string with three
|
|
206
|
+
* or more colon-segments that fails `VAULT_SCOPED_RE`:
|
|
207
|
+
* `vault:work:ADMIN` (uppercase verb), `vault::admin` (empty name),
|
|
208
|
+
* `vault:work:read:admin` (extra segment), `VAULT:work:admin` (uppercase
|
|
209
|
+
* resource).
|
|
210
|
+
*
|
|
211
|
+
* Why this exists (defensive hygiene — adversarial audit, 2026-05-28): a
|
|
212
|
+
* `parachute:host:auth` bearer can today mint those four malformed strings.
|
|
213
|
+
* `isNonRequestableScope`'s strict regexes don't match them, so `canGrant`
|
|
214
|
+
* rule 1 admits them as "requestable" — the mint succeeds (200) carrying the
|
|
215
|
+
* literal junk string and writes a registry row. They grant ZERO access today
|
|
216
|
+
* (the vault consumer's `decomposeVaultScope` is case-sensitive + anchored and
|
|
217
|
+
* rejects all four), so this is NOT exploitable now. The value is (a) registry
|
|
218
|
+
* hygiene (no junk rows) and (b) a backstop against a FUTURE consumer-
|
|
219
|
+
* normalization regression — if vault ever started case-folding scope verbs,
|
|
220
|
+
* those junk tokens could silently become live admin. A strict mint-time shape
|
|
221
|
+
* check closes that door now.
|
|
222
|
+
*
|
|
223
|
+
* Orthogonal to authority: this is an input-shape check applied to ALL mint
|
|
224
|
+
* callers (host:auth, host:admin, vault:<name>:admin) before any `canGrant`
|
|
225
|
+
* attenuation. It does not affect non-vault scopes or the unnamed `vault:<verb>`
|
|
226
|
+
* forms.
|
|
227
|
+
*/
|
|
228
|
+
export function isWellFormedOrNonVaultScope(scope: string): boolean {
|
|
229
|
+
const segments = scope.split(":");
|
|
230
|
+
// Only constrain the *named* per-vault shape: first segment names the vault
|
|
231
|
+
// resource (case-insensitive, to catch `VAULT:`) AND there are three or more
|
|
232
|
+
// segments (an attempt at `vault:<name>:<verb>`). The unnamed two-segment
|
|
233
|
+
// forms (`vault:read|write|admin`) and a bare `vault` are out of remit.
|
|
234
|
+
const firstSegment = segments[0] ?? "";
|
|
235
|
+
if (firstSegment.toLowerCase() !== "vault" || segments.length < 3) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return VAULT_SCOPED_RE.test(scope);
|
|
239
|
+
}
|
|
240
|
+
|
|
152
241
|
/** True when the scope is non-requestable via the public OAuth flow. */
|
|
153
242
|
export function isNonRequestableScope(scope: string): boolean {
|
|
154
243
|
return NON_REQUESTABLE_SCOPES.has(scope) || VAULT_ADMIN_RE.test(scope);
|
package/src/service-spec.ts
CHANGED
|
@@ -68,7 +68,7 @@ export const PORT_RESERVATIONS: readonly PortReservation[] = [
|
|
|
68
68
|
// fallback-port walker (`assignPort` in port-assign.ts) from handing this
|
|
69
69
|
// port out to a colliding third-party module. The matching KNOWN_MODULES
|
|
70
70
|
// row carries the canonicalPort + paths for status/expose surfaces.
|
|
71
|
-
{ port: 1946, name: "parachute-
|
|
71
|
+
{ port: 1946, name: "parachute-surface", status: "assigned" },
|
|
72
72
|
{ port: 1947, name: "unassigned", status: "reserved" },
|
|
73
73
|
{ port: 1948, name: "unassigned", status: "reserved" },
|
|
74
74
|
{ port: 1949, name: "unassigned", status: "reserved" },
|
|
@@ -281,7 +281,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
|
|
|
281
281
|
name: "notes",
|
|
282
282
|
manifestName: "parachute-notes",
|
|
283
283
|
displayName: "Notes",
|
|
284
|
-
tagline: "Notes PWA — daemon deprecated 2026-05-22; install `
|
|
284
|
+
tagline: "Notes PWA — daemon deprecated 2026-05-22; install `surface` for the current path.",
|
|
285
285
|
port: 1942,
|
|
286
286
|
paths: ["/notes"],
|
|
287
287
|
health: "/notes/health",
|
|
@@ -462,28 +462,29 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
462
462
|
hasAuth: true,
|
|
463
463
|
},
|
|
464
464
|
},
|
|
465
|
-
|
|
466
|
-
short: "
|
|
467
|
-
package: "@openparachute/
|
|
468
|
-
manifestName: "parachute-
|
|
465
|
+
surface: {
|
|
466
|
+
short: "surface",
|
|
467
|
+
package: "@openparachute/surface",
|
|
468
|
+
manifestName: "parachute-surface",
|
|
469
469
|
canonicalPort: 1946,
|
|
470
|
-
displayName: "
|
|
470
|
+
displayName: "Surface",
|
|
471
471
|
// Tagline telegraphs the auto-bootstrap so wizard + admin-SPA copy explain
|
|
472
|
-
// the architecture: installing `
|
|
473
|
-
// via the Phase 2.1 bootstrap-default-apps step. The notes-daemon
|
|
474
|
-
// still exists as a back-compat install (CURATED_MODULES still
|
|
475
|
-
// `notes`) but `
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
472
|
+
// the architecture: installing `surface` brings Notes (and other UIs)
|
|
473
|
+
// along via the Phase 2.1 bootstrap-default-apps step. The notes-daemon
|
|
474
|
+
// path still exists as a back-compat install (CURATED_MODULES still
|
|
475
|
+
// lists `notes`) but `surface` is the recommended first install
|
|
476
|
+
// post-vault. Renamed from `app` 2026-05-27 per patterns#102.
|
|
477
|
+
tagline: "Host module for Parachute surfaces — auto-installs Notes on first boot.",
|
|
478
|
+
canonicalPaths: ["/surface", "/.parachute"],
|
|
479
|
+
canonicalHealth: "/surface/healthz",
|
|
479
480
|
canonicalStripPrefix: false,
|
|
480
481
|
extras: {
|
|
481
482
|
// Backward-compat startCmd — same rationale as scribe / vault / runner
|
|
482
483
|
// above. Post-self-register, lifecycle reads module.json's startCmd via
|
|
483
484
|
// `composeKnownModuleSpec` and that path wins.
|
|
484
|
-
startCmd: () => ["parachute-
|
|
485
|
-
//
|
|
486
|
-
// doc §6 same-hub auto-trust + scope `
|
|
485
|
+
startCmd: () => ["parachute-surface", "serve"],
|
|
486
|
+
// Surface's admin + per-UI surfaces gate behind hub-issued JWTs (design
|
|
487
|
+
// doc §6 same-hub auto-trust + scope `surface:admin`). Surfaces in
|
|
487
488
|
// `parachute status` as auth-required by default, same posture as vault
|
|
488
489
|
// + runner.
|
|
489
490
|
hasAuth: true,
|
|
@@ -516,7 +517,27 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
516
517
|
export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
|
|
517
518
|
agent: {
|
|
518
519
|
retiredAt: "2026-05-20",
|
|
519
|
-
replacement: "parachute-
|
|
520
|
+
replacement: "parachute-surface or parachute-runner (depending on use case)",
|
|
521
|
+
},
|
|
522
|
+
// 2026-05-20 retirement caught both forms of legacy rows.
|
|
523
|
+
"parachute-agent": {
|
|
524
|
+
retiredAt: "2026-05-20",
|
|
525
|
+
replacement: "parachute-surface or parachute-runner (depending on use case)",
|
|
526
|
+
},
|
|
527
|
+
// The `parachute-app` row name retires 2026-05-27 along with the
|
|
528
|
+
// app → surface rename (patterns#102). Operators upgrading from
|
|
529
|
+
// 0.5.13-stable will have a `parachute-app` row in services.json
|
|
530
|
+
// pointing at the now-removed @openparachute/app package; this entry
|
|
531
|
+
// drops it on load + steers them at `parachute install surface`.
|
|
532
|
+
// The short-name `app` form is included for legacy rows that used
|
|
533
|
+
// the short name as the `name` field.
|
|
534
|
+
app: {
|
|
535
|
+
retiredAt: "2026-05-27",
|
|
536
|
+
replacement: "parachute-surface (renamed from parachute-app — `parachute install surface`)",
|
|
537
|
+
},
|
|
538
|
+
"parachute-app": {
|
|
539
|
+
retiredAt: "2026-05-27",
|
|
540
|
+
replacement: "parachute-surface (renamed from parachute-app — `parachute install surface`)",
|
|
520
541
|
},
|
|
521
542
|
};
|
|
522
543
|
|