@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/src/hub.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { brandMarkSvg, CANONICAL_TAGLINE, WORDMARK_TEXT } from "./brand.ts";
3
+ import { CANONICAL_TAGLINE, WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
4
4
  import { CONFIG_DIR } from "./config.ts";
5
5
  import { CSRF_FIELD_NAME } from "./csrf.ts";
6
6
 
@@ -84,15 +84,34 @@ function buildHtml({ session }: RenderHubOpts): string {
84
84
  const authBlock = session
85
85
  ? renderSignedIn(session.displayName, session.csrfToken)
86
86
  : renderSignedOut();
87
- return HTML_TEMPLATE.replace("<!--AUTH-INDICATOR-->", authBlock);
87
+ // Gate the verbose discovery sections (Get started / Services / Admin)
88
+ // and their data-loading script on auth state. A signed-out visitor sees
89
+ // a clean, minimal landing — brand + tagline + a clear "Sign in" call —
90
+ // not the hub's service catalog, vault listings, or admin links. The
91
+ // detail un-gates the moment they sign in (the server already knows auth
92
+ // state from the session cookie, so this stays a no-JS-required,
93
+ // session-aware render). Operator feedback from a live multi-user deploy:
94
+ // the signed-out page exposed too much to anonymous visitors.
95
+ const body = session ? SIGNED_IN_BODY : SIGNED_OUT_BODY;
96
+ const script = session ? DISCOVERY_SCRIPT : "";
97
+ return HTML_TEMPLATE.replace("<!--AUTH-INDICATOR-->", authBlock)
98
+ .replace("<!--DISCOVERY-BODY-->", body)
99
+ .replace("<!--DISCOVERY-SCRIPT-->", script);
88
100
  }
89
101
 
90
102
  function renderSignedIn(displayName: string, csrfToken: string): string {
91
103
  // Inline POST form so sign-out works without JS. Submit button is
92
104
  // styled as a text link via `.auth-signout` so the visual weight
93
105
  // matches the surrounding "Signed in as <name>" text.
106
+ //
107
+ // The "Account" link is the single breadcrumb to `/account/` — the
108
+ // self-service home where any signed-in user (admin or invited
109
+ // member) can change their password, see their vault, and sign out.
110
+ // Without it, a friend who's been handed credentials has no way to
111
+ // discover the change-password surface after the first-login prompt.
94
112
  return `<div class="auth-indicator">
95
113
  <span class="muted">Signed in as <strong>${escapeHtml(displayName)}</strong></span>
114
+ <a href="/account/" class="auth-account">Account</a>
96
115
  <form method="POST" action="/logout" class="auth-signout-form">
97
116
  <input type="hidden" name="${CSRF_FIELD_NAME}" value="${escapeAttr(csrfToken)}" />
98
117
  <button type="submit" class="auth-signout">Sign out</button>
@@ -203,7 +222,7 @@ const HTML_TEMPLATE = `<!doctype html>
203
222
  margin: 0;
204
223
  display: inline;
205
224
  }
206
- .auth-signout, .auth-signin {
225
+ .auth-signout, .auth-signin, .auth-account {
207
226
  background: none;
208
227
  border: none;
209
228
  padding: 0;
@@ -214,10 +233,10 @@ const HTML_TEMPLATE = `<!doctype html>
214
233
  text-decoration-thickness: 1px;
215
234
  text-underline-offset: 2px;
216
235
  }
217
- .auth-signout:hover, .auth-signin:hover {
236
+ .auth-signout:hover, .auth-signin:hover, .auth-account:hover {
218
237
  color: var(--accent-hover);
219
238
  }
220
- a.auth-signin {
239
+ a.auth-signin, a.auth-account {
221
240
  /* Anchor needs explicit reset since the a element has its own
222
241
  color/decoration. */
223
242
  border-bottom: none;
@@ -262,6 +281,34 @@ const HTML_TEMPLATE = `<!doctype html>
262
281
  font-size: 0.92rem;
263
282
  margin: 0 0 1.25rem;
264
283
  }
284
+ /* Signed-out landing: a single centered "Sign in" call under the brand.
285
+ Minimal by design — the service catalog + admin surfaces stay hidden
286
+ until the visitor authenticates. */
287
+ .signed-out-cta {
288
+ text-align: center;
289
+ margin-bottom: 0;
290
+ }
291
+ .signed-out-lede {
292
+ color: var(--fg-muted);
293
+ font-size: 1.05rem;
294
+ margin: 0 0 1.5rem;
295
+ }
296
+ .btn-signin {
297
+ display: inline-block;
298
+ background: var(--accent);
299
+ color: var(--card-bg);
300
+ font-family: var(--sans);
301
+ font-size: 1rem;
302
+ font-weight: 500;
303
+ text-decoration: none;
304
+ padding: 0.65rem 1.6rem;
305
+ border-radius: 8px;
306
+ transition: background 0.15s ease, transform 0.15s ease;
307
+ }
308
+ .btn-signin:hover {
309
+ background: var(--accent-hover);
310
+ transform: translateY(-1px);
311
+ }
265
312
  .grid {
266
313
  display: grid;
267
314
  gap: 1.25rem;
@@ -371,7 +418,22 @@ const HTML_TEMPLATE = `<!doctype html>
371
418
  <h1>${WORDMARK_TEXT}</h1>
372
419
  <p class="tagline">${CANONICAL_TAGLINE}</p>
373
420
  </header>
421
+ <!--DISCOVERY-BODY-->
422
+ <footer>
423
+ <a href="/.well-known/parachute.json">discovery</a>
424
+ </footer>
425
+ </main>
426
+ <!--DISCOVERY-SCRIPT-->
427
+ </body>
428
+ </html>
429
+ `;
374
430
 
431
+ // The verbose discovery body — the service catalog, admin surfaces, and the
432
+ // "Get started" CTA. Rendered ONLY for a signed-in visitor (`buildHtml`
433
+ // selects this vs SIGNED_OUT_BODY on `session`). Anonymous visitors get the
434
+ // slim landing below instead, so the hub's internal surface isn't exposed
435
+ // pre-auth.
436
+ const SIGNED_IN_BODY = `
375
437
  <section class="section" id="get-started-section" hidden>
376
438
  <h2>Get started</h2>
377
439
  <p class="section-sub">Jump straight into what you came here for.</p>
@@ -391,12 +453,21 @@ const HTML_TEMPLATE = `<!doctype html>
391
453
  <p class="section-sub">Manage this hub — vaults, permissions, tokens.</p>
392
454
  <div class="grid" id="admin-grid"></div>
393
455
  </section>
456
+ `;
394
457
 
395
- <footer>
396
- <a href="/.well-known/parachute.json">discovery</a>
397
- </footer>
398
- </main>
399
- <script>
458
+ // The slim signed-out landing. Brand + tagline (in the header above) plus a
459
+ // single clear "Sign in" call — no service catalog, no vault listings, no
460
+ // admin links. Keep it tasteful and minimal; the detail un-gates on sign-in.
461
+ const SIGNED_OUT_BODY = `
462
+ <section class="section signed-out-cta" id="signed-out-cta">
463
+ <p class="signed-out-lede">Sign in to reach your vault and the services on this hub.</p>
464
+ <a href="/login?next=/" class="btn-signin" data-testid="signed-out-signin">Sign in →</a>
465
+ </section>
466
+ `;
467
+
468
+ // The data-loading script for the signed-in discovery body. Emitted only
469
+ // when signed in (the signed-out body has nothing for it to populate).
470
+ const DISCOVERY_SCRIPT = `<script>
400
471
  (async () => {
401
472
  const servicesGrid = document.getElementById('services-grid');
402
473
  const adminGrid = document.getElementById('admin-grid');
@@ -607,7 +678,4 @@ const HTML_TEMPLATE = `<!doctype html>
607
678
 
608
679
  void loadServices();
609
680
  })();
610
- </script>
611
- </body>
612
- </html>
613
- `;
681
+ </script>`;
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import type { Database } from "bun:sqlite";
25
25
  import { AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
26
+ import { renderTotpChallenge } from "./admin-login-ui.ts";
26
27
  import {
27
28
  AuthCodeExpiredError,
28
29
  AuthCodeNotFoundError,
@@ -65,7 +66,9 @@ import {
65
66
  renderUnknownClient,
66
67
  } from "./oauth-ui.ts";
67
68
  import { isSameOriginRequest } from "./origin-check.ts";
69
+ import { buildPendingLoginCookie, createPendingLogin } from "./pending-login.ts";
68
70
  import { isHttpsRequest } from "./request-protocol.ts";
71
+ import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
69
72
  import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
70
73
  import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
71
74
  import {
@@ -83,7 +86,14 @@ import {
83
86
  findSession,
84
87
  parseSessionCookie,
85
88
  } from "./sessions.ts";
86
- import { getUserById, getUserByUsername, isFirstAdmin, verifyPassword } from "./users.ts";
89
+ import { isTotpEnrolled } from "./two-factor-store.ts";
90
+ import {
91
+ getUserById,
92
+ getUserByUsername,
93
+ isFirstAdmin,
94
+ vaultVerbsForUserVault,
95
+ verifyPassword,
96
+ } from "./users.ts";
87
97
  import { listVaultNames } from "./vault-names.ts";
88
98
  import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
89
99
 
@@ -460,6 +470,9 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
460
470
  codeChallenge,
461
471
  codeChallengeMethod,
462
472
  state: url.searchParams.get("state"),
473
+ // RFC 8707 resource indicator (optional). When present and resolvable to
474
+ // a per-vault MCP resource, drives the narrow-consent + named-scope path.
475
+ resource: url.searchParams.get("resource"),
463
476
  };
464
477
  }
465
478
 
@@ -821,7 +834,7 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
821
834
  parsed.state,
822
835
  );
823
836
  }
824
- const client = getClient(db, parsed.clientId);
837
+ let client = getClient(db, parsed.clientId);
825
838
  if (!client) {
826
839
  // Can't safely redirect — we don't trust the redirect_uri until we've
827
840
  // matched it against a registered client. Render an HTML error that
@@ -832,7 +845,71 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
832
845
  return unknownClientResponse(parsed.clientId, parsed.redirectUri, deps);
833
846
  }
834
847
  if (client.status !== "approved") {
835
- return pendingClientResponse(db, req, client, url, deps);
848
+ // Single-consent change (2026-05-29): the separate operator "approve this
849
+ // client" gate is retired — the user's OAuth consent IS the authorization.
850
+ // A request carrying a valid session auto-approves the pending client and
851
+ // FALLS THROUGH into the normal consent path below (resource-narrow →
852
+ // non-requestable gate → skip-consent / same-hub → consent render). A
853
+ // session-less request still renders the unauth "App not yet approved"
854
+ // page (`pendingClientResponse`), whose sign-in CTA (`loginNextUrl`)
855
+ // round-trips back to this authorize URL; after login the user re-enters
856
+ // WITH a session → hits this auto-approve branch → consent.
857
+ //
858
+ // We resolve the session via the same `parseSessionCookie` + `findSession`
859
+ // pair the consent path uses below (not `findActiveSession`) so the
860
+ // auto-approve predicate and the consent render agree on session identity.
861
+ const earlySessionId = parseSessionCookie(req.headers.get("cookie"));
862
+ const earlySession = earlySessionId ? findSession(db, earlySessionId) : null;
863
+ if (!earlySession) {
864
+ return pendingClientResponse(db, req, client, url, deps);
865
+ }
866
+ console.log(
867
+ `[oauth] auto-approved client on user consent (single-consent) client_id=${client.clientId} user_id=${earlySession.userId} (2026-05-29)`,
868
+ );
869
+ approveClient(db, client.clientId);
870
+
871
+ // Trust-by-client_name carry-over (hub#409, preserved through the
872
+ // single-consent change). Notes/Claude DCR a fresh client_id per session;
873
+ // when the user has a prior grant under the SAME client_name that covers
874
+ // the requested scopes, re-link must stay SILENT. The skip-consent gate
875
+ // below keys on (user, client_id) and the fresh client_id has no grant
876
+ // yet — so we re-record that prior coverage onto the fresh client_id here.
877
+ // The mint downstream goes through `issueAuthCodeRedirect`, which caps to
878
+ // held authority, so this carry-over can never silently re-grant an
879
+ // un-held verb. Guarded identically to the in-`pendingClientResponse`
880
+ // block: same-origin + non-empty client_name + non-admin requested scopes
881
+ // (`scopeIsAdmin` recognizes the named admin form now — load-bearing) +
882
+ // prior-grant coverage. When it doesn't apply, fall through to the consent
883
+ // render (the single-consent payoff: one consent screen, then silent).
884
+ const earlyRequested = parsed.scope.split(" ").filter((s) => s.length > 0);
885
+ if (
886
+ isSameOriginRequest(req, resolveBoundOrigins(deps)) &&
887
+ client.clientName &&
888
+ earlyRequested.length > 0 &&
889
+ !earlyRequested.some(scopeIsAdmin) &&
890
+ isCoveredByGrantForClientName(db, earlySession.userId, client.clientName, earlyRequested)
891
+ ) {
892
+ console.log(
893
+ `[oauth] carried prior client_name trust onto fresh client_id=${client.clientId} client_name=${JSON.stringify(client.clientName)} user_id=${earlySession.userId} scopes=${earlyRequested.join(" ")} (hub#409)`,
894
+ );
895
+ recordGrant(
896
+ db,
897
+ earlySession.userId,
898
+ client.clientId,
899
+ earlyRequested,
900
+ deps.now?.() ?? new Date(),
901
+ );
902
+ }
903
+
904
+ // Re-fetch so `client.status` reflects `approved` for the rest of this
905
+ // function (the same-hub gate and consent props read `client`). Fall
906
+ // through on success; if the refresh somehow failed, render the unauth
907
+ // pending page defensively (should never happen given approveClient).
908
+ const refreshed = getClient(db, client.clientId);
909
+ if (!refreshed || refreshed.status !== "approved") {
910
+ return pendingClientResponse(db, req, client, url, deps);
911
+ }
912
+ client = refreshed;
836
913
  }
837
914
  try {
838
915
  requireRegisteredRedirectUri(client, parsed.redirectUri);
@@ -844,6 +921,41 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
844
921
  );
845
922
  }
846
923
 
924
+ // RFC 8707 resource binding. When the client named a per-vault MCP
925
+ // resource (`<origin>/vault/<name>/mcp` or its PRM URL), narrow the
926
+ // requested vault verbs to the named `vault:<name>:<verb>` form BEFORE any
927
+ // downstream processing. Two effects:
928
+ //
929
+ // 1. The consent screen shows ONLY that vault's scopes (the picker locks
930
+ // to <name>) instead of the whole-hub catalog — a friend connecting to
931
+ // one vault no longer sees `hub:admin`, `scribe:admin`, or every other
932
+ // vault's verbs.
933
+ // 2. The minted token carries the named scope, so `inferAudience` stamps
934
+ // `aud=vault.<name>` and a current-line vault accepts it (an unnamed
935
+ // `vault:read` token is rejected by `findBroadVaultScopes`).
936
+ //
937
+ // Narrowing happens before the non-requestable gate (below) on purpose: if
938
+ // a resource-bound client somehow asked for `vault:admin`, narrowing makes
939
+ // it `vault:<name>:admin`, which IS non-requestable — so the gate correctly
940
+ // blocks it. Read/write narrow to the requestable named form. Non-vault
941
+ // scopes and already-named scopes for other vaults pass through unchanged.
942
+ //
943
+ // No resource, or a resource that isn't one of our per-vault MCP resources
944
+ // (off-origin, malformed, non-vault path) → `boundVault` is null and the
945
+ // flow is byte-for-byte the pre-#461 behavior (manual picker, etc.).
946
+ const boundVault = resolveResourceVault(parsed.resource, resolveBoundOrigins(deps));
947
+ if (boundVault) {
948
+ const narrowed = narrowResourceVaultScopes(
949
+ parsed.scope.split(" ").filter((s) => s.length > 0),
950
+ boundVault,
951
+ );
952
+ // Rewrite `parsed.scope` so the narrowed named scopes flow through every
953
+ // downstream consumer: the login-redirect query round-trip, the consent
954
+ // props + hidden inputs, the skip-consent grant lookup, and the
955
+ // auth-code mint.
956
+ parsed.scope = narrowed.join(" ");
957
+ }
958
+
847
959
  // Operator-only scope gate (#96). Reject any request that names a scope
848
960
  // we'll never mint via this flow — `parachute:host:admin` and friends.
849
961
  // Per RFC 6749 §4.1.2.1, errors that aren't redirect-uri-related are
@@ -991,10 +1103,13 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
991
1103
  console.log(
992
1104
  `[oauth] auto-approved same-hub client client_id=${client.clientId} user_id=${session.userId} scopes=${requestedScopes.join(" ")} (hub#312)`,
993
1105
  );
994
- // Record the grant so the next /authorize for this (user, client, scopes)
995
- // hits the standard skip-consent path (#75) keeps the audit story
996
- // consistent between same-hub and externally-approved flows.
997
- recordGrant(db, session.userId, client.clientId, requestedScopes);
1106
+ // The grant is recorded INSIDE issueAuthCodeRedirect with the CAPPED
1107
+ // scopes (single choke-point, single source of truth) so the next
1108
+ // /authorize for this (user, client, scopes) hits the standard
1109
+ // skip-consent path (#75) and can never replay an un-held verb. The
1110
+ // `!hasAdminScope` guard above already keeps admin scopes off this path
1111
+ // (they fall through to consent), so the cap is a no-op here for the
1112
+ // common case, but it still runs unconditionally for defense in depth.
998
1113
  return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
999
1114
  }
1000
1115
 
@@ -1008,10 +1123,89 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
1008
1123
  }
1009
1124
 
1010
1125
  /**
1011
- * Mint an auth code and redirect to the client's redirect_uri. Shared by
1012
- * the consent-submit path (`handleConsentSubmit`) and the skip-consent path
1013
- * in `handleAuthorizeGet` (#75). Caller is responsible for having already
1014
- * validated the client + redirect_uri + scopes.
1126
+ * Anti-privilege-escalation cap (THE security crux of the single-consent
1127
+ * change, 2026-05-29). A user may only delegate authority they themselves
1128
+ * hold: the OAuth consent that authorizes a client must never grant a named
1129
+ * vault verb the consenting user doesn't actually hold on that vault.
1130
+ *
1131
+ * For each scope shaped `vault:<name>:<verb>` (verb ∈ VAULT_VERBS) when the
1132
+ * user is NOT the hub owner, the verb is admitted only if it appears in
1133
+ * `vaultVerbsForUserVault(db, userId, name)` (the verbs the user holds on
1134
+ * that vault, derived from their `user_vaults` role). Otherwise the scope is
1135
+ * DROPPED. Non-vault scopes and unnamed `vault:<verb>` (which never reach
1136
+ * mint without picker-narrowing) pass through untouched.
1137
+ *
1138
+ * The owner (`isFirstAdmin`) bypasses the cap entirely — they hold admin on
1139
+ * every vault by construction (admin posture is the unrestricted sentinel;
1140
+ * see `vaultScopeForUser`). Owner=isFirstAdmin is the Phase-1 definition of
1141
+ * "holds admin everywhere"; revisit when multi-admin lands.
1142
+ *
1143
+ * Security argument (documented at the call site too):
1144
+ * - The authority source of truth today is `isFirstAdmin` for owner-wide
1145
+ * authority and `user_vaults.role` (via `vaultVerbsForRole`) for assigned
1146
+ * users.
1147
+ * - `vaultVerbsForRole` provably never returns `admin` for an assigned user
1148
+ * (it maps write→[read,write], read→[read], unknown→[]), so this helper
1149
+ * drops `vault:<name>:admin` for every non-owner BY CONSTRUCTION — without
1150
+ * hardcoding "drop admin". It reads the held verb set and admits only
1151
+ * held verbs, so it's forward-compatible: if a future role ever granted
1152
+ * admin, the cap would admit it automatically.
1153
+ * - Applied inside `issueAuthCodeRedirect` (the single choke-point ALL mint
1154
+ * paths funnel through: consent-submit, skip-consent, and same-hub
1155
+ * auto-trust), the CAPPED set is what gets both recorded (`recordGrant`)
1156
+ * and minted (`issueAuthCode`). No mint path can bypass it, and a later
1157
+ * skip-consent flow can never replay an un-held admin verb because it was
1158
+ * never recorded.
1159
+ */
1160
+ function capScopesToUserAuthority(
1161
+ db: Database,
1162
+ userId: string,
1163
+ scopes: readonly string[],
1164
+ opts: { userIsAdmin: boolean },
1165
+ ): string[] {
1166
+ if (opts.userIsAdmin) return [...scopes];
1167
+ return scopes.filter((s) => {
1168
+ const parts = s.split(":");
1169
+ if (parts.length !== 3 || parts[0] !== "vault") return true; // non-named — pass through
1170
+ const name = parts[1];
1171
+ const verb = parts[2];
1172
+ if (name === undefined || verb === undefined || !VAULT_VERBS.has(verb)) return true;
1173
+ // Named vault verb requested by a non-owner: admit only if the user holds
1174
+ // it. `vaultVerbsForUserVault` returns null for an unassigned vault (drop)
1175
+ // or the held verb list (today read/write only — never admin).
1176
+ const held = vaultVerbsForUserVault(db, userId, name);
1177
+ return held !== null && (held as readonly string[]).includes(verb);
1178
+ });
1179
+ }
1180
+
1181
+ /**
1182
+ * Mint an auth code and redirect to the client's redirect_uri. The SINGLE
1183
+ * mint choke-point — shared by the consent-submit path (`handleConsentSubmit`),
1184
+ * the skip-consent path (#75), and the same-hub auto-trust path (hub#312) in
1185
+ * `handleAuthorizeGet`. Caller is responsible for having already validated the
1186
+ * client + redirect_uri and for having narrowed unnamed `vault:<verb>` scopes
1187
+ * to their named form (so the cap below sees final shapes).
1188
+ *
1189
+ * This is the single choke-point for two responsibilities, so NO mint path can
1190
+ * bypass them:
1191
+ * 1. Anti-privilege-escalation cap (`capScopesToUserAuthority`): a non-owner
1192
+ * can only delegate vault verbs they hold; un-held verbs (notably admin)
1193
+ * are dropped. An admin-only request from a non-owner caps to EMPTY → we
1194
+ * refuse with `invalid_scope` rather than mint a zero-scope token. EVERY
1195
+ * auth code is minted through here, so the cap runs before every mint —
1196
+ * even when a stale `grants` row already lists an un-held admin verb (the
1197
+ * cap, not the grant lookup, is what blocks the mint).
1198
+ * 2. Grant recording (`recordGrant`) with the CAPPED scopes for THIS mint —
1199
+ * so a later skip-consent flow re-entering with the same (user, client)
1200
+ * can never replay an un-held verb. UNION semantics make this idempotent.
1201
+ *
1202
+ * Not the ONLY `recordGrant` call in this module, though: two other guarded
1203
+ * fast-path records exist — the trust-by-client_name auto-promote in
1204
+ * `pendingClientResponse` (~L585) and the auto-approve carry-over in
1205
+ * `handleAuthorizeGet` (~L895). Both are gated by `!some(scopeIsAdmin)`, so
1206
+ * neither can ever record an admin verb, and any mint they unlock still flows
1207
+ * back through this function's cap. So the invariant "no minted token, and no
1208
+ * grant row, ever carries an un-held admin verb" holds across all paths.
1015
1209
  */
1016
1210
  function issueAuthCodeRedirect(
1017
1211
  db: Database,
@@ -1020,11 +1214,37 @@ function issueAuthCodeRedirect(
1020
1214
  userId: string,
1021
1215
  deps: OAuthDeps,
1022
1216
  ): Response {
1217
+ // Anti-privesc cap at the single choke-point. Runs AFTER any narrowing the
1218
+ // callers did (unnamed `vault:admin` → `vault:<picked>:admin`), so it sees
1219
+ // the final named shapes. Owner (isFirstAdmin) bypasses — holds admin
1220
+ // everywhere by construction.
1221
+ const userIsAdmin = isFirstAdmin(db, userId);
1222
+ const cappedScopes = capScopesToUserAuthority(db, userId, scopes, { userIsAdmin });
1223
+
1224
+ // Drop-not-refuse UX, with one hard floor: if capping leaves an EMPTY set
1225
+ // (e.g. a non-owner requested ONLY `vault:<name>:admin`, which they don't
1226
+ // hold), never mint a zero-scope token — refuse with a clear invalid_scope.
1227
+ // A request that started empty (no scopes at all) is a separate, legitimate
1228
+ // "session token only" case the consent UI supports, so we only refuse when
1229
+ // the cap itself removed every scope.
1230
+ if (cappedScopes.length === 0 && scopes.length > 0) {
1231
+ return oauthErrorRedirect(
1232
+ params.redirectUri,
1233
+ "invalid_scope",
1234
+ "You can grant only the access you hold on this vault; an admin grant requires hub-owner authority.",
1235
+ params.state,
1236
+ );
1237
+ }
1238
+
1239
+ // Record the grant with the CAPPED scopes (single source of truth) so
1240
+ // skip-consent re-entry can never widen back to an un-held verb.
1241
+ recordGrant(db, userId, params.clientId, cappedScopes, deps.now?.() ?? new Date());
1242
+
1023
1243
  const code = issueAuthCode(db, {
1024
1244
  clientId: params.clientId,
1025
1245
  userId,
1026
1246
  redirectUri: params.redirectUri,
1027
- scopes,
1247
+ scopes: cappedScopes,
1028
1248
  codeChallenge: params.codeChallenge,
1029
1249
  codeChallengeMethod: params.codeChallengeMethod,
1030
1250
  now: deps.now,
@@ -1100,17 +1320,49 @@ async function handleLoginSubmit(
1100
1320
  401,
1101
1321
  );
1102
1322
  }
1323
+
1324
+ // Where to land after a successful sign-in: back at GET /oauth/authorize
1325
+ // with the original query string so the user resumes the OAuth flow on the
1326
+ // consent screen with full params re-validated.
1327
+ const authorizeReturnUrl = buildAuthorizeReturnUrl(params);
1328
+
1329
+ // 2FA gate (hub#473 — security fix). The OAuth login POST is the MORE-common
1330
+ // sign-in path (every OAuth client: vault, notes-ui, `parachute auth login`).
1331
+ // It must enforce the second factor exactly like `/login` does: after the
1332
+ // password verifies, if the user has TOTP enrolled, do NOT mint a session.
1333
+ // Stash a pending-login whose `next` is the FULL /oauth/authorize return URL
1334
+ // (so the post-TOTP redirect resumes the OAuth flow), and render the TOTP
1335
+ // challenge. The challenge form posts to `/login/2fa` — the shared
1336
+ // completion path (`handleAdminLoginTotpPost`) — which verifies the factor,
1337
+ // mints the session, and 302s to the stored `next`. The pending-login cookie
1338
+ // is scoped `Path=/login`, so it rides the `/login/2fa` POST. Without this
1339
+ // gate a 2FA-enrolled user could obtain a full session with password ONLY.
1340
+ if (isTotpEnrolled(db, user.id)) {
1341
+ const pendingToken = createPendingLogin(user.id, authorizeReturnUrl);
1342
+ return htmlResponse(renderTotpChallenge({ next: authorizeReturnUrl, csrfToken }), 200, {
1343
+ "set-cookie": buildPendingLoginCookie(pendingToken, req),
1344
+ });
1345
+ }
1346
+
1103
1347
  const session = createSession(db, { userId: user.id });
1104
1348
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
1105
1349
  secure: isHttpsRequest(req),
1106
1350
  });
1107
- // Redirect back to GET /oauth/authorize with the original query string so
1108
- // the user lands on the consent screen with full params re-validated.
1351
+ return redirectResponse(authorizeReturnUrl, { "set-cookie": cookie });
1352
+ }
1353
+
1354
+ /**
1355
+ * Build the `/oauth/authorize?...` return URL (path + query, same-origin) that
1356
+ * a successful sign-in redirects back to so the OAuth flow resumes on the
1357
+ * consent screen. Shared by the password-only path and the post-TOTP path
1358
+ * (the latter via the pending-login's `next`).
1359
+ */
1360
+ function buildAuthorizeReturnUrl(params: AuthorizeFormParams): string {
1109
1361
  const u = new URL("/oauth/authorize", "http://placeholder");
1110
1362
  for (const [k, v] of Object.entries(authorizeParamsToQuery(params))) {
1111
1363
  u.searchParams.set(k, v);
1112
1364
  }
1113
- return redirectResponse(`${u.pathname}${u.search}`, { "set-cookie": cookie });
1365
+ return `${u.pathname}${u.search}`;
1114
1366
  }
1115
1367
 
1116
1368
  async function handleConsentSubmit(
@@ -1121,6 +1373,21 @@ async function handleConsentSubmit(
1121
1373
  csrfToken: string,
1122
1374
  ): Promise<Response> {
1123
1375
  const params = paramsFromForm(form);
1376
+ // RFC 8707 resource binding — defense-in-depth (mirror of the GET handler).
1377
+ // The consent form's hidden inputs already carry the narrowed named scopes
1378
+ // (the GET handler rewrote `parsed.scope` before rendering), but a hand-
1379
+ // crafted POST could re-supply an unnamed `vault:read` alongside the
1380
+ // `resource` field. Re-narrow here so the minted token is always named +
1381
+ // correctly-audienced regardless of what the form body claims. Same
1382
+ // semantics as the GET path: only when `resource` resolves to one of our
1383
+ // per-vault MCP resources; no-op otherwise (manual-pick path unchanged).
1384
+ const boundVault = resolveResourceVault(params.resource, resolveBoundOrigins(deps));
1385
+ if (boundVault) {
1386
+ params.scope = narrowResourceVaultScopes(
1387
+ params.scope.split(" ").filter((s) => s.length > 0),
1388
+ boundVault,
1389
+ ).join(" ");
1390
+ }
1124
1391
  const approve = String(form.get("approve") ?? "") === "yes";
1125
1392
  const sessionId = parseSessionCookie(req.headers.get("cookie"));
1126
1393
  const session = sessionId ? findSession(db, sessionId) : null;
@@ -1201,6 +1468,11 @@ async function handleConsentSubmit(
1201
1468
  const sessionUser = getUserById(db, session.userId);
1202
1469
  const assignedVaults: string[] = userIsAdmin ? [] : (sessionUser?.assignedVaults ?? []);
1203
1470
  const isPinned = assignedVaults.length > 0;
1471
+ // By design: the resource-bound re-narrow above does NOT check the bound
1472
+ // vault exists in services.json for the admin path — admin (isPinned=false)
1473
+ // can already consent to any vault via the manual picker, so the asymmetry
1474
+ // (named-scope mint against a possibly-missing vault) is deliberate, not an
1475
+ // oversight. Non-admins still hit the assignment + stale-vault defenses below.
1204
1476
 
1205
1477
  // Zero-vault non-admin gate (Phase 2 PR 2 reviewer fold). A non-admin
1206
1478
  // user with no `user_vaults` rows is a known-but-not-yet-assigned
@@ -1362,12 +1634,12 @@ async function handleConsentSubmit(
1362
1634
  }
1363
1635
  }
1364
1636
 
1365
- // Record (or extend) the grant so the next /oauth/authorize for this
1366
- // (user, client) with these scopes — or any subset can skip the consent
1367
- // screen (#75). UNION semantics: if the user previously granted [a, b, c]
1368
- // and now grants [a, d], the row becomes [a, b, c, d]. Subset re-flows
1369
- // still match.
1370
- recordGrant(db, session.userId, client.clientId, scopes, deps.now?.() ?? new Date());
1637
+ // The grant is recorded (or extended) INSIDE issueAuthCodeRedirect with the
1638
+ // CAPPED scopes — the single mint choke-point owns both the anti-privesc cap
1639
+ // and the recordGrant so no path can record an un-held verb. UNION semantics
1640
+ // there mean a subset re-flow still matches a prior grant, and an admin-only
1641
+ // request from a non-owner caps to empty → refused (no zero-scope token).
1642
+ // (#75 skip-consent depends on this recording.)
1371
1643
  return issueAuthCodeRedirect(db, params, scopes, session.userId, deps);
1372
1644
  }
1373
1645
 
@@ -1554,6 +1826,7 @@ function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): Authori
1554
1826
  codeChallenge: String(form.get("code_challenge") ?? ""),
1555
1827
  codeChallengeMethod: String(form.get("code_challenge_method") ?? "S256"),
1556
1828
  state: (form.get("state") as string | null) ?? null,
1829
+ resource: (form.get("resource") as string | null) ?? null,
1557
1830
  };
1558
1831
  }
1559
1832
 
@@ -1567,6 +1840,10 @@ function authorizeParamsToQuery(p: AuthorizeFormParams): Record<string, string>
1567
1840
  code_challenge_method: p.codeChallengeMethod,
1568
1841
  };
1569
1842
  if (p.state) q.state = p.state;
1843
+ // Round-trip the RFC 8707 resource indicator through the login redirect so
1844
+ // the resource-bound narrowing survives a sign-in (it re-enters GET
1845
+ // /oauth/authorize with the original params).
1846
+ if (p.resource) q.resource = p.resource;
1570
1847
  return q;
1571
1848
  }
1572
1849
 
package/src/oauth-ui.ts CHANGED
@@ -62,6 +62,12 @@ export interface AuthorizeFormParams {
62
62
  codeChallenge: string;
63
63
  codeChallengeMethod: string;
64
64
  state: string | null;
65
+ /**
66
+ * RFC 8707 resource indicator. Carried through the login → consent →
67
+ * form-post round-trip so the resource-bound vault narrowing survives a
68
+ * sign-in redirect. Null when the client sent no `resource` param.
69
+ */
70
+ resource: string | null;
65
71
  }
66
72
 
67
73
  export interface LoginViewProps {
@@ -932,6 +938,10 @@ export function renderHiddenInputs(p: AuthorizeFormParams): string {
932
938
  ["code_challenge_method", p.codeChallengeMethod],
933
939
  ];
934
940
  if (p.state) fields.push(["state", p.state]);
941
+ // RFC 8707 resource indicator — round-tripped through the consent form so
942
+ // the resource-bound vault narrowing survives the POST (the consent submit
943
+ // path re-derives the bound vault from it).
944
+ if (p.resource) fields.push(["resource", p.resource]);
935
945
  return fields
936
946
  .map(([k, v]) => `<input type="hidden" name="${k}" value="${escapeHtml(v)}" />`)
937
947
  .join("\n ");