@openparachute/hub 0.7.4-rc.1 → 0.7.4-rc.11

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 (38) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-vaults.test.ts +164 -1
  3. package/src/__tests__/api-account-2fa.test.ts +381 -0
  4. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  5. package/src/__tests__/clients.test.ts +28 -8
  6. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  7. package/src/__tests__/hub-server.test.ts +127 -5
  8. package/src/__tests__/init.test.ts +153 -0
  9. package/src/__tests__/managed-unit.test.ts +62 -0
  10. package/src/__tests__/oauth-handlers.test.ts +626 -0
  11. package/src/__tests__/oauth-ui.test.ts +107 -1
  12. package/src/__tests__/scope-explanations.test.ts +19 -0
  13. package/src/__tests__/setup-wizard.test.ts +124 -7
  14. package/src/__tests__/status-supervisor.test.ts +152 -3
  15. package/src/__tests__/supervisor.test.ts +25 -0
  16. package/src/__tests__/vault-names.test.ts +32 -3
  17. package/src/__tests__/well-known.test.ts +37 -2
  18. package/src/admin-vaults.ts +7 -12
  19. package/src/api-account-2fa.ts +373 -0
  20. package/src/api-hub-upgrade.ts +38 -3
  21. package/src/api-me.ts +11 -2
  22. package/src/cli.ts +27 -5
  23. package/src/clients.ts +14 -0
  24. package/src/commands/init.ts +108 -0
  25. package/src/commands/status.ts +108 -5
  26. package/src/help.ts +12 -1
  27. package/src/hub-server.ts +72 -7
  28. package/src/managed-unit.ts +30 -1
  29. package/src/oauth-handlers.ts +103 -6
  30. package/src/oauth-ui.ts +174 -0
  31. package/src/scope-explanations.ts +2 -1
  32. package/src/setup-wizard.ts +40 -21
  33. package/src/supervisor.ts +46 -2
  34. package/src/vault-names.ts +15 -4
  35. package/src/well-known.ts +10 -1
  36. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  37. package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
  38. package/web/ui/dist/index.html +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.1",
3
+ "version": "0.7.4-rc.11",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -11,15 +11,8 @@
11
11
  "bin": {
12
12
  "parachute": "src/cli.ts"
13
13
  },
14
- "workspaces": [
15
- "packages/*"
16
- ],
17
- "files": [
18
- "src",
19
- "web/ui/dist",
20
- "README.md",
21
- "LICENSE"
22
- ],
14
+ "workspaces": ["packages/*"],
15
+ "files": ["src", "web/ui/dist", "README.md", "LICENSE"],
23
16
  "repository": {
24
17
  "type": "git",
25
18
  "url": "https://github.com/ParachuteComputer/parachute-hub.git"
@@ -47,7 +40,7 @@
47
40
  },
48
41
  "dependencies": {
49
42
  "@node-rs/argon2": "^2.0.2",
50
- "@openparachute/depcheck": "0.1.0",
43
+ "@openparachute/depcheck": "0.1.1",
51
44
  "jose": "^6.2.2",
52
45
  "otpauth": "^9.5.0",
53
46
  "qrcode": "^1.5.4"
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { HOST_ADMIN_SCOPE, type RunResult, handleCreateVault } from "../admin-vaults.ts";
5
+ import {
6
+ HOST_ADMIN_SCOPE,
7
+ type RunResult,
8
+ handleCreateVault,
9
+ listVaultInstanceNames,
10
+ provisionVault,
11
+ } from "../admin-vaults.ts";
6
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
7
13
  import { signAccessToken } from "../jwt-sign.ts";
8
14
  import { upsertService, writeManifest } from "../services-manifest.ts";
@@ -1304,3 +1310,160 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1304
1310
  }
1305
1311
  });
1306
1312
  });
1313
+
1314
+ // ===========================================================================
1315
+ // #478 — empty-paths vault rows must not resolve to phantom "default"
1316
+ // ===========================================================================
1317
+
1318
+ describe("#478 — empty-paths vault row tolerance", () => {
1319
+ test("findExistingVault: empty-paths vault row does NOT match 'default'", () => {
1320
+ // A vault module registered in services.json with paths:[] is "installed
1321
+ // but no servable vault instance". Hub must skip it — never synthesize a
1322
+ // phantom "default" — so provisionVault can proceed to a real create.
1323
+ const h = makeHarness();
1324
+ try {
1325
+ // Write a services.json with a parachute-vault entry carrying paths:[].
1326
+ writeManifest(
1327
+ {
1328
+ services: [
1329
+ {
1330
+ name: "parachute-vault",
1331
+ port: 1940,
1332
+ paths: [],
1333
+ health: "/health",
1334
+ version: "0.5.0",
1335
+ },
1336
+ ],
1337
+ },
1338
+ h.manifestPath,
1339
+ );
1340
+ // Calling provisionVault("default") internally calls findExistingVault.
1341
+ // We verify the behaviour indirectly via listVaultInstanceNames (exported
1342
+ // for this test) and via provisionVault's created:true path below.
1343
+ const names = listVaultInstanceNames(h.manifestPath);
1344
+ expect(names.has("default")).toBe(false);
1345
+ } finally {
1346
+ h.cleanup();
1347
+ }
1348
+ });
1349
+
1350
+ test("listVaultInstanceNames: empty-paths vault row is omitted from the Set", () => {
1351
+ const h = makeHarness();
1352
+ try {
1353
+ writeManifest(
1354
+ {
1355
+ services: [
1356
+ {
1357
+ name: "parachute-vault",
1358
+ port: 1940,
1359
+ paths: [],
1360
+ health: "/health",
1361
+ version: "0.5.0",
1362
+ },
1363
+ ],
1364
+ },
1365
+ h.manifestPath,
1366
+ );
1367
+ const names = listVaultInstanceNames(h.manifestPath);
1368
+ expect(names.size).toBe(0);
1369
+ } finally {
1370
+ h.cleanup();
1371
+ }
1372
+ });
1373
+
1374
+ test("provisionVault: empty-paths row → created:true (proceeds to orchestrate, not false 'already exists')", async () => {
1375
+ // Core regression test for #478: before the fix, an empty-paths row
1376
+ // resolved to phantom "default" → findExistingVault returned non-null →
1377
+ // provisionVault short-circuited to created:false with "already exists".
1378
+ // After the fix: findExistingVault returns null → orchestrate runs →
1379
+ // created:true.
1380
+ const h = makeHarness();
1381
+ try {
1382
+ const db = openHubDb(hubDbPath(h.dir));
1383
+ try {
1384
+ rotateSigningKey(db);
1385
+ // Seed an empty-paths vault row (what vault's self-register emits at
1386
+ // zero vaults, per the #478 contract).
1387
+ writeManifest(
1388
+ {
1389
+ services: [
1390
+ {
1391
+ name: "parachute-vault",
1392
+ port: 1940,
1393
+ paths: [],
1394
+ health: "/health",
1395
+ version: "0.5.0",
1396
+ },
1397
+ ],
1398
+ },
1399
+ h.manifestPath,
1400
+ );
1401
+
1402
+ const calls: Array<readonly string[]> = [];
1403
+ const runCommand = async (cmd: readonly string[]): Promise<RunResult> => {
1404
+ calls.push(cmd);
1405
+ // Simulate vault CLI writing the real path into services.json after
1406
+ // a successful create. Because vault IS already registered (paths:[]),
1407
+ // orchestrate picks the `parachute-vault create --json` branch and
1408
+ // expects JSON stdout.
1409
+ upsertService(
1410
+ {
1411
+ name: "parachute-vault",
1412
+ port: 1940,
1413
+ paths: ["/vault/default"],
1414
+ health: "/health",
1415
+ version: "0.5.0",
1416
+ },
1417
+ h.manifestPath,
1418
+ );
1419
+ return { exitCode: 0, stdout: vaultCreateJson("default"), stderr: "" };
1420
+ };
1421
+
1422
+ const result = await provisionVault("default", {
1423
+ issuer: ISSUER,
1424
+ manifestPath: h.manifestPath,
1425
+ runCommand,
1426
+ });
1427
+
1428
+ // Must have proceeded to orchestrate and returned created:true.
1429
+ expect(result.ok).toBe(true);
1430
+ if (!result.ok) return; // narrow for TS
1431
+ expect(result.created).toBe(true);
1432
+ // The orchestration command ran (not short-circuited).
1433
+ expect(calls.length).toBeGreaterThan(0);
1434
+ } finally {
1435
+ db.close();
1436
+ }
1437
+ } finally {
1438
+ h.cleanup();
1439
+ }
1440
+ });
1441
+
1442
+ test("listVaultInstanceNames: real paths still enumerate correctly (empty-paths does not break them)", () => {
1443
+ // Sanity: mixing an empty-paths row with a real-paths row — the real
1444
+ // paths are still found, the empty one is still skipped.
1445
+ const h = makeHarness();
1446
+ try {
1447
+ writeManifest(
1448
+ {
1449
+ services: [
1450
+ {
1451
+ name: "parachute-vault",
1452
+ port: 1940,
1453
+ paths: ["/vault/default", "/vault/work"],
1454
+ health: "/health",
1455
+ version: "0.5.0",
1456
+ },
1457
+ ],
1458
+ },
1459
+ h.manifestPath,
1460
+ );
1461
+ const names = listVaultInstanceNames(h.manifestPath);
1462
+ expect(names.has("default")).toBe(true);
1463
+ expect(names.has("work")).toBe(true);
1464
+ expect(names.size).toBe(2);
1465
+ } finally {
1466
+ h.cleanup();
1467
+ }
1468
+ });
1469
+ });
@@ -0,0 +1,381 @@
1
+ /**
2
+ * `/api/account/*` JSON self-service endpoints (hub#85): password change +
3
+ * 2FA start/confirm/disable. Plus `/api/me`'s `two_factor_enabled` field.
4
+ *
5
+ * Coverage:
6
+ * - auth: no session → 401; wrong CSRF → 403; self-only (keyed off session)
7
+ * - password: happy path (hash rotated + tokens revoked); wrong current →
8
+ * 401; too short → 400; mismatch handled client-side (not here); new ===
9
+ * current → 400; too long → 413
10
+ * - 2fa start → secret + qr; already-enrolled → 409
11
+ * - 2fa confirm: round-trip with a live code persists + returns backup codes;
12
+ * bad code → 400; malformed secret → 400
13
+ * - 2fa disable: password-gated (wrong → 401), clears enrollment; idempotent
14
+ * - /api/me reflects two_factor_enabled
15
+ */
16
+ import type { Database } from "bun:sqlite";
17
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
18
+ import { mkdtempSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import * as OTPAuth from "otpauth";
22
+ import { handleApiAccount } from "../api-account-2fa.ts";
23
+ import { handleApiMe } from "../api-me.ts";
24
+ import { CSRF_COOKIE_NAME } from "../csrf.ts";
25
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
26
+ import { recordTokenMint } from "../jwt-sign.ts";
27
+ import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
28
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
29
+ import { _resetTotpReplayCache, generateTotpSecret } from "../totp.ts";
30
+ import { isTotpEnrolled, persistEnrollment } from "../two-factor-store.ts";
31
+ import { createUser, verifyPassword } from "../users.ts";
32
+
33
+ const TEST_CSRF = "csrf-account-2fa-token";
34
+ const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
35
+
36
+ interface Harness {
37
+ db: Database;
38
+ configDir: string;
39
+ cleanup: () => void;
40
+ }
41
+
42
+ function makeHarness(): Harness {
43
+ const configDir = mkdtempSync(join(tmpdir(), "phub-api-account-2fa-"));
44
+ const db = openHubDb(hubDbPath(configDir));
45
+ return {
46
+ db,
47
+ configDir,
48
+ cleanup: () => {
49
+ db.close();
50
+ rmSync(configDir, { recursive: true, force: true });
51
+ },
52
+ };
53
+ }
54
+
55
+ async function userWithSession(
56
+ db: Database,
57
+ username: string,
58
+ password: string,
59
+ ): Promise<{ userId: string; cookie: string }> {
60
+ const user = await createUser(db, username, password, { passwordChanged: true });
61
+ const session = createSession(db, { userId: user.id });
62
+ const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`;
63
+ return { userId: user.id, cookie };
64
+ }
65
+
66
+ function post(
67
+ subpath: string,
68
+ cookie: string | null,
69
+ body: Record<string, unknown>,
70
+ ): Promise<Response> {
71
+ const headers: Record<string, string> = { "content-type": "application/json" };
72
+ if (cookie) headers.cookie = cookie;
73
+ return handleApiAccount(
74
+ new Request(`http://hub.test/api/account${subpath}`, {
75
+ method: "POST",
76
+ headers,
77
+ body: JSON.stringify(body),
78
+ }),
79
+ subpath,
80
+ { db: harness.db },
81
+ );
82
+ }
83
+
84
+ function liveCode(secretBase32: string, label = "owner"): string {
85
+ return new OTPAuth.TOTP({
86
+ issuer: "Parachute Hub",
87
+ label,
88
+ algorithm: "SHA1",
89
+ digits: 6,
90
+ period: 30,
91
+ secret: OTPAuth.Secret.fromBase32(secretBase32),
92
+ }).generate();
93
+ }
94
+
95
+ let harness: Harness;
96
+ beforeEach(() => {
97
+ harness = makeHarness();
98
+ resetRateLimit();
99
+ _resetTotpReplayCache();
100
+ });
101
+ afterEach(() => {
102
+ harness.cleanup();
103
+ });
104
+
105
+ describe("/api/account/* — auth posture", () => {
106
+ test("no session → 401", async () => {
107
+ const res = await post("/password", null, {
108
+ __csrf: TEST_CSRF,
109
+ current_password: "x",
110
+ new_password: "y",
111
+ });
112
+ expect(res.status).toBe(401);
113
+ });
114
+
115
+ test("wrong CSRF → 403", async () => {
116
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
117
+ const res = await post("/password", cookie, {
118
+ __csrf: "not-the-token",
119
+ current_password: "owner-password-123",
120
+ new_password: "brand-new-passphrase",
121
+ });
122
+ expect(res.status).toBe(403);
123
+ });
124
+
125
+ test("unknown subpath → 404", async () => {
126
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
127
+ const res = await post("/bogus", cookie, { __csrf: TEST_CSRF });
128
+ expect(res.status).toBe(404);
129
+ });
130
+
131
+ test("GET → 405", async () => {
132
+ const res = await handleApiAccount(
133
+ new Request("http://hub.test/api/account/password", { method: "GET" }),
134
+ "/password",
135
+ { db: harness.db },
136
+ );
137
+ expect(res.status).toBe(405);
138
+ });
139
+ });
140
+
141
+ describe("/api/account/password", () => {
142
+ test("happy path rotates the hash + revokes active tokens", async () => {
143
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
144
+ // Seed an active token for this user — it should be revoked.
145
+ recordTokenMint(harness.db, {
146
+ jti: "tok-1",
147
+ userId,
148
+ subject: userId,
149
+ clientId: "cli",
150
+ scopes: ["vault:default:read"],
151
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
152
+ createdVia: "cli_mint",
153
+ });
154
+
155
+ const res = await post("/password", cookie, {
156
+ __csrf: TEST_CSRF,
157
+ current_password: "owner-password-123",
158
+ new_password: "brand-new-passphrase",
159
+ });
160
+ expect(res.status).toBe(200);
161
+
162
+ // New password verifies; old does not.
163
+ const row = harness.db
164
+ .query<{ password_hash: string }, [string]>("SELECT password_hash FROM users WHERE id = ?")
165
+ .get(userId);
166
+ expect(row).not.toBeNull();
167
+ const fakeUser = { passwordHash: row!.password_hash } as Parameters<typeof verifyPassword>[0];
168
+ expect(await verifyPassword(fakeUser, "brand-new-passphrase")).toBe(true);
169
+ expect(await verifyPassword(fakeUser, "owner-password-123")).toBe(false);
170
+
171
+ // Token revoked.
172
+ const tok = harness.db
173
+ .query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
174
+ .get("tok-1");
175
+ expect(tok?.revoked_at).not.toBeNull();
176
+ });
177
+
178
+ test("wrong current password → 401 invalid_credentials", async () => {
179
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
180
+ const res = await post("/password", cookie, {
181
+ __csrf: TEST_CSRF,
182
+ current_password: "WRONG",
183
+ new_password: "brand-new-passphrase",
184
+ });
185
+ expect(res.status).toBe(401);
186
+ const body = (await res.json()) as { error: string };
187
+ expect(body.error).toBe("invalid_credentials");
188
+ });
189
+
190
+ test("new password too short → 400 invalid_password", async () => {
191
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
192
+ const res = await post("/password", cookie, {
193
+ __csrf: TEST_CSRF,
194
+ current_password: "owner-password-123",
195
+ new_password: "short",
196
+ });
197
+ expect(res.status).toBe(400);
198
+ const body = (await res.json()) as { error: string };
199
+ expect(body.error).toBe("invalid_password");
200
+ });
201
+
202
+ test("new === current → 400 password_unchanged", async () => {
203
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
204
+ const res = await post("/password", cookie, {
205
+ __csrf: TEST_CSRF,
206
+ current_password: "owner-password-123",
207
+ new_password: "owner-password-123",
208
+ });
209
+ expect(res.status).toBe(400);
210
+ const body = (await res.json()) as { error: string };
211
+ expect(body.error).toBe("password_unchanged");
212
+ });
213
+
214
+ test("missing fields → 400", async () => {
215
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
216
+ const res = await post("/password", cookie, { __csrf: TEST_CSRF });
217
+ expect(res.status).toBe(400);
218
+ });
219
+
220
+ test("new password over PASSWORD_MAX_LEN → 413 (before argon2id hash)", async () => {
221
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
222
+ const res = await post("/password", cookie, {
223
+ __csrf: TEST_CSRF,
224
+ current_password: "owner-password-123",
225
+ new_password: "x".repeat(257),
226
+ });
227
+ expect(res.status).toBe(413);
228
+ });
229
+
230
+ test("rate-limited after repeated wrong-current attempts → 429", async () => {
231
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
232
+ // Bucket is 3 attempts / 5 min (CHANGE_PASSWORD_*). Burn 3 wrong-current
233
+ // attempts (each 401), then the 4th is rejected at 429 BEFORE the verify.
234
+ for (let i = 0; i < 3; i++) {
235
+ const r = await post("/password", cookie, {
236
+ __csrf: TEST_CSRF,
237
+ current_password: "WRONG",
238
+ new_password: "brand-new-passphrase",
239
+ });
240
+ expect(r.status).toBe(401);
241
+ }
242
+ const limited = await post("/password", cookie, {
243
+ __csrf: TEST_CSRF,
244
+ current_password: "WRONG",
245
+ new_password: "brand-new-passphrase",
246
+ });
247
+ expect(limited.status).toBe(429);
248
+ expect(limited.headers.get("retry-after")).not.toBeNull();
249
+ });
250
+ });
251
+
252
+ describe("/api/account/2fa start + confirm", () => {
253
+ test("start returns a secret + otpauth_url + qr_data_url", async () => {
254
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
255
+ const res = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
256
+ expect(res.status).toBe(200);
257
+ const body = (await res.json()) as {
258
+ secret: string;
259
+ otpauth_url: string;
260
+ qr_data_url: string;
261
+ };
262
+ expect(body.secret).toMatch(/^[A-Z2-7]+$/);
263
+ expect(body.otpauth_url.startsWith("otpauth://totp/")).toBe(true);
264
+ expect(body.qr_data_url.startsWith("data:image/png;base64,")).toBe(true);
265
+ });
266
+
267
+ test("start refuses (409) when already enrolled", async () => {
268
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
269
+ await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
270
+ const res = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
271
+ expect(res.status).toBe(409);
272
+ });
273
+
274
+ test("confirm with a live code persists enrollment + returns backup codes", async () => {
275
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
276
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
277
+ const { secret } = (await startRes.json()) as { secret: string };
278
+
279
+ const confirmRes = await post("/2fa/confirm", cookie, {
280
+ __csrf: TEST_CSRF,
281
+ secret,
282
+ code: liveCode(secret),
283
+ });
284
+ expect(confirmRes.status).toBe(200);
285
+ const body = (await confirmRes.json()) as { enrolled: boolean; backup_codes: string[] };
286
+ expect(body.enrolled).toBe(true);
287
+ expect(body.backup_codes.length).toBe(10);
288
+ expect(isTotpEnrolled(harness.db, userId)).toBe(true);
289
+ });
290
+
291
+ test("confirm with a wrong code → 400 invalid_code (not persisted)", async () => {
292
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
293
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
294
+ const { secret } = (await startRes.json()) as { secret: string };
295
+ const res = await post("/2fa/confirm", cookie, {
296
+ __csrf: TEST_CSRF,
297
+ secret,
298
+ code: "000000",
299
+ });
300
+ expect(res.status).toBe(400);
301
+ expect(isTotpEnrolled(harness.db, userId)).toBe(false);
302
+ });
303
+
304
+ test("confirm with a malformed secret → 400 setup_expired", async () => {
305
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
306
+ const res = await post("/2fa/confirm", cookie, {
307
+ __csrf: TEST_CSRF,
308
+ secret: "not-base32!!",
309
+ code: "123456",
310
+ });
311
+ expect(res.status).toBe(400);
312
+ const body = (await res.json()) as { error: string };
313
+ expect(body.error).toBe("setup_expired");
314
+ });
315
+ });
316
+
317
+ describe("/api/account/2fa/disable", () => {
318
+ test("password-gated: wrong password → 401, enrollment intact", async () => {
319
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
320
+ await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
321
+ const res = await post("/2fa/disable", cookie, {
322
+ __csrf: TEST_CSRF,
323
+ password: "WRONG",
324
+ });
325
+ expect(res.status).toBe(401);
326
+ expect(isTotpEnrolled(harness.db, userId)).toBe(true);
327
+ });
328
+
329
+ test("correct password clears enrollment", async () => {
330
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
331
+ await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
332
+ const res = await post("/2fa/disable", cookie, {
333
+ __csrf: TEST_CSRF,
334
+ password: "owner-password-123",
335
+ });
336
+ expect(res.status).toBe(200);
337
+ expect(isTotpEnrolled(harness.db, userId)).toBe(false);
338
+ });
339
+
340
+ test("idempotent when already off", async () => {
341
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
342
+ const res = await post("/2fa/disable", cookie, {
343
+ __csrf: TEST_CSRF,
344
+ password: "owner-password-123",
345
+ });
346
+ expect(res.status).toBe(200);
347
+ });
348
+
349
+ test("missing password → 400", async () => {
350
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
351
+ await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
352
+ const res = await post("/2fa/disable", cookie, { __csrf: TEST_CSRF });
353
+ expect(res.status).toBe(400);
354
+ });
355
+ });
356
+
357
+ describe("/api/me — two_factor_enabled", () => {
358
+ test("false when not enrolled, true after enrollment", async () => {
359
+ const user = await createUser(harness.db, "owner", "owner-password-123", {
360
+ passwordChanged: true,
361
+ });
362
+ const session = createSession(harness.db, { userId: user.id });
363
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
364
+
365
+ const before = await handleApiMe(
366
+ new Request("http://hub.test/api/me", { headers: { cookie } }),
367
+ { db: harness.db },
368
+ );
369
+ const beforeBody = (await before.json()) as { two_factor_enabled?: boolean };
370
+ expect(beforeBody.two_factor_enabled).toBe(false);
371
+
372
+ await persistEnrollment(harness.db, user.id, generateTotpSecret("owner").secret);
373
+
374
+ const after = await handleApiMe(
375
+ new Request("http://hub.test/api/me", { headers: { cookie } }),
376
+ { db: harness.db },
377
+ );
378
+ const afterBody = (await after.json()) as { two_factor_enabled?: boolean };
379
+ expect(afterBody.two_factor_enabled).toBe(true);
380
+ });
381
+ });
@@ -323,8 +323,15 @@ describe("POST /api/hub/upgrade — redeploy-required short-circuit (§5.3)", ()
323
323
  });
324
324
 
325
325
  describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", () => {
326
- /** Seed the status file with a prior op in the given phase. */
327
- function seedStatus(dir: string, phase: HubUpgradeStatus["phase"], opId = "prior-op"): void {
326
+ /** Seed the status file with a prior op in the given phase. `startedAt`
327
+ * defaults to now (a FRESH in-flight slot); pass an old ISO string to seed a
328
+ * stale / abandoned slot for the #506 TTL tests. */
329
+ function seedStatus(
330
+ dir: string,
331
+ phase: HubUpgradeStatus["phase"],
332
+ opId = "prior-op",
333
+ startedAt: string = new Date().toISOString(),
334
+ ): void {
328
335
  writeHubUpgradeStatus(dir, {
329
336
  operation_id: opId,
330
337
  phase,
@@ -333,7 +340,7 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
333
340
  target_version: "0.6.3-rc.2",
334
341
  channel: "rc",
335
342
  log: [],
336
- started_at: new Date().toISOString(),
343
+ started_at: startedAt,
337
344
  });
338
345
  }
339
346
 
@@ -385,6 +392,55 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
385
392
  expect(res.status).toBe(202);
386
393
  expect(spawned.length).toBe(1);
387
394
  });
395
+
396
+ // #506: a crashed helper leaves an in-flight slot stuck forever — without a
397
+ // TTL it 409-deadlocks every future upgrade. A STALE in-flight slot must be
398
+ // treated as abandoned so the new request proceeds.
399
+ for (const phase of ["pending", "running", "restarting"] as const) {
400
+ test(`#506: STALE in-flight slot (phase=${phase}, started 30m ago) → proceeds, not 409`, async () => {
401
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
402
+ const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
403
+ seedStatus(harness.dir, phase, "crashed-op", thirtyMinAgo);
404
+ const { deps, spawned } = baseDeps(harness);
405
+ const res = await handleHubUpgrade(
406
+ postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
407
+ deps,
408
+ );
409
+ // Abandoned slot freed: a fresh op took over + spawned its helper.
410
+ expect(res.status).toBe(202);
411
+ expect(spawned.length).toBe(1);
412
+ const status = readHubUpgradeStatus(harness.dir);
413
+ expect(status?.operation_id).not.toBe("crashed-op");
414
+ expect(spawned[0]?.operationId).toBe(status?.operation_id);
415
+ });
416
+ }
417
+
418
+ test("#506: FRESH in-flight slot (started just now) → still 409", async () => {
419
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
420
+ // Just-started (well within the 15m TTL) → a real, live upgrade → 409.
421
+ seedStatus(harness.dir, "running", "live-op", new Date().toISOString());
422
+ const { deps, spawned } = baseDeps(harness);
423
+ const res = await handleHubUpgrade(
424
+ postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
425
+ deps,
426
+ );
427
+ expect(res.status).toBe(409);
428
+ expect(spawned.length).toBe(0);
429
+ expect(readHubUpgradeStatus(harness.dir)?.operation_id).toBe("live-op");
430
+ });
431
+
432
+ test("#506: in-flight slot with a malformed started_at → treated as stale, proceeds", async () => {
433
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
434
+ seedStatus(harness.dir, "running", "garbage-op", "not-a-date");
435
+ const { deps, spawned } = baseDeps(harness);
436
+ const res = await handleHubUpgrade(
437
+ postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
438
+ deps,
439
+ );
440
+ // An unparseable timestamp must not deadlock — treat as abandoned.
441
+ expect(res.status).toBe(202);
442
+ expect(spawned.length).toBe(1);
443
+ });
388
444
  });
389
445
 
390
446
  describe("appendHubUpgradeStatus — operation_id guard (stale-helper isolation)", () => {