@openparachute/hub 0.7.3 → 0.7.4-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/package.json +4 -11
- package/src/__tests__/admin-vaults.test.ts +164 -1
- package/src/__tests__/api-account-2fa.test.ts +381 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/cli.test.ts +22 -0
- package/src/__tests__/clients.test.ts +28 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/init.test.ts +507 -1
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +488 -0
- package/src/__tests__/oauth-ui.test.ts +55 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/status-supervisor.test.ts +152 -3
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-vaults.ts +7 -12
- package/src/api-account-2fa.ts +373 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/cli.ts +49 -5
- package/src/clients.ts +14 -0
- package/src/commands/init.ts +224 -37
- package/src/commands/status.ts +108 -5
- package/src/help.ts +17 -0
- package/src/hub-server.ts +72 -7
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +98 -6
- package/src/oauth-ui.ts +123 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
- package/web/ui/dist/index.html +2 -2
package/src/hub-server.ts
CHANGED
|
@@ -69,9 +69,11 @@
|
|
|
69
69
|
* # "CSRF-belted" = strict same-origin Origin check on cookie-authed
|
|
70
70
|
* # mutations (hub#632, boundary C1) — origin-check.ts
|
|
71
71
|
* # `assertSameOriginForCookieMutation` carries the canonical enumeration.
|
|
72
|
-
* /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
|
|
72
|
+
* /api/me (GET) → who-am-I (session+CSRF+two_factor_enabled or hasSession:false)
|
|
73
73
|
* /api/admin-lock (GET) → screen-lock status (cookie-gated; first-admin)
|
|
74
74
|
* /api/admin-lock/{set,change,remove,unlock,lock,heartbeat} (POST) → manage the optional admin idle PIN lock (cookie-gated; CSRF)
|
|
75
|
+
* /api/account/2fa/{start,confirm,disable} (POST) → self-service 2FA for the SPA (cookie-gated; CSRF; self-only) — hub#85
|
|
76
|
+
* /api/account/password (POST) → self-service password change for the SPA (cookie-gated; CSRF; self-only) — hub#85
|
|
75
77
|
* /api/hub (GET) → hub version + uptime + install-source (host:admin)
|
|
76
78
|
* /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
|
|
77
79
|
* /api/hub/upgrade/status (GET) → poll the on-disk hub-upgrade status (host:admin)
|
|
@@ -186,6 +188,7 @@ import { handleHostAdminToken } from "./admin-host-admin-token.ts";
|
|
|
186
188
|
import { handleModuleToken } from "./admin-module-token.ts";
|
|
187
189
|
import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
|
|
188
190
|
import { handleCreateVault, handleDeleteVault } from "./admin-vaults.ts";
|
|
191
|
+
import { handleApiAccount } from "./api-account-2fa.ts";
|
|
189
192
|
import {
|
|
190
193
|
handleAccountChangePasswordGet,
|
|
191
194
|
handleAccountChangePasswordPost,
|
|
@@ -3044,6 +3047,24 @@ export function hubFetch(
|
|
|
3044
3047
|
return handleAdminLock(req, subpath, { db: getDb() });
|
|
3045
3048
|
}
|
|
3046
3049
|
|
|
3050
|
+
// JSON self-service account surfaces for the admin SPA "My account" page
|
|
3051
|
+
// (hub#85): password change + 2FA enroll/confirm/disable. Self-only
|
|
3052
|
+
// (acts on `session.userId`, never a client-supplied id) — ANY signed-in
|
|
3053
|
+
// user, not just the first admin. Same cookie + CSRF + same-origin
|
|
3054
|
+
// posture as /api/admin-lock above (NOT the host-admin Bearer posture —
|
|
3055
|
+
// a user managing their own credentials needs no admin scope). The
|
|
3056
|
+
// server-rendered /account/2fa + /account/change-password pages stay for
|
|
3057
|
+
// the no-JS / friend-facing path; these are the JSON twins.
|
|
3058
|
+
if (pathname.startsWith("/api/account/")) {
|
|
3059
|
+
if (!getDb) return dbNotConfigured();
|
|
3060
|
+
{
|
|
3061
|
+
const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
|
|
3062
|
+
if (rejected) return rejected;
|
|
3063
|
+
}
|
|
3064
|
+
const subpath = pathname.slice("/api/account".length);
|
|
3065
|
+
return handleApiAccount(req, subpath, { db: getDb() });
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3047
3068
|
// SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4). Dedicated
|
|
3048
3069
|
// endpoint — the hub is NOT a supervised module (no /api/modules/hub/*),
|
|
3049
3070
|
// so it gets its own route. Checked BEFORE the `/api/hub` exact match
|
|
@@ -3824,13 +3845,57 @@ async function decorateWithChrome(
|
|
|
3824
3845
|
if (setCookie && out !== res) {
|
|
3825
3846
|
const headers = new Headers(out.headers);
|
|
3826
3847
|
headers.append("set-cookie", setCookie);
|
|
3827
|
-
return
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3848
|
+
return withProxySecurityHeaders(
|
|
3849
|
+
new Response(out.body, {
|
|
3850
|
+
status: out.status,
|
|
3851
|
+
statusText: out.statusText,
|
|
3852
|
+
headers,
|
|
3853
|
+
}),
|
|
3854
|
+
);
|
|
3832
3855
|
}
|
|
3833
|
-
|
|
3856
|
+
// hub#643: every exit runs through the security-header step, which self-
|
|
3857
|
+
// gates on content-type — so a non-HTML pass-through (`out === res`, e.g. a
|
|
3858
|
+
// 502 proxy error or a JSON/asset body) is returned unchanged, preserving
|
|
3859
|
+
// the pre-existing behavior for those responses.
|
|
3860
|
+
return withProxySecurityHeaders(out);
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
/**
|
|
3864
|
+
* hub#643 (Tier-1): stamp non-script security headers on proxied `text/html`
|
|
3865
|
+
* pages — the per-vault `/vault/<name>/*` proxy and the generic
|
|
3866
|
+
* services-mount `/<mount>/*` proxy both flow through `decorateWithChrome`,
|
|
3867
|
+
* so this is the single chokepoint that covers a module / surface page.
|
|
3868
|
+
*
|
|
3869
|
+
* - `X-Content-Type-Options: nosniff` — stops content-type sniffing.
|
|
3870
|
+
* - `Content-Security-Policy: frame-ancestors 'self'; object-src 'none';
|
|
3871
|
+
* base-uri 'self'` — clickjacking (external framing) + plugin + base-tag
|
|
3872
|
+
* hardening.
|
|
3873
|
+
*
|
|
3874
|
+
* Deliberately NO `script-src`: a strict script-src would white-screen
|
|
3875
|
+
* self-built GitHub-hosted surfaces (the primary surface story) and
|
|
3876
|
+
* inline-script module pages. The opt-in strict script-src CSP is Tier-2,
|
|
3877
|
+
* explicitly deferred (hub#643 stays open).
|
|
3878
|
+
*
|
|
3879
|
+
* Header-only: we never buffer the body. Only `text/html` responses are
|
|
3880
|
+
* decorated, so JSON / `.js` / CSS / image assets proxied through the same
|
|
3881
|
+
* path are left untouched. Existing headers are preserved (a fresh Headers
|
|
3882
|
+
* copy is mutated); we set (not append) so a re-decorated response can't
|
|
3883
|
+
* accumulate duplicates.
|
|
3884
|
+
*/
|
|
3885
|
+
function withProxySecurityHeaders(res: Response): Response {
|
|
3886
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
3887
|
+
if (!contentType.toLowerCase().includes("text/html")) return res;
|
|
3888
|
+
const headers = new Headers(res.headers);
|
|
3889
|
+
headers.set("x-content-type-options", "nosniff");
|
|
3890
|
+
headers.set(
|
|
3891
|
+
"content-security-policy",
|
|
3892
|
+
"frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
|
|
3893
|
+
);
|
|
3894
|
+
return new Response(res.body, {
|
|
3895
|
+
status: res.status,
|
|
3896
|
+
statusText: res.statusText,
|
|
3897
|
+
headers,
|
|
3898
|
+
});
|
|
3834
3899
|
}
|
|
3835
3900
|
|
|
3836
3901
|
if (import.meta.main) {
|
package/src/managed-unit.ts
CHANGED
|
@@ -454,6 +454,25 @@ function installLaunchdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallRes
|
|
|
454
454
|
};
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
/**
|
|
458
|
+
* #528: is `loginctl` linger already enabled for `userName`? Best-effort probe:
|
|
459
|
+
* `loginctl show-user <user> --property=Linger` prints `Linger=yes` / `Linger=no`.
|
|
460
|
+
* Returns true ONLY on a clear `Linger=yes`; ANY ambiguity (non-zero exit, a
|
|
461
|
+
* throw, or unparseable output) returns false so the caller falls through to the
|
|
462
|
+
* enable attempt — we never SKIP enabling on a guess, only when linger is
|
|
463
|
+
* provably already on. (`show-user` of a user with no session can itself exit
|
|
464
|
+
* non-zero; treat that as "unknown → try to enable".)
|
|
465
|
+
*/
|
|
466
|
+
function lingerAlreadyOn(deps: ManagedUnitDeps, userName: string): boolean {
|
|
467
|
+
try {
|
|
468
|
+
const probe = deps.run(["loginctl", "show-user", userName, "--property=Linger"]);
|
|
469
|
+
if (probe.code !== 0) return false;
|
|
470
|
+
return /(^|\n)\s*Linger=yes\s*(\n|$)/i.test(probe.stdout);
|
|
471
|
+
} catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
457
476
|
function installSystemdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallResult {
|
|
458
477
|
const { unit, deps, messages } = opts;
|
|
459
478
|
const start = opts.start ?? true;
|
|
@@ -490,10 +509,20 @@ function installSystemdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallRes
|
|
|
490
509
|
// systemctl but not loginctl would propagate the spawn error out and hard-fail
|
|
491
510
|
// the calling command. (Run on both start + install-without-start: linger is a
|
|
492
511
|
// boot-survival nicety independent of whether we start the unit now.)
|
|
512
|
+
//
|
|
513
|
+
// #528: pre-check the CURRENT linger state before trying to enable it. When
|
|
514
|
+
// linger is ALREADY on (the common re-install / re-migrate case on a box
|
|
515
|
+
// whose owner already enabled it), `enable-linger` is a no-op we don't need —
|
|
516
|
+
// and on some systemd builds it can return non-zero even though linger is
|
|
517
|
+
// genuinely on, raising a scary "couldn't enable lingering, your hub won't
|
|
518
|
+
// survive reboot" warning that is a FALSE ALARM. So: probe first; if linger
|
|
519
|
+
// is on, skip both the enable AND the warning. Only when linger is genuinely
|
|
520
|
+
// OFF and the enable attempt then fails do we warn. This is the single-owner
|
|
521
|
+
// self-host reboot-survival happy path — keep it quiet when it's already good.
|
|
493
522
|
if (!root && userName) {
|
|
494
523
|
if (deps.which("loginctl") === null) {
|
|
495
524
|
outMessages.push(messages.lingerWarning);
|
|
496
|
-
} else {
|
|
525
|
+
} else if (!lingerAlreadyOn(deps, userName)) {
|
|
497
526
|
try {
|
|
498
527
|
const linger = deps.run(["loginctl", "enable-linger", userName]);
|
|
499
528
|
if (linger.code !== 0) outMessages.push(messages.lingerWarning);
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -294,8 +294,11 @@ export function buildServicesCatalog(
|
|
|
294
294
|
if (audiences.has("vault")) {
|
|
295
295
|
for (const entry of manifest.services) {
|
|
296
296
|
if (!isVaultEntry(entry)) continue;
|
|
297
|
-
|
|
298
|
-
|
|
297
|
+
// #478: an empty-paths vault row is "installed but no servable instance"
|
|
298
|
+
// — skip it so it never counts toward (or, below, fabricates) a phantom
|
|
299
|
+
// vault. Mirrors the continue in well-known.ts / admin-vaults.ts.
|
|
300
|
+
if (entry.paths.length === 0) continue;
|
|
301
|
+
for (const path of entry.paths) {
|
|
299
302
|
const instance = vaultInstanceNameFor(entry.name, path);
|
|
300
303
|
if (broadVaultScope || namedVaults.has(instance)) admittedVaultPathCount++;
|
|
301
304
|
}
|
|
@@ -308,12 +311,16 @@ export function buildServicesCatalog(
|
|
|
308
311
|
for (const entry of manifest.services) {
|
|
309
312
|
if (isVaultEntry(entry)) {
|
|
310
313
|
if (!audiences.has("vault")) continue;
|
|
314
|
+
// #478: an empty-paths vault row is "installed but no servable instance"
|
|
315
|
+
// — skip it so the catalog never offers a phantom `vault` / `vault:default`
|
|
316
|
+
// entry pointing at root before any vault exists. Mirrors well-known.ts /
|
|
317
|
+
// admin-vaults.ts / vault-names.ts.
|
|
318
|
+
if (entry.paths.length === 0) continue;
|
|
311
319
|
// Walk every path the row exposes. Real multi-vault on the hub is a
|
|
312
320
|
// single `parachute-vault` row with N paths (one per vault instance);
|
|
313
321
|
// legacy per-vault rows (`parachute-vault-<name>`) are handled by the
|
|
314
322
|
// same loop because each contributes one path.
|
|
315
|
-
const
|
|
316
|
-
for (const path of paths) {
|
|
323
|
+
for (const path of entry.paths) {
|
|
317
324
|
const instance = vaultInstanceNameFor(entry.name, path);
|
|
318
325
|
const admit = broadVaultScope || namedVaults.has(instance);
|
|
319
326
|
if (!admit) continue;
|
|
@@ -1209,9 +1216,31 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
1209
1216
|
return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
|
|
1210
1217
|
}
|
|
1211
1218
|
|
|
1219
|
+
// hub#689 — does the user hold ADMIN on every vault they could pick? Admin
|
|
1220
|
+
// (isFirstAdmin) owns the whole hub. A non-admin owns a vault only if their
|
|
1221
|
+
// `user_vaults` role grants admin there (today role=write does; a role=read
|
|
1222
|
+
// assignment would NOT). Re-derived from the DB so the owner-verb-selector
|
|
1223
|
+
// is offered only to a genuine owner — the submit path re-checks the PICKED
|
|
1224
|
+
// vault and the cap is the backstop, but rendering it precisely avoids
|
|
1225
|
+
// promising an admin upgrade the cap would silently demote.
|
|
1226
|
+
const userHoldsAdminOnPickable =
|
|
1227
|
+
userIsAdmin ||
|
|
1228
|
+
(assignedVaults.length > 0 &&
|
|
1229
|
+
assignedVaults.every((v) =>
|
|
1230
|
+
(vaultVerbsForUserVault(db, session.userId, v) ?? []).includes("admin"),
|
|
1231
|
+
));
|
|
1232
|
+
|
|
1212
1233
|
return htmlResponse(
|
|
1213
1234
|
renderConsent(
|
|
1214
|
-
consentProps(
|
|
1235
|
+
consentProps(
|
|
1236
|
+
client,
|
|
1237
|
+
parsed,
|
|
1238
|
+
vaultNames,
|
|
1239
|
+
csrf.token,
|
|
1240
|
+
assignedVaults,
|
|
1241
|
+
userIsAdmin,
|
|
1242
|
+
userHoldsAdminOnPickable,
|
|
1243
|
+
),
|
|
1215
1244
|
),
|
|
1216
1245
|
200,
|
|
1217
1246
|
extra,
|
|
@@ -1270,7 +1299,8 @@ function capScopesToUserAuthority(
|
|
|
1270
1299
|
if (name === undefined || verb === undefined || !VAULT_VERBS.has(verb)) return true;
|
|
1271
1300
|
// Named vault verb requested by a non-owner: admit only if the user holds
|
|
1272
1301
|
// it. `vaultVerbsForUserVault` returns null for an unassigned vault (drop)
|
|
1273
|
-
// or the held verb list
|
|
1302
|
+
// or the held verb list — a `write` role holds [read, write, admin], a
|
|
1303
|
+
// `read` role holds [read] (see `vaultVerbsForRole`).
|
|
1274
1304
|
const held = vaultVerbsForUserVault(db, userId, name);
|
|
1275
1305
|
return held !== null && (held as readonly string[]).includes(verb);
|
|
1276
1306
|
});
|
|
@@ -1682,6 +1712,44 @@ async function handleConsentSubmit(
|
|
|
1682
1712
|
400,
|
|
1683
1713
|
);
|
|
1684
1714
|
}
|
|
1715
|
+
// hub#689 — owner-on-own-vault verb widening. The consent screen offers
|
|
1716
|
+
// owners a read/write/admin selector (pre-selected to admin) for an
|
|
1717
|
+
// unnamed `vault:read`/`vault:write` request, so an owner whose AI client
|
|
1718
|
+
// asked for read-only can grant the level it actually needs in-flow. The
|
|
1719
|
+
// submitted `verb_select` is an UNTRUSTED hint — we re-derive ownership of
|
|
1720
|
+
// the PICKED vault server-side here, and `capScopesToUserAuthority` (inside
|
|
1721
|
+
// issueAuthCodeRedirect) is the backstop that drops any verb the user
|
|
1722
|
+
// doesn't actually hold. This only ever rewrites the unnamed read/write
|
|
1723
|
+
// verb(s) to the selected level on the picked vault; named scopes and every
|
|
1724
|
+
// other scope are untouched. A forged `verb_select=admin` from a user who
|
|
1725
|
+
// doesn't own the picked vault gets capped back to what they hold (or, for
|
|
1726
|
+
// a vault outside a pinned user's assignment, never reaches here — the
|
|
1727
|
+
// mismatch checks above already 400'd it).
|
|
1728
|
+
const selectedVerb = String(form.get("verb_select") ?? "").trim();
|
|
1729
|
+
if (selectedVerb === "read" || selectedVerb === "write" || selectedVerb === "admin") {
|
|
1730
|
+
// Re-derive, server-side, whether THIS user owns (holds admin on) the
|
|
1731
|
+
// PICKED vault. Owner === first admin (holds admin everywhere) OR an
|
|
1732
|
+
// assigned user whose role grants admin on this vault. Never trust the
|
|
1733
|
+
// client-submitted selector to establish authority.
|
|
1734
|
+
const heldOnPicked = vaultVerbsForUserVault(db, session.userId, pickedVault);
|
|
1735
|
+
const ownsPicked = userIsAdmin || (heldOnPicked?.includes("admin") ?? false);
|
|
1736
|
+
if (ownsPicked) {
|
|
1737
|
+
scopes = scopes.map((s) => {
|
|
1738
|
+
const parts = s.split(":");
|
|
1739
|
+
// Only widen the unnamed read/write verbs the selector was offered
|
|
1740
|
+
// for — leave an unnamed `vault:admin`, named scopes, and non-vault
|
|
1741
|
+
// scopes exactly as requested.
|
|
1742
|
+
if (
|
|
1743
|
+
parts.length === 2 &&
|
|
1744
|
+
parts[0] === "vault" &&
|
|
1745
|
+
(parts[1] === "read" || parts[1] === "write")
|
|
1746
|
+
) {
|
|
1747
|
+
return `vault:${selectedVerb}`;
|
|
1748
|
+
}
|
|
1749
|
+
return s;
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1685
1753
|
scopes = narrowVaultScopes(scopes, pickedVault);
|
|
1686
1754
|
}
|
|
1687
1755
|
|
|
@@ -2746,6 +2814,10 @@ function consentProps(
|
|
|
2746
2814
|
csrfToken: string,
|
|
2747
2815
|
assignedVaults: readonly string[],
|
|
2748
2816
|
userIsAdmin: boolean,
|
|
2817
|
+
// hub#689 — true when the user holds admin on every vault they could pick
|
|
2818
|
+
// (admin owns the hub; an assigned non-admin only if their role grants admin
|
|
2819
|
+
// on each assigned vault). Gates whether the owner-verb-selector renders.
|
|
2820
|
+
userHoldsAdminOnPickable = userIsAdmin,
|
|
2749
2821
|
) {
|
|
2750
2822
|
const scopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
2751
2823
|
const unnamedVerbs = unnamedVaultVerbs(scopes);
|
|
@@ -2875,6 +2947,25 @@ function consentProps(
|
|
|
2875
2947
|
const only = vaultNames[0];
|
|
2876
2948
|
if (only) displayVault = only;
|
|
2877
2949
|
}
|
|
2950
|
+
// hub#689 — owner-on-own-vault verb selector. The client requested an
|
|
2951
|
+
// unnamed `vault:read`/`vault:write` verb, and the consenting user owns
|
|
2952
|
+
// (holds admin on) every vault they could pick — first admin owns the whole
|
|
2953
|
+
// hub; an assigned non-admin holds admin on each of their assigned vaults
|
|
2954
|
+
// (vaultVerbsForRole('write') → [read,write,admin]). Offer the selector so
|
|
2955
|
+
// they can grant the level their client actually needs (or downgrade), with
|
|
2956
|
+
// admin pre-selected. Suppressed when the request can't be authorized (zero-
|
|
2957
|
+
// vault non-admin) or the assignment is stale (no valid vault to own).
|
|
2958
|
+
//
|
|
2959
|
+
// SECURITY: this only DECIDES WHETHER TO RENDER. The actual widening is
|
|
2960
|
+
// re-derived server-side in `handleConsentSubmit` against the *picked* vault
|
|
2961
|
+
// and capped by `capScopesToUserAuthority`. The selector value is a hint.
|
|
2962
|
+
const upgradeableUnnamedVerbs = unnamedVerbs.filter((v) => v === "read" || v === "write");
|
|
2963
|
+
const userOwnsEveryPickableVault =
|
|
2964
|
+
!hasStaleAssignment && userCanAuthorizeRequest && userHoldsAdminOnPickable;
|
|
2965
|
+
const ownerVerbSelector =
|
|
2966
|
+
upgradeableUnnamedVerbs.length > 0 && userOwnsEveryPickableVault
|
|
2967
|
+
? { requestedVerbs: upgradeableUnnamedVerbs }
|
|
2968
|
+
: undefined;
|
|
2878
2969
|
return {
|
|
2879
2970
|
params,
|
|
2880
2971
|
clientId: client.clientId,
|
|
@@ -2883,6 +2974,7 @@ function consentProps(
|
|
|
2883
2974
|
csrfToken,
|
|
2884
2975
|
vaultPicker,
|
|
2885
2976
|
displayVault,
|
|
2977
|
+
ownerVerbSelector,
|
|
2886
2978
|
staleAssignedVault,
|
|
2887
2979
|
// Approve stays enabled for non-vault scopes even when assigned_vault
|
|
2888
2980
|
// is stale — the user can still consent to e.g. `scribe:transcribe`
|
package/src/oauth-ui.ts
CHANGED
|
@@ -147,6 +147,31 @@ export interface ConsentViewProps {
|
|
|
147
147
|
* the user on an error page. Defaults to authorizable when omitted.
|
|
148
148
|
*/
|
|
149
149
|
userCanAuthorizeRequest?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* hub#689 — owner-on-own-vault verb selector. Set when the consenting user
|
|
152
|
+
* OWNS (holds admin on) every vault they could pick AND the client requested
|
|
153
|
+
* an unnamed `vault:read`/`vault:write` verb. Renders a read/write/admin
|
|
154
|
+
* radio group, pre-selected to admin, so the owner can grant the level their
|
|
155
|
+
* AI client actually needs in-flow (the requested-scope shape was the
|
|
156
|
+
* blocker, not the user's authority) — or transparently downgrade.
|
|
157
|
+
*
|
|
158
|
+
* The submitted `verb_select` is an UNTRUSTED hint: the consent-submit
|
|
159
|
+
* handler re-derives, server-side, whether the user actually owns the picked
|
|
160
|
+
* vault before widening, and `capScopesToUserAuthority` remains the backstop
|
|
161
|
+
* that drops any verb the user doesn't hold. The selector only ever WIDENS
|
|
162
|
+
* the unnamed verb(s) on the picked vault; it never touches any other scope.
|
|
163
|
+
*/
|
|
164
|
+
ownerVerbSelector?: OwnerVerbSelector;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface OwnerVerbSelector {
|
|
168
|
+
/**
|
|
169
|
+
* The unnamed read/write verb(s) the client requested. Only `read`/`write`
|
|
170
|
+
* are upgradeable here — an unnamed `vault:admin` request already renders
|
|
171
|
+
* with the admin badge and needs no selector. Used to word the selector
|
|
172
|
+
* help text ("the app asked for write access").
|
|
173
|
+
*/
|
|
174
|
+
requestedVerbs: string[];
|
|
150
175
|
}
|
|
151
176
|
|
|
152
177
|
export interface VaultPicker {
|
|
@@ -328,6 +353,7 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
328
353
|
staleAssignedVault,
|
|
329
354
|
blockApproveForStaleAssignment,
|
|
330
355
|
userCanAuthorizeRequest,
|
|
356
|
+
ownerVerbSelector,
|
|
331
357
|
} = props;
|
|
332
358
|
// Substitute unnamed `vault:<verb>` rows with the resolved named form so
|
|
333
359
|
// the operator sees the scope shape that will appear in the token. Raw
|
|
@@ -339,6 +365,7 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
339
365
|
? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
|
|
340
366
|
: displayedScopes.map(renderScopeRow).join("\n");
|
|
341
367
|
const pickerSection = vaultPicker ? renderVaultPicker(vaultPicker) : "";
|
|
368
|
+
const verbSelectorSection = ownerVerbSelector ? renderOwnerVerbSelector(ownerVerbSelector) : "";
|
|
342
369
|
// Approve is disabled when the picker can't yield a valid vault. The
|
|
343
370
|
// empty-vault branch (no vaults registered) is the original case. A
|
|
344
371
|
// locked-vault picker (multi-user Phase 1) always has a valid value via
|
|
@@ -418,6 +445,7 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
418
445
|
${renderCsrfHiddenInput(csrfToken)}
|
|
419
446
|
${renderHiddenInputs(params)}
|
|
420
447
|
${pickerSection}
|
|
448
|
+
${verbSelectorSection}
|
|
421
449
|
<div class="button-row">
|
|
422
450
|
<button type="submit" name="approve" value="yes" class="btn btn-primary"${approveDisabled}>Approve</button>
|
|
423
451
|
<button type="submit" name="approve" value="no" class="btn btn-secondary">Deny</button>
|
|
@@ -492,6 +520,60 @@ function renderVaultPicker(picker: VaultPicker): string {
|
|
|
492
520
|
</section>`;
|
|
493
521
|
}
|
|
494
522
|
|
|
523
|
+
/**
|
|
524
|
+
* hub#689 — owner-on-own-vault verb selector. Rendered only when the
|
|
525
|
+
* consenting user owns (holds admin on) every vault they could pick and the
|
|
526
|
+
* client requested an unnamed `vault:read`/`vault:write` verb. Three radios
|
|
527
|
+
* (read / write / admin), pre-selected to **admin** so the common case (the
|
|
528
|
+
* owner's own AI client that needs full access) is one click — but the owner
|
|
529
|
+
* sees and submits the choice, and can downgrade.
|
|
530
|
+
*
|
|
531
|
+
* The `admin` option keeps the `.scope-admin` red border + admin badge so an
|
|
532
|
+
* admin grant stays visibly flagged even when pre-selected. The submitted
|
|
533
|
+
* `verb_select` is an untrusted hint re-checked server-side (ownership
|
|
534
|
+
* re-derivation in `handleConsentSubmit` + `capScopesToUserAuthority` backstop);
|
|
535
|
+
* this template only renders the choice.
|
|
536
|
+
*/
|
|
537
|
+
function renderOwnerVerbSelector(selector: OwnerVerbSelector): string {
|
|
538
|
+
const requested = selector.requestedVerbs.map((v) => `<code>vault:${escapeHtml(v)}</code>`);
|
|
539
|
+
const requestedList =
|
|
540
|
+
requested.length === 1
|
|
541
|
+
? requested[0]
|
|
542
|
+
: `${requested.slice(0, -1).join(", ")} and ${requested.at(-1)}`;
|
|
543
|
+
const option = (
|
|
544
|
+
verb: "read" | "write" | "admin",
|
|
545
|
+
title: string,
|
|
546
|
+
desc: string,
|
|
547
|
+
checked: boolean,
|
|
548
|
+
): string => {
|
|
549
|
+
const isAdmin = verb === "admin";
|
|
550
|
+
const cls = `verb-option${isAdmin ? " verb-option-admin scope-admin" : ""}`;
|
|
551
|
+
const badge = isAdmin ? `<span class="badge badge-admin">admin</span>` : "";
|
|
552
|
+
return `
|
|
553
|
+
<label class="${cls}">
|
|
554
|
+
<input type="radio" name="verb_select" value="${verb}"${checked ? " checked" : ""} />
|
|
555
|
+
<span class="verb-option-body">
|
|
556
|
+
<span class="verb-option-head">
|
|
557
|
+
<span class="verb-option-title">${escapeHtml(title)}</span>
|
|
558
|
+
${badge}
|
|
559
|
+
</span>
|
|
560
|
+
<span class="verb-option-desc">${escapeHtml(desc)}</span>
|
|
561
|
+
</span>
|
|
562
|
+
</label>`;
|
|
563
|
+
};
|
|
564
|
+
return `
|
|
565
|
+
<section class="verb-selector">
|
|
566
|
+
<h2 class="scopes-title">Access level</h2>
|
|
567
|
+
<p class="picker-help">
|
|
568
|
+
This app asked for ${requestedList} access to your vault. Because you own
|
|
569
|
+
this vault, you can grant a different level — admin is selected so your app
|
|
570
|
+
can do everything it might need; lower it if you'd rather not.
|
|
571
|
+
</p>
|
|
572
|
+
<div class="verb-options">${option("read", "Read only", "View notes, tags, attachments, and config.", false)}${option("write", "Read & write", "Create, edit, and delete notes, tags, and attachments.", false)}${option("admin", "Admin", "Full access plus config, triggers/automation, GitHub backup, and minting tokens.", true)}
|
|
573
|
+
</div>
|
|
574
|
+
</section>`;
|
|
575
|
+
}
|
|
576
|
+
|
|
495
577
|
/**
|
|
496
578
|
* "App not yet approved" page (#74). Two branches:
|
|
497
579
|
*
|
|
@@ -1282,6 +1364,47 @@ const STYLES = `
|
|
|
1282
1364
|
font-size: 0.88rem;
|
|
1283
1365
|
color: ${PALETTE.fg};
|
|
1284
1366
|
}
|
|
1367
|
+
/* hub#689 — owner-on-own-vault verb selector. Same card shell as the
|
|
1368
|
+
vault picker; the admin option carries the .scope-admin red border so an
|
|
1369
|
+
admin grant stays visibly flagged even when pre-selected. */
|
|
1370
|
+
.verb-selector {
|
|
1371
|
+
margin: 0 0 1.25rem;
|
|
1372
|
+
padding: 0.75rem 0.85rem;
|
|
1373
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1374
|
+
border-radius: 6px;
|
|
1375
|
+
background: ${PALETTE.bgSoft};
|
|
1376
|
+
}
|
|
1377
|
+
.verb-selector .scopes-title { margin-bottom: 0.4rem; }
|
|
1378
|
+
.verb-options {
|
|
1379
|
+
display: flex;
|
|
1380
|
+
flex-direction: column;
|
|
1381
|
+
gap: 0.4rem;
|
|
1382
|
+
}
|
|
1383
|
+
.verb-option {
|
|
1384
|
+
display: flex;
|
|
1385
|
+
align-items: flex-start;
|
|
1386
|
+
gap: 0.5rem;
|
|
1387
|
+
padding: 0.5rem 0.65rem;
|
|
1388
|
+
border: 1px solid ${PALETTE.border};
|
|
1389
|
+
border-radius: 6px;
|
|
1390
|
+
background: ${PALETTE.cardBg};
|
|
1391
|
+
cursor: pointer;
|
|
1392
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
1393
|
+
}
|
|
1394
|
+
.verb-option:hover { border-color: ${PALETTE.accent}; }
|
|
1395
|
+
.verb-option input[type=radio] { margin-top: 0.25rem; }
|
|
1396
|
+
.verb-option input[type=radio]:focus { outline: 2px solid ${PALETTE.accent}; outline-offset: 2px; }
|
|
1397
|
+
.verb-option-body { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
1398
|
+
.verb-option-head {
|
|
1399
|
+
display: flex;
|
|
1400
|
+
align-items: center;
|
|
1401
|
+
gap: 0.4rem;
|
|
1402
|
+
flex-wrap: wrap;
|
|
1403
|
+
}
|
|
1404
|
+
.verb-option-title { font-weight: 500; color: ${PALETTE.fg}; font-size: 0.9rem; }
|
|
1405
|
+
.verb-option-desc { font-size: 0.82rem; color: ${PALETTE.fgMuted}; }
|
|
1406
|
+
.verb-option-admin .verb-option-title { color: ${PALETTE.danger}; }
|
|
1407
|
+
|
|
1285
1408
|
.vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
|
|
1286
1409
|
.vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
|
|
1287
1410
|
.vault-picker-locked .picker-help { color: ${PALETTE.fgMuted}; }
|
|
@@ -42,7 +42,8 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
|
|
|
42
42
|
level: "write",
|
|
43
43
|
},
|
|
44
44
|
"vault:admin": {
|
|
45
|
-
label:
|
|
45
|
+
label:
|
|
46
|
+
"Read and write everything, plus admin: config & settings, triggers & automation, GitHub backup, and minting access tokens.",
|
|
46
47
|
level: "admin",
|
|
47
48
|
},
|
|
48
49
|
// Optional-module scopes (scribe / agent). These are in FIRST_PARTY_SCOPES
|
package/src/setup-wizard.ts
CHANGED
|
@@ -1592,14 +1592,33 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1592
1592
|
// poll on the auth the wizard already carries.
|
|
1593
1593
|
const opId = url.searchParams.get("op");
|
|
1594
1594
|
if (opId) {
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1595
|
+
// hub#618: post-setup this JSON `?op=` surface is unauth-reachable —
|
|
1596
|
+
// `/admin/setup` is always lockout-exempt (the dispatcher's
|
|
1597
|
+
// `shouldGateForSetup` lets it through so a stale bookmark resolves), and
|
|
1598
|
+
// the snapshot is read BEFORE any session check. The leak is small (an
|
|
1599
|
+
// in-memory op's status + install-progress log lines, behind an
|
|
1600
|
+
// unguessable UUID), but it's still a post-setup admin surface, so gate
|
|
1601
|
+
// it once setup is COMPLETE. During setup (no admin yet) the surface
|
|
1602
|
+
// stays OPEN: the unauth CLI wizard (`parachute init`) AND the brand-new-
|
|
1603
|
+
// operator browser both poll this `?op=` snapshot mid-setup before any
|
|
1604
|
+
// session exists — gating then would break first-boot vault
|
|
1605
|
+
// provisioning. Loopback always passes (same on-box trust as the
|
|
1606
|
+
// `bootstrapToken` branch below); a valid session also passes.
|
|
1607
|
+
const setupComplete = state.hasAdmin && state.hasVault && state.hasExposeMode;
|
|
1608
|
+
const opSnapshotAllowed =
|
|
1609
|
+
!setupComplete ||
|
|
1610
|
+
deps.requestIsLoopback === true ||
|
|
1611
|
+
findActiveSession(deps.db, req) !== null;
|
|
1612
|
+
if (opSnapshotAllowed) {
|
|
1613
|
+
const op = deps.registry?.get(opId);
|
|
1614
|
+
if (op) {
|
|
1615
|
+
envelope.operation = {
|
|
1616
|
+
id: op.id,
|
|
1617
|
+
status: op.status,
|
|
1618
|
+
log: op.log,
|
|
1619
|
+
...(op.error !== undefined ? { error: op.error } : {}),
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1603
1622
|
}
|
|
1604
1623
|
}
|
|
1605
1624
|
// hub#576: hand the actual token to a LOOPBACK caller only. The on-box
|
|
@@ -2325,19 +2344,19 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
2325
2344
|
if (registry) {
|
|
2326
2345
|
// hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
|
|
2327
2346
|
// vault's first-boot path (vault#342) names the created vault
|
|
2328
|
-
// accordingly.
|
|
2329
|
-
// field blank — vault's `resolveFirstBootVaultName` defaults to
|
|
2330
|
-
// `default` on absent env vars, so this preserves the prior
|
|
2331
|
-
// behaviour for the empty-input case.
|
|
2347
|
+
// accordingly.
|
|
2332
2348
|
//
|
|
2333
|
-
//
|
|
2334
|
-
//
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2349
|
+
// #478 Part 2: ALWAYS set `PARACHUTE_VAULT_NAME`, even when the name
|
|
2350
|
+
// is "default". Once vault removes its silent auto-create-on-missing-env
|
|
2351
|
+
// behavior, vault's first-boot will require the env var to know which
|
|
2352
|
+
// vault to create — a missing `PARACHUTE_VAULT_NAME` will mean "no vault
|
|
2353
|
+
// to create" rather than "create one named default". Passing it
|
|
2354
|
+
// explicitly for every path (including the default) is correct and safe:
|
|
2355
|
+
// vault's `resolveFirstBootVaultName` accepts "default" as a valid name
|
|
2356
|
+
// and behaves identically to the prior implicit default.
|
|
2357
|
+
const spawnEnv: Record<string, string> = {
|
|
2358
|
+
PARACHUTE_VAULT_NAME: vaultName,
|
|
2359
|
+
};
|
|
2341
2360
|
// Capture importParams + deps in the runInstall promise chain — when
|
|
2342
2361
|
// mode === "import", run the vault-side `/.parachute/mirror/import`
|
|
2343
2362
|
// POST as a follow-up step once the supervised vault has come up
|
|
@@ -2358,7 +2377,7 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
2358
2377
|
registry,
|
|
2359
2378
|
...(deps.run ? { run: deps.run } : {}),
|
|
2360
2379
|
...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
|
|
2361
|
-
|
|
2380
|
+
spawnEnv,
|
|
2362
2381
|
})
|
|
2363
2382
|
.then(async () => {
|
|
2364
2383
|
if (!importToRun) return;
|