@openparachute/hub 0.5.13 → 0.5.14-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/src/admin-login-ui.ts
CHANGED
|
@@ -67,7 +67,7 @@ function header(): string {
|
|
|
67
67
|
<div class="brand">
|
|
68
68
|
<span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "admin-login")}</span>
|
|
69
69
|
<span class="brand-name">${WORDMARK_TEXT}</span>
|
|
70
|
-
<span class="brand-tag">
|
|
70
|
+
<span class="brand-tag">sign in</span>
|
|
71
71
|
</div>`;
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -87,8 +87,8 @@ export function renderAdminLogin(props: AdminLoginProps): string {
|
|
|
87
87
|
<div class="card">
|
|
88
88
|
<div class="card-header">
|
|
89
89
|
${header()}
|
|
90
|
-
<h1>Sign in</h1>
|
|
91
|
-
<p class="subtitle">
|
|
90
|
+
<h1>Sign in to your Parachute account</h1>
|
|
91
|
+
<p class="subtitle">Hub operators and invited members sign in here.</p>
|
|
92
92
|
</div>
|
|
93
93
|
${error}
|
|
94
94
|
<form method="POST" action="/login" class="auth-form">
|
|
@@ -105,7 +105,7 @@ export function renderAdminLogin(props: AdminLoginProps): string {
|
|
|
105
105
|
<button type="submit" class="btn btn-primary">Sign in</button>
|
|
106
106
|
</form>
|
|
107
107
|
</div>`;
|
|
108
|
-
return baseDocument("Sign in
|
|
108
|
+
return baseDocument("Sign in — Parachute", body);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// --- error page ------------------------------------------------------------
|
|
@@ -18,10 +18,16 @@
|
|
|
18
18
|
* masking a typo as a real (but unusable) credential. Resolved via the
|
|
19
19
|
* already-built well-known doc — same source of truth the SPA's vault list
|
|
20
20
|
* reads.
|
|
21
|
+
*
|
|
22
|
+
* Multi-user Phase 1 gate: the session must belong to the first admin (the
|
|
23
|
+
* single hub admin under the Phase 1 model — see `users.ts:isFirstAdmin`).
|
|
24
|
+
* Friends pinned to a vault use the OAuth flow to get vault:<name>:read/write
|
|
25
|
+
* for their assigned vault; they don't get vault admin via this endpoint.
|
|
21
26
|
*/
|
|
22
27
|
import type { Database } from "bun:sqlite";
|
|
23
28
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
24
29
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
30
|
+
import { isFirstAdmin } from "./users.ts";
|
|
25
31
|
|
|
26
32
|
/** Short TTL — matches host-admin-token. SPA re-fetches on near-expiry. */
|
|
27
33
|
export const VAULT_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
|
|
@@ -57,6 +63,17 @@ export async function handleVaultAdminToken(
|
|
|
57
63
|
if (!session) {
|
|
58
64
|
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
59
65
|
}
|
|
66
|
+
// Multi-user Phase 1 privesc gate (mirrors host-admin-token). vault:<name>:admin
|
|
67
|
+
// is the per-vault operator scope used by the vault admin SPA — friends pinned
|
|
68
|
+
// to a vault get vault:<name>:read/write via OAuth, never admin. Without this
|
|
69
|
+
// gate, any signed-in friend can mint a vault admin token for any vault.
|
|
70
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
71
|
+
return jsonError(
|
|
72
|
+
403,
|
|
73
|
+
"not_admin",
|
|
74
|
+
"vault admin token mint is restricted to the hub admin — your account home is at /account/",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
60
77
|
const scope = `vault:${vaultName}:admin`;
|
|
61
78
|
// Per-vault audience: vault validates the JWT's `aud` claim against
|
|
62
79
|
// `vault.<name>` derived from its own URL-bound config (vault src/auth.ts
|
package/src/admin-vaults.ts
CHANGED
|
@@ -12,16 +12,24 @@
|
|
|
12
12
|
* Content-Type: application/json
|
|
13
13
|
* { "name": "<vault-name>" }
|
|
14
14
|
*
|
|
15
|
-
* 201 → { name, url, version, token?, paths? }
|
|
16
|
-
* // vault freshly created. `token`
|
|
17
|
-
* //
|
|
18
|
-
* // `parachute-vault create --json` branch —
|
|
19
|
-
* //
|
|
20
|
-
* //
|
|
21
|
-
* //
|
|
15
|
+
* 201 → { name, url, version, token?, token_guidance?, paths? }
|
|
16
|
+
* // vault freshly created. `token` is a hub-issued ACCESS token
|
|
17
|
+
* // (a JWT scoped `vault:<name>:admin`) captured from the
|
|
18
|
+
* // `parachute-vault create --json` branch — NOT a `pvt_*` vault
|
|
19
|
+
* // token (those were dropped). Post-DROP `token` may be the
|
|
20
|
+
* // empty string `""` when the bootstrap mint was unavailable
|
|
21
|
+
* // (e.g. a loopback origin the hub can't mint against); in that
|
|
22
|
+
* // case `token_guidance` carries the vault's human-readable
|
|
23
|
+
* // reason, forwarded verbatim so the SPA can explain the gap.
|
|
24
|
+
* // `paths` is the new vault's filesystem layout. The
|
|
25
|
+
* // first-vault-on-host bootstrap (`parachute install vault`)
|
|
26
|
+
* // doesn't emit JSON yet, so a fresh-box response carries
|
|
27
|
+
* // name/url/version only.
|
|
22
28
|
* 200 → { name, url, version }
|
|
23
29
|
* // idempotent re-POST: existing vault. Never includes `token` —
|
|
24
|
-
* //
|
|
30
|
+
* // the create-time access token isn't retrievable later. The
|
|
31
|
+
* // caller branches on HTTP status (201 vs 200), not on `token`
|
|
32
|
+
* // truthiness, so an empty-token 201 isn't confused with a 200.
|
|
25
33
|
* 400 → { error: "invalid_request", error_description: ... }
|
|
26
34
|
* 401/403 → bearer-auth failure
|
|
27
35
|
* 500 → orchestration failure
|
|
@@ -50,7 +58,7 @@
|
|
|
50
58
|
import type { Database } from "bun:sqlite";
|
|
51
59
|
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
52
60
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
53
|
-
import { findService, readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
61
|
+
import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
54
62
|
import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
55
63
|
|
|
56
64
|
/** Scope required to call POST /vaults. */
|
|
@@ -74,7 +82,20 @@ export interface CreateVaultRequest {
|
|
|
74
82
|
/** Output shape of `parachute-vault create --json` (vault PR #184). */
|
|
75
83
|
export interface VaultCreateJson {
|
|
76
84
|
name: string;
|
|
85
|
+
/**
|
|
86
|
+
* Hub-issued access token (a JWT scoped `vault:<name>:admin`) the vault
|
|
87
|
+
* minted at create time. Post the pvt_* DROP this is the empty string
|
|
88
|
+
* `""` when no hub origin was reachable to mint against (e.g. a loopback
|
|
89
|
+
* create) — the field is always present but may be empty.
|
|
90
|
+
*/
|
|
77
91
|
token: string;
|
|
92
|
+
/**
|
|
93
|
+
* Vault-supplied human-readable reason no token was minted, present only
|
|
94
|
+
* when `token` is empty (e.g. "no hub origin reachable to mint against").
|
|
95
|
+
* Optional — older vaults that always minted don't emit it. Forwarded
|
|
96
|
+
* verbatim to the caller so the SPA can explain the empty-token state.
|
|
97
|
+
*/
|
|
98
|
+
token_guidance?: string;
|
|
78
99
|
paths: {
|
|
79
100
|
vault_dir: string;
|
|
80
101
|
vault_db: string;
|
|
@@ -239,8 +260,9 @@ interface OrchestrateError {
|
|
|
239
260
|
* Run the orchestration step. Picks `parachute install` (bootstrap) vs
|
|
240
261
|
* `parachute-vault create --json` (subsequent) based on whether vault is
|
|
241
262
|
* already registered in services.json. The create branch parses stdout for
|
|
242
|
-
* the just-
|
|
243
|
-
*
|
|
263
|
+
* the just-minted hub access token (a `vault:<name>:admin` JWT, possibly
|
|
264
|
+
* empty post-DROP), the optional `token_guidance`, and filesystem paths so
|
|
265
|
+
* the caller can talk to the new vault — the access token is single-emit.
|
|
244
266
|
*/
|
|
245
267
|
async function orchestrate(
|
|
246
268
|
manifestPath: string,
|
|
@@ -348,14 +370,25 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
|
|
|
348
370
|
}
|
|
349
371
|
|
|
350
372
|
const entry = buildEntry(name, created.path, created.version, deps.issuer);
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
373
|
+
// Access token (a `vault:<name>:admin` JWT, possibly empty post-DROP) +
|
|
374
|
+
// filesystem paths are single-emit at create time. We surface them here so
|
|
375
|
+
// the caller can immediately bootstrap a connection to the new vault.
|
|
376
|
+
// `token_guidance` (when the vault couldn't mint) is forwarded verbatim so
|
|
377
|
+
// the SPA can explain the empty-token state rather than rendering a blank.
|
|
378
|
+
// Idempotent re-POSTs intentionally never include any of these.
|
|
354
379
|
const body: WellKnownVaultEntry & {
|
|
355
380
|
token?: string;
|
|
381
|
+
token_guidance?: string;
|
|
356
382
|
paths?: VaultCreateJson["paths"];
|
|
357
383
|
} = result.createJson
|
|
358
|
-
? {
|
|
384
|
+
? {
|
|
385
|
+
...entry,
|
|
386
|
+
token: result.createJson.token,
|
|
387
|
+
...(result.createJson.token_guidance
|
|
388
|
+
? { token_guidance: result.createJson.token_guidance }
|
|
389
|
+
: {}),
|
|
390
|
+
paths: result.createJson.paths,
|
|
391
|
+
}
|
|
359
392
|
: entry;
|
|
360
393
|
|
|
361
394
|
return new Response(JSON.stringify(body), {
|
package/src/api-account.ts
CHANGED
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
import type { Database } from "bun:sqlite";
|
|
49
49
|
import { hash as argonHash } from "@node-rs/argon2";
|
|
50
50
|
import { type ChangePasswordMode, renderChangePassword } from "./account-change-password-ui.ts";
|
|
51
|
+
import { renderAccountHome } from "./account-home-ui.ts";
|
|
52
|
+
import { POST_LOGIN_DEFAULT } from "./admin-handlers.ts";
|
|
51
53
|
import { renderAdminError } from "./admin-login-ui.ts";
|
|
52
54
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
53
55
|
import { changePasswordRateLimiter } from "./rate-limit.ts";
|
|
@@ -57,6 +59,7 @@ import {
|
|
|
57
59
|
PASSWORD_MAX_LEN,
|
|
58
60
|
UserNotFoundError,
|
|
59
61
|
getUserById,
|
|
62
|
+
isFirstAdmin,
|
|
60
63
|
validatePassword,
|
|
61
64
|
verifyPassword,
|
|
62
65
|
} from "./users.ts";
|
|
@@ -69,12 +72,10 @@ export interface ApiAccountDeps {
|
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* Where to land after a successful password change when no `next` param
|
|
72
|
-
* is present.
|
|
73
|
-
*
|
|
74
|
-
* file doesn't accidentally couple to admin-handlers' internals; if the
|
|
75
|
-
* default ever diverges the two should reconcile via a shared config.
|
|
75
|
+
* is present. Re-exported from `admin-handlers.ts` so login + change-
|
|
76
|
+
* password share a single source of truth (reviewer fold on hub#425).
|
|
76
77
|
*/
|
|
77
|
-
const POST_CHANGE_DEFAULT =
|
|
78
|
+
const POST_CHANGE_DEFAULT = POST_LOGIN_DEFAULT;
|
|
78
79
|
|
|
79
80
|
function safeNext(raw: string | null | undefined): string {
|
|
80
81
|
if (!raw) return POST_CHANGE_DEFAULT;
|
|
@@ -234,7 +235,20 @@ export async function handleAccountChangePasswordPost(
|
|
|
234
235
|
const currentPassword = String(form.get("current_password") ?? "");
|
|
235
236
|
const newPassword = String(form.get("new_password") ?? "");
|
|
236
237
|
const confirmPassword = String(form.get("new_password_confirm") ?? "");
|
|
237
|
-
|
|
238
|
+
// Friend-facing redirect: non-admin users who would otherwise land in the
|
|
239
|
+
// admin SPA (because POST_CHANGE_DEFAULT = /admin/vaults) get bounced to
|
|
240
|
+
// /account/ directly. Without this, the SPA loads, hits 403 on its host-
|
|
241
|
+
// admin-token mint, then redirects to /account/ via the SPA's auth.ts —
|
|
242
|
+
// a visible two-hop flash for the friend. The login-redirect path
|
|
243
|
+
// (admin-handlers.ts:118) does the same rewrite at sign-in time; this
|
|
244
|
+
// mirrors it for the change-password POST. (hub#425 reviewer fold —
|
|
245
|
+
// operator runbook accuracy: the doc said "lands at /account/", but
|
|
246
|
+
// without this fix the user briefly sees the admin shell.)
|
|
247
|
+
const rawNext = safeNext(String(form.get("next") ?? ""));
|
|
248
|
+
const next =
|
|
249
|
+
!isFirstAdmin(deps.db, user.id) && (rawNext === "/admin" || rawNext.startsWith("/admin/"))
|
|
250
|
+
? "/account/"
|
|
251
|
+
: rawNext;
|
|
238
252
|
const mode = modeFor(user.passwordChanged);
|
|
239
253
|
|
|
240
254
|
// Rate-limit gate (hub#282). Fires *after* CSRF (so a junk cross-site
|
|
@@ -437,6 +451,58 @@ export async function handleAccountChangePasswordPost(
|
|
|
437
451
|
});
|
|
438
452
|
}
|
|
439
453
|
|
|
454
|
+
/**
|
|
455
|
+
* GET /account/ — friend-facing user home (multi-user Phase 1 follow-up).
|
|
456
|
+
*
|
|
457
|
+
* Companion surface to the first-admin gate on
|
|
458
|
+
* `/admin/host-admin-token`: friend users who can't reach the admin
|
|
459
|
+
* SPA need a coherent landing page that shows their assigned vault, a
|
|
460
|
+
* sign-out form, and a link to rotate their password. The admin lands
|
|
461
|
+
* here too (via the SPA's 403 redirect path) but mostly bounces back
|
|
462
|
+
* to `/admin/` immediately — for admins this is a "wait, what?" exit
|
|
463
|
+
* ramp.
|
|
464
|
+
*
|
|
465
|
+
* Auth: requires an active session. Session-less requests 302 to
|
|
466
|
+
* `/login?next=/account/` — same posture as `handleAccountChangePasswordGet`.
|
|
467
|
+
*
|
|
468
|
+
* `hubOrigin` is passed in by the route handler (resolved per-request
|
|
469
|
+
* via `resolveIssuer` in `hub-server.ts`). The page uses it to build
|
|
470
|
+
* the canonical Notes "Open" CTA URL and to show as inline code in
|
|
471
|
+
* the "use a custom client" disclosure.
|
|
472
|
+
*/
|
|
473
|
+
export interface AccountHomeDeps extends ApiAccountDeps {
|
|
474
|
+
/** Canonical hub origin for this request (e.g. `https://my-hub.example`). */
|
|
475
|
+
hubOrigin: string;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Response {
|
|
479
|
+
const session = findActiveSession(deps.db, req);
|
|
480
|
+
if (!session) {
|
|
481
|
+
return redirect(`/login?next=${encodeURIComponent("/account/")}`);
|
|
482
|
+
}
|
|
483
|
+
const user = getUserById(deps.db, session.userId);
|
|
484
|
+
if (!user) {
|
|
485
|
+
// Stale session pointing at a deleted user — hand back to /login;
|
|
486
|
+
// the orphaned session row will time out on its own.
|
|
487
|
+
return redirect("/login");
|
|
488
|
+
}
|
|
489
|
+
const adminFlag = isFirstAdmin(deps.db, user.id);
|
|
490
|
+
const csrf = ensureCsrfToken(req);
|
|
491
|
+
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
492
|
+
return htmlResponse(
|
|
493
|
+
renderAccountHome({
|
|
494
|
+
username: user.username,
|
|
495
|
+
assignedVaults: user.assignedVaults,
|
|
496
|
+
passwordChanged: user.passwordChanged,
|
|
497
|
+
hubOrigin: deps.hubOrigin,
|
|
498
|
+
isFirstAdmin: adminFlag,
|
|
499
|
+
csrfToken: csrf.token,
|
|
500
|
+
}),
|
|
501
|
+
200,
|
|
502
|
+
extra,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
440
506
|
/**
|
|
441
507
|
* Flip `users.password_changed` from 0 to 1 for the given user.
|
|
442
508
|
* Idempotent — running against an already-`true` row is a no-op.
|
package/src/api-mint-token.ts
CHANGED
|
@@ -8,10 +8,31 @@
|
|
|
8
8
|
* - the future admin SPA when the operator wants to mint a one-shot
|
|
9
9
|
* scope-narrow token without dropping to a terminal.
|
|
10
10
|
*
|
|
11
|
-
* Auth
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Auth — capability attenuation: any bearer may mint a token whose authority
|
|
12
|
+
* is a SUBSET of its own. A requested scope `s` is grantable (`canGrant`) iff:
|
|
13
|
+
*
|
|
14
|
+
* 1. `s` is requestable AND the bearer holds `parachute:host:auth`
|
|
15
|
+
* — host:auth mints any requestable scope (vault/scribe verbs, etc.).
|
|
16
|
+
* 2. `s` is `vault:<N>:admin` AND the bearer holds `parachute:host:admin`
|
|
17
|
+
* — box-wide admin attenuates to one named vault's admin.
|
|
18
|
+
* 3. `s` is `vault:<N>:<verb>` (verb ∈ read/write/admin) AND the bearer
|
|
19
|
+
* holds `vault:<N>:admin` for the SAME `<N>` — a vault-admin attenuates
|
|
20
|
+
* to any same-vault subset, including an equal-level admin.
|
|
21
|
+
*
|
|
22
|
+
* Otherwise `s` is refused (400 `invalid_scope`). This single rule subsumes
|
|
23
|
+
* the former two-part guard: the old hard `parachute:host:auth` gate is now
|
|
24
|
+
* rule 1, and PR-A's `host:admin → vault:<name>:admin` carve-out (hub#449) is
|
|
25
|
+
* now rule 2. Rule 3 is new — it lets a `vault:<name>:admin` bearer mint
|
|
26
|
+
* same-vault sub-tokens (the canonical headless path to per-vault admin,
|
|
27
|
+
* replacing deprecated `pvt_*` — vault#282 — and the path the SPA tokens
|
|
28
|
+
* page uses via session → /admin/host-admin-token → here). Cross-vault and
|
|
29
|
+
* host-authority escalation are always blocked: a `vault:work:admin` bearer
|
|
30
|
+
* can never mint `vault:other:*` or any `parachute:host:*`.
|
|
31
|
+
*
|
|
32
|
+
* Entry gate: the bearer must hold at least one minting authority —
|
|
33
|
+
* `parachute:host:auth`, `parachute:host:admin`, or some `vault:<*>:admin`.
|
|
34
|
+
* A bearer with none (e.g. a read-only token) gets 403 `insufficient_scope`
|
|
35
|
+
* before any per-scope check; it cannot mint anything.
|
|
15
36
|
*
|
|
16
37
|
* Why a separate endpoint instead of extending /admin/host-admin-token:
|
|
17
38
|
* that endpoint is session-cookie-gated for the SPA's needs and only
|
|
@@ -26,14 +47,38 @@
|
|
|
26
47
|
import type { Database } from "bun:sqlite";
|
|
27
48
|
import { inferAudience } from "./jwt-audience.ts";
|
|
28
49
|
import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
29
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
MINT_HOST_ADMIN_SCOPE,
|
|
52
|
+
MINT_HOST_AUTH_SCOPE,
|
|
53
|
+
canGrant,
|
|
54
|
+
hasMintingAuthority,
|
|
55
|
+
} from "./scope-attenuation.ts";
|
|
56
|
+
import {
|
|
57
|
+
isVaultAdminScope,
|
|
58
|
+
isWellFormedOrNonVaultScope,
|
|
59
|
+
vaultScopeName,
|
|
60
|
+
} from "./scope-explanations.ts";
|
|
61
|
+
|
|
62
|
+
// Re-export `canGrant` so existing importers (and the symmetric revoke path)
|
|
63
|
+
// have a single name to reach for; the implementation lives in the shared
|
|
64
|
+
// `scope-attenuation.ts` module alongside `hasMintingAuthority`.
|
|
65
|
+
export { canGrant } from "./scope-attenuation.ts";
|
|
30
66
|
|
|
31
67
|
/** Default lifetime when --expires-in / `expires_in` is omitted. Matches the CLI. */
|
|
32
68
|
export const API_MINT_TOKEN_DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
33
69
|
/** Hard cap. Matches the CLI's --expires-in upper bound. */
|
|
34
70
|
export const API_MINT_TOKEN_MAX_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
35
|
-
/**
|
|
36
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Bearer scope that authorises minting any *requestable* scope (rule 1 of the
|
|
73
|
+
* attenuation model). Re-exported alias of the shared `MINT_HOST_AUTH_SCOPE`
|
|
74
|
+
* for back-compat with existing importers.
|
|
75
|
+
*/
|
|
76
|
+
export const API_MINT_TOKEN_HOST_AUTH_SCOPE = MINT_HOST_AUTH_SCOPE;
|
|
77
|
+
/**
|
|
78
|
+
* Bearer scope that authorises minting `vault:<name>:admin` (rule 2).
|
|
79
|
+
* Re-exported alias of the shared `MINT_HOST_ADMIN_SCOPE`.
|
|
80
|
+
*/
|
|
81
|
+
export const API_MINT_TOKEN_VAULT_ADMIN_BEARER_SCOPE = MINT_HOST_ADMIN_SCOPE;
|
|
37
82
|
/** client_id stamped on minted tokens. Matches the CLI flow's value. */
|
|
38
83
|
export const API_MINT_TOKEN_CLIENT_ID = "parachute-hub";
|
|
39
84
|
|
|
@@ -87,12 +132,17 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
87
132
|
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
88
133
|
}
|
|
89
134
|
|
|
90
|
-
// 3.
|
|
91
|
-
|
|
135
|
+
// 3. Entry gate — the bearer must hold at least one minting authority
|
|
136
|
+
// (`parachute:host:auth`, `parachute:host:admin`, or some
|
|
137
|
+
// `vault:<*>:admin`). A bearer with none can mint nothing under the
|
|
138
|
+
// attenuation model, so we 403 before per-scope checks. Per-scope
|
|
139
|
+
// grantability (which authority covers which scope) is enforced below
|
|
140
|
+
// via `canGrant`.
|
|
141
|
+
if (!hasMintingAuthority(bearerScopes)) {
|
|
92
142
|
return jsonError(
|
|
93
143
|
403,
|
|
94
144
|
"insufficient_scope",
|
|
95
|
-
`bearer token
|
|
145
|
+
`bearer token holds no minting authority (need ${API_MINT_TOKEN_HOST_AUTH_SCOPE}, ${API_MINT_TOKEN_VAULT_ADMIN_BEARER_SCOPE}, or vault:<name>:admin)`,
|
|
96
146
|
);
|
|
97
147
|
}
|
|
98
148
|
|
|
@@ -117,19 +167,40 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
117
167
|
return jsonError(400, "invalid_request", "scope must contain at least one scope");
|
|
118
168
|
}
|
|
119
169
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
170
|
+
// Shape guard (defensive hygiene — adversarial audit 2026-05-28): reject any
|
|
171
|
+
// scope that is shaped like a *named* per-vault scope but malformed —
|
|
172
|
+
// `vault:work:ADMIN` (uppercase verb), `vault::admin` (empty name),
|
|
173
|
+
// `vault:work:read:admin` (extra segment), `VAULT:work:admin` (uppercase
|
|
174
|
+
// resource). These slip past `isNonRequestableScope`'s strict regexes, so
|
|
175
|
+
// `canGrant` rule 1 would admit them as "requestable" and mint a junk
|
|
176
|
+
// registry row. They grant zero access today (the vault consumer's
|
|
177
|
+
// `decomposeVaultScope` rejects all four), so this is NOT exploitable now —
|
|
178
|
+
// the check is a backstop against a future consumer-normalization regression
|
|
179
|
+
// plus registry hygiene. It's an input-shape check, orthogonal to authority,
|
|
180
|
+
// so it runs for ALL callers before any `canGrant` attenuation. Non-vault
|
|
181
|
+
// scopes and the unnamed `vault:<verb>` forms are unaffected.
|
|
182
|
+
const malformed = scopes.filter((s) => !isWellFormedOrNonVaultScope(s));
|
|
183
|
+
if (malformed.length > 0) {
|
|
184
|
+
return jsonError(
|
|
185
|
+
400,
|
|
186
|
+
"invalid_scope",
|
|
187
|
+
`malformed vault scope ${malformed.join(", ")}; expected vault:<name>:<read|write|admin>`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Capability-attenuation guard: every requested scope must be a subset of
|
|
192
|
+
// the bearer's own authority under `canGrant` (rules in the file docstring).
|
|
193
|
+
// A `parachute:host:auth` bearer mints any requestable scope; a
|
|
194
|
+
// `parachute:host:admin` bearer additionally mints `vault:<name>:admin`; a
|
|
195
|
+
// `vault:<name>:admin` bearer mints same-vault subsets only. Anything else
|
|
196
|
+
// — host:* escalation, cross-vault, a non-requestable with no covering
|
|
197
|
+
// authority — is blocked. One blocked scope rejects the whole request.
|
|
198
|
+
const blocked = scopes.filter((s) => !canGrant(bearerScopes, s));
|
|
128
199
|
if (blocked.length > 0) {
|
|
129
200
|
return jsonError(
|
|
130
201
|
400,
|
|
131
202
|
"invalid_scope",
|
|
132
|
-
`scope ${blocked.join(", ")} is not
|
|
203
|
+
`scope ${blocked.join(", ")} is not grantable by this bearer; use OAuth flow or operator rotation`,
|
|
133
204
|
);
|
|
134
205
|
}
|
|
135
206
|
|
|
@@ -183,6 +254,40 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
183
254
|
permissionsCanonical = JSON.stringify(permissionsClaim);
|
|
184
255
|
}
|
|
185
256
|
|
|
257
|
+
// Derive the `vault_scope` pin. Collect the set of vault names `<N>` from
|
|
258
|
+
// every requested `vault:<N>:<verb>` scope that was authorized via a
|
|
259
|
+
// vault-scoped authority — rule 2 (host:admin → vault:<N>:admin) or rule 3
|
|
260
|
+
// (vault:<N>:admin → same-vault subset). These are the vault-scoped mints,
|
|
261
|
+
// so we pin the token to those vault(s): it can ONLY ever be used against
|
|
262
|
+
// them (defense-in-depth + least privilege), matching the canonical
|
|
263
|
+
// session-path mint in `admin-vault-admin-token.ts`.
|
|
264
|
+
//
|
|
265
|
+
// Pure `parachute:host:auth` requestable mints (a `vault:<N>:read/write`
|
|
266
|
+
// granted by rule 1 with no covering vault-admin authority) stay UNpinned
|
|
267
|
+
// (`[]`) — the "no per-user restriction" sentinel; the scope string +
|
|
268
|
+
// audience are the authorization-bearing gate there, as before. We
|
|
269
|
+
// distinguish by checking the bearer's own vault-scoped authority: a vault
|
|
270
|
+
// name is pinned only when the bearer held `vault:<N>:admin` (rule 3) or
|
|
271
|
+
// host:admin and the scope is admin (rule 2).
|
|
272
|
+
//
|
|
273
|
+
// Note: `audience` is single-valued and `inferAudience` is first-wins, so a
|
|
274
|
+
// multi-vault request gets `aud=vault.<first>` and only authenticates
|
|
275
|
+
// against that vault. Mint one token per vault for the multi-vault case.
|
|
276
|
+
// The canonical consumers (mcp-install, SPA tokens page) request a single
|
|
277
|
+
// vault.
|
|
278
|
+
const bearerHasHostAdmin = bearerScopes.includes(API_MINT_TOKEN_VAULT_ADMIN_BEARER_SCOPE);
|
|
279
|
+
const vaultScopePinSet = new Set<string>();
|
|
280
|
+
for (const s of scopes) {
|
|
281
|
+
const name = vaultScopeName(s);
|
|
282
|
+
if (name === null) continue;
|
|
283
|
+
const grantedByVaultAdminBearer = bearerScopes.includes(`vault:${name}:admin`); // rule 3
|
|
284
|
+
const grantedByHostAdminForAdmin = isVaultAdminScope(s) && bearerHasHostAdmin; // rule 2
|
|
285
|
+
if (grantedByVaultAdminBearer || grantedByHostAdminForAdmin) {
|
|
286
|
+
vaultScopePinSet.add(name);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const vaultScopePin = [...vaultScopePinSet];
|
|
290
|
+
|
|
186
291
|
// 6. Mint + register.
|
|
187
292
|
const minted = await signAccessToken(deps.db, {
|
|
188
293
|
sub: subject,
|
|
@@ -192,11 +297,14 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
192
297
|
issuer: deps.issuer,
|
|
193
298
|
ttlSeconds,
|
|
194
299
|
// Operator-driven CLI/API mint — the bearer already cleared the
|
|
195
|
-
// `
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
300
|
+
// attenuation guard. `vault_scope` is `[]` (no restriction) for any
|
|
301
|
+
// verb scope granted by rule 1, or the named vault(s) for vault-scoped
|
|
302
|
+
// mints authorized via rule 2 / rule 3 (see above). The pin tracks the
|
|
303
|
+
// grant rule, not the bearer: a host:admin bearer minting
|
|
304
|
+
// `vault:work:write` goes through rule 1 (write is requestable), so it
|
|
305
|
+
// ALSO gets `vault_scope:[]` — only its `vault:work:admin` mints (rule 2)
|
|
306
|
+
// are pinned.
|
|
307
|
+
vaultScope: vaultScopePin,
|
|
200
308
|
...(permissionsClaim !== undefined ? { extraClaims: { permissions: permissionsClaim } } : {}),
|
|
201
309
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
202
310
|
});
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -36,6 +36,7 @@ import type { Database } from "bun:sqlite";
|
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
import { dirname } from "node:path";
|
|
38
38
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
39
|
+
import { isLinked as defaultIsLinked } from "./bun-link.ts";
|
|
39
40
|
import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
|
|
40
41
|
import { getModuleInstallChannel } from "./hub-settings.ts";
|
|
41
42
|
import { validateAccessToken } from "./jwt-sign.ts";
|
|
@@ -187,6 +188,21 @@ export interface ApiModulesOpsDeps {
|
|
|
187
188
|
* null when not found.
|
|
188
189
|
*/
|
|
189
190
|
findGlobalInstall?: (pkg: string) => string | null;
|
|
191
|
+
/**
|
|
192
|
+
* Override `isLinked` (test seam). Production probes bun's globals
|
|
193
|
+
* for a symlink-shaped entry under `<prefix>/node_modules/<pkg>` —
|
|
194
|
+
* true iff the package was installed via `bun link` from a local
|
|
195
|
+
* checkout. When true, `runInstall` skips `bun add -g <pkg>`
|
|
196
|
+
* entirely; the linked checkout already provides the binary on
|
|
197
|
+
* PATH and `bun add -g` would either be a wasted npm round-trip
|
|
198
|
+
* or fail outright on unrelated global-lockfile noise (smoke
|
|
199
|
+
* 2026-05-27 finding 1).
|
|
200
|
+
*
|
|
201
|
+
* Mirrors the CLI install path's `isLinked` short-circuit in
|
|
202
|
+
* `commands/install.ts`. Both paths use the same `src/bun-link.ts`
|
|
203
|
+
* helper so they can't drift again.
|
|
204
|
+
*/
|
|
205
|
+
isLinked?: (pkg: string) => boolean;
|
|
190
206
|
/**
|
|
191
207
|
* Extra env vars merged onto the supervised child at spawn time (hub#267).
|
|
192
208
|
*
|
|
@@ -543,23 +559,43 @@ export async function runInstall(
|
|
|
543
559
|
// without a hub restart.
|
|
544
560
|
const channel = resolveApiInstallChannel(channelOverride, deps);
|
|
545
561
|
const spec_str = `${spec.package}@${channel}`;
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
562
|
+
// bun-link short-circuit (smoke 2026-05-27, finding 1): mirror the
|
|
563
|
+
// CLI install path's `isLinked` check. When the package is already
|
|
564
|
+
// linked globally via `bun link <abspath>` (the standard local-dev
|
|
565
|
+
// shape — Aaron + every workspace contributor runs this way), the
|
|
566
|
+
// linked checkout already provides the binary on PATH. `bun add -g`
|
|
567
|
+
// is at best a wasted ~3s npm round-trip and at worst a hard failure
|
|
568
|
+
// on unrelated global-lockfile noise (one stale entry can crash the
|
|
569
|
+
// whole `bun add`, failing the wizard's vault step even though the
|
|
570
|
+
// linked vault is fine). The wizard's parallel install path diverged
|
|
571
|
+
// pre-this-fix; the shared `src/bun-link.ts` keeps both paths in
|
|
572
|
+
// lockstep going forward.
|
|
573
|
+
const isLinked = deps.isLinked ?? defaultIsLinked;
|
|
574
|
+
if (isLinked(spec.package)) {
|
|
575
|
+
registry.update(
|
|
576
|
+
opId,
|
|
577
|
+
{ status: "running" },
|
|
578
|
+
`${spec.package} is already linked globally (bun link) — skipping bun add -g`,
|
|
579
|
+
);
|
|
580
|
+
} else {
|
|
581
|
+
registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
|
|
582
|
+
const code = await run(["bun", "add", "-g", spec_str]);
|
|
583
|
+
if (code !== 0) {
|
|
584
|
+
// Bun 1.2.x lockfile-recovery noise: probe the global prefix
|
|
585
|
+
// before treating non-zero as fatal. Mirrors the same defense in
|
|
586
|
+
// commands/install.ts.
|
|
587
|
+
const findGlobalInstall = deps.findGlobalInstall;
|
|
588
|
+
const probed = findGlobalInstall?.(spec.package) ?? null;
|
|
589
|
+
if (!probed) {
|
|
590
|
+
registry.update(
|
|
591
|
+
opId,
|
|
592
|
+
{ status: "failed", error: `bun add -g exited ${code}` },
|
|
593
|
+
`bun add -g ${spec_str} failed (exit ${code})`,
|
|
594
|
+
);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
|
|
561
598
|
}
|
|
562
|
-
registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
|
|
563
599
|
}
|
|
564
600
|
|
|
565
601
|
// Seed services.json if absent (the install flow does this for the
|