@openparachute/hub 0.7.4-rc.2 → 0.7.4-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 (75) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-lock.test.ts +7 -1
  5. package/src/__tests__/admin-vaults.test.ts +216 -10
  6. package/src/__tests__/api-account-2fa.test.ts +453 -0
  7. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  8. package/src/__tests__/api-mint-token.test.ts +75 -0
  9. package/src/__tests__/api-modules.test.ts +143 -0
  10. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  11. package/src/__tests__/auth.test.ts +336 -0
  12. package/src/__tests__/clients.test.ts +326 -8
  13. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-server.test.ts +127 -5
  18. package/src/__tests__/hub-settings.test.ts +188 -0
  19. package/src/__tests__/init.test.ts +153 -0
  20. package/src/__tests__/jwt-sign.test.ts +27 -0
  21. package/src/__tests__/managed-unit.test.ts +62 -0
  22. package/src/__tests__/oauth-handlers.test.ts +626 -0
  23. package/src/__tests__/oauth-ui.test.ts +107 -1
  24. package/src/__tests__/scope-explanations.test.ts +19 -0
  25. package/src/__tests__/setup-gate.test.ts +111 -3
  26. package/src/__tests__/setup-wizard.test.ts +124 -7
  27. package/src/__tests__/supervisor.test.ts +25 -0
  28. package/src/__tests__/vault-names.test.ts +32 -3
  29. package/src/__tests__/vault-remove.test.ts +40 -19
  30. package/src/__tests__/well-known.test.ts +37 -2
  31. package/src/admin-agent-grants.ts +16 -1
  32. package/src/admin-auth.ts +13 -4
  33. package/src/admin-clients.ts +66 -5
  34. package/src/admin-grants.ts +11 -2
  35. package/src/admin-vaults.ts +77 -27
  36. package/src/api-account-2fa.ts +395 -0
  37. package/src/api-admin-lock.ts +7 -0
  38. package/src/api-hub-upgrade.ts +52 -4
  39. package/src/api-hub.ts +10 -1
  40. package/src/api-invites.ts +18 -3
  41. package/src/api-me.ts +11 -2
  42. package/src/api-mint-token.ts +16 -1
  43. package/src/api-modules.ts +119 -1
  44. package/src/api-revoke-token.ts +14 -1
  45. package/src/api-settings-hub-origin.ts +14 -1
  46. package/src/api-settings-root-redirect.ts +201 -0
  47. package/src/api-tokens.ts +14 -1
  48. package/src/api-users.ts +15 -6
  49. package/src/api-vault-caps.ts +11 -2
  50. package/src/cli.ts +56 -5
  51. package/src/clients.ts +178 -0
  52. package/src/commands/auth.ts +263 -1
  53. package/src/commands/doctor.ts +1250 -0
  54. package/src/commands/hub.ts +102 -1
  55. package/src/commands/init.ts +108 -0
  56. package/src/commands/vault-remove.ts +16 -24
  57. package/src/cors.ts +7 -3
  58. package/src/help.ts +65 -1
  59. package/src/hub-db.ts +14 -0
  60. package/src/hub-server.ts +173 -25
  61. package/src/hub-settings.ts +163 -1
  62. package/src/jwt-sign.ts +25 -6
  63. package/src/managed-unit.ts +30 -1
  64. package/src/oauth-handlers.ts +110 -7
  65. package/src/oauth-ui.ts +174 -0
  66. package/src/rate-limit.ts +28 -0
  67. package/src/scope-explanations.ts +2 -1
  68. package/src/setup-wizard.ts +40 -21
  69. package/src/supervisor.ts +46 -2
  70. package/src/vault-names.ts +15 -4
  71. package/src/well-known.ts +10 -1
  72. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  73. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  74. package/web/ui/dist/index.html +2 -2
  75. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -353,6 +353,88 @@ describe("handleAuthorizeGet", () => {
353
353
  }
354
354
  });
355
355
 
356
+ // hub#314 — same-hub vs external trust marker reaches the rendered consent
357
+ // screen via `client.sameHub`. An unnamed `vault:read` request from a
358
+ // same-hub client falls through to consent (the auto-approve gate requires
359
+ // `!hasUnnamedVault`), so we can assert the marker on the GET render.
360
+ test("renders the EXTERNAL trust marker for a third-party DCR client", async () => {
361
+ const { db, cleanup } = await makeDb();
362
+ try {
363
+ const user = await createUser(db, "owner", "pw");
364
+ const session = createSession(db, { userId: user.id });
365
+ // registerClient defaults sameHub:false → external (third-party DCR).
366
+ const reg = registerClient(db, {
367
+ redirectUris: ["https://app.example/cb"],
368
+ clientName: "ThirdPartyApp",
369
+ });
370
+ const { challenge } = makePkce();
371
+ const req = new Request(
372
+ authorizeUrl({
373
+ client_id: reg.client.clientId,
374
+ redirect_uri: "https://app.example/cb",
375
+ response_type: "code",
376
+ code_challenge: challenge,
377
+ code_challenge_method: "S256",
378
+ scope: "vault:read",
379
+ }),
380
+ {
381
+ headers: {
382
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
383
+ },
384
+ },
385
+ );
386
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
387
+ expect(res.status).toBe(200);
388
+ const html = await res.text();
389
+ // Element form — the `.badge-trust-*` CSS class names are always in the
390
+ // inlined <style>; the rendered ELEMENT only appears when the marker fires.
391
+ expect(html).toContain('class="badge badge-trust-external"');
392
+ expect(html).toContain("third-party app that registered itself");
393
+ expect(html).not.toContain('class="badge badge-trust-same-hub"');
394
+ } finally {
395
+ cleanup();
396
+ }
397
+ });
398
+
399
+ test("renders the FIRST-PARTY trust marker for a same-hub client", async () => {
400
+ const { db, cleanup } = await makeDb();
401
+ try {
402
+ const user = await createUser(db, "owner", "pw");
403
+ const session = createSession(db, { userId: user.id });
404
+ const reg = registerClient(db, {
405
+ redirectUris: ["https://app.example/cb"],
406
+ clientName: "FirstPartyApp",
407
+ sameHub: true,
408
+ });
409
+ const { challenge } = makePkce();
410
+ const req = new Request(
411
+ authorizeUrl({
412
+ client_id: reg.client.clientId,
413
+ redirect_uri: "https://app.example/cb",
414
+ response_type: "code",
415
+ code_challenge: challenge,
416
+ code_challenge_method: "S256",
417
+ // Unnamed vault verb → bypasses the same-hub auto-approve gate
418
+ // (`!hasUnnamedVault`) and falls through to the consent render.
419
+ scope: "vault:read",
420
+ }),
421
+ {
422
+ headers: {
423
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
424
+ },
425
+ },
426
+ );
427
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
428
+ expect(res.status).toBe(200);
429
+ const html = await res.text();
430
+ expect(html).toContain('class="badge badge-trust-same-hub"');
431
+ expect(html).toContain("Registered through this hub");
432
+ expect(html).not.toContain('class="badge badge-trust-external"');
433
+ } finally {
434
+ cleanup();
435
+ }
436
+ });
437
+
356
438
  test("rejects unknown client_id with 400", async () => {
357
439
  const { db, cleanup } = await makeDb();
358
440
  try {
@@ -2914,6 +2996,75 @@ describe("handleToken — full OAuth dance", () => {
2914
2996
  notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
2915
2997
  });
2916
2998
  });
2999
+
3000
+ // closes #478 — an empty-paths vault row ("installed but no servable
3001
+ // instance"; vault's self-register emits `paths: []` at zero vaults) must
3002
+ // NOT synthesize a phantom `vault` / `vault:default` entry pointing at
3003
+ // root in the /oauth/token services catalog. Pre-fix the `["/"]` fallback
3004
+ // resolved `vaultInstanceNameFor(name, "/")` → "default" and advertised
3005
+ // `${ISSUER}/` as the vault. Mirrors the skip in well-known.ts /
3006
+ // admin-vaults.ts / vault-names.ts.
3007
+ test("empty-paths vault row produces NO catalog entry — no phantom default (#478)", () => {
3008
+ const emptyPathsManifest: ServicesManifest = {
3009
+ services: [
3010
+ {
3011
+ name: "parachute-vault",
3012
+ port: 1940,
3013
+ paths: [],
3014
+ health: "/vault/default/health",
3015
+ version: "0.7.0",
3016
+ },
3017
+ ],
3018
+ };
3019
+ // Broad scope: would have leaked `vault` + `vault:default` at `/`.
3020
+ expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:read"])).toEqual({});
3021
+ // Per-vault-narrowed scope for the phantom name: also nothing.
3022
+ expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:default:read"])).toEqual({});
3023
+ });
3024
+
3025
+ test("positive control: a vault row WITH a path is still cataloged (#478)", () => {
3026
+ const realManifest: ServicesManifest = {
3027
+ services: [
3028
+ {
3029
+ name: "parachute-vault",
3030
+ port: 1940,
3031
+ paths: ["/vault/default"],
3032
+ health: "/vault/default/health",
3033
+ version: "0.7.0",
3034
+ },
3035
+ ],
3036
+ };
3037
+ expect(buildServicesCatalog(realManifest, ISSUER, ["vault:read"])).toEqual({
3038
+ vault: { url: `${ISSUER}/vault/default`, version: "0.7.0" },
3039
+ });
3040
+ });
3041
+
3042
+ test("empty-paths vault row alongside a real vault: only the real one is cataloged (#478)", () => {
3043
+ // A transitional manifest could carry both a path-less bare row and a
3044
+ // real instance row. The empty-paths row must contribute nothing; the
3045
+ // real vault is unaffected.
3046
+ const mixedManifest: ServicesManifest = {
3047
+ services: [
3048
+ {
3049
+ name: "parachute-vault",
3050
+ port: 1940,
3051
+ paths: [],
3052
+ health: "/vault/default/health",
3053
+ version: "0.7.0",
3054
+ },
3055
+ {
3056
+ name: "parachute-vault-work",
3057
+ port: 1941,
3058
+ paths: ["/vault/work"],
3059
+ health: "/vault/work/health",
3060
+ version: "0.7.0",
3061
+ },
3062
+ ],
3063
+ };
3064
+ expect(buildServicesCatalog(mixedManifest, ISSUER, ["vault:read"])).toEqual({
3065
+ vault: { url: `${ISSUER}/vault/work`, version: "0.7.0" },
3066
+ });
3067
+ });
2917
3068
  });
2918
3069
  });
2919
3070
 
@@ -9899,3 +10050,478 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
9899
10050
  }
9900
10051
  });
9901
10052
  });
10053
+
10054
+ // ───────────────────────────────────────────────────────────────────────────
10055
+ // hub#689 — owner-on-own-vault VERB SELECTOR. The consent screen offers an
10056
+ // owner of the picked vault a read/write/admin selector (pre-selected to
10057
+ // admin) when the client requested an UNNAMED `vault:read`/`vault:write`. On
10058
+ // submit, the owner's selection widens the unnamed verb to the chosen level
10059
+ // on the picked vault — BEFORE `capScopesToUserAuthority`, which remains the
10060
+ // backstop. The selector value is an UNTRUSTED hint: the handler re-derives
10061
+ // ownership of the picked vault server-side, and the cap drops any verb the
10062
+ // user doesn't actually hold.
10063
+ // ───────────────────────────────────────────────────────────────────────────
10064
+ describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
10065
+ const TTL_S = Math.floor(SESSION_TTL_MS / 1000);
10066
+ const SEL_MANIFEST: ServicesManifest = {
10067
+ services: [
10068
+ {
10069
+ name: "parachute-vault",
10070
+ port: 1940,
10071
+ paths: ["/vault/work", "/vault/other"],
10072
+ health: "/health",
10073
+ version: "0.7.0",
10074
+ },
10075
+ ],
10076
+ };
10077
+ const selDeps = {
10078
+ issuer: ISSUER,
10079
+ loadServicesManifest: () => SEL_MANIFEST,
10080
+ hubBoundOrigins: () => [ISSUER],
10081
+ };
10082
+
10083
+ async function submitConsent(
10084
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
10085
+ sessionId: string,
10086
+ clientId: string,
10087
+ scope: string,
10088
+ challenge: string,
10089
+ extra: Record<string, string> = {},
10090
+ ): Promise<Response> {
10091
+ const form = new URLSearchParams({
10092
+ __action: "consent",
10093
+ __csrf: TEST_CSRF,
10094
+ approve: "yes",
10095
+ client_id: clientId,
10096
+ redirect_uri: "https://app.example/cb",
10097
+ response_type: "code",
10098
+ scope,
10099
+ code_challenge: challenge,
10100
+ code_challenge_method: "S256",
10101
+ ...extra,
10102
+ });
10103
+ return handleAuthorizePost(
10104
+ db,
10105
+ new Request(`${ISSUER}/oauth/authorize`, {
10106
+ method: "POST",
10107
+ body: form,
10108
+ headers: {
10109
+ "content-type": "application/x-www-form-urlencoded",
10110
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(sessionId, TTL_S)}`,
10111
+ },
10112
+ }),
10113
+ selDeps,
10114
+ );
10115
+ }
10116
+
10117
+ async function redeemScope(
10118
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
10119
+ code: string,
10120
+ clientId: string,
10121
+ verifier: string,
10122
+ ): Promise<string> {
10123
+ const tokenRes = await handleToken(
10124
+ db,
10125
+ new Request(`${ISSUER}/oauth/token`, {
10126
+ method: "POST",
10127
+ body: new URLSearchParams({
10128
+ grant_type: "authorization_code",
10129
+ code,
10130
+ client_id: clientId,
10131
+ redirect_uri: "https://app.example/cb",
10132
+ code_verifier: verifier,
10133
+ }),
10134
+ headers: { "content-type": "application/x-www-form-urlencoded" },
10135
+ }),
10136
+ selDeps,
10137
+ );
10138
+ expect(tokenRes.status).toBe(200);
10139
+ const body = (await tokenRes.json()) as { scope: string };
10140
+ return body.scope;
10141
+ }
10142
+
10143
+ // GET render: owner of the picked vault sees the selector. A non-admin
10144
+ // assigned to exactly one vault gets the locked picker → the selector is
10145
+ // offered (they hold admin on their assigned vault).
10146
+ test("selector RENDERED for an owner (assigned user) of the picked vault", async () => {
10147
+ const { db, cleanup } = await makeDb();
10148
+ try {
10149
+ await createUser(db, "owner", "pw"); // consumes the admin slot
10150
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
10151
+ setUserVaults(db, friend.id, ["work"]); // role=write → holds admin on "work"
10152
+ const session = createSession(db, { userId: friend.id });
10153
+ const reg = registerClient(db, {
10154
+ redirectUris: ["https://app.example/cb"],
10155
+ status: "approved",
10156
+ });
10157
+ const { challenge } = makePkce();
10158
+ const res = handleAuthorizeGet(
10159
+ db,
10160
+ new Request(
10161
+ authorizeUrl({
10162
+ client_id: reg.client.clientId,
10163
+ redirect_uri: "https://app.example/cb",
10164
+ response_type: "code",
10165
+ code_challenge: challenge,
10166
+ code_challenge_method: "S256",
10167
+ scope: "vault:read",
10168
+ }),
10169
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10170
+ ),
10171
+ selDeps,
10172
+ );
10173
+ expect(res.status).toBe(200);
10174
+ const html = await res.text();
10175
+ expect(html).toContain("Access level");
10176
+ expect(html).toContain('name="verb_select"');
10177
+ // Admin pre-selected, still visibly flagged.
10178
+ expect(html).toMatch(/name="verb_select" value="admin"[^>]*checked/);
10179
+ expect(html).toContain("badge-admin");
10180
+ } finally {
10181
+ cleanup();
10182
+ }
10183
+ });
10184
+
10185
+ // GET render: a read-only-assigned user (role=read → holds read, NOT admin)
10186
+ // does NOT see the selector — offering admin pre-selected would promise an
10187
+ // upgrade the cap silently demotes. They hold the vault but not admin on it.
10188
+ test("selector NOT rendered for a read-only-assigned user (holds read, not admin)", async () => {
10189
+ const { db, cleanup } = await makeDb();
10190
+ try {
10191
+ await createUser(db, "owner", "pw");
10192
+ const reader = await createUser(db, "reader", "pw", { allowMulti: true });
10193
+ // role=read directly (setUserVaults hardcodes write) → holds read only.
10194
+ db.prepare(
10195
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
10196
+ ).run(reader.id, "work", new Date().toISOString());
10197
+ const session = createSession(db, { userId: reader.id });
10198
+ const reg = registerClient(db, {
10199
+ redirectUris: ["https://app.example/cb"],
10200
+ status: "approved",
10201
+ });
10202
+ const { challenge } = makePkce();
10203
+ const res = handleAuthorizeGet(
10204
+ db,
10205
+ new Request(
10206
+ authorizeUrl({
10207
+ client_id: reg.client.clientId,
10208
+ redirect_uri: "https://app.example/cb",
10209
+ response_type: "code",
10210
+ code_challenge: challenge,
10211
+ code_challenge_method: "S256",
10212
+ scope: "vault:read",
10213
+ }),
10214
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10215
+ ),
10216
+ selDeps,
10217
+ );
10218
+ expect(res.status).toBe(200);
10219
+ const html = await res.text();
10220
+ expect(html).not.toContain("Access level");
10221
+ expect(html).not.toContain('name="verb_select"');
10222
+ } finally {
10223
+ cleanup();
10224
+ }
10225
+ });
10226
+
10227
+ // GET render (hub#703, folded into hub#314): a user with MIXED authority —
10228
+ // admin on vault A (role=write → holds admin) but only read on vault B
10229
+ // (direct INSERT role=read) — does NOT see the selector. The user could pick
10230
+ // either vault, but doesn't own (hold admin on) EVERY pickable vault, so the
10231
+ // `userHoldsAdminOnPickable` predicate (`assignedVaults.every(v => verbs
10232
+ // includes "admin")`) fails on vault B and the selector is suppressed. The
10233
+ // suppression logic already ships + is correct (oauth-handlers.ts ~2963 +
10234
+ // ~1226); this test closes the coverage gap with no code change.
10235
+ test("selector NOT rendered for a mixed-authority user (admin on A, read-only on B)", async () => {
10236
+ const { db, cleanup } = await makeDb();
10237
+ try {
10238
+ await createUser(db, "owner", "pw");
10239
+ const mixed = await createUser(db, "mixed", "pw", { allowMulti: true });
10240
+ // Vault A ("work"): role=write → vaultVerbsForRole maps to [read,write,
10241
+ // admin], so the user holds admin on A. (setUserVaults hardcodes write.)
10242
+ setUserVaults(db, mixed.id, ["work"]);
10243
+ // Vault B ("other"): direct INSERT role=read → holds read only, NOT admin.
10244
+ // setUserVaults DELETEs first, so this INSERT must come after it to keep A.
10245
+ db.prepare(
10246
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
10247
+ ).run(mixed.id, "other", new Date().toISOString());
10248
+ const session = createSession(db, { userId: mixed.id });
10249
+ const reg = registerClient(db, {
10250
+ redirectUris: ["https://app.example/cb"],
10251
+ status: "approved",
10252
+ });
10253
+ const { challenge } = makePkce();
10254
+ const res = handleAuthorizeGet(
10255
+ db,
10256
+ new Request(
10257
+ authorizeUrl({
10258
+ client_id: reg.client.clientId,
10259
+ redirect_uri: "https://app.example/cb",
10260
+ response_type: "code",
10261
+ code_challenge: challenge,
10262
+ code_challenge_method: "S256",
10263
+ scope: "vault:read",
10264
+ }),
10265
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10266
+ ),
10267
+ selDeps,
10268
+ );
10269
+ expect(res.status).toBe(200);
10270
+ const html = await res.text();
10271
+ // Suppressed: the `.every(v => verbs includes "admin")` check fails on B.
10272
+ expect(html).not.toContain("Access level");
10273
+ expect(html).not.toContain('name="verb_select"');
10274
+ // Sanity: the multi-vault picker DID render (two assigned vaults), so the
10275
+ // suppression is specifically the verb selector, not the whole flow.
10276
+ expect(html).toContain('name="vault_pick" value="work"');
10277
+ expect(html).toContain('name="vault_pick" value="other"');
10278
+ } finally {
10279
+ cleanup();
10280
+ }
10281
+ });
10282
+
10283
+ // GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
10284
+ // see the selector — they can't authorize a vault scope at all.
10285
+ test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
10286
+ const { db, cleanup } = await makeDb();
10287
+ try {
10288
+ await createUser(db, "owner", "pw");
10289
+ const stranger = await createUser(db, "stranger", "pw", { allowMulti: true });
10290
+ // No setUserVaults → zero assignments → not an owner of anything.
10291
+ const session = createSession(db, { userId: stranger.id });
10292
+ const reg = registerClient(db, {
10293
+ redirectUris: ["https://app.example/cb"],
10294
+ status: "approved",
10295
+ });
10296
+ const { challenge } = makePkce();
10297
+ const res = handleAuthorizeGet(
10298
+ db,
10299
+ new Request(
10300
+ authorizeUrl({
10301
+ client_id: reg.client.clientId,
10302
+ redirect_uri: "https://app.example/cb",
10303
+ response_type: "code",
10304
+ code_challenge: challenge,
10305
+ code_challenge_method: "S256",
10306
+ scope: "vault:read",
10307
+ }),
10308
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10309
+ ),
10310
+ selDeps,
10311
+ );
10312
+ expect(res.status).toBe(200);
10313
+ const html = await res.text();
10314
+ expect(html).not.toContain("Access level");
10315
+ expect(html).not.toContain('name="verb_select"');
10316
+ } finally {
10317
+ cleanup();
10318
+ }
10319
+ });
10320
+
10321
+ // Submit: owner (first admin) + client requested unnamed vault:read + selects
10322
+ // admin → minted vault:<picked>:admin. THE core bug fix.
10323
+ test("owner selects admin on an unnamed vault:read → minted vault:work:admin", async () => {
10324
+ const { db, cleanup } = await makeDb();
10325
+ try {
10326
+ const owner = await createUser(db, "owner", "pw"); // first admin
10327
+ const session = createSession(db, { userId: owner.id });
10328
+ const reg = registerClient(db, {
10329
+ redirectUris: ["https://app.example/cb"],
10330
+ status: "approved",
10331
+ });
10332
+ const { verifier, challenge } = makePkce();
10333
+ const res = await submitConsent(
10334
+ db,
10335
+ session.id,
10336
+ reg.client.clientId,
10337
+ "vault:read",
10338
+ challenge,
10339
+ {
10340
+ vault_pick: "work",
10341
+ verb_select: "admin",
10342
+ },
10343
+ );
10344
+ expect(res.status).toBe(302);
10345
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10346
+ expect(code).toBeTruthy();
10347
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10348
+ expect(scope).toBe("vault:work:admin");
10349
+ } finally {
10350
+ cleanup();
10351
+ }
10352
+ });
10353
+
10354
+ // Submit: owner selects write → vault:<picked>:write.
10355
+ test("owner selects write on an unnamed vault:read → minted vault:work:write", async () => {
10356
+ const { db, cleanup } = await makeDb();
10357
+ try {
10358
+ const owner = await createUser(db, "owner", "pw");
10359
+ const session = createSession(db, { userId: owner.id });
10360
+ const reg = registerClient(db, {
10361
+ redirectUris: ["https://app.example/cb"],
10362
+ status: "approved",
10363
+ });
10364
+ const { verifier, challenge } = makePkce();
10365
+ const res = await submitConsent(
10366
+ db,
10367
+ session.id,
10368
+ reg.client.clientId,
10369
+ "vault:read",
10370
+ challenge,
10371
+ {
10372
+ vault_pick: "work",
10373
+ verb_select: "write",
10374
+ },
10375
+ );
10376
+ expect(res.status).toBe(302);
10377
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10378
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10379
+ expect(scope).toBe("vault:work:write");
10380
+ } finally {
10381
+ cleanup();
10382
+ }
10383
+ });
10384
+
10385
+ // Submit: owner DOWNGRADES — selects read on an unnamed vault:write → read.
10386
+ test("owner selects read on an unnamed vault:write → minted vault:work:read (downgrade)", async () => {
10387
+ const { db, cleanup } = await makeDb();
10388
+ try {
10389
+ const owner = await createUser(db, "owner", "pw");
10390
+ const session = createSession(db, { userId: owner.id });
10391
+ const reg = registerClient(db, {
10392
+ redirectUris: ["https://app.example/cb"],
10393
+ status: "approved",
10394
+ });
10395
+ const { verifier, challenge } = makePkce();
10396
+ const res = await submitConsent(
10397
+ db,
10398
+ session.id,
10399
+ reg.client.clientId,
10400
+ "vault:write",
10401
+ challenge,
10402
+ {
10403
+ vault_pick: "work",
10404
+ verb_select: "read",
10405
+ },
10406
+ );
10407
+ expect(res.status).toBe(302);
10408
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10409
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10410
+ expect(scope).toBe("vault:work:read");
10411
+ } finally {
10412
+ cleanup();
10413
+ }
10414
+ });
10415
+
10416
+ // SECURITY: a non-owner who holds only READ on the picked vault forges
10417
+ // verb_select=admin → the server re-derives ownership (no admin held) and
10418
+ // refuses to widen; the cap is the backstop. Minted scope is capped to
10419
+ // their actual authority (read), NOT elevated to admin.
10420
+ test("SECURITY: read-only-assigned non-owner forges verb_select=admin → minted vault:work:read, NOT admin", async () => {
10421
+ const { db, cleanup } = await makeDb();
10422
+ try {
10423
+ await createUser(db, "owner", "pw"); // first admin = owner
10424
+ const reader = await createUser(db, "reader", "pw", { allowMulti: true });
10425
+ // Assign "work" with role=read directly → holds read only (NOT admin).
10426
+ // setUserVaults hardcodes role=write, so insert the read row by hand to
10427
+ // construct the read-only-authority case the cap must defend.
10428
+ db.prepare(
10429
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
10430
+ ).run(reader.id, "work", new Date().toISOString());
10431
+ const session = createSession(db, { userId: reader.id });
10432
+ const reg = registerClient(db, {
10433
+ redirectUris: ["https://app.example/cb"],
10434
+ status: "approved",
10435
+ });
10436
+ const { verifier, challenge } = makePkce();
10437
+ const res = await submitConsent(
10438
+ db,
10439
+ session.id,
10440
+ reg.client.clientId,
10441
+ "vault:read",
10442
+ challenge,
10443
+ {
10444
+ vault_pick: "work",
10445
+ verb_select: "admin", // FORGED — reader holds read only
10446
+ },
10447
+ );
10448
+ // Read survives (held); admin never rides along.
10449
+ expect(res.status).toBe(302);
10450
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10451
+ expect(code).toBeTruthy();
10452
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10453
+ expect(scope).toBe("vault:work:read");
10454
+ expect(scope).not.toContain("admin");
10455
+ // And the recorded grant carries no admin verb either.
10456
+ const grant = findGrant(db, reader.id, reg.client.clientId);
10457
+ expect(grant?.scopes ?? []).not.toContain("vault:work:admin");
10458
+ } finally {
10459
+ cleanup();
10460
+ }
10461
+ });
10462
+
10463
+ // SECURITY: a non-admin assigned to "work" picks/forges admin on "other"
10464
+ // (a vault outside their assignment) — the assignment-mismatch gate refuses
10465
+ // before widening ever runs. No token minted.
10466
+ test("SECURITY: forged verb_select=admin against an UNASSIGNED vault → 400 (mismatch gate, no mint)", async () => {
10467
+ const { db, cleanup } = await makeDb();
10468
+ try {
10469
+ await createUser(db, "owner", "pw");
10470
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
10471
+ setUserVaults(db, friend.id, ["work"]); // assigned "work" only
10472
+ const session = createSession(db, { userId: friend.id });
10473
+ const reg = registerClient(db, {
10474
+ redirectUris: ["https://app.example/cb"],
10475
+ status: "approved",
10476
+ });
10477
+ const { challenge } = makePkce();
10478
+ const res = await submitConsent(
10479
+ db,
10480
+ session.id,
10481
+ reg.client.clientId,
10482
+ "vault:read",
10483
+ challenge,
10484
+ {
10485
+ vault_pick: "other", // NOT in friend's assignment
10486
+ verb_select: "admin",
10487
+ },
10488
+ );
10489
+ expect(res.status).toBe(400);
10490
+ expect(findGrant(db, friend.id, reg.client.clientId)).toBeNull();
10491
+ } finally {
10492
+ cleanup();
10493
+ }
10494
+ });
10495
+
10496
+ // Owner without a verb_select field (older form / JS-off) → unchanged
10497
+ // behavior: the unnamed verb narrows as-requested (vault:read → work:read).
10498
+ test("owner with NO verb_select → unchanged narrowing (vault:read → vault:work:read)", async () => {
10499
+ const { db, cleanup } = await makeDb();
10500
+ try {
10501
+ const owner = await createUser(db, "owner", "pw");
10502
+ const session = createSession(db, { userId: owner.id });
10503
+ const reg = registerClient(db, {
10504
+ redirectUris: ["https://app.example/cb"],
10505
+ status: "approved",
10506
+ });
10507
+ const { verifier, challenge } = makePkce();
10508
+ const res = await submitConsent(
10509
+ db,
10510
+ session.id,
10511
+ reg.client.clientId,
10512
+ "vault:read",
10513
+ challenge,
10514
+ {
10515
+ vault_pick: "work",
10516
+ // no verb_select
10517
+ },
10518
+ );
10519
+ expect(res.status).toBe(302);
10520
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
10521
+ const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
10522
+ expect(scope).toBe("vault:work:read");
10523
+ } finally {
10524
+ cleanup();
10525
+ }
10526
+ });
10527
+ });