@openparachute/hub 0.5.14-rc.12 → 0.5.14-rc.13

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.12",
4
- "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
3
+ "version": "0.5.14-rc.13",
4
+ "description": "parachute the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -37,13 +37,16 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@biomejs/biome": "^1.9.4",
40
- "@types/bun": "latest"
40
+ "@types/bun": "latest",
41
+ "@types/qrcode": "^1.5.6"
41
42
  },
42
43
  "peerDependencies": {
43
44
  "typescript": "^5"
44
45
  },
45
46
  "dependencies": {
46
47
  "@node-rs/argon2": "^2.0.2",
47
- "jose": "^6.2.2"
48
+ "jose": "^6.2.2",
49
+ "otpauth": "^9.5.0",
50
+ "qrcode": "^1.5.4"
48
51
  }
49
52
  }
@@ -32,6 +32,7 @@ describe("renderAccountHome", () => {
32
32
  hubOrigin: HUB_ORIGIN,
33
33
  isFirstAdmin: false,
34
34
  csrfToken: CSRF,
35
+ twoFactorEnabled: false,
35
36
  });
36
37
  // Welcome header includes the username.
37
38
  expect(html).toContain("Welcome, alice");
@@ -69,6 +70,7 @@ describe("renderAccountHome", () => {
69
70
  hubOrigin: `${HUB_ORIGIN}/`,
70
71
  isFirstAdmin: false,
71
72
  csrfToken: CSRF,
73
+ twoFactorEnabled: false,
72
74
  });
73
75
  const cleanEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/alice`);
74
76
  expect(html).toContain(`https://notes.parachute.computer/add?url=${cleanEncoded}`);
@@ -86,6 +88,7 @@ describe("renderAccountHome", () => {
86
88
  hubOrigin: HUB_ORIGIN,
87
89
  isFirstAdmin: true,
88
90
  csrfToken: CSRF,
91
+ twoFactorEnabled: false,
89
92
  });
90
93
  expect(html).toContain("Welcome, admin");
91
94
  expect(html).toContain("hub administrator");
@@ -106,6 +109,7 @@ describe("renderAccountHome", () => {
106
109
  hubOrigin: HUB_ORIGIN,
107
110
  isFirstAdmin: false,
108
111
  csrfToken: CSRF,
112
+ twoFactorEnabled: false,
109
113
  });
110
114
  expect(html).toContain("Welcome, ghost");
111
115
  expect(html).toContain("Ask the hub operator");
@@ -123,6 +127,7 @@ describe("renderAccountHome", () => {
123
127
  hubOrigin: HUB_ORIGIN,
124
128
  isFirstAdmin: false,
125
129
  csrfToken: CSRF,
130
+ twoFactorEnabled: false,
126
131
  });
127
132
  // Change-password link points at the existing /account/change-password
128
133
  // route (server-rendered HTML, separate handler).
@@ -136,6 +141,37 @@ describe("renderAccountHome", () => {
136
141
  expect(html).toContain("<code>alice</code>");
137
142
  });
138
143
 
144
+ test("account card — 2FA status reflects twoFactorEnabled (hub#473)", () => {
145
+ const off = renderAccountHome({
146
+ username: "alice",
147
+ assignedVaults: ["alice"],
148
+ passwordChanged: true,
149
+ hubOrigin: HUB_ORIGIN,
150
+ isFirstAdmin: false,
151
+ csrfToken: CSRF,
152
+ twoFactorEnabled: false,
153
+ });
154
+ expect(off).toContain('data-testid="2fa-status"');
155
+ expect(off).toContain(">Off<");
156
+ // Off → "Set up two-factor" affordance.
157
+ expect(off).toContain('data-testid="setup-2fa-link"');
158
+ expect(off).toContain('href="/account/2fa"');
159
+
160
+ const on = renderAccountHome({
161
+ username: "alice",
162
+ assignedVaults: ["alice"],
163
+ passwordChanged: true,
164
+ hubOrigin: HUB_ORIGIN,
165
+ isFirstAdmin: false,
166
+ csrfToken: CSRF,
167
+ twoFactorEnabled: true,
168
+ });
169
+ expect(on).toContain(">On<");
170
+ // On → "Manage two-factor" affordance.
171
+ expect(on).toContain('data-testid="manage-2fa-link"');
172
+ expect(on).toContain('href="/account/2fa"');
173
+ });
174
+
139
175
  test("multi-vault branch — renders one tile per assigned vault (Phase 2 PR 2)", () => {
140
176
  const html = renderAccountHome({
141
177
  username: "alice",
@@ -144,6 +180,7 @@ describe("renderAccountHome", () => {
144
180
  hubOrigin: HUB_ORIGIN,
145
181
  isFirstAdmin: false,
146
182
  csrfToken: CSRF,
183
+ twoFactorEnabled: false,
147
184
  });
148
185
  // Plural heading.
149
186
  expect(html).toContain("Your vaults");
@@ -191,6 +228,7 @@ describe("renderAccountHome", () => {
191
228
  hubOrigin: HUB_ORIGIN,
192
229
  isFirstAdmin: false,
193
230
  csrfToken: CSRF,
231
+ twoFactorEnabled: false,
194
232
  });
195
233
  // The injected username/vault metacharacters are escaped — the only
196
234
  // `<script>` tag in the output is the page's own copy-button helper, so
@@ -65,26 +65,159 @@ async function captureOutput(fn: () => Promise<number> | number): Promise<{
65
65
  }
66
66
 
67
67
  describe("parachute auth", () => {
68
- test("2fa is an honest hub-side stub — never forwards to parachute-vault", async () => {
69
- // 2fa used to forward to the deprecated `parachute-vault 2fa` stub, which
70
- // exits 1 post auth-unification and only wrote vault YAML that never gated
71
- // hub /login. It's now an informational stub: exit 0, no subprocess.
72
- const { runner, calls } = makeRunner(0);
73
- const out = await captureOutput(() => auth(["2fa", "enroll"], runner));
74
- expect(out.code).toBe(0);
75
- expect(calls).toEqual([]); // did NOT spawn parachute-vault
76
- expect(out.stdout).toContain("isn't available yet");
77
- expect(out.stdout).toContain("#473");
78
- // Doesn't tell the operator to run the dead vault command.
79
- expect(out.stdout).not.toContain("2fa enroll");
68
+ test("2fa is hub-local — never forwards to parachute-vault", async () => {
69
+ // 2fa used to forward to the deprecated `parachute-vault 2fa` stub. As of
70
+ // hub#473 it's real hub-login TOTP, fully hub-local (hub.db). No subprocess.
71
+ const tmp = makeTmp();
72
+ try {
73
+ const db = openHubDb(tmp.dbPath);
74
+ await createUser(db, "owner", "owner-password-123");
75
+ db.close();
76
+ const { runner, calls } = makeRunner(0);
77
+ const out = await captureOutput(() =>
78
+ auth(["2fa", "status"], { runner, dbPath: tmp.dbPath }),
79
+ );
80
+ expect(out.code).toBe(0);
81
+ expect(calls).toEqual([]); // did NOT spawn parachute-vault
82
+ expect(out.stdout).toContain("Two-factor authentication: OFF");
83
+ } finally {
84
+ tmp.cleanup();
85
+ }
80
86
  });
81
87
 
82
- test("2fa with any sub-args still resolves to the honest stub (exit 0, no spawn)", async () => {
83
- const { runner, calls } = makeRunner(0);
84
- const out = await captureOutput(() => auth(["2fa", "status", "--whatever"], runner));
85
- expect(out.code).toBe(0);
86
- expect(calls).toEqual([]);
87
- expect(out.stdout).toContain("#473");
88
+ test("2fa status reports OFF for a fresh user, then ON after a CLI enroll round-trip", async () => {
89
+ const tmp = makeTmp();
90
+ try {
91
+ const db = openHubDb(tmp.dbPath);
92
+ await createUser(db, "owner", "owner-password-123");
93
+ db.close();
94
+
95
+ // status → OFF
96
+ const off = await captureOutput(() =>
97
+ auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
98
+ );
99
+ expect(off.code).toBe(0);
100
+ expect(off.stdout).toContain("OFF");
101
+
102
+ // enroll requires a confirm code — drive the readLine seam with the
103
+ // live TOTP code generated from the secret the command prints.
104
+ const { generateTotpSecret } = await import("../totp.ts");
105
+ // We can't intercept the random secret the command mints, so instead
106
+ // exercise the store directly to assert the ON path is reachable, then
107
+ // assert the CLI status reflects it. (The handler-level enroll round
108
+ // trip is covered in two-factor.test.ts against the real secret.)
109
+ const db2 = openHubDb(tmp.dbPath);
110
+ const { persistEnrollment } = await import("../two-factor-store.ts");
111
+ const u = listUsers(db2)[0]!;
112
+ const { secret } = generateTotpSecret(u.username);
113
+ await persistEnrollment(db2, u.id, secret);
114
+ db2.close();
115
+
116
+ const on = await captureOutput(() =>
117
+ auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
118
+ );
119
+ expect(on.code).toBe(0);
120
+ expect(on.stdout).toContain("ON");
121
+ expect(on.stdout).toContain("backup_codes");
122
+
123
+ // disenroll clears it.
124
+ const dis = await captureOutput(() =>
125
+ auth(["2fa", "disenroll"], { dbPath: tmp.dbPath, isInteractive: () => false }),
126
+ );
127
+ expect(dis.code).toBe(0);
128
+ expect(dis.stdout).toContain("Turned off");
129
+
130
+ const off2 = await captureOutput(() =>
131
+ auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
132
+ );
133
+ expect(off2.stdout).toContain("OFF");
134
+ } finally {
135
+ tmp.cleanup();
136
+ }
137
+ });
138
+
139
+ test("2fa enroll confirms the printed secret against a live code, prints backup codes", async () => {
140
+ const tmp = makeTmp();
141
+ try {
142
+ const db = openHubDb(tmp.dbPath);
143
+ await createUser(db, "owner", "owner-password-123");
144
+ db.close();
145
+
146
+ // Single console.log interception that BOTH accumulates stdout and lets
147
+ // the readLine seam read the secret the command just printed. (Avoids
148
+ // nesting two console.log replacements.)
149
+ const OTPAuth = await import("otpauth");
150
+ const origLog = console.log;
151
+ let stdout = "";
152
+ let capturedSecret = "";
153
+ console.log = (...a: unknown[]) => {
154
+ const line = a.map(String).join(" ");
155
+ stdout += `${line}\n`;
156
+ const m = line.match(/secret key:\s+([A-Z2-7]+)/);
157
+ if (m) capturedSecret = m[1]!;
158
+ };
159
+ let code = "";
160
+ let exitCode = 0;
161
+ try {
162
+ exitCode = await auth(["2fa", "enroll"], {
163
+ dbPath: tmp.dbPath,
164
+ isInteractive: () => true,
165
+ readLine: async () => {
166
+ // The secret has been printed by the time the prompt fires.
167
+ const totp = new OTPAuth.TOTP({
168
+ issuer: "Parachute Hub",
169
+ label: "owner",
170
+ algorithm: "SHA1",
171
+ digits: 6,
172
+ period: 30,
173
+ secret: OTPAuth.Secret.fromBase32(capturedSecret),
174
+ });
175
+ code = totp.generate();
176
+ return code;
177
+ },
178
+ });
179
+ } finally {
180
+ console.log = origLog;
181
+ }
182
+ expect(exitCode).toBe(0);
183
+ expect(capturedSecret.length).toBeGreaterThan(0);
184
+ expect(stdout).toContain("now ON");
185
+ // 10 backup codes printed (hyphenated form).
186
+ const backupLines = stdout.split("\n").filter((l) => /^ {2}[a-z2-9]{5}-[a-z2-9]{5}$/.test(l));
187
+ expect(backupLines.length).toBe(10);
188
+ expect(code.length).toBe(6);
189
+ // The persisted state reflects the enrollment: the captured secret is
190
+ // now stored on the user row. (We don't re-verify `code` — the enroll
191
+ // confirm consumed it via the replay cache, by design.)
192
+ const db2 = openHubDb(tmp.dbPath);
193
+ const { getTotpState } = await import("../two-factor-store.ts");
194
+ const uid = listUsers(db2)[0]!.id;
195
+ const persisted = getTotpState(db2, uid);
196
+ expect(persisted.secret).toBe(capturedSecret);
197
+ expect(persisted.backupCodes.length).toBe(10);
198
+ db2.close();
199
+ } finally {
200
+ tmp.cleanup();
201
+ }
202
+ });
203
+
204
+ test("2fa enroll refuses to re-enroll when already on", async () => {
205
+ const tmp = makeTmp();
206
+ try {
207
+ const db = openHubDb(tmp.dbPath);
208
+ const u = await createUser(db, "owner", "owner-password-123");
209
+ const { generateTotpSecret } = await import("../totp.ts");
210
+ const { persistEnrollment } = await import("../two-factor-store.ts");
211
+ await persistEnrollment(db, u.id, generateTotpSecret("owner").secret);
212
+ db.close();
213
+ const out = await captureOutput(() =>
214
+ auth(["2fa", "enroll"], { dbPath: tmp.dbPath, isInteractive: () => true }),
215
+ );
216
+ expect(out.code).toBe(1);
217
+ expect(out.stderr).toContain("already enabled");
218
+ } finally {
219
+ tmp.cleanup();
220
+ }
88
221
  });
89
222
 
90
223
  test("set-password no longer forwards to vault", async () => {
@@ -138,12 +271,13 @@ describe("authHelp", () => {
138
271
  expect(h).toContain("parachute auth rotate-key");
139
272
  });
140
273
 
141
- test("2fa help is honest about hub-login TOTP not being shipped (#473)", () => {
274
+ test("2fa help documents the real hub-login TOTP subcommands (#473)", () => {
142
275
  expect(h).toContain("#473");
143
- // No longer advertises the dead enroll/disable/backup-codes subcommands.
144
- expect(h).not.toContain("2fa enroll");
145
- expect(h).not.toContain("2fa disable");
146
- expect(h).not.toContain("2fa backup-codes");
276
+ // Real enroll / disenroll subcommands are now advertised.
277
+ expect(h).toContain("2fa enroll");
278
+ expect(h).toContain("2fa disenroll");
279
+ expect(h).toContain("otpauth://");
280
+ expect(h).toContain("backup codes");
147
281
  });
148
282
 
149
283
  test("set-password help mentions the new flags + hub-local home", () => {
@@ -24,46 +24,53 @@ describe("is2FAEnrolled", () => {
24
24
  expect(is2FAEnrolled({ status: status({ hasTotp: false }) })).toBe(false);
25
25
  });
26
26
 
27
- test("reads totp_secret from vaultHome's config.yaml when status not supplied", () => {
27
+ test("falls back to legacy config.yaml totp_secret when hub.db is absent", () => {
28
+ // hub#473: hub.db is the source of truth for real 2FA, but a super-old
29
+ // install with no hub.db still suppresses the warning if the legacy vault
30
+ // YAML totp_secret is set. Point hubDbPath at a nonexistent file so the
31
+ // YAML fallback is exercised.
28
32
  const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
29
33
  try {
30
34
  writeFileSync(join(dir, "config.yaml"), 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
31
- expect(is2FAEnrolled({ vaultHome: dir })).toBe(true);
35
+ expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(true);
32
36
  } finally {
33
37
  rmSync(dir, { recursive: true, force: true });
34
38
  }
35
39
  });
36
40
 
37
- test("missing config.yaml → not enrolled (false)", () => {
41
+ test("missing config.yaml + absent hub.db → not enrolled (false)", () => {
38
42
  const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
39
43
  try {
40
- // No config.yaml written.
41
- expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
44
+ // No config.yaml written, no hub.db.
45
+ expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(false);
42
46
  } finally {
43
47
  rmSync(dir, { recursive: true, force: true });
44
48
  }
45
49
  });
46
50
 
47
- test("empty totp_secret value → not enrolled (matches vault's readGlobalConfig)", () => {
51
+ test("empty totp_secret value + absent hub.db → not enrolled", () => {
48
52
  const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
49
53
  try {
50
54
  writeFileSync(join(dir, "config.yaml"), 'totp_secret: ""\n');
51
- expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
55
+ expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(false);
52
56
  } finally {
53
57
  rmSync(dir, { recursive: true, force: true });
54
58
  }
55
59
  });
56
60
 
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.
61
+ test("hub.db with an enrolled user enrolled (the real signal)", async () => {
63
62
  const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
64
63
  try {
65
- writeFileSync(join(dir, "config.yaml"), "totp_secret: [unbalanced\n ::: not yaml\n");
66
- expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
64
+ const { hubDbPath, openHubDb } = await import("../hub-db.ts");
65
+ const { createUser } = await import("../users.ts");
66
+ const { persistEnrollment } = await import("../two-factor-store.ts");
67
+ const { generateTotpSecret } = await import("../totp.ts");
68
+ const dbPath = hubDbPath(dir);
69
+ const db = openHubDb(dbPath);
70
+ const u = await createUser(db, "owner", "owner-password-123");
71
+ await persistEnrollment(db, u.id, generateTotpSecret("owner").secret);
72
+ db.close();
73
+ expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: dbPath })).toBe(true);
67
74
  } finally {
68
75
  rmSync(dir, { recursive: true, force: true });
69
76
  }
@@ -80,13 +87,14 @@ describe("printPublic2FAWarning", () => {
80
87
  });
81
88
  expect(fired).toBe(true);
82
89
  const joined = logs.join("\n");
83
- // Honest copy: /login is public, owner password is the wall, hub-login 2FA
84
- // is coming (#473) does NOT recommend the dead `auth 2fa enroll` path.
90
+ // hub#473: real hub-login 2FA. The warning now recommends the real
91
+ // `parachute auth 2fa enroll` path (+ the /account/2fa browser path) and
92
+ // still nudges a strong owner password.
85
93
  expect(joined).toContain("/login is now reachable on the public internet");
86
94
  expect(joined).toContain("https://vault.example.com/login");
87
- expect(joined).toContain("#473");
95
+ expect(joined).toContain("parachute auth 2fa enroll");
96
+ expect(joined).toContain("/account/2fa");
88
97
  expect(joined).toContain("parachute auth set-password");
89
- expect(joined).not.toContain("parachute auth 2fa enroll");
90
98
  });
91
99
 
92
100
  test("enrolled → suppressed, returns false, logs nothing", () => {
@@ -41,8 +41,8 @@ function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
41
41
  }
42
42
 
43
43
  describe("runAuthPreflight — wide open (no owner password)", () => {
44
- test("warns loudly, offers password only, prints hub-JWT token guidance + honest 2FA note", async () => {
45
- const h = makeHarness(["y"]); // password yes
44
+ test("warns loudly, offers password + real 2FA enroll, prints hub-JWT token guidance", async () => {
45
+ const h = makeHarness(["y", "y"]); // password yes, 2FA yes
46
46
  await runAuthPreflight({ status: status(), ...wire(h) });
47
47
  const joined = h.logs.join("\n");
48
48
  expect(joined).toContain("No owner password");
@@ -50,42 +50,38 @@ describe("runAuthPreflight — wide open (no owner password)", () => {
50
50
  // Programmatic-client guidance points at the hub mint path, not pvt_*.
51
51
  expect(joined).toContain("parachute auth mint-token --scope vault:default:read");
52
52
  expect(joined).toContain("Bearer <hub-jwt>");
53
- // Honest 2FA state — coming (#473), not offered as a dead enroll command.
54
- expect(joined).toContain("#473");
55
- expect(joined).not.toContain("2fa enroll");
56
- // Only password is an interactive offer; token guidance + 2FA note are
57
- // printed, not prompted.
58
- expect(h.commands).toHaveLength(1);
59
- expect(h.commands[0]).toEqual(["parachute", "auth", "set-password"]);
60
- expect(h.prompts).toHaveLength(1);
53
+ // Real 2FA (hub#473) both commands offered + run.
54
+ expect(h.commands.map((c) => c.join(" "))).toEqual([
55
+ "parachute auth set-password",
56
+ "parachute auth 2fa enroll",
57
+ ]);
58
+ // Two interactive offers now: password + 2FA. Token guidance is printed.
59
+ expect(h.prompts).toHaveLength(2);
61
60
  });
62
61
 
63
62
  test("token guidance uses the first discovered vault name", async () => {
64
- const h = makeHarness(["n"]);
63
+ const h = makeHarness(["n", "n"]);
65
64
  await runAuthPreflight({ status: status({ vaultNames: ["work"] }), ...wire(h) });
66
65
  expect(h.logs.join("\n")).toContain("--scope vault:work:read");
67
66
  });
68
67
 
69
- test("user declines the password prompt → no subprocesses run", async () => {
70
- const h = makeHarness([""]); // Enter = skip
68
+ test("user declines both prompts → no subprocesses run", async () => {
69
+ const h = makeHarness(["", ""]); // Enter = skip both
71
70
  await runAuthPreflight({ status: status(), ...wire(h) });
72
71
  expect(h.commands).toHaveLength(0);
73
- // Only one prompt now (password); token guidance + 2FA note aren't prompts.
74
- expect(h.prompts).toHaveLength(1);
72
+ expect(h.prompts).toHaveLength(2);
75
73
  });
76
74
 
77
75
  test("user accepts the password offer → set-password invoked", async () => {
78
- const h = makeHarness(["y"]);
76
+ const h = makeHarness(["y", "n"]);
79
77
  await runAuthPreflight({ status: status(), ...wire(h) });
80
78
  expect(h.commands.map((c) => c.join(" "))).toEqual(["parachute auth set-password"]);
81
79
  });
82
80
 
83
81
  test("null tokenCount with no owner password still classifies wide-open", async () => {
84
82
  // The `tokenCount: null` (unreadable vault DB) path is vestigial post-DROP
85
- // — `classify()` gates on `hasOwnerPassword` alone. A box with no owner
86
- // password AND an unreadable token DB must still take the loud wide-open
87
- // branch, not silently fall through to a quieter state.
88
- const h = makeHarness(["n"]);
83
+ // — `classify()` gates on `hasOwnerPassword` alone.
84
+ const h = makeHarness(["n", "n"]);
89
85
  await runAuthPreflight({
90
86
  status: status({ hasOwnerPassword: false, tokenCount: null }),
91
87
  ...wire(h),
@@ -93,58 +89,64 @@ describe("runAuthPreflight — wide open (no owner password)", () => {
93
89
  const joined = h.logs.join("\n");
94
90
  expect(joined).toContain("No owner password");
95
91
  expect(joined).toContain("public internet");
96
- // Wide-open offers the password (one prompt); not the password-set path.
97
- expect(h.prompts).toHaveLength(1);
92
+ expect(h.prompts).toHaveLength(2);
98
93
  });
99
94
 
100
- test("never offers a dead command (vault tokens create OR auth 2fa enroll)", async () => {
101
- const h = makeHarness(["y"]);
95
+ test("never offers the dead vault-tokens-create command", async () => {
96
+ const h = makeHarness(["y", "n"]);
102
97
  await runAuthPreflight({ status: status(), ...wire(h) });
103
98
  const allCommands = h.commands.map((c) => c.join(" ")).join("\n");
104
99
  expect(allCommands).not.toContain("vault tokens create");
105
- expect(allCommands).not.toContain("auth 2fa enroll");
106
- // And no log line steers the operator at a dead command as guidance.
107
100
  const guidance = h.logs.join("\n");
108
101
  expect(guidance).not.toContain("parachute vault tokens create");
109
- expect(guidance).not.toContain("parachute auth 2fa enroll");
110
102
  });
111
103
  });
112
104
 
113
- describe("runAuthPreflight — password set", () => {
114
- test("single confirmation line + honest 2FA note, no prompts (ignores vestigial tokenCount)", async () => {
115
- const h = makeHarness([]);
105
+ describe("runAuthPreflight — password set, no 2FA", () => {
106
+ test("confirms password set + offers real 2FA enroll (ignores vestigial tokenCount)", async () => {
107
+ const h = makeHarness(["n"]); // decline 2FA
116
108
  await runAuthPreflight({
117
109
  // tokenCount is non-zero (vestigial pvt_* rows) but no longer consulted.
118
- status: status({ hasOwnerPassword: true, tokenCount: 3 }),
110
+ status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: 3 }),
119
111
  ...wire(h),
120
112
  });
121
113
  const joined = h.logs.join("\n");
122
114
  expect(joined).toContain("Owner password is set");
123
- // Honest 2FA note (#473) — not a prompt, not the dead enroll command.
124
- expect(joined).toContain("#473");
125
- expect(joined).not.toContain("2fa enroll");
126
- expect(h.prompts).toHaveLength(0);
127
- expect(h.commands).toHaveLength(0);
115
+ // Real 2FA offer (hub#473) — exactly one prompt (the 2FA offer).
116
+ expect(h.prompts).toHaveLength(1);
117
+ expect(h.commands).toHaveLength(0); // declined
118
+ });
119
+
120
+ test("accepting the 2FA offer runs `parachute auth 2fa enroll`", async () => {
121
+ const h = makeHarness(["y"]);
122
+ await runAuthPreflight({
123
+ status: status({ hasOwnerPassword: true, hasTotp: false }),
124
+ ...wire(h),
125
+ });
126
+ expect(h.commands.map((c) => c.join(" "))).toEqual(["parachute auth 2fa enroll"]);
128
127
  });
129
128
 
130
129
  test("null tokenCount (DB unreadable) is irrelevant — password gates the branch", async () => {
131
- const h = makeHarness([]);
130
+ const h = makeHarness(["n"]);
132
131
  await runAuthPreflight({
133
132
  status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: null }),
134
133
  ...wire(h),
135
134
  });
136
135
  expect(h.logs.join("\n")).toContain("Owner password is set");
137
- expect(h.prompts).toHaveLength(0);
138
- expect(h.commands).toHaveLength(0);
136
+ expect(h.prompts).toHaveLength(1);
139
137
  });
138
+ });
140
139
 
141
- test("password + legacy vault TOTP — still the quiet password-set path", async () => {
140
+ describe("runAuthPreflight — password + 2FA both set", () => {
141
+ test("two-line confirmation, no prompts", async () => {
142
142
  const h = makeHarness([]);
143
143
  await runAuthPreflight({
144
144
  status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 0 }),
145
145
  ...wire(h),
146
146
  });
147
- expect(h.logs.join("\n")).toContain("Owner password is set");
147
+ const joined = h.logs.join("\n");
148
+ expect(joined).toContain("Owner password is set");
149
+ expect(joined).toContain("Two-factor authentication is on");
148
150
  expect(h.prompts).toHaveLength(0);
149
151
  expect(h.commands).toHaveLength(0);
150
152
  });
@@ -152,7 +154,7 @@ describe("runAuthPreflight — password set", () => {
152
154
 
153
155
  describe("runAuthPreflight — subprocess failure handling", () => {
154
156
  test("non-zero exit from set-password doesn't abort the rest of the preflight", async () => {
155
- const h = makeHarness(["y"]);
157
+ const h = makeHarness(["y", "n"]);
156
158
  const interactiveRunner = async (cmd: readonly string[]) => {
157
159
  h.commands.push([...cmd]);
158
160
  return 7;
@@ -177,11 +179,11 @@ describe("runAuthPreflight — subprocess failure handling", () => {
177
179
 
178
180
  describe("runAuthPreflight — case-insensitive yes", () => {
179
181
  test('"Y", "YES", and "y" all count as affirmative; anything else is decline', async () => {
180
- // Now only the wide-open path prompts (for the password). Drive it there.
182
+ // Drive the password-set-no-2FA path so there's exactly one prompt (2FA).
181
183
  for (const yes of ["y", "Y", "yes", "YES"]) {
182
184
  const h = makeHarness([yes]);
183
185
  await runAuthPreflight({
184
- status: status({ hasOwnerPassword: false }),
186
+ status: status({ hasOwnerPassword: true, hasTotp: false }),
185
187
  ...wire(h),
186
188
  });
187
189
  expect(h.commands).toHaveLength(1);
@@ -189,7 +191,7 @@ describe("runAuthPreflight — case-insensitive yes", () => {
189
191
  for (const no of ["", "n", "no", "q", "bogus"]) {
190
192
  const h = makeHarness([no]);
191
193
  await runAuthPreflight({
192
- status: status({ hasOwnerPassword: false }),
194
+ status: status({ hasOwnerPassword: true, hasTotp: false }),
193
195
  ...wire(h),
194
196
  });
195
197
  expect(h.commands).toHaveLength(0);
@@ -683,12 +683,11 @@ describe("exposeCloudflareUp", () => {
683
683
 
684
684
  expect(code).toBe(0);
685
685
  const joined = logs.join("\n");
686
- // Honest copy: /login is public, owner password is the wall, hub-login
687
- // 2FA is coming (#473) — does NOT recommend the dead `2fa enroll`.
686
+ // hub#473: real hub-login 2FA the warning now recommends the real
687
+ // `parachute auth 2fa enroll` path.
688
688
  expect(joined).toContain("/login is now reachable on the public internet");
689
689
  expect(joined).toContain("https://vault.example.com/login");
690
- expect(joined).toContain("#473");
691
- expect(joined).not.toContain("parachute auth 2fa enroll");
690
+ expect(joined).toContain("parachute auth 2fa enroll");
692
691
  } finally {
693
692
  env.cleanup();
694
693
  }
@@ -736,11 +735,12 @@ describe("exposeCloudflareUp", () => {
736
735
  expect(code).toBe(0);
737
736
  const joined = logs.join("\n");
738
737
  expect(joined).not.toContain("/login is now reachable on the public internet");
739
- // The contextual 2FA warning is suppressed (legacy vault TOTP present);
740
- // the always-shown owner-password guidance from `printAuthGuidance`
741
- // still appears, and it no longer recommends the dead `2fa enroll`.
738
+ // The contextual 2FA warning is suppressed (2FA already enrolled); the
739
+ // always-shown owner-password guidance from `printAuthGuidance` still
740
+ // appears, and it now (hub#473) also surfaces the real `2fa enroll`
741
+ // path in the humans section.
742
742
  expect(joined).toContain("parachute auth set-password");
743
- expect(joined).not.toContain("parachute auth 2fa enroll");
743
+ expect(joined).toContain("parachute auth 2fa enroll");
744
744
  } finally {
745
745
  env.cleanup();
746
746
  }
@@ -1009,11 +1009,10 @@ describe("expose public up", () => {
1009
1009
  });
1010
1010
  expect(code).toBe(0);
1011
1011
  const joined = logs.join("\n");
1012
- // Honest copy: /login is public, owner password is the wall, hub-login
1013
- // 2FA is coming (#473) — no longer recommends the dead `2fa enroll`.
1012
+ // hub#473: real hub-login 2FA. The warning now recommends the real
1013
+ // `parachute auth 2fa enroll` path.
1014
1014
  expect(joined).toContain("/login is now reachable on the public internet");
1015
- expect(joined).toContain("#473");
1016
- expect(joined).not.toContain("parachute auth 2fa enroll");
1015
+ expect(joined).toContain("parachute auth 2fa enroll");
1017
1016
  // /login pointer uses the canonical https://<fqdn> origin.
1018
1017
  expect(joined).toContain("https://parachute.taildf9ce2.ts.net/login");
1019
1018
  } finally {