@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
@@ -308,7 +308,7 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
308
308
  const body = (await resp.json()) as { error: string; error_description: string };
309
309
  expect(body.error).toBe("invalid_scope");
310
310
  expect(body.error_description).toContain("parachute:host:auth");
311
- expect(body.error_description).toContain("not requestable");
311
+ expect(body.error_description).toContain("not grantable");
312
312
  } finally {
313
313
  db.close();
314
314
  }
@@ -342,19 +342,104 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
342
342
  }
343
343
  });
344
344
 
345
- test("400 invalid_scope when minting vault:<name>:admin (regex non-requestable)", async () => {
345
+ // De-escalation (PR-A): a `parachute:host:admin` bearer MAY mint a
346
+ // vault-pinned admin token — host:admin already implies box-wide vault
347
+ // administration, so narrowing it to one vault is a privilege reduction.
348
+ // This is the canonical headless path replacing deprecated pvt_* (vault#282).
349
+ test("200 when host:admin bearer mints vault:<name>:admin (de-escalation)", async () => {
346
350
  const h = makeHarness();
347
351
  try {
348
352
  const { db, userId } = await bootstrap(h.dir);
349
353
  try {
354
+ // The default admin operator scope-set carries parachute:host:admin.
350
355
  const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
351
356
  const resp = await handleApiMintToken(
352
357
  jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
353
358
  { db, issuer: ISSUER },
354
359
  );
355
- expect(resp.status).toBe(400);
356
- const body = (await resp.json()) as { error: string };
357
- expect(body.error).toBe("invalid_scope");
360
+ expect(resp.status).toBe(200);
361
+ const body = (await resp.json()) as { jti: string; token: string; scope: string };
362
+ expect(body.scope).toBe("vault:work:admin");
363
+ const validated = await validateAccessToken(db, body.token, ISSUER);
364
+ // Audience must be the per-vault resource so vault's strict-equality
365
+ // audience check accepts it.
366
+ expect(validated.payload.aud).toBe("vault.work");
367
+ expect(validated.payload.scope).toBe("vault:work:admin");
368
+ // vault_scope is pinned to the named vault (defense-in-depth — the
369
+ // token can ONLY be used against `work`), matching the canonical
370
+ // session-path mint in admin-vault-admin-token.ts.
371
+ expect(validated.payload.vault_scope).toEqual(["work"]);
372
+ // Registry row written → revocable like any operator mint.
373
+ const row = db
374
+ .query<{ jti: string }, [string]>("SELECT jti FROM tokens WHERE jti = ?")
375
+ .get(body.jti);
376
+ expect(row).not.toBeNull();
377
+ } finally {
378
+ db.close();
379
+ }
380
+ } finally {
381
+ h.cleanup();
382
+ }
383
+ });
384
+
385
+ // Single-consent change (2026-05-29) — INTENTIONAL canGrant widening. Once
386
+ // `vault:<name>:admin` became requestable (`isNonRequestableScope` dropped
387
+ // the per-vault-admin clause), canGrant rule 1 (`!isNonRequestableScope` +
388
+ // bearer holds `parachute:host:auth`) now ADMITS it. A `parachute:host:auth`
389
+ // bearer is an on-box operator credential, so minting a vault-pinned admin
390
+ // from it is a de-escalation, not an escalation. Pinned here so the widening
391
+ // is deliberate, not an accidental regression.
392
+ test("200 when auth-only bearer mints vault:<name>:admin (intentional canGrant widening)", async () => {
393
+ const h = makeHarness();
394
+ try {
395
+ const { db, userId } = await bootstrap(h.dir);
396
+ try {
397
+ const op = await mintOperatorToken(db, userId, {
398
+ issuer: ISSUER,
399
+ scopeSet: "auth",
400
+ });
401
+ const resp = await handleApiMintToken(
402
+ jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
403
+ { db, issuer: ISSUER },
404
+ );
405
+ expect(resp.status).toBe(200);
406
+ const body = (await resp.json()) as { scope: string; token: string };
407
+ expect(body.scope).toBe("vault:work:admin");
408
+ const validated = await validateAccessToken(db, body.token, ISSUER);
409
+ expect(validated.payload.aud).toBe("vault.work");
410
+ expect(validated.payload.scope).toBe("vault:work:admin");
411
+ } finally {
412
+ db.close();
413
+ }
414
+ } finally {
415
+ h.cleanup();
416
+ }
417
+ });
418
+
419
+ // A bare `vault:admin` (no vault name) is NOT a per-vault admin scope —
420
+ // the de-escalation exception only covers `vault:<name>:admin`. It isn't
421
+ // in the non-requestable set either, so it's treated as an ordinary
422
+ // (unnamed) scope and mints — but with the `vault` fallback audience, not
423
+ // a per-vault one. Pinned so a future regex loosening can't silently let
424
+ // an unnamed admin through the named-vault exemption.
425
+ test("bare vault:admin (no name) is not caught by the de-escalation exemption", async () => {
426
+ const h = makeHarness();
427
+ try {
428
+ const { db, userId } = await bootstrap(h.dir);
429
+ try {
430
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
431
+ const resp = await handleApiMintToken(
432
+ jsonRequest({ scope: "vault:admin" }, { authorization: `Bearer ${op.token}` }),
433
+ { db, issuer: ISSUER },
434
+ );
435
+ // `vault:admin` isn't a per-vault admin scope and isn't in the
436
+ // non-requestable set, so it mints as an ordinary scope. The point
437
+ // of this test is that it does NOT get a per-vault audience/pin.
438
+ expect(resp.status).toBe(200);
439
+ const body = (await resp.json()) as { token: string };
440
+ const validated = await validateAccessToken(db, body.token, ISSUER);
441
+ expect(validated.payload.aud).toBe("vault");
442
+ expect(validated.payload.vault_scope).toEqual([]);
358
443
  } finally {
359
444
  db.close();
360
445
  }
@@ -363,6 +448,609 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
363
448
  }
364
449
  });
365
450
 
451
+ // ── Capability attenuation (hub PR — subsumes hub#449's PR-A carve-out) ──
452
+ //
453
+ // A `vault:<name>:admin` bearer may mint a token whose authority is a
454
+ // SUBSET of its own: same-vault read/write/admin. It can NEVER mint a
455
+ // cross-vault scope or any host-level authority. We hand-mint the bearer
456
+ // via signAccessToken (scope `vault:work:admin`, aud `vault.work`,
457
+ // vaultScope `["work"]`) — mirroring how the SPA / mcp-install obtain one.
458
+ async function mintVaultAdminBearer(
459
+ db: ReturnType<typeof openHubDb>,
460
+ userId: string,
461
+ vault: string,
462
+ ): Promise<string> {
463
+ const signed = await signAccessToken(db, {
464
+ sub: userId,
465
+ scopes: [`vault:${vault}:admin`],
466
+ audience: `vault.${vault}`,
467
+ clientId: "parachute-hub",
468
+ issuer: ISSUER,
469
+ ttlSeconds: 3600,
470
+ vaultScope: [vault],
471
+ });
472
+ return signed.token;
473
+ }
474
+
475
+ describe("capability attenuation — vault:<name>:admin bearer", () => {
476
+ test("mints vault:work:write → 200, aud=vault.work, vault_scope=[work]", async () => {
477
+ const h = makeHarness();
478
+ try {
479
+ const { db, userId } = await bootstrap(h.dir);
480
+ try {
481
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
482
+ const resp = await handleApiMintToken(
483
+ jsonRequest({ scope: "vault:work:write" }, { authorization: `Bearer ${bearer}` }),
484
+ { db, issuer: ISSUER },
485
+ );
486
+ expect(resp.status).toBe(200);
487
+ const body = (await resp.json()) as { token: string };
488
+ const validated = await validateAccessToken(db, body.token, ISSUER);
489
+ expect(validated.payload.aud).toBe("vault.work");
490
+ expect(validated.payload.scope).toBe("vault:work:write");
491
+ expect(validated.payload.vault_scope).toEqual(["work"]);
492
+ } finally {
493
+ db.close();
494
+ }
495
+ } finally {
496
+ h.cleanup();
497
+ }
498
+ });
499
+
500
+ test("mints vault:work:read → 200", async () => {
501
+ const h = makeHarness();
502
+ try {
503
+ const { db, userId } = await bootstrap(h.dir);
504
+ try {
505
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
506
+ const resp = await handleApiMintToken(
507
+ jsonRequest({ scope: "vault:work:read" }, { authorization: `Bearer ${bearer}` }),
508
+ { db, issuer: ISSUER },
509
+ );
510
+ expect(resp.status).toBe(200);
511
+ const body = (await resp.json()) as { token: string };
512
+ const validated = await validateAccessToken(db, body.token, ISSUER);
513
+ expect(validated.payload.vault_scope).toEqual(["work"]);
514
+ } finally {
515
+ db.close();
516
+ }
517
+ } finally {
518
+ h.cleanup();
519
+ }
520
+ });
521
+
522
+ test("mints vault:work:admin → 200 (same-level allowed)", async () => {
523
+ const h = makeHarness();
524
+ try {
525
+ const { db, userId } = await bootstrap(h.dir);
526
+ try {
527
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
528
+ const resp = await handleApiMintToken(
529
+ jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${bearer}` }),
530
+ { db, issuer: ISSUER },
531
+ );
532
+ expect(resp.status).toBe(200);
533
+ const body = (await resp.json()) as { token: string };
534
+ const validated = await validateAccessToken(db, body.token, ISSUER);
535
+ expect(validated.payload.aud).toBe("vault.work");
536
+ expect(validated.payload.scope).toBe("vault:work:admin");
537
+ expect(validated.payload.vault_scope).toEqual(["work"]);
538
+ } finally {
539
+ db.close();
540
+ }
541
+ } finally {
542
+ h.cleanup();
543
+ }
544
+ });
545
+
546
+ // THE CRUX: cross-vault is the security boundary. A work-admin bearer
547
+ // MUST NOT be able to mint authority over any other vault.
548
+ test("mints vault:other:write → 400 (cross-vault BLOCKED)", async () => {
549
+ const h = makeHarness();
550
+ try {
551
+ const { db, userId } = await bootstrap(h.dir);
552
+ try {
553
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
554
+ const resp = await handleApiMintToken(
555
+ jsonRequest({ scope: "vault:other:write" }, { authorization: `Bearer ${bearer}` }),
556
+ { db, issuer: ISSUER },
557
+ );
558
+ expect(resp.status).toBe(400);
559
+ const body = (await resp.json()) as { error: string; error_description: string };
560
+ expect(body.error).toBe("invalid_scope");
561
+ expect(body.error_description).toContain("vault:other:write");
562
+ } finally {
563
+ db.close();
564
+ }
565
+ } finally {
566
+ h.cleanup();
567
+ }
568
+ });
569
+
570
+ test("mints vault:other:admin → 400 (cross-vault BLOCKED)", async () => {
571
+ const h = makeHarness();
572
+ try {
573
+ const { db, userId } = await bootstrap(h.dir);
574
+ try {
575
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
576
+ const resp = await handleApiMintToken(
577
+ jsonRequest({ scope: "vault:other:admin" }, { authorization: `Bearer ${bearer}` }),
578
+ { db, issuer: ISSUER },
579
+ );
580
+ expect(resp.status).toBe(400);
581
+ const body = (await resp.json()) as { error: string };
582
+ expect(body.error).toBe("invalid_scope");
583
+ } finally {
584
+ db.close();
585
+ }
586
+ } finally {
587
+ h.cleanup();
588
+ }
589
+ });
590
+
591
+ test("mints parachute:host:auth → 400 (no escalation to host authority)", async () => {
592
+ const h = makeHarness();
593
+ try {
594
+ const { db, userId } = await bootstrap(h.dir);
595
+ try {
596
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
597
+ const resp = await handleApiMintToken(
598
+ jsonRequest({ scope: "parachute:host:auth" }, { authorization: `Bearer ${bearer}` }),
599
+ { db, issuer: ISSUER },
600
+ );
601
+ expect(resp.status).toBe(400);
602
+ const body = (await resp.json()) as { error: string };
603
+ expect(body.error).toBe("invalid_scope");
604
+ } finally {
605
+ db.close();
606
+ }
607
+ } finally {
608
+ h.cleanup();
609
+ }
610
+ });
611
+
612
+ test("mints parachute:host:admin → 400 (no escalation to host authority)", async () => {
613
+ const h = makeHarness();
614
+ try {
615
+ const { db, userId } = await bootstrap(h.dir);
616
+ try {
617
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
618
+ const resp = await handleApiMintToken(
619
+ jsonRequest({ scope: "parachute:host:admin" }, { authorization: `Bearer ${bearer}` }),
620
+ { db, issuer: ISSUER },
621
+ );
622
+ expect(resp.status).toBe(400);
623
+ const body = (await resp.json()) as { error: string };
624
+ expect(body.error).toBe("invalid_scope");
625
+ } finally {
626
+ db.close();
627
+ }
628
+ } finally {
629
+ h.cleanup();
630
+ }
631
+ });
632
+
633
+ // No host:auth, and scribe:transcribe is not a vault:work scope → blocked.
634
+ test("mints scribe:transcribe → 400 (not a vault:work scope, no host:auth)", async () => {
635
+ const h = makeHarness();
636
+ try {
637
+ const { db, userId } = await bootstrap(h.dir);
638
+ try {
639
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
640
+ const resp = await handleApiMintToken(
641
+ jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${bearer}` }),
642
+ { db, issuer: ISSUER },
643
+ );
644
+ expect(resp.status).toBe(400);
645
+ const body = (await resp.json()) as { error: string };
646
+ expect(body.error).toBe("invalid_scope");
647
+ } finally {
648
+ db.close();
649
+ }
650
+ } finally {
651
+ h.cleanup();
652
+ }
653
+ });
654
+
655
+ test("multi-scope vault:work:read vault:work:write → 200, vault_scope=[work]", async () => {
656
+ const h = makeHarness();
657
+ try {
658
+ const { db, userId } = await bootstrap(h.dir);
659
+ try {
660
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
661
+ const resp = await handleApiMintToken(
662
+ jsonRequest(
663
+ { scope: "vault:work:read vault:work:write" },
664
+ { authorization: `Bearer ${bearer}` },
665
+ ),
666
+ { db, issuer: ISSUER },
667
+ );
668
+ expect(resp.status).toBe(200);
669
+ const body = (await resp.json()) as { token: string };
670
+ const validated = await validateAccessToken(db, body.token, ISSUER);
671
+ expect(validated.payload.vault_scope).toEqual(["work"]);
672
+ } finally {
673
+ db.close();
674
+ }
675
+ } finally {
676
+ h.cleanup();
677
+ }
678
+ });
679
+
680
+ // Realistic headless-runner shape: a bearer holding admin over MULTIPLE
681
+ // vaults composes rule 3 across them. Minting same-vault subsets of both
682
+ // succeeds; vault_scope collects every authorized vault name (order-
683
+ // insensitive). aud is first-wins (vault.work), which is fine — a
684
+ // multi-vault token only authenticates against its single aud, the pin is
685
+ // defense-in-depth.
686
+ test("multi-vault-admin bearer mints vault:work:read vault:other:read → 200, vault_scope=[work,other]", async () => {
687
+ const h = makeHarness();
688
+ try {
689
+ const { db, userId } = await bootstrap(h.dir);
690
+ try {
691
+ const bearer = await signAccessToken(db, {
692
+ sub: userId,
693
+ scopes: ["vault:work:admin", "vault:other:admin"],
694
+ audience: "vault.work",
695
+ clientId: "parachute-hub",
696
+ issuer: ISSUER,
697
+ ttlSeconds: 3600,
698
+ vaultScope: ["work", "other"],
699
+ });
700
+ const resp = await handleApiMintToken(
701
+ jsonRequest(
702
+ { scope: "vault:work:read vault:other:read" },
703
+ { authorization: `Bearer ${bearer.token}` },
704
+ ),
705
+ { db, issuer: ISSUER },
706
+ );
707
+ expect(resp.status).toBe(200);
708
+ const body = (await resp.json()) as { token: string };
709
+ const validated = await validateAccessToken(db, body.token, ISSUER);
710
+ expect(validated.payload.aud).toBe("vault.work");
711
+ const pin = validated.payload.vault_scope as string[];
712
+ expect(pin).toContain("work");
713
+ expect(pin).toContain("other");
714
+ expect(pin).toHaveLength(2);
715
+ } finally {
716
+ db.close();
717
+ }
718
+ } finally {
719
+ h.cleanup();
720
+ }
721
+ });
722
+
723
+ // One blocked scope rejects the whole request (no partial mint).
724
+ test("multi-scope vault:work:read vault:other:read → 400 (one blocked → all rejected)", async () => {
725
+ const h = makeHarness();
726
+ try {
727
+ const { db, userId } = await bootstrap(h.dir);
728
+ try {
729
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
730
+ const resp = await handleApiMintToken(
731
+ jsonRequest(
732
+ { scope: "vault:work:read vault:other:read" },
733
+ { authorization: `Bearer ${bearer}` },
734
+ ),
735
+ { db, issuer: ISSUER },
736
+ );
737
+ expect(resp.status).toBe(400);
738
+ const body = (await resp.json()) as { error: string; error_description: string };
739
+ expect(body.error).toBe("invalid_scope");
740
+ expect(body.error_description).toContain("vault:other:read");
741
+ } finally {
742
+ db.close();
743
+ }
744
+ } finally {
745
+ h.cleanup();
746
+ }
747
+ });
748
+ });
749
+
750
+ describe("capability attenuation — entry gate + regression", () => {
751
+ test("host:auth-only bearer mints vault:work:read → 200 (rule 1, preserved)", async () => {
752
+ const h = makeHarness();
753
+ try {
754
+ const { db, userId } = await bootstrap(h.dir);
755
+ try {
756
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
757
+ const resp = await handleApiMintToken(
758
+ jsonRequest({ scope: "vault:work:read" }, { authorization: `Bearer ${op.token}` }),
759
+ { db, issuer: ISSUER },
760
+ );
761
+ expect(resp.status).toBe(200);
762
+ const body = (await resp.json()) as { token: string };
763
+ const validated = await validateAccessToken(db, body.token, ISSUER);
764
+ // Pure host:auth requestable mint → no vault pin.
765
+ expect(validated.payload.vault_scope).toEqual([]);
766
+ } finally {
767
+ db.close();
768
+ }
769
+ } finally {
770
+ h.cleanup();
771
+ }
772
+ });
773
+
774
+ test("host:auth-only bearer mints vault:work:admin → 200 (single-consent: rule 1 now covers admin)", async () => {
775
+ // Single-consent change (2026-05-29): vault:<name>:admin is requestable
776
+ // now, so canGrant rule 1 admits it for a host:auth bearer. De-escalation
777
+ // from an on-box operator credential — intentional widening.
778
+ const h = makeHarness();
779
+ try {
780
+ const { db, userId } = await bootstrap(h.dir);
781
+ try {
782
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
783
+ const resp = await handleApiMintToken(
784
+ jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
785
+ { db, issuer: ISSUER },
786
+ );
787
+ expect(resp.status).toBe(200);
788
+ const body = (await resp.json()) as { scope: string };
789
+ expect(body.scope).toBe("vault:work:admin");
790
+ } finally {
791
+ db.close();
792
+ }
793
+ } finally {
794
+ h.cleanup();
795
+ }
796
+ });
797
+
798
+ test("host:admin bearer mints vault:work:admin → 200 (rule 2, PR-A preserved)", async () => {
799
+ const h = makeHarness();
800
+ try {
801
+ const { db, userId } = await bootstrap(h.dir);
802
+ try {
803
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
804
+ const resp = await handleApiMintToken(
805
+ jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
806
+ { db, issuer: ISSUER },
807
+ );
808
+ expect(resp.status).toBe(200);
809
+ const body = (await resp.json()) as { token: string };
810
+ const validated = await validateAccessToken(db, body.token, ISSUER);
811
+ expect(validated.payload.aud).toBe("vault.work");
812
+ expect(validated.payload.vault_scope).toEqual(["work"]);
813
+ } finally {
814
+ db.close();
815
+ }
816
+ } finally {
817
+ h.cleanup();
818
+ }
819
+ });
820
+
821
+ // Entry gate: a bearer with no host:* and no vault-admin holds no minting
822
+ // authority → 403 before any per-scope check.
823
+ test("403 entry gate when bearer holds no minting authority", async () => {
824
+ const h = makeHarness();
825
+ try {
826
+ const { db, userId } = await bootstrap(h.dir);
827
+ try {
828
+ const noAuthority = await signAccessToken(db, {
829
+ sub: userId,
830
+ scopes: ["hub:admin", "scribe:transcribe"],
831
+ audience: "hub",
832
+ clientId: "parachute-hub",
833
+ issuer: ISSUER,
834
+ ttlSeconds: 3600,
835
+ });
836
+ const resp = await handleApiMintToken(
837
+ jsonRequest(
838
+ { scope: "vault:work:read" },
839
+ { authorization: `Bearer ${noAuthority.token}` },
840
+ ),
841
+ { db, issuer: ISSUER },
842
+ );
843
+ expect(resp.status).toBe(403);
844
+ const body = (await resp.json()) as { error: string };
845
+ expect(body.error).toBe("insufficient_scope");
846
+ } finally {
847
+ db.close();
848
+ }
849
+ } finally {
850
+ h.cleanup();
851
+ }
852
+ });
853
+
854
+ // A read-only token used AS A BEARER is not minting authority → 403.
855
+ test("403 entry gate when bearer is a vault:work:read token (read is not minting authority)", async () => {
856
+ const h = makeHarness();
857
+ try {
858
+ const { db, userId } = await bootstrap(h.dir);
859
+ try {
860
+ const readOnly = await signAccessToken(db, {
861
+ sub: userId,
862
+ scopes: ["vault:work:read"],
863
+ audience: "vault.work",
864
+ clientId: "parachute-hub",
865
+ issuer: ISSUER,
866
+ ttlSeconds: 3600,
867
+ vaultScope: ["work"],
868
+ });
869
+ const resp = await handleApiMintToken(
870
+ jsonRequest(
871
+ { scope: "vault:work:read" },
872
+ { authorization: `Bearer ${readOnly.token}` },
873
+ ),
874
+ { db, issuer: ISSUER },
875
+ );
876
+ expect(resp.status).toBe(403);
877
+ const body = (await resp.json()) as { error: string };
878
+ expect(body.error).toBe("insufficient_scope");
879
+ } finally {
880
+ db.close();
881
+ }
882
+ } finally {
883
+ h.cleanup();
884
+ }
885
+ });
886
+ });
887
+
888
+ // ── Malformed vault-shaped scope guard (defensive hygiene, audit 2026-05-28) ──
889
+ //
890
+ // A `parachute:host:auth` bearer can craft scope strings that LOOK like a
891
+ // named per-vault scope but slip past `isNonRequestableScope`'s strict
892
+ // regexes, so `canGrant` rule 1 would admit them as "requestable" and mint a
893
+ // junk registry row. They grant zero access today (the vault consumer rejects
894
+ // them), so this isn't exploitable now — the mint-time shape check is a
895
+ // backstop against a future consumer-normalization regression + registry
896
+ // hygiene. It's an input-shape check, orthogonal to authority.
897
+ describe("malformed vault-shaped scope rejection", () => {
898
+ const MALFORMED = [
899
+ "vault:work:ADMIN", // uppercase verb
900
+ "vault::admin", // empty name
901
+ "vault:work:read:admin", // extra segment
902
+ "VAULT:work:admin", // uppercase resource
903
+ ];
904
+
905
+ for (const scope of MALFORMED) {
906
+ test(`host:auth bearer minting ${scope} → 400 invalid_scope (malformed)`, async () => {
907
+ const h = makeHarness();
908
+ try {
909
+ const { db, userId } = await bootstrap(h.dir);
910
+ try {
911
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
912
+ const resp = await handleApiMintToken(
913
+ jsonRequest({ scope }, { authorization: `Bearer ${op.token}` }),
914
+ { db, issuer: ISSUER },
915
+ );
916
+ expect(resp.status).toBe(400);
917
+ const body = (await resp.json()) as { error: string; error_description: string };
918
+ expect(body.error).toBe("invalid_scope");
919
+ expect(body.error_description).toContain("malformed vault scope");
920
+ expect(body.error_description).toContain(scope);
921
+ // No junk registry row written — the request was rejected before
922
+ // mint. The only `cli_mint` provenance comes from this endpoint;
923
+ // the operator bearer's own row is `operator` provenance, so a
924
+ // count of zero proves the malformed mint never landed.
925
+ const row = db
926
+ .query<{ n: number }, []>(
927
+ "SELECT COUNT(*) AS n FROM tokens WHERE created_via = 'cli_mint'",
928
+ )
929
+ .get();
930
+ expect(row?.n ?? 0).toBe(0);
931
+ } finally {
932
+ db.close();
933
+ }
934
+ } finally {
935
+ h.cleanup();
936
+ }
937
+ });
938
+ }
939
+
940
+ // Regression: well-formed named scopes still mint (host:auth → read/write
941
+ // via rule 1; host:admin → admin via rule 2). The guard is shape-only.
942
+ test("host:auth bearer minting vault:work:read → 200 (well-formed, regression)", async () => {
943
+ const h = makeHarness();
944
+ try {
945
+ const { db, userId } = await bootstrap(h.dir);
946
+ try {
947
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
948
+ const resp = await handleApiMintToken(
949
+ jsonRequest({ scope: "vault:work:read" }, { authorization: `Bearer ${op.token}` }),
950
+ { db, issuer: ISSUER },
951
+ );
952
+ expect(resp.status).toBe(200);
953
+ const body = (await resp.json()) as { scope: string };
954
+ expect(body.scope).toBe("vault:work:read");
955
+ } finally {
956
+ db.close();
957
+ }
958
+ } finally {
959
+ h.cleanup();
960
+ }
961
+ });
962
+
963
+ test("host:auth bearer minting vault:work:write → 200 (well-formed, regression)", async () => {
964
+ const h = makeHarness();
965
+ try {
966
+ const { db, userId } = await bootstrap(h.dir);
967
+ try {
968
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
969
+ const resp = await handleApiMintToken(
970
+ jsonRequest({ scope: "vault:work:write" }, { authorization: `Bearer ${op.token}` }),
971
+ { db, issuer: ISSUER },
972
+ );
973
+ expect(resp.status).toBe(200);
974
+ const body = (await resp.json()) as { scope: string };
975
+ expect(body.scope).toBe("vault:work:write");
976
+ } finally {
977
+ db.close();
978
+ }
979
+ } finally {
980
+ h.cleanup();
981
+ }
982
+ });
983
+
984
+ test("host:admin bearer minting vault:work:admin → 200 (well-formed, attenuation path)", async () => {
985
+ const h = makeHarness();
986
+ try {
987
+ const { db, userId } = await bootstrap(h.dir);
988
+ try {
989
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
990
+ const resp = await handleApiMintToken(
991
+ jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
992
+ { db, issuer: ISSUER },
993
+ );
994
+ expect(resp.status).toBe(200);
995
+ const body = (await resp.json()) as { scope: string };
996
+ expect(body.scope).toBe("vault:work:admin");
997
+ } finally {
998
+ db.close();
999
+ }
1000
+ } finally {
1001
+ h.cleanup();
1002
+ }
1003
+ });
1004
+
1005
+ // Contrast with the malformed forms above: a WELL-FORMED `vault:work:admin`
1006
+ // clears the shape guard, and (single-consent change, 2026-05-29) now mints
1007
+ // 200 via canGrant rule 1 for a host:auth bearer. The malformed forms are
1008
+ // rejected by the shape guard BEFORE canGrant; this one passes the guard
1009
+ // and is admitted.
1010
+ test("host:auth-only bearer minting well-formed vault:work:admin → 200 (clears shape guard, mints)", async () => {
1011
+ const h = makeHarness();
1012
+ try {
1013
+ const { db, userId } = await bootstrap(h.dir);
1014
+ try {
1015
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
1016
+ const resp = await handleApiMintToken(
1017
+ jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
1018
+ { db, issuer: ISSUER },
1019
+ );
1020
+ expect(resp.status).toBe(200);
1021
+ const body = (await resp.json()) as { scope: string };
1022
+ expect(body.scope).toBe("vault:work:admin");
1023
+ } finally {
1024
+ db.close();
1025
+ }
1026
+ } finally {
1027
+ h.cleanup();
1028
+ }
1029
+ });
1030
+
1031
+ // Non-vault scopes are entirely unaffected by the shape guard.
1032
+ test("host:auth bearer minting scribe:transcribe → 200 (non-vault, unaffected)", async () => {
1033
+ const h = makeHarness();
1034
+ try {
1035
+ const { db, userId } = await bootstrap(h.dir);
1036
+ try {
1037
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
1038
+ const resp = await handleApiMintToken(
1039
+ jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
1040
+ { db, issuer: ISSUER },
1041
+ );
1042
+ expect(resp.status).toBe(200);
1043
+ const body = (await resp.json()) as { scope: string };
1044
+ expect(body.scope).toBe("scribe:transcribe");
1045
+ } finally {
1046
+ db.close();
1047
+ }
1048
+ } finally {
1049
+ h.cleanup();
1050
+ }
1051
+ });
1052
+ });
1053
+
366
1054
  test("405 on non-POST", async () => {
367
1055
  const h = makeHarness();
368
1056
  try {