@openparachute/hub 0.7.0 → 0.7.2-rc.1

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 (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Per-UI audience gate (H3, surface-runtime design §12 — fixes
3
+ * parachute-surface#88: the `public` flag existed but nothing enforced it).
4
+ *
5
+ * A module's services.json row may carry a `uis{}` map of hosted UI
6
+ * sub-units; each sub-unit now declares an `audience`
7
+ * (`public | hub-users | operator | surface`, default `hub-users`). The HUB
8
+ * PROXY enforces it BEFORE forwarding — surface-host serves whatever the
9
+ * proxy lets through, exactly like the publicExposure cloak.
10
+ *
11
+ * Scope discipline: the gate covers the SURFACE UI MOUNTS specifically (the
12
+ * uis sub-unit paths), not every module path — a module's own APIs keep
13
+ * their own auth (vault validates Bearers, scribe validates its token, …).
14
+ * The gate also runs before WebSocket upgrades on a gated mount (threaded
15
+ * into `maybeUpgradeWebSocket`).
16
+ *
17
+ * The four audiences:
18
+ *
19
+ * public — pass (and the chrome strip is disabled — H5: public readers
20
+ * aren't hub users).
21
+ * hub-users — a valid hub session cookie OR a valid hub-issued Bearer
22
+ * whose scopes satisfy the sub-unit's `scopes_required`. The
23
+ * OR keeps installed PWAs working: a standalone PWA holds
24
+ * OAuth tokens, not a hub session. Bearer validation reuses
25
+ * the hub#516 seam (signature/expiry/revocation via the JWKS,
26
+ * `iss` ∈ the hub's bound-origin set — a PWA token carries the
27
+ * public origin while the proxied request may resolve the
28
+ * loopback issuer).
29
+ * operator — the first-admin session only. A Bearer never satisfies this
30
+ * tier (operator surfaces are interactive; the session is the
31
+ * operator's presence).
32
+ * surface — pass: the surface backend owns admission END-TO-END
33
+ * (backed surfaces — parachute-surface runtime, surfaces with
34
+ * their own server entry, kit-authenticated via
35
+ * @openparachute/surface-server: hub JWTs / capability links /
36
+ * anon, deny-by-default with a public conformance suite). The
37
+ * hub gating these would add a SECOND auth layer that BLOCKS
38
+ * the surface's own audience plane — e.g. the docs-editor's
39
+ * capability-link invitees are NOT hub users by design, so a
40
+ * hub-users gate would 302 them to /login before the surface's
41
+ * own auth ever ran. Chrome strip follows the `public`
42
+ * precedent (disabled — the visitors are mostly capability
43
+ * invitees, not hub users), and WS upgrades pass through too
44
+ * (the gate threads into `maybeUpgradeWebSocket`; null = no
45
+ * deny = the upgrade proceeds to the capability check).
46
+ *
47
+ * Deny shape: document requests (GET + Accept: text/html, no session) get a
48
+ * 302 to `/login?next=<path>`; everything else gets 401/403 JSON. A
49
+ * signed-in-but-insufficient caller (non-admin on an operator surface, or a
50
+ * Bearer missing the required scopes) gets 403, not a login redirect.
51
+ *
52
+ * Exposure layers are ORTHOGONAL to the audience: the `publicExposure`
53
+ * cloak runs at the service-row level BEFORE this gate (dispatch skips the
54
+ * gate entirely on a cloaked row so the 404 stays indistinguishable from
55
+ * not-installed). A `surface`-audience mount on a loopback-only row is
56
+ * therefore still unreachable from tailnet/funnel — exactly like `public`.
57
+ *
58
+ * Fail-closed posture: malformed `audience` metadata never reaches this
59
+ * module — `services-manifest.ts` validation rejects the row and the lenient
60
+ * read drops it (the mount 404s). An absent DB (hub booted stateless) denies
61
+ * every audience that needs an identity store: `hub-users` and `operator`.
62
+ * `public` and `surface` still pass — neither consults hub identity
63
+ * (`surface` self-auths every request; denying it on absent DB would break
64
+ * the surface for zero security gain). Version skew: a surface declaring
65
+ * `audience: "surface"` registered against an OLDER hub (pre-dating the
66
+ * value) gets its row rejected by manifest validation — the mount 404s
67
+ * until the hub upgrades; the surface-side meta-schema ships the value
68
+ * separately with a hub-version requirement note.
69
+ */
70
+
71
+ import type { Database } from "bun:sqlite";
72
+ import { validateHostAdminToken } from "./host-admin-token-validation.ts";
73
+ import type { ServiceEntry, UiAudience, UiSubUnit } from "./services-manifest.ts";
74
+ import { findActiveSession } from "./sessions.ts";
75
+ import { isFirstAdmin } from "./users.ts";
76
+
77
+ /** A pathname resolved to the UI sub-unit that hosts it. */
78
+ export interface UiMountMatch {
79
+ readonly entry: ServiceEntry;
80
+ readonly uiKey: string;
81
+ readonly ui: UiSubUnit;
82
+ /** The normalized mount path (no trailing slash) the match keyed on. */
83
+ readonly mount: string;
84
+ /** The effective audience (default applied). */
85
+ readonly audience: UiAudience;
86
+ }
87
+
88
+ /** The effective audience for a sub-unit — absent means `hub-users`. */
89
+ export function effectiveUiAudience(ui: UiSubUnit): UiAudience {
90
+ return ui.audience ?? "hub-users";
91
+ }
92
+
93
+ /**
94
+ * Resolve which UI sub-unit (across every service's `uis{}` map) a pathname
95
+ * falls under. Longest-prefix match, same comparison shape as
96
+ * `findServiceUpstream` (trailing slashes normalized; `pathname === mount`
97
+ * or `pathname.startsWith(mount + "/")`). Returns undefined when the path is
98
+ * not under any declared UI — module API paths, undeclared mounts, and
99
+ * legacy flat rows are NOT gated here.
100
+ */
101
+ export function resolveUiMount(
102
+ services: readonly ServiceEntry[],
103
+ pathname: string,
104
+ ): UiMountMatch | undefined {
105
+ let best: UiMountMatch | undefined;
106
+ for (const entry of services) {
107
+ if (!entry.uis) continue;
108
+ for (const [uiKey, ui] of Object.entries(entry.uis)) {
109
+ const norm = ui.path.replace(/\/+$/, "") || "/";
110
+ if (pathname === norm || pathname.startsWith(`${norm}/`)) {
111
+ if (!best || norm.length > best.mount.length) {
112
+ best = { entry, uiKey, ui, mount: norm, audience: effectiveUiAudience(ui) };
113
+ }
114
+ }
115
+ }
116
+ }
117
+ return best;
118
+ }
119
+
120
+ /**
121
+ * Does one bearer scope satisfy one required-scope pattern?
122
+ *
123
+ * Patterns are colon-segmented with `*` as a single-segment wildcard —
124
+ * `vault:*:read` matches `vault:default:read`. One asymmetry is deliberate:
125
+ * the broad UNNAMED form (`vault:read`) satisfies `vault:*:<verb>` — a token
126
+ * carrying any-vault read authority is strictly wider than one pinned vault,
127
+ * so refusing it would deny a caller that holds MORE than required.
128
+ */
129
+ export function scopeMatchesPattern(pattern: string, scope: string): boolean {
130
+ const p = pattern.split(":");
131
+ const s = scope.split(":");
132
+ if (p.length === s.length) {
133
+ return p.every((seg, i) => seg === "*" || seg === s[i]);
134
+ }
135
+ // Broad unnamed form: vault:<verb> ⊇ vault:*:<verb>.
136
+ if (p.length === 3 && s.length === 2 && p[1] === "*") {
137
+ return p[0] === s[0] && p[2] === s[1];
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /**
143
+ * Do the bearer's scopes satisfy the sub-unit's requirement? EVERY pattern
144
+ * in `scopes_required` must be matched by at least one bearer scope ("a
145
+ * Bearer whose scopes include the surface's scopes"). An empty/absent
146
+ * requirement means any valid hub-issued Bearer passes — the surface
147
+ * declared no scope shape, so hub identity alone is the bar.
148
+ */
149
+ export function scopesSatisfyRequirement(
150
+ required: readonly string[] | undefined,
151
+ bearerScopes: readonly string[],
152
+ ): boolean {
153
+ if (!required || required.length === 0) return true;
154
+ return required.every((pattern) => bearerScopes.some((s) => scopeMatchesPattern(pattern, s)));
155
+ }
156
+
157
+ export interface AudienceGateDeps {
158
+ /** Hub DB — absent (stateless boot) denies every hub-gated audience
159
+ * (`hub-users` / `operator`); `public` and `surface` pass without it. */
160
+ db: Database | undefined;
161
+ /**
162
+ * The hub's bound-origin set for Bearer `iss` validation (the hub#516
163
+ * seam — PWA tokens carry the public origin while the request may resolve
164
+ * loopback). Lazy: only consulted on the Bearer branch.
165
+ */
166
+ knownIssuers: () => readonly string[];
167
+ }
168
+
169
+ function wantsDocument(req: Request): boolean {
170
+ return req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html");
171
+ }
172
+
173
+ function loginRedirect(req: Request): Response {
174
+ const url = new URL(req.url);
175
+ const next = encodeURIComponent(url.pathname + url.search);
176
+ return new Response(null, {
177
+ status: 302,
178
+ headers: { location: `/login?next=${next}`, "cache-control": "no-store" },
179
+ });
180
+ }
181
+
182
+ function denyJson(status: number, error: string, description: string): Response {
183
+ return new Response(JSON.stringify({ error, error_description: description }), {
184
+ status,
185
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Enforce a sub-unit's audience for a request. Returns `null` when the
191
+ * request may proceed to the proxy, or the deny Response (302 login for
192
+ * anonymous document requests; 401/403 JSON otherwise).
193
+ */
194
+ export async function gateUiAudience(
195
+ req: Request,
196
+ audience: UiAudience,
197
+ ui: UiSubUnit,
198
+ deps: AudienceGateDeps,
199
+ ): Promise<Response | null> {
200
+ if (audience === "public") return null;
201
+
202
+ // `surface` — pass through unconditionally (incl. on an absent DB, below):
203
+ // the surface backend authenticates EVERY request itself (deny-by-default
204
+ // kit auth: hub JWTs / capability links / anon). A hub-side deny here
205
+ // would block the surface's own audience plane — its capability-link
206
+ // invitees are not hub users by design.
207
+ if (audience === "surface") return null;
208
+
209
+ // Fail closed without a DB: no identity store, no way to admit anyone to
210
+ // a hub-gated (`hub-users` / `operator`) surface.
211
+ if (!deps.db) {
212
+ return wantsDocument(req)
213
+ ? loginRedirect(req)
214
+ : denyJson(401, "unauthenticated", "this surface requires a hub identity");
215
+ }
216
+ const db = deps.db;
217
+
218
+ const session = findActiveSession(db, req);
219
+
220
+ if (audience === "operator") {
221
+ if (session && isFirstAdmin(db, session.userId)) return null;
222
+ if (session) {
223
+ return denyJson(
224
+ 403,
225
+ "not_admin",
226
+ "this surface is restricted to the hub operator — your account home is at /account/",
227
+ );
228
+ }
229
+ return wantsDocument(req)
230
+ ? loginRedirect(req)
231
+ : denyJson(401, "unauthenticated", "this surface requires the hub operator's session");
232
+ }
233
+
234
+ // audience === "hub-users"
235
+ if (session) return null;
236
+
237
+ const auth = req.headers.get("authorization");
238
+ if (auth?.startsWith("Bearer ")) {
239
+ const token = auth.slice("Bearer ".length).trim();
240
+ try {
241
+ const validated = await validateHostAdminToken(db, token, [...deps.knownIssuers()]);
242
+ const bearerScopes =
243
+ typeof validated.payload.scope === "string"
244
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
245
+ : [];
246
+ if (scopesSatisfyRequirement(ui.scopes_required, bearerScopes)) return null;
247
+ return denyJson(
248
+ 403,
249
+ "insufficient_scope",
250
+ `this surface requires scopes: ${(ui.scopes_required ?? []).join(", ")}`,
251
+ );
252
+ } catch (err) {
253
+ return denyJson(
254
+ 401,
255
+ "unauthenticated",
256
+ `bearer token invalid — ${err instanceof Error ? err.message : String(err)}`,
257
+ );
258
+ }
259
+ }
260
+
261
+ return wantsDocument(req)
262
+ ? loginRedirect(req)
263
+ : denyJson(
264
+ 401,
265
+ "unauthenticated",
266
+ "this surface requires a hub session or a hub-issued bearer token",
267
+ );
268
+ }
@@ -19,6 +19,13 @@
19
19
  * application, looks distinctively Notes, reads as Parachute because the
20
20
  * tokens are continuous").
21
21
  *
22
+ * H5 (surface-runtime design): the opt-out generalized — when a UI
23
+ * sub-unit's declared `audience` resolves `public` at the proxy's audience
24
+ * gate (H3), the dispatch passes that mount as an extra opt-out prefix
25
+ * (hub-server `decorateWithChrome`). Public readers aren't hub users; the
26
+ * identity chrome never rides their pages. The static list below remains
27
+ * for hub-users surfaces that own their own chrome (Notes).
28
+ *
22
29
  * Why path-based and not module-declared:
23
30
  * - Notes is a `uis[]` sub-unit of parachute-app, not its own module —
24
31
  * adding `chrome: "off"` to parachute-app's module.json would suppress
@@ -38,7 +45,7 @@
38
45
  * defense is cheap and protects future refactors).
39
46
  */
40
47
 
41
- import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
48
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
42
49
  import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
43
50
 
44
51
  /**
package/src/cli.ts CHANGED
@@ -939,7 +939,19 @@ async function main(argv: string[]): Promise<number> {
939
939
  // after `vault` (including --help) is passed through verbatim.
940
940
  if (rest.length === 0) return await mod.dispatchVault(["--help"]);
941
941
 
942
- // Everything under `vault` forwards transparently to `parachute-vault`.
942
+ // Intercept the delete verbs BEFORE the transparent passthrough (B3):
943
+ // `parachute vault remove <name>` must route through the hub's identity
944
+ // cascade (`DELETE /vaults/<name>`), NOT forward verbatim to
945
+ // `parachute-vault remove` (mechanics-only — orphans hub-side tokens,
946
+ // grants, user_vaults rows). `rm` is a convenience alias to the same path.
947
+ const sub = rest[0];
948
+ if (sub === "remove" || sub === "rm") {
949
+ const rm = await loadCommand("vault-remove", () => import("./commands/vault-remove.ts"));
950
+ if (!rm) return 1;
951
+ return await rm.vaultRemove(rest.slice(1));
952
+ }
953
+
954
+ // Everything else under `vault` forwards transparently to `parachute-vault`.
943
955
  // `vault tokens create` used to route through a guided interactive
944
956
  // wrapper, but the pvt_* DROP (vault#412 / hub#466) removed that vault
945
957
  // subcommand — it now exits 1 with migration guidance. Access tokens are
package/src/clients.ts CHANGED
@@ -215,6 +215,94 @@ export function requireRegisteredRedirectUri(client: OAuthClient, candidate: str
215
215
  return candidate;
216
216
  }
217
217
 
218
+ /**
219
+ * Cross-hub-origin redirect_uri expansion for hub-served DCR clients
220
+ * (surface#118; the "hub assist" half of the fix).
221
+ *
222
+ * THE PROBLEM. A hub-served module (surface-host, notes) registers its OAuth
223
+ * client at install time, when the only origin it knows is the loopback hub
224
+ * URL (`http://127.0.0.1:1939`). Once the operator runs `parachute expose`,
225
+ * the browser loads the surface from the PUBLIC hub origin and the
226
+ * surface-client runtime computes its `redirect_uri` from `window.location.
227
+ * origin` (the public one) — which was never registered. Authorize-time
228
+ * matching is deliberately strict exact-match (`requireRegisteredRedirectUri`,
229
+ * RFC 8252 anti-open-redirect), so the public-origin redirect_uri is rejected
230
+ * ("Redirect mismatch") and no off-localhost user can sign in.
231
+ *
232
+ * THE FIX. At DCR time, for each submitted redirect_uri WHOSE ORIGIN IS ONE
233
+ * OF THE HUB'S OWN KNOWN ORIGINS (issuer, expose-state hubOrigin, platform
234
+ * origin, loopback aliases — i.e. the same set `buildHubBoundOrigins`
235
+ * produces), ALSO register the same PATH on every OTHER known hub origin. A
236
+ * loopback-registered hub-served client thus becomes valid on the public hub
237
+ * origin too, without ever loosening the strict authorize-time match.
238
+ *
239
+ * THE INVARIANT (no open redirect). Expansion ONLY ever produces URIs rooted
240
+ * at the hub's OWN known origins. A submitted redirect_uri whose origin is
241
+ * FOREIGN — a separate-origin surface on its own domain (e.g. my-vault-ui at
242
+ * `https://notes.example`), a third-party app — is stored VERBATIM: not
243
+ * expanded onto any hub origin, and not dropped. Only hub-origin-rooted URIs
244
+ * receive the cross-origin fan-out. Because authorize-time matching stays
245
+ * strict exact-match against this stored set, an attacker who registers a
246
+ * foreign redirect_uri gets back exactly what they submitted (no hub-origin
247
+ * variant minted for them), and a hub-origin-rooted URI only ever fans out to
248
+ * other hub origins the operator already controls.
249
+ *
250
+ * Order-preserving + de-duplicated: the originally-submitted URIs come first
251
+ * (preserving caller order), with the synthesized hub-origin variants
252
+ * appended in a stable order; duplicates are collapsed.
253
+ */
254
+ export function expandRedirectUrisForHubOrigins(
255
+ submitted: readonly string[],
256
+ hubOrigins: readonly string[],
257
+ ): string[] {
258
+ // Parse the hub's known origins into a Set for membership tests. Malformed
259
+ // entries are skipped — the function fails safe (no expansion) rather than
260
+ // throwing.
261
+ const hubOriginSet = new Set<string>();
262
+ for (const raw of hubOrigins) {
263
+ try {
264
+ hubOriginSet.add(new URL(raw).origin);
265
+ } catch {
266
+ // Skip malformed hub origin.
267
+ }
268
+ }
269
+
270
+ // Preserve submitted order + dedupe; append synthesized variants after.
271
+ const out: string[] = [];
272
+ const seen = new Set<string>();
273
+ const push = (uri: string) => {
274
+ if (seen.has(uri)) return;
275
+ seen.add(uri);
276
+ out.push(uri);
277
+ };
278
+
279
+ // 1) Every submitted URI is stored as-is (foreign + hub-origin alike). This
280
+ // is what guarantees a foreign redirect_uri is never dropped.
281
+ for (const uri of submitted) push(uri);
282
+
283
+ // 2) For each submitted URI rooted at a hub origin, synthesize the same path
284
+ // on every OTHER hub origin. Foreign-origin URIs are skipped here — they
285
+ // never spawn a hub-origin variant (the open-redirect guard).
286
+ if (hubOriginSet.size > 1) {
287
+ for (const uri of submitted) {
288
+ let parsed: URL;
289
+ try {
290
+ parsed = new URL(uri);
291
+ } catch {
292
+ continue;
293
+ }
294
+ if (!hubOriginSet.has(parsed.origin)) continue; // foreign → no expansion
295
+ const pathSuffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
296
+ for (const origin of hubOriginSet) {
297
+ if (origin === parsed.origin) continue;
298
+ push(`${origin}${pathSuffix}`);
299
+ }
300
+ }
301
+ }
302
+
303
+ return out;
304
+ }
305
+
218
306
  export function verifyClientSecret(client: OAuthClient, presented: string): boolean {
219
307
  if (!client.clientSecretHash) return false;
220
308
  const presentedHash = createHash("sha256").update(presented).digest("hex");
@@ -125,9 +125,16 @@ export function resolveInstallChannel(opts: {
125
125
  * install lens` during the ~3-day window keeps working. Remove after
126
126
  * launch sinks in and `parachute install lens` has stopped appearing
127
127
  * in support threads.
128
+ *
129
+ * `channel → agent` (2026-06-17): parachute-channel was renamed to
130
+ * parachute-agent. `parachute install channel` keeps resolving to the
131
+ * agent module for one release cycle so operators mid-upgrade aren't
132
+ * stranded. Remove alongside the `parachute-channel → agent` legacy
133
+ * manifest alias in service-spec.ts.
128
134
  */
129
135
  const SERVICE_ALIASES: Record<string, string> = {
130
136
  lens: "notes",
137
+ channel: "agent",
131
138
  };
132
139
 
133
140
  export interface InstallOpts {