@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
@@ -3,8 +3,9 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { getClient, registerClient } from "../clients.ts";
6
+ import { approveClient, getClient, registerClient } from "../clients.ts";
7
7
  import { CSRF_COOKIE_NAME } from "../csrf.ts";
8
+ import { findGrant, recordGrant } from "../grants.ts";
8
9
  import { hubDbPath, openHubDb } from "../hub-db.ts";
9
10
  import {
10
11
  FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
@@ -13,7 +14,6 @@ import {
13
14
  setSetting,
14
15
  } from "../hub-settings.ts";
15
16
  import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
16
- import { findGrant, recordGrant } from "../grants.ts";
17
17
  import {
18
18
  authorizationServerMetadata,
19
19
  buildServicesCatalog,
@@ -24,10 +24,11 @@ import {
24
24
  handleRevoke,
25
25
  handleToken,
26
26
  protectedResourceMetadata,
27
+ vaultScopeForUser,
27
28
  } from "../oauth-handlers.ts";
28
29
  import type { ServicesManifest } from "../services-manifest.ts";
29
30
  import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
30
- import { createUser } from "../users.ts";
31
+ import { createUser, setUserVaults } from "../users.ts";
31
32
 
32
33
  const ISSUER = "https://hub.example";
33
34
  const TEST_CSRF = "csrf-test-token";
@@ -4880,6 +4881,66 @@ describe("DCR first-client auto-approve window (hub#268 Item 3)", () => {
4880
4881
  // non-admin users (with `assigned_vault` non-null) see the consent picker
4881
4882
  // locked, and the OAuth issuer mints tokens carrying `vault_scope: [<assigned>]`.
4882
4883
  // Server-side defense refuses any mint whose picked vault disagrees.
4884
+ describe("vaultScopeForUser (multi-user Phase 2 PR 2 — many-to-many)", () => {
4885
+ test("first admin returns [] regardless of any user_vaults rows", async () => {
4886
+ const { db, cleanup } = await makeDb();
4887
+ try {
4888
+ const admin = await createUser(db, "admin-aaron", "pw");
4889
+ expect(vaultScopeForUser(db, admin.id)).toEqual([]);
4890
+ } finally {
4891
+ cleanup();
4892
+ }
4893
+ });
4894
+
4895
+ test("non-admin with zero vault assignments returns []", async () => {
4896
+ const { db, cleanup } = await makeDb();
4897
+ try {
4898
+ await createUser(db, "admin-aaron", "pw");
4899
+ const bob = await createUser(db, "bob", "pw", { allowMulti: true });
4900
+ expect(vaultScopeForUser(db, bob.id)).toEqual([]);
4901
+ } finally {
4902
+ cleanup();
4903
+ }
4904
+ });
4905
+
4906
+ test("non-admin with one assigned vault returns [name]", async () => {
4907
+ const { db, cleanup } = await makeDb();
4908
+ try {
4909
+ await createUser(db, "admin-aaron", "pw");
4910
+ const bob = await createUser(db, "bob", "pw", {
4911
+ allowMulti: true,
4912
+ assignedVaults: ["default"],
4913
+ });
4914
+ expect(vaultScopeForUser(db, bob.id)).toEqual(["default"]);
4915
+ } finally {
4916
+ cleanup();
4917
+ }
4918
+ });
4919
+
4920
+ test("non-admin with multiple assigned vaults returns each name", async () => {
4921
+ const { db, cleanup } = await makeDb();
4922
+ try {
4923
+ await createUser(db, "admin-aaron", "pw");
4924
+ const bob = await createUser(db, "bob", "pw", {
4925
+ allowMulti: true,
4926
+ assignedVaults: ["personal", "family"],
4927
+ });
4928
+ expect(new Set(vaultScopeForUser(db, bob.id))).toEqual(new Set(["personal", "family"]));
4929
+ } finally {
4930
+ cleanup();
4931
+ }
4932
+ });
4933
+
4934
+ test("unknown user id returns [] defensively", async () => {
4935
+ const { db, cleanup } = await makeDb();
4936
+ try {
4937
+ expect(vaultScopeForUser(db, "no-such-id")).toEqual([]);
4938
+ } finally {
4939
+ cleanup();
4940
+ }
4941
+ });
4942
+ });
4943
+
4883
4944
  describe("handleAuthorizeGet — multi-user assigned vault picker lock (PR 4)", () => {
4884
4945
  test("admin user (assigned_vault null) sees the free dropdown", async () => {
4885
4946
  const { db, cleanup } = await makeDb();
@@ -4918,13 +4979,131 @@ describe("handleAuthorizeGet — multi-user assigned vault picker lock (PR 4)",
4918
4979
  }
4919
4980
  });
4920
4981
 
4982
+ test("non-admin user with 2+ assigned vaults sees a free dropdown filtered to those (Phase 2 PR 2)", async () => {
4983
+ const { db, cleanup } = await makeDb();
4984
+ try {
4985
+ const admin = await createUser(db, "admin-aaron", "pw");
4986
+ void admin;
4987
+ const bob = await createUser(db, "bob", "pw", {
4988
+ allowMulti: true,
4989
+ // Only "default" is in the fixture manifest; non-admins with a
4990
+ // mix of valid + invalid vaults effectively get the intersection.
4991
+ assignedVaults: ["default", "personal"],
4992
+ });
4993
+ // Add a second vault to the manifest so the dropdown has two
4994
+ // valid choices to filter to.
4995
+ const multiVaultManifest: ServicesManifest = {
4996
+ services: [
4997
+ {
4998
+ name: "parachute-vault",
4999
+ port: 1940,
5000
+ paths: ["/vault/default", "/vault/personal", "/vault/family"],
5001
+ health: "/health",
5002
+ version: "0.3.0",
5003
+ },
5004
+ ],
5005
+ };
5006
+ const session = createSession(db, { userId: bob.id });
5007
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5008
+ const { challenge } = makePkce();
5009
+ const req = new Request(
5010
+ authorizeUrl({
5011
+ client_id: reg.client.clientId,
5012
+ redirect_uri: "https://app.example/cb",
5013
+ response_type: "code",
5014
+ code_challenge: challenge,
5015
+ code_challenge_method: "S256",
5016
+ scope: "vault:read",
5017
+ }),
5018
+ {
5019
+ headers: {
5020
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
5021
+ },
5022
+ },
5023
+ );
5024
+ const res = handleAuthorizeGet(db, req, {
5025
+ issuer: ISSUER,
5026
+ loadServicesManifest: () => multiVaultManifest,
5027
+ });
5028
+ expect(res.status).toBe(200);
5029
+ const html = await res.text();
5030
+ // NOT the locked picker section — two vaults is a dropdown.
5031
+ expect(html).not.toContain('class="vault-picker vault-picker-locked"');
5032
+ // No locked-vault hidden input.
5033
+ expect(html).not.toContain('<input type="hidden" name="vault_pick"');
5034
+ // Two radios — for the two assigned vaults, in order.
5035
+ expect(html).toContain('name="vault_pick" value="default"');
5036
+ expect(html).toContain('name="vault_pick" value="personal"');
5037
+ // NOT the third hub-wide vault (`family`) — filtered out.
5038
+ expect(html).not.toContain('name="vault_pick" value="family"');
5039
+ } finally {
5040
+ cleanup();
5041
+ }
5042
+ });
5043
+
5044
+ test("non-admin requesting a named vault outside their list → 400 vault_scope_mismatch (Phase 2 PR 2)", async () => {
5045
+ const { db, cleanup } = await makeDb();
5046
+ try {
5047
+ const admin = await createUser(db, "admin-aaron", "pw");
5048
+ void admin;
5049
+ const bob = await createUser(db, "bob", "pw", {
5050
+ allowMulti: true,
5051
+ assignedVaults: ["personal", "family"],
5052
+ });
5053
+ const multiVaultManifest: ServicesManifest = {
5054
+ services: [
5055
+ {
5056
+ name: "parachute-vault",
5057
+ port: 1940,
5058
+ paths: ["/vault/personal", "/vault/family", "/vault/work"],
5059
+ health: "/health",
5060
+ version: "0.3.0",
5061
+ },
5062
+ ],
5063
+ };
5064
+ const session = createSession(db, { userId: bob.id });
5065
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5066
+ const { challenge } = makePkce();
5067
+ const consentForm = new URLSearchParams({
5068
+ __action: "consent",
5069
+ __csrf: TEST_CSRF,
5070
+ approve: "yes",
5071
+ client_id: reg.client.clientId,
5072
+ redirect_uri: "https://app.example/cb",
5073
+ response_type: "code",
5074
+ // Asking for "work" which exists on the hub but is NOT in
5075
+ // bob's assigned list.
5076
+ scope: "vault:work:read",
5077
+ code_challenge: challenge,
5078
+ code_challenge_method: "S256",
5079
+ });
5080
+ const consentRes = await handleAuthorizePost(
5081
+ db,
5082
+ new Request(`${ISSUER}/oauth/authorize`, {
5083
+ method: "POST",
5084
+ body: consentForm,
5085
+ headers: {
5086
+ "content-type": "application/x-www-form-urlencoded",
5087
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
5088
+ },
5089
+ }),
5090
+ { issuer: ISSUER, loadServicesManifest: () => multiVaultManifest },
5091
+ );
5092
+ expect(consentRes.status).toBe(400);
5093
+ const body = await consentRes.text();
5094
+ expect(body).toContain("vault_scope_mismatch");
5095
+ } finally {
5096
+ cleanup();
5097
+ }
5098
+ });
5099
+
4921
5100
  test("non-admin user (assigned_vault set) sees the locked picker with admin-managed note", async () => {
4922
5101
  const { db, cleanup } = await makeDb();
4923
5102
  try {
4924
5103
  const admin = await createUser(db, "admin-aaron", "pw");
4925
5104
  const bob = await createUser(db, "bob", "pw", {
4926
5105
  allowMulti: true,
4927
- assignedVault: "default",
5106
+ assignedVaults: ["default"],
4928
5107
  });
4929
5108
  void admin;
4930
5109
  const session = createSession(db, { userId: bob.id });
@@ -4974,7 +5153,12 @@ describe("handleAuthorizeGet — resolved scope display (approval-UX rc.19)", ()
4974
5153
  test("non-admin user (assigned_vault set) sees vault:<assigned>:read on consent, not raw vault:read", async () => {
4975
5154
  const { db, cleanup } = await makeDb();
4976
5155
  try {
4977
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
5156
+ const admin = await createUser(db, "admin-aaron", "pw");
5157
+ void admin;
5158
+ const bob = await createUser(db, "bob", "pw", {
5159
+ allowMulti: true,
5160
+ assignedVaults: ["default"],
5161
+ });
4978
5162
  const session = createSession(db, { userId: bob.id });
4979
5163
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4980
5164
  const { challenge } = makePkce();
@@ -5050,7 +5234,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
5050
5234
  test("non-admin happy path: token carries vault_scope=[assigned] and narrowed scope", async () => {
5051
5235
  const { db, cleanup } = await makeDb();
5052
5236
  try {
5053
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
5237
+ const admin = await createUser(db, "admin-aaron", "pw");
5238
+ void admin;
5239
+ const bob = await createUser(db, "bob", "pw", {
5240
+ allowMulti: true,
5241
+ assignedVaults: ["default"],
5242
+ });
5054
5243
  const session = createSession(db, { userId: bob.id });
5055
5244
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5056
5245
  const { verifier, challenge } = makePkce();
@@ -5164,7 +5353,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
5164
5353
  test("non-admin with disagreeing vault_pick → 400 vault_scope_mismatch", async () => {
5165
5354
  const { db, cleanup } = await makeDb();
5166
5355
  try {
5167
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
5356
+ const admin = await createUser(db, "admin-aaron", "pw");
5357
+ void admin;
5358
+ const bob = await createUser(db, "bob", "pw", {
5359
+ allowMulti: true,
5360
+ assignedVaults: ["default"],
5361
+ });
5168
5362
  const session = createSession(db, { userId: bob.id });
5169
5363
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5170
5364
  const { challenge } = makePkce();
@@ -5223,7 +5417,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
5223
5417
  test("non-admin requesting named scope for the wrong vault → 400 vault_scope_mismatch", async () => {
5224
5418
  const { db, cleanup } = await makeDb();
5225
5419
  try {
5226
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
5420
+ const admin = await createUser(db, "admin-aaron", "pw");
5421
+ void admin;
5422
+ const bob = await createUser(db, "bob", "pw", {
5423
+ allowMulti: true,
5424
+ assignedVaults: ["default"],
5425
+ });
5227
5426
  const session = createSession(db, { userId: bob.id });
5228
5427
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5229
5428
  const { challenge } = makePkce();
@@ -5263,7 +5462,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
5263
5462
  test("non-admin requesting named scope for the assigned vault → happy path", async () => {
5264
5463
  const { db, cleanup } = await makeDb();
5265
5464
  try {
5266
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
5465
+ const admin = await createUser(db, "admin-aaron", "pw");
5466
+ void admin;
5467
+ const bob = await createUser(db, "bob", "pw", {
5468
+ allowMulti: true,
5469
+ assignedVaults: ["default"],
5470
+ });
5267
5471
  const session = createSession(db, { userId: bob.id });
5268
5472
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5269
5473
  const { verifier, challenge } = makePkce();
@@ -5320,7 +5524,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
5320
5524
  test("refresh flow re-derives vault_scope from current assigned_vault", async () => {
5321
5525
  const { db, cleanup } = await makeDb();
5322
5526
  try {
5323
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
5527
+ const admin = await createUser(db, "admin-aaron", "pw");
5528
+ void admin;
5529
+ const bob = await createUser(db, "bob", "pw", {
5530
+ allowMulti: true,
5531
+ assignedVaults: ["default"],
5532
+ });
5324
5533
  const session = createSession(db, { userId: bob.id });
5325
5534
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5326
5535
  const { verifier, challenge } = makePkce();
@@ -5407,7 +5616,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
5407
5616
  test("refresh flow picks up a mid-session assigned_vault change", async () => {
5408
5617
  const { db, cleanup } = await makeDb();
5409
5618
  try {
5410
- const bob = await createUser(db, "bob", "pw", { assignedVault: "vault-a" });
5619
+ const admin = await createUser(db, "admin-aaron", "pw");
5620
+ void admin;
5621
+ const bob = await createUser(db, "bob", "pw", {
5622
+ allowMulti: true,
5623
+ assignedVaults: ["vault-a"],
5624
+ });
5411
5625
  const session = createSession(db, { userId: bob.id });
5412
5626
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5413
5627
  const { verifier, challenge } = makePkce();
@@ -5477,12 +5691,15 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
5477
5691
  expect(initial.payload.vault_scope).toEqual(["vault-a"]);
5478
5692
  expect(initial.payload.scope).toBe("vault:vault-a:read");
5479
5693
 
5480
- // Step 2: admin updates bob's assigned_vault to vault-b. Direct UPDATE
5481
- // because Phase 1 has no PATCH endpoint; same effect a future admin
5482
- // path would have. The refresh path reads the live row at mint time
5483
- // (`vaultScopeForUser`), so the next refresh should pick up the new
5484
- // value.
5485
- db.prepare("UPDATE users SET assigned_vault = ? WHERE id = ?").run("vault-b", bob.id);
5694
+ // Step 2: admin updates bob's vault assignments to ["vault-b"].
5695
+ // Direct DB writes against user_vaults same effect as the PATCH
5696
+ // /api/users/:id/vaults endpoint. The refresh path reads the live
5697
+ // row at mint time (`vaultScopeForUser`), so the next refresh
5698
+ // should pick up the new value.
5699
+ db.prepare("DELETE FROM user_vaults WHERE user_id = ?").run(bob.id);
5700
+ db.prepare(
5701
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'write', ?)",
5702
+ ).run(bob.id, "vault-b", new Date().toISOString());
5486
5703
 
5487
5704
  // Step 3: refresh the token. vault_scope should be ["vault-b"] (the
5488
5705
  // new live value); the `scope` claim stays narrowed to the original
@@ -5982,7 +6199,12 @@ describe("handleAuthorizeGet — stale assigned_vault surfaces banner (hub#284)"
5982
6199
  test("unnamed vault scope + stale assignment → banner + no-vaults picker + disabled Approve", async () => {
5983
6200
  const { db, cleanup } = await makeDb();
5984
6201
  try {
5985
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6202
+ const admin = await createUser(db, "admin-aaron", "pw");
6203
+ void admin;
6204
+ const bob = await createUser(db, "bob", "pw", {
6205
+ allowMulti: true,
6206
+ assignedVaults: ["default"],
6207
+ });
5986
6208
  const session = createSession(db, { userId: bob.id });
5987
6209
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5988
6210
  const { challenge } = makePkce();
@@ -6026,7 +6248,12 @@ describe("handleAuthorizeGet — stale assigned_vault surfaces banner (hub#284)"
6026
6248
  test("named vault scope targeting stale assignment → banner + disabled Approve", async () => {
6027
6249
  const { db, cleanup } = await makeDb();
6028
6250
  try {
6029
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6251
+ const admin = await createUser(db, "admin-aaron", "pw");
6252
+ void admin;
6253
+ const bob = await createUser(db, "bob", "pw", {
6254
+ allowMulti: true,
6255
+ assignedVaults: ["default"],
6256
+ });
6030
6257
  const session = createSession(db, { userId: bob.id });
6031
6258
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
6032
6259
  const { challenge } = makePkce();
@@ -6065,7 +6292,12 @@ describe("handleAuthorizeGet — stale assigned_vault surfaces banner (hub#284)"
6065
6292
  test("non-vault scope + stale assignment → informational banner + Approve stays enabled", async () => {
6066
6293
  const { db, cleanup } = await makeDb();
6067
6294
  try {
6068
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6295
+ const admin = await createUser(db, "admin-aaron", "pw");
6296
+ void admin;
6297
+ const bob = await createUser(db, "bob", "pw", {
6298
+ allowMulti: true,
6299
+ assignedVaults: ["default"],
6300
+ });
6069
6301
  const session = createSession(db, { userId: bob.id });
6070
6302
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
6071
6303
  const { challenge } = makePkce();
@@ -6143,7 +6375,12 @@ describe("handleAuthorizeGet — stale assigned_vault surfaces banner (hub#284)"
6143
6375
  test("user with intact assignment → no banner (pre-#284 happy path stays clean)", async () => {
6144
6376
  const { db, cleanup } = await makeDb();
6145
6377
  try {
6146
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6378
+ const admin = await createUser(db, "admin-aaron", "pw");
6379
+ void admin;
6380
+ const bob = await createUser(db, "bob", "pw", {
6381
+ allowMulti: true,
6382
+ assignedVaults: ["default"],
6383
+ });
6147
6384
  const session = createSession(db, { userId: bob.id });
6148
6385
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
6149
6386
  const { challenge } = makePkce();
@@ -6195,7 +6432,12 @@ describe("handleAuthorizePost — stale assigned_vault clean 400 (hub#284)", ()
6195
6432
  test("hand-crafted POST with stale vault_pick → 'Assigned vault was removed' 400 (not generic Unknown vault)", async () => {
6196
6433
  const { db, cleanup } = await makeDb();
6197
6434
  try {
6198
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6435
+ const admin = await createUser(db, "admin-aaron", "pw");
6436
+ void admin;
6437
+ const bob = await createUser(db, "bob", "pw", {
6438
+ allowMulti: true,
6439
+ assignedVaults: ["default"],
6440
+ });
6199
6441
  const session = createSession(db, { userId: bob.id });
6200
6442
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
6201
6443
  const { challenge } = makePkce();
@@ -6241,7 +6483,12 @@ describe("handleAuthorizePost — stale assigned_vault clean 400 (hub#284)", ()
6241
6483
  test("hand-crafted POST with vault_pick naming a never-existed vault → still hits generic Unknown vault 400", async () => {
6242
6484
  const { db, cleanup } = await makeDb();
6243
6485
  try {
6244
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6486
+ const admin = await createUser(db, "admin-aaron", "pw");
6487
+ void admin;
6488
+ const bob = await createUser(db, "bob", "pw", {
6489
+ allowMulti: true,
6490
+ assignedVaults: ["default"],
6491
+ });
6245
6492
  const session = createSession(db, { userId: bob.id });
6246
6493
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
6247
6494
  const { challenge } = makePkce();
@@ -6289,7 +6536,12 @@ describe("handleAuthorizePost — stale assigned_vault clean 400 (hub#284)", ()
6289
6536
  test("named scope vault:<stale>:read → POST refuses with 'Assigned vault was removed' 400", async () => {
6290
6537
  const { db, cleanup } = await makeDb();
6291
6538
  try {
6292
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6539
+ const admin = await createUser(db, "admin-aaron", "pw");
6540
+ void admin;
6541
+ const bob = await createUser(db, "bob", "pw", {
6542
+ allowMulti: true,
6543
+ assignedVaults: ["default"],
6544
+ });
6293
6545
  const session = createSession(db, { userId: bob.id });
6294
6546
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
6295
6547
  const { challenge } = makePkce();
@@ -6361,7 +6613,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
6361
6613
  // Post-fold the gate skips, the consent screen renders, banner shows.
6362
6614
  const { db, cleanup } = await makeDb();
6363
6615
  try {
6364
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6616
+ const admin = await createUser(db, "admin-aaron", "pw");
6617
+ void admin;
6618
+ const bob = await createUser(db, "bob", "pw", {
6619
+ allowMulti: true,
6620
+ assignedVaults: ["default"],
6621
+ });
6365
6622
  const session = createSession(db, { userId: bob.id });
6366
6623
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
6367
6624
  const { recordGrant } = await import("../grants.ts");
@@ -6409,7 +6666,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
6409
6666
  // consent screen renders.
6410
6667
  const { db, cleanup } = await makeDb();
6411
6668
  try {
6412
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6669
+ const admin = await createUser(db, "admin-aaron", "pw");
6670
+ void admin;
6671
+ const bob = await createUser(db, "bob", "pw", {
6672
+ allowMulti: true,
6673
+ assignedVaults: ["default"],
6674
+ });
6413
6675
  const session = createSession(db, { userId: bob.id });
6414
6676
  const reg = registerClient(db, {
6415
6677
  redirectUris: ["https://app.example/cb"],
@@ -6456,7 +6718,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
6456
6718
  // enabled — the user can still consent to scribe access.
6457
6719
  const { db, cleanup } = await makeDb();
6458
6720
  try {
6459
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6721
+ const admin = await createUser(db, "admin-aaron", "pw");
6722
+ void admin;
6723
+ const bob = await createUser(db, "bob", "pw", {
6724
+ allowMulti: true,
6725
+ assignedVaults: ["default"],
6726
+ });
6460
6727
  const session = createSession(db, { userId: bob.id });
6461
6728
  const reg = registerClient(db, {
6462
6729
  redirectUris: ["https://app.example/cb"],
@@ -6501,7 +6768,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
6501
6768
  // the fast-path so the fold doesn't break the existing UX.
6502
6769
  const { db, cleanup } = await makeDb();
6503
6770
  try {
6504
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6771
+ const admin = await createUser(db, "admin-aaron", "pw");
6772
+ void admin;
6773
+ const bob = await createUser(db, "bob", "pw", {
6774
+ allowMulti: true,
6775
+ assignedVaults: ["default"],
6776
+ });
6505
6777
  const session = createSession(db, { userId: bob.id });
6506
6778
  const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
6507
6779
  const { recordGrant } = await import("../grants.ts");
@@ -6543,7 +6815,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
6543
6815
  // non-admin vault scope, user's assignment is intact, gate fires.
6544
6816
  const { db, cleanup } = await makeDb();
6545
6817
  try {
6546
- const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
6818
+ const admin = await createUser(db, "admin-aaron", "pw");
6819
+ void admin;
6820
+ const bob = await createUser(db, "bob", "pw", {
6821
+ allowMulti: true,
6822
+ assignedVaults: ["default"],
6823
+ });
6547
6824
  const session = createSession(db, { userId: bob.id });
6548
6825
  const reg = registerClient(db, {
6549
6826
  redirectUris: ["https://app.example/cb"],
@@ -6776,3 +7053,757 @@ describe("handleAuthorizeGet — trust-by-client_name auto-approve (hub#409)", (
6776
7053
  }
6777
7054
  });
6778
7055
  });
7056
+
7057
+ // Phase 2 PR 2 reviewer fold (hub#429 reviewer): a non-admin user with
7058
+ // ZERO `user_vaults` rows is a known-but-not-yet-assigned posture. They can
7059
+ // sign in to /account/, change their password, and see the home page, but
7060
+ // they have no vaults to authorize against. The original Phase 2 PR 2 left
7061
+ // three OAuth privilege-escalation paths open:
7062
+ //
7063
+ // 1. Named scope consent submit: handleConsentSubmit's vault-validation
7064
+ // gates ran only when `isPinned = assignedVaults.length > 0`. Zero-
7065
+ // vault non-admin slipped past every gate, minted a token with the
7066
+ // admin "unrestricted" vault_scope sentinel ([]).
7067
+ //
7068
+ // 2. Same-hub auto-trust gate (hub#312): zero-vault non-admin satisfied
7069
+ // every condition (no admin scope, no unnamed verb, no stale-
7070
+ // assignment — length === 0 short-circuits the stale predicate).
7071
+ // Silently minted a token, no consent screen.
7072
+ //
7073
+ // 3. Unnamed-scope picker: the empty-`assignedVaults` branch of
7074
+ // `consentProps` rendered the FULL hub-wide vault list, letting a
7075
+ // zero-vault non-admin pick any vault on the hub.
7076
+ //
7077
+ // The fix gates all three paths. These tests pin each one + verify the
7078
+ // first-admin happy path (`vaultScopeForUser` returns [] for first admin
7079
+ // too, and that posture must NOT regress to "no access").
7080
+ describe("zero-vault non-admin privesc gate (hub#429 reviewer)", () => {
7081
+ test("named scope consent submit → blocked with vault_scope_mismatch (path 1)", async () => {
7082
+ const { db, cleanup } = await makeDb();
7083
+ try {
7084
+ // First admin exists so `bob` is a non-admin.
7085
+ await createUser(db, "admin-aaron", "pw");
7086
+ const bob = await createUser(db, "bob", "pw", {
7087
+ allowMulti: true,
7088
+ // No assigned vaults — the "known but not yet assigned" posture.
7089
+ });
7090
+ expect(bob.assignedVaults).toEqual([]);
7091
+ const session = createSession(db, { userId: bob.id });
7092
+ const reg = registerClient(db, {
7093
+ redirectUris: ["https://app.example/cb"],
7094
+ status: "approved",
7095
+ });
7096
+ const { challenge } = makePkce();
7097
+ // Hand-crafted POST naming a vault bob has no business consenting to.
7098
+ const form = new URLSearchParams({
7099
+ __action: "consent",
7100
+ __csrf: TEST_CSRF,
7101
+ approve: "yes",
7102
+ client_id: reg.client.clientId,
7103
+ redirect_uri: "https://app.example/cb",
7104
+ response_type: "code",
7105
+ scope: "vault:default:read",
7106
+ code_challenge: challenge,
7107
+ code_challenge_method: "S256",
7108
+ state: "zv-1",
7109
+ });
7110
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
7111
+ method: "POST",
7112
+ body: form,
7113
+ headers: {
7114
+ "content-type": "application/x-www-form-urlencoded",
7115
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
7116
+ },
7117
+ });
7118
+ const res = await handleAuthorizePost(db, req, {
7119
+ issuer: ISSUER,
7120
+ loadServicesManifest: fixtureLoadServicesManifest,
7121
+ });
7122
+ // NOT a 302 redirect (which would mean the auth code was issued).
7123
+ expect(res.status).not.toBe(302);
7124
+ expect(res.status).toBe(400);
7125
+ const html = await res.text();
7126
+ expect(html).toContain("vault_scope_mismatch");
7127
+ expect(html).toContain("No vaults assigned");
7128
+ } finally {
7129
+ cleanup();
7130
+ }
7131
+ });
7132
+
7133
+ test("same-hub auto-trust GET with named vault scope → consent shown, not silent grant (path 2)", async () => {
7134
+ const { db, cleanup } = await makeDb();
7135
+ try {
7136
+ await createUser(db, "admin-aaron", "pw");
7137
+ const bob = await createUser(db, "bob", "pw", {
7138
+ allowMulti: true,
7139
+ });
7140
+ const session = createSession(db, { userId: bob.id });
7141
+ // Same-hub client (DCR'd by the operator, sameHub=true).
7142
+ const reg = registerClient(db, {
7143
+ redirectUris: ["https://app.example/cb"],
7144
+ status: "approved",
7145
+ sameHub: true,
7146
+ });
7147
+ const { challenge } = makePkce();
7148
+ const req = new Request(
7149
+ authorizeUrl({
7150
+ client_id: reg.client.clientId,
7151
+ redirect_uri: "https://app.example/cb",
7152
+ response_type: "code",
7153
+ scope: "vault:default:read",
7154
+ code_challenge: challenge,
7155
+ code_challenge_method: "S256",
7156
+ state: "zv-2",
7157
+ }),
7158
+ {
7159
+ headers: {
7160
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7161
+ },
7162
+ },
7163
+ );
7164
+ const res = handleAuthorizeGet(db, req, {
7165
+ issuer: ISSUER,
7166
+ loadServicesManifest: fixtureLoadServicesManifest,
7167
+ });
7168
+ // Pre-fix: 302 with auth code (silent mint). Post-fix: 200 HTML
7169
+ // consent screen — falls through the same-hub gate to the consent
7170
+ // render.
7171
+ expect(res.status).toBe(200);
7172
+ expect(res.headers.get("content-type")).toContain("text/html");
7173
+ } finally {
7174
+ cleanup();
7175
+ }
7176
+ });
7177
+
7178
+ test("unnamed vault scope GET → empty-state picker with no-assignments copy, not hub-wide list (path 3)", async () => {
7179
+ const { db, cleanup } = await makeDb();
7180
+ try {
7181
+ await createUser(db, "admin-aaron", "pw");
7182
+ const bob = await createUser(db, "bob", "pw", {
7183
+ allowMulti: true,
7184
+ });
7185
+ const session = createSession(db, { userId: bob.id });
7186
+ const reg = registerClient(db, {
7187
+ redirectUris: ["https://app.example/cb"],
7188
+ status: "approved",
7189
+ });
7190
+ const { challenge } = makePkce();
7191
+ const req = new Request(
7192
+ authorizeUrl({
7193
+ client_id: reg.client.clientId,
7194
+ redirect_uri: "https://app.example/cb",
7195
+ response_type: "code",
7196
+ // Unnamed `vault:read` — needs the picker to narrow.
7197
+ scope: "vault:read",
7198
+ code_challenge: challenge,
7199
+ code_challenge_method: "S256",
7200
+ }),
7201
+ {
7202
+ headers: {
7203
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7204
+ },
7205
+ },
7206
+ );
7207
+ const res = handleAuthorizeGet(db, req, {
7208
+ issuer: ISSUER,
7209
+ // Manifest with TWO vaults — pre-fix the picker would render BOTH
7210
+ // as a free dropdown for bob (the "admin posture" empty-vaults
7211
+ // branch).
7212
+ loadServicesManifest: fixtureLoadServicesManifest,
7213
+ });
7214
+ expect(res.status).toBe(200);
7215
+ const html = await res.text();
7216
+ // No-assignments empty-state picker. Approve disabled.
7217
+ expect(html).toContain("you have no vaults assigned on this hub yet");
7218
+ expect(html).toContain("/admin/users");
7219
+ // NO hub-wide vault picker options rendered (the `default` and any
7220
+ // other vault from the fixture must not appear as radio options).
7221
+ expect(html).not.toMatch(/<input type="radio" name="vault_pick"/);
7222
+ // Approve button disabled.
7223
+ expect(html).toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
7224
+ } finally {
7225
+ cleanup();
7226
+ }
7227
+ });
7228
+
7229
+ test("token endpoint cannot mint with empty vault_scope for zero-vault non-admin (defense in depth, path 4)", async () => {
7230
+ // If the auth-code path is fully blocked above, the user can never
7231
+ // get an auth code in the first place — this verifies the auth-code
7232
+ // path stays blocked at the consent-submit boundary so no code is
7233
+ // issued (the token endpoint never sees one for this user posture).
7234
+ const { db, cleanup } = await makeDb();
7235
+ try {
7236
+ await createUser(db, "admin-aaron", "pw");
7237
+ const bob = await createUser(db, "bob", "pw", {
7238
+ allowMulti: true,
7239
+ });
7240
+ const session = createSession(db, { userId: bob.id });
7241
+ const reg = registerClient(db, {
7242
+ redirectUris: ["https://app.example/cb"],
7243
+ status: "approved",
7244
+ });
7245
+ const { challenge } = makePkce();
7246
+ const form = new URLSearchParams({
7247
+ __action: "consent",
7248
+ __csrf: TEST_CSRF,
7249
+ approve: "yes",
7250
+ client_id: reg.client.clientId,
7251
+ redirect_uri: "https://app.example/cb",
7252
+ response_type: "code",
7253
+ scope: "vault:default:read",
7254
+ code_challenge: challenge,
7255
+ code_challenge_method: "S256",
7256
+ });
7257
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
7258
+ method: "POST",
7259
+ body: form,
7260
+ headers: {
7261
+ "content-type": "application/x-www-form-urlencoded",
7262
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
7263
+ },
7264
+ });
7265
+ const res = await handleAuthorizePost(db, req, {
7266
+ issuer: ISSUER,
7267
+ loadServicesManifest: fixtureLoadServicesManifest,
7268
+ });
7269
+ // No 302 — no auth code redirect, no `code` param ever issued.
7270
+ expect(res.status).toBe(400);
7271
+ expect(res.headers.get("location")).toBeNull();
7272
+ // Belt-and-suspenders: `vaultScopeForUser` would return [] for
7273
+ // bob (the very sentinel the OAuth flow refuses to mint into a
7274
+ // token for non-admin users). Pin the helper's behavior so a
7275
+ // future change can't silently lift it to "include all vaults".
7276
+ expect(vaultScopeForUser(db, bob.id)).toEqual([]);
7277
+ } finally {
7278
+ cleanup();
7279
+ }
7280
+ });
7281
+
7282
+ test("stale grant survives setUserVaults([]) → skip-consent gate fires consent screen, not silent code (path 5)", async () => {
7283
+ // The 4th attack the prior fold didn't cover. Sequence:
7284
+ // 1. bob has assignedVaults=["default"] (legitimate).
7285
+ // 2. bob consents to a vault-scoped client → grants row recorded.
7286
+ // 3. Admin clears bob's assignments via setUserVaults(_, []).
7287
+ // 4. Grants table has no FK cascade from user_vaults, so the
7288
+ // grant row survives the assignment delete.
7289
+ // 5. bob re-hits /oauth/authorize?scope=vault:default:read for
7290
+ // the same client. Pre-fix: skip-consent gate fires
7291
+ // (isCoveredByGrant=true), silent 302 with auth code and
7292
+ // vault_scope=[] (the admin "unrestricted" sentinel).
7293
+ // Post-fix: userHasVaultPosture=false → fall through to
7294
+ // consent render where the zero-vault gate also refuses.
7295
+ const { db, cleanup } = await makeDb();
7296
+ try {
7297
+ await createUser(db, "admin-aaron", "pw");
7298
+ const bob = await createUser(db, "bob", "pw", {
7299
+ allowMulti: true,
7300
+ assignedVaults: ["default"],
7301
+ });
7302
+ expect(bob.assignedVaults).toEqual(["default"]);
7303
+ const session = createSession(db, { userId: bob.id });
7304
+ const reg = registerClient(db, {
7305
+ redirectUris: ["https://app.example/cb"],
7306
+ status: "approved",
7307
+ });
7308
+ // Record the grant while bob still has the assignment.
7309
+ recordGrant(db, bob.id, reg.client.clientId, ["vault:default:read"]);
7310
+ // Admin clears bob's assignments. Grant row is NOT cascaded.
7311
+ expect(setUserVaults(db, bob.id, [])).toBe(true);
7312
+ expect(findGrant(db, bob.id, reg.client.clientId)).not.toBeNull();
7313
+ const { challenge } = makePkce();
7314
+ const req = new Request(
7315
+ authorizeUrl({
7316
+ client_id: reg.client.clientId,
7317
+ redirect_uri: "https://app.example/cb",
7318
+ response_type: "code",
7319
+ scope: "vault:default:read",
7320
+ code_challenge: challenge,
7321
+ code_challenge_method: "S256",
7322
+ state: "stale-grant",
7323
+ }),
7324
+ {
7325
+ headers: {
7326
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7327
+ },
7328
+ },
7329
+ );
7330
+ const res = handleAuthorizeGet(db, req, {
7331
+ issuer: ISSUER,
7332
+ loadServicesManifest: fixtureLoadServicesManifest,
7333
+ });
7334
+ // Post-fix: 200 HTML consent screen, NOT 302 with code.
7335
+ expect(res.status).toBe(200);
7336
+ expect(res.headers.get("content-type")).toContain("text/html");
7337
+ expect(res.headers.get("location")).toBeNull();
7338
+ } finally {
7339
+ cleanup();
7340
+ }
7341
+ });
7342
+
7343
+ test("trust-by-client_name auto-promote → recursive re-entry hits skip-consent gate (path 6)", async () => {
7344
+ // The trust-by-client_name path (hub#409, ~line 554) recursively
7345
+ // calls handleAuthorizeGet after approveClient + recordGrant on
7346
+ // the fresh client_id. That recursive call now passes through
7347
+ // the skip-consent gate with our new userHasVaultPosture check.
7348
+ //
7349
+ // Sequence:
7350
+ // 1. bob has assignedVaults=["default"], consents to "claude-code"
7351
+ // (prior client_id) for vault:default:read.
7352
+ // 2. Admin clears bob's assignments.
7353
+ // 3. Claude re-DCRs a fresh "claude-code" with a new client_id
7354
+ // (status=pending).
7355
+ // 4. bob hits /oauth/authorize on the fresh client_id.
7356
+ // 5. Pre-fix: trust-by-client_name matches by name+scope,
7357
+ // approves the fresh client, records grant, recurses into
7358
+ // handleAuthorizeGet — which now finds a covering grant and
7359
+ // silently mints the auth code with vault_scope=[].
7360
+ // Post-fix: the recursive call's skip-consent gate sees
7361
+ // userHasVaultPosture=false and falls through to the consent
7362
+ // render. Same-hub gate also refuses (sameHub=false here, but
7363
+ // the posture check is the load-bearing constraint).
7364
+ const { db, cleanup } = await makeDb();
7365
+ try {
7366
+ await createUser(db, "admin-aaron", "pw");
7367
+ const bob = await createUser(db, "bob", "pw", {
7368
+ allowMulti: true,
7369
+ assignedVaults: ["default"],
7370
+ });
7371
+ const session = createSession(db, { userId: bob.id });
7372
+ // Prior approved client_name="claude-code" + grant.
7373
+ const prior = registerClient(db, {
7374
+ redirectUris: ["https://app.example/cb"],
7375
+ status: "approved",
7376
+ clientName: "claude-code",
7377
+ });
7378
+ recordGrant(db, bob.id, prior.client.clientId, ["vault:default:read"]);
7379
+ // Admin clears bob's assignments. Prior grant survives.
7380
+ expect(setUserVaults(db, bob.id, [])).toBe(true);
7381
+ // Fresh DCR — same client_name, fresh client_id, status=pending.
7382
+ const fresh = registerClient(db, {
7383
+ redirectUris: ["https://app.example/cb"],
7384
+ status: "pending",
7385
+ clientName: "claude-code",
7386
+ });
7387
+ const { challenge } = makePkce();
7388
+ const req = new Request(
7389
+ authorizeUrl({
7390
+ client_id: fresh.client.clientId,
7391
+ redirect_uri: "https://app.example/cb",
7392
+ response_type: "code",
7393
+ code_challenge: challenge,
7394
+ code_challenge_method: "S256",
7395
+ scope: "vault:default:read",
7396
+ state: "trust-by-name-zero",
7397
+ }),
7398
+ {
7399
+ headers: {
7400
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7401
+ origin: ISSUER,
7402
+ },
7403
+ },
7404
+ );
7405
+ const res = handleAuthorizeGet(db, req, {
7406
+ issuer: ISSUER,
7407
+ loadServicesManifest: fixtureLoadServicesManifest,
7408
+ });
7409
+ // Post-fix: 200 HTML consent screen, NOT 302 with code. The
7410
+ // fresh client_id IS approved (the auto-promote ran), and a
7411
+ // grant IS recorded — that's the design of hub#409. The
7412
+ // load-bearing assertion is that the recursive re-entry into
7413
+ // handleAuthorizeGet did NOT issue an auth code, because the
7414
+ // zero-vault posture failed our gate.
7415
+ expect(res.status).toBe(200);
7416
+ expect(res.headers.get("content-type")).toContain("text/html");
7417
+ expect(res.headers.get("location")).toBeNull();
7418
+ expect(getClient(db, fresh.client.clientId)?.status).toBe("approved");
7419
+ } finally {
7420
+ cleanup();
7421
+ }
7422
+ });
7423
+
7424
+ test("first admin with no vault assignments still gets unrestricted access (regression guard)", async () => {
7425
+ // First admin's `assignedVaults` is empty by design — that's the
7426
+ // admin "unrestricted" sentinel. The zero-vault gate must NOT
7427
+ // catch the first admin: they're not "non-admin with no
7428
+ // assignments," they're "admin posture, empty list is the signal."
7429
+ const { db, cleanup } = await makeDb();
7430
+ try {
7431
+ // Only one user — the first admin. No `user_vaults` rows.
7432
+ const admin = await createUser(db, "admin-aaron", "pw");
7433
+ expect(admin.assignedVaults).toEqual([]);
7434
+ const session = createSession(db, { userId: admin.id });
7435
+ const reg = registerClient(db, {
7436
+ redirectUris: ["https://app.example/cb"],
7437
+ status: "approved",
7438
+ sameHub: true,
7439
+ });
7440
+ const { challenge } = makePkce();
7441
+ const req = new Request(
7442
+ authorizeUrl({
7443
+ client_id: reg.client.clientId,
7444
+ redirect_uri: "https://app.example/cb",
7445
+ response_type: "code",
7446
+ scope: "vault:default:read",
7447
+ code_challenge: challenge,
7448
+ code_challenge_method: "S256",
7449
+ state: "first-admin-ok",
7450
+ }),
7451
+ {
7452
+ headers: {
7453
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7454
+ },
7455
+ },
7456
+ );
7457
+ const res = handleAuthorizeGet(db, req, {
7458
+ issuer: ISSUER,
7459
+ loadServicesManifest: fixtureLoadServicesManifest,
7460
+ });
7461
+ // Silent grant — same-hub auto-trust fires for the first admin
7462
+ // exactly as it did pre-fix.
7463
+ expect(res.status).toBe(302);
7464
+ const loc = new URL(res.headers.get("location") ?? "");
7465
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
7466
+ expect(loc.searchParams.get("code")?.length).toBeGreaterThan(20);
7467
+ expect(loc.searchParams.get("state")).toBe("first-admin-ok");
7468
+ } finally {
7469
+ cleanup();
7470
+ }
7471
+ });
7472
+ });
7473
+
7474
+ // RFC 8707 resource binding (fix #461). A friend connecting an MCP client to
7475
+ // ONE vault (`<origin>/vault/<name>/mcp`) must see ONLY that vault's scopes on
7476
+ // consent, and the minted token must carry the narrow, NAMED scope +
7477
+ // `aud=vault.<name>` — otherwise (a) the consent screen is scary (whole-hub
7478
+ // catalog) and (b) a current-line vault REJECTS the token via
7479
+ // `findBroadVaultScopes` (unnamed `vault:read` → `aud=vault` → 401).
7480
+ //
7481
+ // The deps thread `hubBoundOrigins` so the resource's origin is recognized as
7482
+ // one the hub fronts — same set the same-origin CSRF gate consults.
7483
+ describe("RFC 8707 resource binding — vault-bound MCP (fix #461)", () => {
7484
+ const RESOURCE_DEPS = {
7485
+ issuer: ISSUER,
7486
+ loadServicesManifest: () => MULTI_VAULT_MANIFEST,
7487
+ hubBoundOrigins: () => [ISSUER],
7488
+ };
7489
+
7490
+ // Two vaults on the hub so "narrow to ONE" is observable: a request bound to
7491
+ // `jon` must NOT surface `boulder`'s scopes nor the rest of the catalog.
7492
+ const MULTI_VAULT_MANIFEST: ServicesManifest = {
7493
+ services: [
7494
+ {
7495
+ name: "parachute-vault",
7496
+ port: 1940,
7497
+ paths: ["/vault/jon", "/vault/boulder"],
7498
+ health: "/health",
7499
+ version: "0.6.0",
7500
+ },
7501
+ {
7502
+ name: "parachute-scribe",
7503
+ port: 1943,
7504
+ paths: ["/scribe"],
7505
+ health: "/health",
7506
+ version: "0.6.0",
7507
+ },
7508
+ ],
7509
+ };
7510
+
7511
+ /**
7512
+ * Mirror of `parachute-vault/src/scopes.ts:findBroadVaultScopes` — the exact
7513
+ * predicate `authenticateHubJwt` runs to REJECT hub tokens. A token a
7514
+ * current-line vault accepts must (a) carry zero broad `vault:<verb>` scopes
7515
+ * and (b) name the vault in the audience. Inlined (vault is a separate
7516
+ * package, not a hub dep) so this hub test genuinely encodes vault's
7517
+ * contract — the cross-cutting half of the E2E gate.
7518
+ */
7519
+ function findBroadVaultScopes(granted: string[]): string[] {
7520
+ return granted.filter((s) => {
7521
+ const parts = s.split(":");
7522
+ return (
7523
+ parts.length === 2 &&
7524
+ parts[0] === "vault" &&
7525
+ ["read", "write", "admin"].includes(parts[1] ?? "")
7526
+ );
7527
+ });
7528
+ }
7529
+
7530
+ test("E2E GATE: DCR → /authorize?resource=…/vault/jon/mcp → consent → code → /token mints aud=vault.jon + NAMED narrow scopes that a current-line vault accepts", async () => {
7531
+ const { db, cleanup } = await makeDb();
7532
+ try {
7533
+ // --- the operator (first admin) signed into the hub ---
7534
+ const user = await createUser(db, "owner", "pw");
7535
+ const session = createSession(db, { userId: user.id });
7536
+ const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`;
7537
+
7538
+ // --- DCR: register the friend's MCP client (plain pending, then
7539
+ // operator-approve so consent renders rather than same-hub
7540
+ // auto-trust skipping it) ---
7541
+ const regRes = await handleRegister(
7542
+ db,
7543
+ new Request(`${ISSUER}/oauth/register`, {
7544
+ method: "POST",
7545
+ body: JSON.stringify({
7546
+ redirect_uris: ["https://claude.ai/mcp/callback"],
7547
+ client_name: "claude-code",
7548
+ scope: "vault:read vault:write",
7549
+ }),
7550
+ headers: { "content-type": "application/json" },
7551
+ }),
7552
+ RESOURCE_DEPS,
7553
+ );
7554
+ expect(regRes.status).toBe(201);
7555
+ const reg = (await regRes.json()) as { client_id: string };
7556
+ approveClient(db, reg.client_id);
7557
+
7558
+ // --- /authorize WITH the RFC 8707 resource indicator. The client asks
7559
+ // for UNNAMED vault:read/write but names the jon MCP resource. ---
7560
+ const { verifier, challenge } = makePkce();
7561
+ const authReq = new Request(
7562
+ authorizeUrl({
7563
+ client_id: reg.client_id,
7564
+ redirect_uri: "https://claude.ai/mcp/callback",
7565
+ response_type: "code",
7566
+ code_challenge: challenge,
7567
+ code_challenge_method: "S256",
7568
+ scope: "vault:read vault:write",
7569
+ resource: `${ISSUER}/vault/jon/mcp`,
7570
+ }),
7571
+ { headers: { cookie } },
7572
+ );
7573
+ const authRes = handleAuthorizeGet(db, authReq, RESOURCE_DEPS);
7574
+ expect(authRes.status).toBe(200);
7575
+ const consentHtml = await authRes.text();
7576
+
7577
+ // Consent shows ONLY jon's scopes — narrowed + locked, no whole-hub
7578
+ // catalog, no dropdown to guess, no other vault.
7579
+ expect(consentHtml).toContain("vault:jon:read");
7580
+ expect(consentHtml).toContain("vault:jon:write");
7581
+ // Scary-scope guard: the friend never sees the rest of the catalog or
7582
+ // the other vault.
7583
+ expect(consentHtml).not.toContain("vault:boulder");
7584
+ expect(consentHtml).not.toContain("hub:admin");
7585
+ expect(consentHtml).not.toContain("scribe:");
7586
+ // No vault-picker dropdown — the vault is locked to jon by the resource.
7587
+ expect(consentHtml).not.toContain('name="vault_pick"');
7588
+
7589
+ // --- consent submit (approve). The hidden inputs already carry the
7590
+ // narrowed named scopes; the POST path re-narrows defensively. ---
7591
+ const consentForm = new URLSearchParams({
7592
+ __action: "consent",
7593
+ __csrf: TEST_CSRF,
7594
+ approve: "yes",
7595
+ client_id: reg.client_id,
7596
+ redirect_uri: "https://claude.ai/mcp/callback",
7597
+ response_type: "code",
7598
+ scope: "vault:jon:read vault:jon:write",
7599
+ code_challenge: challenge,
7600
+ code_challenge_method: "S256",
7601
+ resource: `${ISSUER}/vault/jon/mcp`,
7602
+ });
7603
+ const consentRes = await handleAuthorizePost(
7604
+ db,
7605
+ new Request(`${ISSUER}/oauth/authorize`, {
7606
+ method: "POST",
7607
+ body: consentForm,
7608
+ headers: { "content-type": "application/x-www-form-urlencoded", cookie },
7609
+ }),
7610
+ RESOURCE_DEPS,
7611
+ );
7612
+ expect(consentRes.status).toBe(302);
7613
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
7614
+ expect(code).toBeTruthy();
7615
+
7616
+ // --- /token exchange ---
7617
+ const tokenRes = await handleToken(
7618
+ db,
7619
+ new Request(`${ISSUER}/oauth/token`, {
7620
+ method: "POST",
7621
+ body: new URLSearchParams({
7622
+ grant_type: "authorization_code",
7623
+ code: code ?? "",
7624
+ client_id: reg.client_id,
7625
+ redirect_uri: "https://claude.ai/mcp/callback",
7626
+ code_verifier: verifier,
7627
+ }),
7628
+ headers: { "content-type": "application/x-www-form-urlencoded" },
7629
+ }),
7630
+ RESOURCE_DEPS,
7631
+ );
7632
+ expect(tokenRes.status).toBe(200);
7633
+ const tok = (await tokenRes.json()) as { access_token: string; scope: string };
7634
+
7635
+ // Wire-level scope: NAMED + narrow — NOT the catalog, NOT unnamed.
7636
+ expect(tok.scope).toBe("vault:jon:read vault:jon:write");
7637
+
7638
+ // --- minted access token claims ---
7639
+ const { payload } = await validateAccessToken(db, tok.access_token, ISSUER);
7640
+ expect(payload.aud).toBe("vault.jon"); // resource-bound audience (RFC 8707)
7641
+ expect(payload.scope).toBe("vault:jon:read vault:jon:write");
7642
+ expect(payload.iss).toBe(ISSUER);
7643
+
7644
+ // --- CROSS-CUTTING: the token shape a current-line vault REQUIRES.
7645
+ // vault's `authenticateHubJwt` runs `findBroadVaultScopes` (reject
7646
+ // any unnamed vault verb) + audience strict-check `vault.<name>`.
7647
+ const grantedScopes = (payload.scope as string).split(" ");
7648
+ expect(findBroadVaultScopes(grantedScopes)).toEqual([]); // no broad-scope rejection
7649
+ expect(payload.aud).toBe("vault.jon"); // matches the URL-derived vault name at /vault/jon/mcp
7650
+ // Every granted scope is the named form vault accepts.
7651
+ for (const s of grantedScopes) {
7652
+ expect(s).toMatch(/^vault:jon:(read|write)$/);
7653
+ }
7654
+ } finally {
7655
+ cleanup();
7656
+ }
7657
+ });
7658
+
7659
+ test("resource → consent narrows to the named vault (no whole-hub catalog, picker locked)", async () => {
7660
+ const { db, cleanup } = await makeDb();
7661
+ try {
7662
+ const user = await createUser(db, "owner", "pw");
7663
+ const session = createSession(db, { userId: user.id });
7664
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
7665
+ const { challenge } = makePkce();
7666
+ const res = handleAuthorizeGet(
7667
+ db,
7668
+ new Request(
7669
+ authorizeUrl({
7670
+ client_id: reg.client.clientId,
7671
+ redirect_uri: "https://app.example/cb",
7672
+ response_type: "code",
7673
+ code_challenge: challenge,
7674
+ code_challenge_method: "S256",
7675
+ scope: "vault:read",
7676
+ resource: `${ISSUER}/vault/jon/.well-known/oauth-protected-resource`,
7677
+ }),
7678
+ {
7679
+ headers: {
7680
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7681
+ },
7682
+ },
7683
+ ),
7684
+ RESOURCE_DEPS,
7685
+ );
7686
+ expect(res.status).toBe(200);
7687
+ const html = await res.text();
7688
+ // PRM-URL form of the resource resolves to jon too.
7689
+ expect(html).toContain("vault:jon:read");
7690
+ expect(html).not.toContain('name="vault_pick"');
7691
+ expect(html).not.toContain("vault:boulder");
7692
+ } finally {
7693
+ cleanup();
7694
+ }
7695
+ });
7696
+
7697
+ test("no resource param → behavior unchanged (unnamed scope still renders the picker)", async () => {
7698
+ const { db, cleanup } = await makeDb();
7699
+ try {
7700
+ const user = await createUser(db, "owner", "pw");
7701
+ const session = createSession(db, { userId: user.id });
7702
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
7703
+ const { challenge } = makePkce();
7704
+ const res = handleAuthorizeGet(
7705
+ db,
7706
+ new Request(
7707
+ authorizeUrl({
7708
+ client_id: reg.client.clientId,
7709
+ redirect_uri: "https://app.example/cb",
7710
+ response_type: "code",
7711
+ code_challenge: challenge,
7712
+ code_challenge_method: "S256",
7713
+ scope: "vault:read",
7714
+ // no resource param
7715
+ }),
7716
+ {
7717
+ headers: {
7718
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7719
+ },
7720
+ },
7721
+ ),
7722
+ RESOURCE_DEPS,
7723
+ );
7724
+ expect(res.status).toBe(200);
7725
+ const html = await res.text();
7726
+ // Manual-pick path preserved: picker renders, vault not pre-narrowed.
7727
+ expect(html).toContain("Pick a vault");
7728
+ expect(html).toContain('name="vault_pick"');
7729
+ } finally {
7730
+ cleanup();
7731
+ }
7732
+ });
7733
+
7734
+ test("off-origin resource → ignored (no narrowing; manual-pick path)", async () => {
7735
+ const { db, cleanup } = await makeDb();
7736
+ try {
7737
+ const user = await createUser(db, "owner", "pw");
7738
+ const session = createSession(db, { userId: user.id });
7739
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
7740
+ const { challenge } = makePkce();
7741
+ const res = handleAuthorizeGet(
7742
+ db,
7743
+ new Request(
7744
+ authorizeUrl({
7745
+ client_id: reg.client.clientId,
7746
+ redirect_uri: "https://app.example/cb",
7747
+ response_type: "code",
7748
+ code_challenge: challenge,
7749
+ code_challenge_method: "S256",
7750
+ scope: "vault:read",
7751
+ resource: "https://evil.example/vault/jon/mcp",
7752
+ }),
7753
+ {
7754
+ headers: {
7755
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7756
+ },
7757
+ },
7758
+ ),
7759
+ RESOURCE_DEPS,
7760
+ );
7761
+ expect(res.status).toBe(200);
7762
+ const html = await res.text();
7763
+ // An attacker-controlled resource origin can't drive narrowing — the
7764
+ // flow falls back to the normal manual picker.
7765
+ expect(html).toContain("Pick a vault");
7766
+ expect(html).not.toContain("vault:jon:read");
7767
+ } finally {
7768
+ cleanup();
7769
+ }
7770
+ });
7771
+
7772
+ test("non-requestable scope still refused even with a resource (vault:admin → vault:jon:admin blocked)", async () => {
7773
+ const { db, cleanup } = await makeDb();
7774
+ try {
7775
+ const user = await createUser(db, "owner", "pw");
7776
+ const session = createSession(db, { userId: user.id });
7777
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
7778
+ const { challenge } = makePkce();
7779
+ const res = handleAuthorizeGet(
7780
+ db,
7781
+ new Request(
7782
+ authorizeUrl({
7783
+ client_id: reg.client.clientId,
7784
+ redirect_uri: "https://app.example/cb",
7785
+ response_type: "code",
7786
+ code_challenge: challenge,
7787
+ code_challenge_method: "S256",
7788
+ scope: "vault:admin",
7789
+ resource: `${ISSUER}/vault/jon/mcp`,
7790
+ }),
7791
+ {
7792
+ headers: {
7793
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7794
+ },
7795
+ },
7796
+ ),
7797
+ RESOURCE_DEPS,
7798
+ );
7799
+ // Narrowing turns vault:admin → vault:jon:admin which is NON-requestable;
7800
+ // the gate rejects via redirect with invalid_scope.
7801
+ expect(res.status).toBe(302);
7802
+ const loc = res.headers.get("location") ?? "";
7803
+ expect(loc).toContain("error=invalid_scope");
7804
+ expect(loc).toContain("vault%3Ajon%3Aadmin");
7805
+ } finally {
7806
+ cleanup();
7807
+ }
7808
+ });
7809
+ });