@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
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { is2FAEnrolled, printPublic2FAWarning } from "../commands/expose-2fa-warning.ts";
6
+ import type { VaultAuthStatus } from "../vault/auth-status.ts";
7
+
8
+ function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
9
+ return {
10
+ hasOwnerPassword: false,
11
+ hasTotp: false,
12
+ tokenCount: 0,
13
+ vaultNames: [],
14
+ ...partial,
15
+ };
16
+ }
17
+
18
+ describe("is2FAEnrolled", () => {
19
+ test("returns true when status carries hasTotp: true", () => {
20
+ expect(is2FAEnrolled({ status: status({ hasTotp: true }) })).toBe(true);
21
+ });
22
+
23
+ test("returns false when status carries hasTotp: false", () => {
24
+ expect(is2FAEnrolled({ status: status({ hasTotp: false }) })).toBe(false);
25
+ });
26
+
27
+ test("reads totp_secret from vaultHome's config.yaml when status not supplied", () => {
28
+ const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
29
+ try {
30
+ writeFileSync(join(dir, "config.yaml"), 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
31
+ expect(is2FAEnrolled({ vaultHome: dir })).toBe(true);
32
+ } finally {
33
+ rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test("missing config.yaml → not enrolled (false)", () => {
38
+ const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
39
+ try {
40
+ // No config.yaml written.
41
+ expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
42
+ } finally {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ test("empty totp_secret value → not enrolled (matches vault's readGlobalConfig)", () => {
48
+ const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
49
+ try {
50
+ writeFileSync(join(dir, "config.yaml"), 'totp_secret: ""\n');
51
+ expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
52
+ } finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("malformed config.yaml → not enrolled (safer fail mode, fires warning)", () => {
58
+ // The probe parses config.yaml with a line-anchored regex (no YAML
59
+ // dependency), so junk content simply doesn't match `totp_secret: "..."`
60
+ // and resolves to `hasTotp: false` — which fires the public-exposure
61
+ // warning rather than silently suppressing it. Pin that contract so a
62
+ // future refactor of auth-status.ts can't quietly invert it.
63
+ const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
64
+ try {
65
+ writeFileSync(join(dir, "config.yaml"), "totp_secret: [unbalanced\n ::: not yaml\n");
66
+ expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
67
+ } finally {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+ });
72
+
73
+ describe("printPublic2FAWarning", () => {
74
+ test("not enrolled → fires warning, returns true", () => {
75
+ const logs: string[] = [];
76
+ const fired = printPublic2FAWarning({
77
+ status: status({ hasTotp: false }),
78
+ log: (l) => logs.push(l),
79
+ publicUrl: "https://vault.example.com",
80
+ });
81
+ expect(fired).toBe(true);
82
+ const joined = logs.join("\n");
83
+ expect(joined).toContain("2FA is not enrolled");
84
+ expect(joined).toContain("https://vault.example.com/login");
85
+ expect(joined).toContain("parachute auth 2fa enroll");
86
+ });
87
+
88
+ test("enrolled → suppressed, returns false, logs nothing", () => {
89
+ const logs: string[] = [];
90
+ const fired = printPublic2FAWarning({
91
+ status: status({ hasTotp: true }),
92
+ log: (l) => logs.push(l),
93
+ publicUrl: "https://vault.example.com",
94
+ });
95
+ expect(fired).toBe(false);
96
+ expect(logs).toEqual([]);
97
+ });
98
+
99
+ test("password-also-missing case still fires (warning is layer-independent of password state)", () => {
100
+ // The wide-open state (no password, no 2FA) hits this branch too — the
101
+ // hub's own `printAuthGuidance` (cloudflare) and `runAuthPreflight`
102
+ // (interactive wizard) cover the password remediation; this warning is
103
+ // strictly about 2FA.
104
+ const logs: string[] = [];
105
+ const fired = printPublic2FAWarning({
106
+ status: status({ hasOwnerPassword: false, hasTotp: false }),
107
+ log: (l) => logs.push(l),
108
+ publicUrl: "https://vault.example.com",
109
+ });
110
+ expect(fired).toBe(true);
111
+ expect(logs.some((l) => l.includes("2FA is not enrolled"))).toBe(true);
112
+ });
113
+
114
+ test("embeds the supplied publicUrl into the /login pointer", () => {
115
+ const logs: string[] = [];
116
+ printPublic2FAWarning({
117
+ status: status({ hasTotp: false }),
118
+ log: (l) => logs.push(l),
119
+ publicUrl: "https://parachute.taildf9ce2.ts.net",
120
+ });
121
+ expect(logs.some((l) => l.includes("https://parachute.taildf9ce2.ts.net/login"))).toBe(true);
122
+ });
123
+ });
@@ -511,6 +511,107 @@ describe("exposeCloudflareUp", () => {
511
511
  env.cleanup();
512
512
  }
513
513
  });
514
+
515
+ // 2FA-enrollment warning (#186). The cloudflare path is always public —
516
+ // every successful bringup makes /admin/login reachable on the open
517
+ // internet, where 2FA is the primary defense beyond #188's rate-limit floor.
518
+ describe("2FA-enrollment warning", () => {
519
+ test("not enrolled → warning fires after the success block", async () => {
520
+ const env = makeEnv();
521
+ try {
522
+ const uuid = "cccccccc-0000-0000-0000-000000000003";
523
+ const { runner } = queueRunner([
524
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
525
+ { code: 0, stdout: "[]", stderr: "" },
526
+ {
527
+ code: 0,
528
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
529
+ stderr: "",
530
+ },
531
+ { code: 0, stdout: "", stderr: "" },
532
+ ]);
533
+ const { spawner } = fakeSpawner(42100);
534
+ const logs: string[] = [];
535
+
536
+ const code = await exposeCloudflareUp("vault.example.com", {
537
+ runner,
538
+ spawner,
539
+ alive: () => false,
540
+ kill: () => {},
541
+ log: (l) => logs.push(l),
542
+ manifestPath: env.manifestPath,
543
+ statePath: env.statePath,
544
+ configPath: env.configPath,
545
+ logPath: env.logPath,
546
+ cloudflaredHome: env.cloudflaredHome,
547
+ // No password, no 2FA — fully wide open. The warning should still
548
+ // fire; password-recovery copy already lives in `printAuthGuidance`.
549
+ vaultAuthStatus: {
550
+ hasOwnerPassword: false,
551
+ hasTotp: false,
552
+ tokenCount: 0,
553
+ vaultNames: [],
554
+ },
555
+ });
556
+
557
+ expect(code).toBe(0);
558
+ const joined = logs.join("\n");
559
+ expect(joined).toContain("2FA is not enrolled");
560
+ expect(joined).toContain("https://vault.example.com/login");
561
+ expect(joined).toContain("parachute auth 2fa enroll");
562
+ } finally {
563
+ env.cleanup();
564
+ }
565
+ });
566
+
567
+ test("enrolled → warning suppressed (no '2FA is not enrolled' line)", async () => {
568
+ const env = makeEnv();
569
+ try {
570
+ const uuid = "dddddddd-0000-0000-0000-000000000004";
571
+ const { runner } = queueRunner([
572
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
573
+ { code: 0, stdout: "[]", stderr: "" },
574
+ {
575
+ code: 0,
576
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
577
+ stderr: "",
578
+ },
579
+ { code: 0, stdout: "", stderr: "" },
580
+ ]);
581
+ const { spawner } = fakeSpawner(42101);
582
+ const logs: string[] = [];
583
+
584
+ const code = await exposeCloudflareUp("vault.example.com", {
585
+ runner,
586
+ spawner,
587
+ alive: () => false,
588
+ kill: () => {},
589
+ log: (l) => logs.push(l),
590
+ manifestPath: env.manifestPath,
591
+ statePath: env.statePath,
592
+ configPath: env.configPath,
593
+ logPath: env.logPath,
594
+ cloudflaredHome: env.cloudflaredHome,
595
+ vaultAuthStatus: {
596
+ hasOwnerPassword: true,
597
+ hasTotp: true,
598
+ tokenCount: 1,
599
+ vaultNames: ["default"],
600
+ },
601
+ });
602
+
603
+ expect(code).toBe(0);
604
+ const joined = logs.join("\n");
605
+ expect(joined).not.toContain("2FA is not enrolled");
606
+ // The existing `printAuthGuidance` 2FA-recommend bullet is unrelated
607
+ // to the new contextual warning and stays in place — assert it on a
608
+ // shape that doesn't collide with the warning text.
609
+ expect(joined).toContain("(recommended) TOTP + backup codes");
610
+ } finally {
611
+ env.cleanup();
612
+ }
613
+ });
614
+ });
514
615
  });
515
616
 
516
617
  describe("exposeCloudflareOff", () => {