@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -556,6 +556,103 @@ describe("parachute auth rotate-operator", () => {
556
556
  tmp.cleanup();
557
557
  }
558
558
  });
559
+
560
+ // closes #213 — `--scope-set` flag.
561
+ test("--scope-set=start mints with parachute:host:start only", async () => {
562
+ const tmp = makeTmp();
563
+ try {
564
+ const deps: AuthDeps = {
565
+ dbPath: tmp.dbPath,
566
+ configDir: tmp.dir,
567
+ isInteractive: () => false,
568
+ };
569
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
570
+ const { code, stdout } = await captureOutput(() =>
571
+ auth(["rotate-operator", "--scope-set", "start"], deps),
572
+ );
573
+ expect(code).toBe(0);
574
+ expect(stdout).toContain("scope_set: start");
575
+ const onDisk = await readOperatorTokenFile(tmp.dir);
576
+ expect(onDisk).not.toBeNull();
577
+ const db = openHubDb(tmp.dbPath);
578
+ try {
579
+ const validated = await validateAccessToken(db, onDisk!, "http://127.0.0.1:1939");
580
+ expect(validated.payload.scope).toBe("parachute:host:start");
581
+ } finally {
582
+ db.close();
583
+ }
584
+ } finally {
585
+ tmp.cleanup();
586
+ }
587
+ });
588
+
589
+ test("--scope-set=admin (default) mints the full admin set", async () => {
590
+ const tmp = makeTmp();
591
+ try {
592
+ const deps: AuthDeps = {
593
+ dbPath: tmp.dbPath,
594
+ configDir: tmp.dir,
595
+ isInteractive: () => false,
596
+ };
597
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
598
+ const { code, stdout } = await captureOutput(() => auth(["rotate-operator"], deps));
599
+ expect(code).toBe(0);
600
+ expect(stdout).toContain("scope_set: admin");
601
+ const onDisk = await readOperatorTokenFile(tmp.dir);
602
+ const db = openHubDb(tmp.dbPath);
603
+ try {
604
+ const validated = await validateAccessToken(db, onDisk!, "http://127.0.0.1:1939");
605
+ const scopes = String(validated.payload.scope ?? "").split(" ");
606
+ expect(scopes).toContain("hub:admin");
607
+ expect(scopes).toContain("parachute:host:admin");
608
+ expect(scopes).toContain("vault:admin");
609
+ } finally {
610
+ db.close();
611
+ }
612
+ } finally {
613
+ tmp.cleanup();
614
+ }
615
+ });
616
+
617
+ test("--scope-set=bogus rejected with usage message", async () => {
618
+ const tmp = makeTmp();
619
+ try {
620
+ const deps: AuthDeps = {
621
+ dbPath: tmp.dbPath,
622
+ configDir: tmp.dir,
623
+ isInteractive: () => false,
624
+ };
625
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
626
+ const { code, stderr } = await captureOutput(() =>
627
+ auth(["rotate-operator", "--scope-set", "wallet"], deps),
628
+ );
629
+ expect(code).toBe(1);
630
+ expect(stderr).toContain("--scope-set must be one of");
631
+ expect(stderr).toContain("install");
632
+ expect(stderr).toContain("admin");
633
+ } finally {
634
+ tmp.cleanup();
635
+ }
636
+ });
637
+
638
+ test("unknown flag is rejected", async () => {
639
+ const tmp = makeTmp();
640
+ try {
641
+ const deps: AuthDeps = {
642
+ dbPath: tmp.dbPath,
643
+ configDir: tmp.dir,
644
+ isInteractive: () => false,
645
+ };
646
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
647
+ const { code, stderr } = await captureOutput(() =>
648
+ auth(["rotate-operator", "--bogus"], deps),
649
+ );
650
+ expect(code).toBe(1);
651
+ expect(stderr).toContain("unknown flag");
652
+ } finally {
653
+ tmp.cleanup();
654
+ }
655
+ });
559
656
  });
560
657
 
561
658
  // closes #74 — the operator's surface for the DCR approval gate. The CLI
@@ -863,7 +960,8 @@ describe("parachute auth mint-token", () => {
863
960
  }
864
961
  });
865
962
 
866
- test("operator token without hub:admin scope is rejected (no token emitted)", async () => {
963
+ // closes #213 auto-rotation banner must not leak into stdout (pipe purity).
964
+ test("operator token within 7d of expiry: auto-rotates, banner on stderr only", async () => {
867
965
  const tmp = makeTmp();
868
966
  try {
869
967
  const deps: AuthDeps = {
@@ -873,40 +971,172 @@ describe("parachute auth mint-token", () => {
873
971
  };
874
972
  // Bootstrap: set-password to seed the user + signing key.
875
973
  await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
876
- // Now overwrite operator.token with a valid-signature, valid-expiry
877
- // JWT that lacks hub:admin simulating someone stashing a narrow
878
- // token at the operator path.
974
+ // Overwrite operator.token with one that's within 7d of expiry — the
975
+ // auto-rotation path should fire on the next mint-token invocation.
976
+ const { issueOperatorToken } = await import("../operator-token.ts");
879
977
  const db = openHubDb(tmp.dbPath);
880
- let narrow: string;
978
+ const originalOnDisk = await readOperatorTokenFile(tmp.dir);
881
979
  try {
882
- const owner = listUsers(db)[0]!;
883
- const signed = await signAccessToken(db, {
884
- sub: owner.id,
885
- scopes: ["scribe:transcribe"],
886
- audience: "scribe",
887
- clientId: OPERATOR_TOKEN_CLIENT_ID,
980
+ await issueOperatorToken(db, listUsers(db)[0]!.id, {
981
+ dir: tmp.dir,
888
982
  issuer: "http://127.0.0.1:1939",
889
- ttlSeconds: 3600,
983
+ ttlSeconds: 24 * 60 * 60,
890
984
  });
891
- narrow = signed.token;
892
985
  } finally {
893
986
  db.close();
894
987
  }
895
- await writeOperatorTokenFile(narrow, tmp.dir);
896
988
 
989
+ const { code, stdout, stderr } = await captureOutput(() =>
990
+ auth(["mint-token", "--scope", "scribe:transcribe"], deps),
991
+ );
992
+ expect(code).toBe(0);
993
+ // The minted JWT is the only thing on stdout (pipe purity).
994
+ const token = stdout.trim();
995
+ expect(token.split(".").length).toBe(3);
996
+ expect(stdout).toBe(`${token}\n`);
997
+ // Auto-rotation banner went to stderr.
998
+ expect(stderr).toContain("auto-rotated");
999
+ expect(stderr).toContain("scope_set=admin");
1000
+ // The on-disk operator.token was replaced.
1001
+ const after = await readOperatorTokenFile(tmp.dir);
1002
+ expect(after).not.toBeNull();
1003
+ expect(after).not.toBe(originalOnDisk);
1004
+ } finally {
1005
+ tmp.cleanup();
1006
+ }
1007
+ });
1008
+
1009
+ // Helper: stash a token with the chosen scopes at operator.token, returning
1010
+ // the deps bag so the caller can immediately invoke mint-token. Used by
1011
+ // every gating-scope test below to exercise the gate against a known
1012
+ // narrow / wide token without going through `rotate-operator` (which would
1013
+ // always mint admin-set).
1014
+ //
1015
+ // TTL is 30d (well beyond the 7d auto-rotation window) so the token
1016
+ // survives the next mint-token call without being silently swapped for a
1017
+ // fresh admin-set token by the auto-rotation path. Without this, narrow
1018
+ // tokens would auto-rotate to admin and our gate tests would all see the
1019
+ // post-rotation token, defeating the test entirely.
1020
+ async function bootstrapWithOperatorScopes(
1021
+ tmp: { dir: string; dbPath: string; cleanup: () => void },
1022
+ scopes: readonly string[],
1023
+ ): Promise<AuthDeps> {
1024
+ const deps: AuthDeps = {
1025
+ dbPath: tmp.dbPath,
1026
+ configDir: tmp.dir,
1027
+ isInteractive: () => false,
1028
+ };
1029
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1030
+ const db = openHubDb(tmp.dbPath);
1031
+ let token: string;
1032
+ try {
1033
+ const owner = listUsers(db)[0]!;
1034
+ const signed = await signAccessToken(db, {
1035
+ sub: owner.id,
1036
+ scopes: [...scopes],
1037
+ audience: "operator",
1038
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
1039
+ issuer: "http://127.0.0.1:1939",
1040
+ ttlSeconds: 30 * 24 * 60 * 60,
1041
+ });
1042
+ token = signed.token;
1043
+ } finally {
1044
+ db.close();
1045
+ }
1046
+ await writeOperatorTokenFile(token, tmp.dir);
1047
+ return deps;
1048
+ }
1049
+
1050
+ // hub#222: gate widened from `hub:admin` to `parachute:host:auth`. The
1051
+ // following tests pin the new behaviour:
1052
+ // - admin scope-set (which carries both) still succeeds (regression);
1053
+ // - `auth` scope-set (carries only `:host:auth`) NOW succeeds (gain);
1054
+ // - other narrow scope-sets (vault/install/etc.) still rejected;
1055
+ // - error message updated to name the new gate.
1056
+
1057
+ test("operator token with `auth` scope-set (parachute:host:auth only) mints successfully (hub#222)", async () => {
1058
+ const tmp = makeTmp();
1059
+ try {
1060
+ const deps = await bootstrapWithOperatorScopes(tmp, ["parachute:host:auth"]);
1061
+ const { code, stdout, stderr } = await captureOutput(() =>
1062
+ auth(["mint-token", "--scope", "scribe:transcribe"], deps),
1063
+ );
1064
+ expect(code).toBe(0);
1065
+ // Pipe purity: stdout is the JWT, stderr empty.
1066
+ const token = stdout.trim();
1067
+ expect(token.split(".").length).toBe(3);
1068
+ expect(stdout).toBe(`${token}\n`);
1069
+ expect(stderr).toBe("");
1070
+ } finally {
1071
+ tmp.cleanup();
1072
+ }
1073
+ });
1074
+
1075
+ test("operator token with admin scope-set still mints (regression — admin includes :host:auth as superset)", async () => {
1076
+ const tmp = makeTmp();
1077
+ try {
1078
+ // The full admin scope-set carries both `hub:admin` and `parachute:host:auth`.
1079
+ const deps = await bootstrapWithOperatorScopes(tmp, [
1080
+ "hub:admin",
1081
+ "parachute:host:auth",
1082
+ "vault:admin",
1083
+ ]);
1084
+ const { code, stdout } = await captureOutput(() =>
1085
+ auth(["mint-token", "--scope", "scribe:transcribe"], deps),
1086
+ );
1087
+ expect(code).toBe(0);
1088
+ expect(stdout.trim().split(".").length).toBe(3);
1089
+ } finally {
1090
+ tmp.cleanup();
1091
+ }
1092
+ });
1093
+
1094
+ test("operator token without parachute:host:auth is rejected (no token emitted)", async () => {
1095
+ const tmp = makeTmp();
1096
+ try {
1097
+ // Narrow non-auth token (resembles what someone might stash by mistake,
1098
+ // or a `--scope-set vault` operator token from rotate-operator).
1099
+ const deps = await bootstrapWithOperatorScopes(tmp, ["scribe:transcribe"]);
897
1100
  const { code, stdout, stderr } = await captureOutput(() =>
898
1101
  auth(["mint-token", "--scope", "scribe:transcribe"], deps),
899
1102
  );
900
1103
  expect(code).toBe(1);
901
- expect(stderr).toContain("lacks hub:admin scope");
1104
+ expect(stderr).toContain("lacks parachute:host:auth scope");
902
1105
  expect(stderr).toContain("rotate-operator");
903
- // Purity: no token written to stdout.
904
1106
  expect(stdout).toBe("");
905
1107
  } finally {
906
1108
  tmp.cleanup();
907
1109
  }
908
1110
  });
909
1111
 
1112
+ test("`vault` scope-set is rejected (regression — narrow scope-sets still can't mint)", async () => {
1113
+ const tmp = makeTmp();
1114
+ try {
1115
+ const deps = await bootstrapWithOperatorScopes(tmp, ["parachute:host:vault"]);
1116
+ const { code, stderr } = await captureOutput(() =>
1117
+ auth(["mint-token", "--scope", "scribe:transcribe"], deps),
1118
+ );
1119
+ expect(code).toBe(1);
1120
+ expect(stderr).toContain("lacks parachute:host:auth scope");
1121
+ } finally {
1122
+ tmp.cleanup();
1123
+ }
1124
+ });
1125
+
1126
+ test("`install` scope-set is rejected (regression — narrow scope-sets still can't mint)", async () => {
1127
+ const tmp = makeTmp();
1128
+ try {
1129
+ const deps = await bootstrapWithOperatorScopes(tmp, ["parachute:host:install", "vault:read"]);
1130
+ const { code, stderr } = await captureOutput(() =>
1131
+ auth(["mint-token", "--scope", "scribe:transcribe"], deps),
1132
+ );
1133
+ expect(code).toBe(1);
1134
+ expect(stderr).toContain("lacks parachute:host:auth scope");
1135
+ } finally {
1136
+ tmp.cleanup();
1137
+ }
1138
+ });
1139
+
910
1140
  test("named vault scope infers aud=vault.<name>", async () => {
911
1141
  const tmp = makeTmp();
912
1142
  try {
@@ -1144,4 +1374,438 @@ describe("parachute auth mint-token", () => {
1144
1374
  tmp.cleanup();
1145
1375
  }
1146
1376
  });
1377
+
1378
+ // closes #212 Phase 1 — registry write, --permissions, --expires-in,
1379
+ // --ttl deprecation notice.
1380
+ test("every successful mint writes a tokens registry row (created_via=cli_mint)", async () => {
1381
+ const tmp = makeTmp();
1382
+ try {
1383
+ const deps: AuthDeps = {
1384
+ dbPath: tmp.dbPath,
1385
+ configDir: tmp.dir,
1386
+ isInteractive: () => false,
1387
+ };
1388
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1389
+ const { code, stdout } = await captureOutput(() =>
1390
+ auth(["mint-token", "--scope", "scribe:transcribe"], deps),
1391
+ );
1392
+ expect(code).toBe(0);
1393
+ const token = stdout.trim();
1394
+ const db = openHubDb(tmp.dbPath);
1395
+ try {
1396
+ const validated = await validateAccessToken(db, token);
1397
+ const jti = validated.payload.jti as string;
1398
+ const row = db
1399
+ .query<{ jti: string; created_via: string; subject: string | null }, [string]>(
1400
+ "SELECT jti, created_via, subject FROM tokens WHERE jti = ?",
1401
+ )
1402
+ .get(jti);
1403
+ expect(row).not.toBeNull();
1404
+ expect(row?.created_via).toBe("cli_mint");
1405
+ // Default subject = operator's sub (the hub user id).
1406
+ expect(typeof row?.subject).toBe("string");
1407
+ expect(row?.subject?.length).toBeGreaterThan(0);
1408
+ } finally {
1409
+ db.close();
1410
+ }
1411
+ } finally {
1412
+ tmp.cleanup();
1413
+ }
1414
+ });
1415
+
1416
+ test("--permissions JSON object round-trips into JWT + registry row", async () => {
1417
+ const tmp = makeTmp();
1418
+ try {
1419
+ const deps: AuthDeps = {
1420
+ dbPath: tmp.dbPath,
1421
+ configDir: tmp.dir,
1422
+ isInteractive: () => false,
1423
+ };
1424
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1425
+ const permissions = '{"vault":{"default":{"write_tags":["health"]}}}';
1426
+ const { code, stdout } = await captureOutput(() =>
1427
+ auth(["mint-token", "--scope", "vault:default:write", "--permissions", permissions], deps),
1428
+ );
1429
+ expect(code).toBe(0);
1430
+ const token = stdout.trim();
1431
+ const db = openHubDb(tmp.dbPath);
1432
+ try {
1433
+ const validated = await validateAccessToken(db, token);
1434
+ expect(validated.payload.permissions).toEqual({
1435
+ vault: { default: { write_tags: ["health"] } },
1436
+ });
1437
+ const jti = validated.payload.jti as string;
1438
+ const row = db
1439
+ .query<{ permissions: string }, [string]>("SELECT permissions FROM tokens WHERE jti = ?")
1440
+ .get(jti);
1441
+ expect(JSON.parse(row!.permissions)).toEqual({
1442
+ vault: { default: { write_tags: ["health"] } },
1443
+ });
1444
+ } finally {
1445
+ db.close();
1446
+ }
1447
+ } finally {
1448
+ tmp.cleanup();
1449
+ }
1450
+ });
1451
+
1452
+ test("--permissions with malformed JSON is rejected", async () => {
1453
+ const tmp = makeTmp();
1454
+ try {
1455
+ const deps: AuthDeps = {
1456
+ dbPath: tmp.dbPath,
1457
+ configDir: tmp.dir,
1458
+ isInteractive: () => false,
1459
+ };
1460
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1461
+ const { code, stderr } = await captureOutput(() =>
1462
+ auth(["mint-token", "--scope", "vault:read", "--permissions", "{not-json}"], deps),
1463
+ );
1464
+ expect(code).toBe(1);
1465
+ expect(stderr).toContain("not valid JSON");
1466
+ } finally {
1467
+ tmp.cleanup();
1468
+ }
1469
+ });
1470
+
1471
+ test("--permissions with non-object JSON (array) is rejected", async () => {
1472
+ const tmp = makeTmp();
1473
+ try {
1474
+ const deps: AuthDeps = {
1475
+ dbPath: tmp.dbPath,
1476
+ configDir: tmp.dir,
1477
+ isInteractive: () => false,
1478
+ };
1479
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1480
+ const { code, stderr } = await captureOutput(() =>
1481
+ auth(["mint-token", "--scope", "vault:read", "--permissions", "[1,2,3]"], deps),
1482
+ );
1483
+ expect(code).toBe(1);
1484
+ expect(stderr).toContain("must be a JSON object");
1485
+ } finally {
1486
+ tmp.cleanup();
1487
+ }
1488
+ });
1489
+
1490
+ test("--expires-in (canonical) sets the JWT TTL in seconds", async () => {
1491
+ const tmp = makeTmp();
1492
+ try {
1493
+ const deps: AuthDeps = {
1494
+ dbPath: tmp.dbPath,
1495
+ configDir: tmp.dir,
1496
+ isInteractive: () => false,
1497
+ };
1498
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1499
+ const { code, stdout } = await captureOutput(() =>
1500
+ auth(["mint-token", "--scope", "scribe:transcribe", "--expires-in", "7200"], deps),
1501
+ );
1502
+ expect(code).toBe(0);
1503
+ const token = stdout.trim();
1504
+ const db = openHubDb(tmp.dbPath);
1505
+ try {
1506
+ const validated = await validateAccessToken(db, token);
1507
+ const exp = validated.payload.exp as number;
1508
+ const iat = validated.payload.iat as number;
1509
+ expect(exp - iat).toBe(7200);
1510
+ } finally {
1511
+ db.close();
1512
+ }
1513
+ } finally {
1514
+ tmp.cleanup();
1515
+ }
1516
+ });
1517
+
1518
+ test("--expires-in with non-integer value rejected", async () => {
1519
+ const tmp = makeTmp();
1520
+ try {
1521
+ const deps: AuthDeps = {
1522
+ dbPath: tmp.dbPath,
1523
+ configDir: tmp.dir,
1524
+ isInteractive: () => false,
1525
+ };
1526
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1527
+ const { code, stderr } = await captureOutput(() =>
1528
+ auth(["mint-token", "--scope", "vault:read", "--expires-in", "1d"], deps),
1529
+ );
1530
+ expect(code).toBe(1);
1531
+ expect(stderr).toContain("integer seconds count");
1532
+ } finally {
1533
+ tmp.cleanup();
1534
+ }
1535
+ });
1536
+
1537
+ test("--expires-in over 365d cap rejected", async () => {
1538
+ const tmp = makeTmp();
1539
+ try {
1540
+ const deps: AuthDeps = {
1541
+ dbPath: tmp.dbPath,
1542
+ configDir: tmp.dir,
1543
+ isInteractive: () => false,
1544
+ };
1545
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1546
+ const { code, stderr } = await captureOutput(() =>
1547
+ auth(["mint-token", "--scope", "vault:read", "--expires-in", String(366 * 86400)], deps),
1548
+ );
1549
+ expect(code).toBe(1);
1550
+ expect(stderr).toContain("365d cap");
1551
+ } finally {
1552
+ tmp.cleanup();
1553
+ }
1554
+ });
1555
+
1556
+ test("--ttl emits deprecation notice on stderr but still works", async () => {
1557
+ const tmp = makeTmp();
1558
+ try {
1559
+ const deps: AuthDeps = {
1560
+ dbPath: tmp.dbPath,
1561
+ configDir: tmp.dir,
1562
+ isInteractive: () => false,
1563
+ };
1564
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1565
+ const { code, stdout, stderr } = await captureOutput(() =>
1566
+ auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "1h"], deps),
1567
+ );
1568
+ expect(code).toBe(0);
1569
+ expect(stdout.trim().split(".").length).toBe(3);
1570
+ expect(stderr).toContain("--ttl is deprecated");
1571
+ expect(stderr).toContain("--expires-in");
1572
+ expect(stderr).toContain("0.6.0");
1573
+ } finally {
1574
+ tmp.cleanup();
1575
+ }
1576
+ });
1577
+
1578
+ // closes #215 reviewer F1 — privilege-diffusion guard on the CLI mint path.
1579
+ test("CLI mint-token rejects parachute:host:auth (non-requestable scope)", async () => {
1580
+ const tmp = makeTmp();
1581
+ try {
1582
+ const deps: AuthDeps = {
1583
+ dbPath: tmp.dbPath,
1584
+ configDir: tmp.dir,
1585
+ isInteractive: () => false,
1586
+ };
1587
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1588
+ const { code, stdout, stderr } = await captureOutput(() =>
1589
+ auth(["mint-token", "--scope", "parachute:host:auth"], deps),
1590
+ );
1591
+ expect(code).toBe(1);
1592
+ expect(stderr).toContain("not requestable");
1593
+ expect(stderr).toContain("parachute:host:auth");
1594
+ // No token leaked to stdout.
1595
+ expect(stdout).toBe("");
1596
+ } finally {
1597
+ tmp.cleanup();
1598
+ }
1599
+ });
1600
+
1601
+ test("passing both --ttl and --expires-in is an error", async () => {
1602
+ const tmp = makeTmp();
1603
+ try {
1604
+ const deps: AuthDeps = {
1605
+ dbPath: tmp.dbPath,
1606
+ configDir: tmp.dir,
1607
+ isInteractive: () => false,
1608
+ };
1609
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1610
+ const { code, stderr } = await captureOutput(() =>
1611
+ auth(["mint-token", "--scope", "vault:read", "--ttl", "1h", "--expires-in", "3600"], deps),
1612
+ );
1613
+ expect(code).toBe(1);
1614
+ expect(stderr).toContain("--ttl");
1615
+ expect(stderr).toContain("--expires-in");
1616
+ } finally {
1617
+ tmp.cleanup();
1618
+ }
1619
+ });
1620
+ });
1621
+
1622
+ describe("parachute auth revoke-token", () => {
1623
+ // Each test mints a fresh token via mint-token and then revokes it. Going
1624
+ // through the public mint surface (rather than calling signAccessToken +
1625
+ // recordTokenMint directly) keeps these tests honest about the contract:
1626
+ // mint writes a registry row, revoke-token flips its bit, and a future
1627
+ // round-trip through validateAccessToken would reject it. The Phase 4
1628
+ // RS-side enforcement is exercised in scope-guard's own integration suite.
1629
+
1630
+ async function mintAJti(deps: AuthDeps): Promise<string> {
1631
+ const { stdout } = await captureOutput(() =>
1632
+ auth(["mint-token", "--scope", "scribe:transcribe"], deps),
1633
+ );
1634
+ const token = stdout.trim();
1635
+ // Decode the unverified payload to recover the jti — every mint stamps one.
1636
+ const [, payloadB64] = token.split(".");
1637
+ const payload = JSON.parse(Buffer.from(payloadB64!, "base64url").toString("utf8")) as {
1638
+ jti: string;
1639
+ };
1640
+ return payload.jti;
1641
+ }
1642
+
1643
+ test("missing jti positional is a usage error", async () => {
1644
+ const tmp = makeTmp();
1645
+ try {
1646
+ const { code, stderr } = await captureOutput(() =>
1647
+ auth(["revoke-token"], { dbPath: tmp.dbPath, configDir: tmp.dir }),
1648
+ );
1649
+ expect(code).toBe(1);
1650
+ expect(stderr).toContain("missing jti argument");
1651
+ } finally {
1652
+ tmp.cleanup();
1653
+ }
1654
+ });
1655
+
1656
+ test("unexpected flag is a usage error", async () => {
1657
+ const tmp = makeTmp();
1658
+ try {
1659
+ const { code, stderr } = await captureOutput(() =>
1660
+ auth(["revoke-token", "--reason", "compromise", "abc123"], {
1661
+ dbPath: tmp.dbPath,
1662
+ configDir: tmp.dir,
1663
+ }),
1664
+ );
1665
+ expect(code).toBe(1);
1666
+ expect(stderr).toContain("unexpected flag");
1667
+ expect(stderr).toContain("--reason");
1668
+ } finally {
1669
+ tmp.cleanup();
1670
+ }
1671
+ });
1672
+
1673
+ test("revokes a fresh token and prints subject + scope", async () => {
1674
+ const tmp = makeTmp();
1675
+ try {
1676
+ const deps: AuthDeps = {
1677
+ dbPath: tmp.dbPath,
1678
+ configDir: tmp.dir,
1679
+ isInteractive: () => false,
1680
+ };
1681
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1682
+ const jti = await mintAJti(deps);
1683
+ const { code, stdout, stderr } = await captureOutput(() => auth(["revoke-token", jti], deps));
1684
+ expect(code).toBe(0);
1685
+ expect(stderr).toBe("");
1686
+ expect(stdout).toContain(`revoked: jti=${jti}`);
1687
+ expect(stdout).toContain("identity=");
1688
+ expect(stdout).toContain("scope=scribe:transcribe");
1689
+
1690
+ // Registry row really has revoked_at set now.
1691
+ const db = openHubDb(tmp.dbPath);
1692
+ try {
1693
+ const { findTokenRowByJti } = await import("../jwt-sign.ts");
1694
+ const row = findTokenRowByJti(db, jti);
1695
+ expect(row?.revokedAt).not.toBeNull();
1696
+ } finally {
1697
+ db.close();
1698
+ }
1699
+ } finally {
1700
+ tmp.cleanup();
1701
+ }
1702
+ });
1703
+
1704
+ test("re-revoke is idempotent: exit 0, prints existing revoked_at", async () => {
1705
+ const tmp = makeTmp();
1706
+ try {
1707
+ const deps: AuthDeps = {
1708
+ dbPath: tmp.dbPath,
1709
+ configDir: tmp.dir,
1710
+ isInteractive: () => false,
1711
+ };
1712
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1713
+ const jti = await mintAJti(deps);
1714
+ const first = await captureOutput(() => auth(["revoke-token", jti], deps));
1715
+ expect(first.code).toBe(0);
1716
+
1717
+ // Capture the timestamp from the first revoke for cross-check.
1718
+ const db = openHubDb(tmp.dbPath);
1719
+ let firstRevokedAt: string | null;
1720
+ try {
1721
+ const { findTokenRowByJti } = await import("../jwt-sign.ts");
1722
+ firstRevokedAt = findTokenRowByJti(db, jti)?.revokedAt ?? null;
1723
+ } finally {
1724
+ db.close();
1725
+ }
1726
+ expect(firstRevokedAt).not.toBeNull();
1727
+
1728
+ const second = await captureOutput(() => auth(["revoke-token", jti], deps));
1729
+ expect(second.code).toBe(0);
1730
+ expect(second.stdout).toContain("already revoked at");
1731
+ expect(second.stdout).toContain(firstRevokedAt!);
1732
+ } finally {
1733
+ tmp.cleanup();
1734
+ }
1735
+ });
1736
+
1737
+ test("unknown jti exits 1 with not-found error", async () => {
1738
+ const tmp = makeTmp();
1739
+ try {
1740
+ const deps: AuthDeps = {
1741
+ dbPath: tmp.dbPath,
1742
+ configDir: tmp.dir,
1743
+ isInteractive: () => false,
1744
+ };
1745
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1746
+ const { code, stderr } = await captureOutput(() =>
1747
+ auth(["revoke-token", "this-jti-does-not-exist"], deps),
1748
+ );
1749
+ expect(code).toBe(1);
1750
+ expect(stderr).toContain("no token with jti this-jti-does-not-exist found in registry");
1751
+ } finally {
1752
+ tmp.cleanup();
1753
+ }
1754
+ });
1755
+
1756
+ test("operator token without parachute:host:auth is rejected", async () => {
1757
+ const tmp = makeTmp();
1758
+ try {
1759
+ const deps: AuthDeps = {
1760
+ dbPath: tmp.dbPath,
1761
+ configDir: tmp.dir,
1762
+ isInteractive: () => false,
1763
+ };
1764
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1765
+ // Replace operator.token with a narrow JWT that lacks parachute:host:auth.
1766
+ const db = openHubDb(tmp.dbPath);
1767
+ let narrow: string;
1768
+ try {
1769
+ const owner = listUsers(db)[0]!;
1770
+ const signed = await signAccessToken(db, {
1771
+ sub: owner.id,
1772
+ scopes: ["scribe:transcribe"],
1773
+ audience: "scribe",
1774
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
1775
+ issuer: "http://127.0.0.1:1939",
1776
+ ttlSeconds: 3600,
1777
+ });
1778
+ narrow = signed.token;
1779
+ } finally {
1780
+ db.close();
1781
+ }
1782
+ await writeOperatorTokenFile(narrow, tmp.dir);
1783
+
1784
+ const { code, stderr } = await captureOutput(() => auth(["revoke-token", "any-jti"], deps));
1785
+ expect(code).toBe(1);
1786
+ expect(stderr).toContain("lacks parachute:host:auth scope");
1787
+ expect(stderr).toContain("rotate-operator");
1788
+ } finally {
1789
+ tmp.cleanup();
1790
+ }
1791
+ });
1792
+
1793
+ test("missing operator.token is an actionable error", async () => {
1794
+ const tmp = makeTmp();
1795
+ try {
1796
+ const { code, stderr } = await captureOutput(() =>
1797
+ auth(["revoke-token", "any-jti"], { dbPath: tmp.dbPath, configDir: tmp.dir }),
1798
+ );
1799
+ expect(code).toBe(1);
1800
+ expect(stderr).toContain("operator.token");
1801
+ expect(stderr).toContain("rotate-operator");
1802
+ } finally {
1803
+ tmp.cleanup();
1804
+ }
1805
+ });
1806
+
1807
+ test("authHelp lists revoke-token alongside mint-token", () => {
1808
+ expect(authHelp()).toContain("revoke-token");
1809
+ expect(authHelp()).toContain("revoke-token <jti>");
1810
+ });
1147
1811
  });