@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.
Files changed (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. 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: they let
142
- * the holder mint, revoke, and rotate tokens for a specific vault instance,
143
- * which is operator-only territory. Like `parachute:host:admin`, these are
144
- * minted by a session-cookie-gated hub endpoint (`/admin/vault-admin-token/:name`),
145
- * never by the public OAuth flow.
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);
@@ -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-app", status: "assigned" },
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 `app` for the current path.",
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
- app: {
466
- short: "app",
467
- package: "@openparachute/app",
468
- manifestName: "parachute-app",
465
+ surface: {
466
+ short: "surface",
467
+ package: "@openparachute/surface",
468
+ manifestName: "parachute-surface",
469
469
  canonicalPort: 1946,
470
- displayName: "App",
470
+ displayName: "Surface",
471
471
  // Tagline telegraphs the auto-bootstrap so wizard + admin-SPA copy explain
472
- // the architecture: installing `app` brings Notes (and other UIs) along
473
- // via the Phase 2.1 bootstrap-default-apps step. The notes-daemon path
474
- // still exists as a back-compat install (CURATED_MODULES still lists
475
- // `notes`) but `app` is the recommended first install post-vault.
476
- tagline: "Host module for Parachute UIs — auto-installs Notes on first boot.",
477
- canonicalPaths: ["/app", "/.parachute"],
478
- canonicalHealth: "/app/healthz",
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-app", "serve"],
485
- // App's admin + per-UI surfaces gate behind hub-issued JWTs (design
486
- // doc §6 same-hub auto-trust + scope `app:admin`). Surfaces in
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-app or parachute-runner (depending on use case)",
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