@openparachute/hub 0.5.13 → 0.5.14-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -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">admin</span>
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">to administer this hub</p>
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 to Parachute Hub admin", body);
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
@@ -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` (single-emit `pvt_*`) and
17
- * // filesystem `paths` are present when the create path took the
18
- * // `parachute-vault create --json` branch — that's the only time
19
- * // the just-emitted token is captured. The first-vault-on-host
20
- * // bootstrap (`parachute install vault`) doesn't emit JSON yet,
21
- * // so a fresh-box response carries name/url/version only.
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
- * // tokens are single-emit at create time, not retrievable later.
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-emitted `pvt_*` token + filesystem paths so the caller can talk
243
- * to the new vault those creds are single-emit.
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
- // Token + filesystem paths are single-emit at create time. We surface them
352
- // here so the caller can immediately bootstrap a connection to the new
353
- // vault. Idempotent re-POSTs intentionally never include them.
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
- ? { ...entry, token: result.createJson.token, paths: result.createJson.paths }
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), {
@@ -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. Matches `POST_LOGIN_DEFAULT` in `admin-handlers.ts` the
73
- * admin SPA's vault list. Kept as a local const (not imported) so this
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 = "/admin/vaults";
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
- const next = safeNext(String(form.get("next") ?? ""));
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.
@@ -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: `Authorization: Bearer <token>` where `token`'s `scope` claim
12
- * contains `parachute:host:auth`. The operator's local operator.token
13
- * (admin scope-set) covers this; a narrow `--scope-set=auth` operator
14
- * token also covers this.
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 { isNonRequestableScope } from "./scope-explanations.ts";
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
- /** Scope required on the bearer token to call this endpoint. */
36
- export const API_MINT_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
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. Scope gate.
91
- if (!bearerScopes.includes(API_MINT_TOKEN_REQUIRED_SCOPE)) {
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 lacks ${API_MINT_TOKEN_REQUIRED_SCOPE}`,
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
- // Privilege-diffusion guard: mint paths cannot themselves mint tokens
121
- // carrying non-requestable scopes (parachute:host:admin, the host:*
122
- // narrow scopes, vault:<name>:admin). Holder of `parachute:host:auth`
123
- // can mint vault/scribe/agent verb scopes for downstream services, but
124
- // cannot mint another `:auth` (or any other non-requestable) without
125
- // forced re-auth via the operator.token rotation path. Same set the
126
- // public OAuth flow already rejects.
127
- const blocked = scopes.filter((s) => isNonRequestableScope(s));
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 requestable via mint-token; use OAuth flow or operator rotation`,
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
- // `parachute:host:auth` privilege gate, so there's no per-user vault
196
- // pin to enforce. Empty `vault_scope` is the "no restriction"
197
- // sentinel; the `scopes` themselves remain authorization-bearing as
198
- // before.
199
- vaultScope: [],
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
  });
@@ -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
- registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
547
- const code = await run(["bun", "add", "-g", spec_str]);
548
- if (code !== 0) {
549
- // Bun 1.2.x lockfile-recovery noise: probe the global prefix
550
- // before treating non-zero as fatal. Mirrors the same defense in
551
- // commands/install.ts.
552
- const findGlobalInstall = deps.findGlobalInstall;
553
- const probed = findGlobalInstall?.(spec.package) ?? null;
554
- if (!probed) {
555
- registry.update(
556
- opId,
557
- { status: "failed", error: `bun add -g exited ${code}` },
558
- `bun add -g ${spec_str} failed (exit ${code})`,
559
- );
560
- return;
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