@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.
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- 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
|
+
}
|
package/src/chrome-strip.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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");
|
package/src/commands/install.ts
CHANGED
|
@@ -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 {
|