@openparachute/hub 0.6.3 → 0.6.4-rc.1

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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +609 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +180 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +342 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +94 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +347 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -15,6 +15,7 @@ import {
15
15
  } from "../hub-server.ts";
16
16
  import { setNotesRedirectDisabled } from "../hub-settings.ts";
17
17
  import { clearNotesRedirectLogState } from "../notes-redirect.ts";
18
+ import { mintOperatorToken } from "../operator-token.ts";
18
19
  import { pidPath } from "../process-state.ts";
19
20
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
20
21
  import { buildSessionCookie, createSession } from "../sessions.ts";
@@ -530,9 +531,13 @@ describe("hubFetch routing", () => {
530
531
  const h = makeHarness();
531
532
  try {
532
533
  writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
533
- const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
534
- new Request("http://127.0.0.1:1939/.well-known/parachute.json"),
535
- );
534
+ const res = await hubFetch(h.dir, {
535
+ manifestPath: h.manifestPath,
536
+ // No exposure recorded — isolate from the host's real expose-state.json
537
+ // so the request-origin fallback (not the #531 expose tier) is what
538
+ // this test exercises.
539
+ loadExposeHubOrigin: () => undefined,
540
+ })(new Request("http://127.0.0.1:1939/.well-known/parachute.json"));
536
541
  const body = (await res.json()) as { vaults: Array<{ url: string }> };
537
542
  expect(body.vaults[0]?.url).toBe("http://127.0.0.1:1939/vault/default");
538
543
  } finally {
@@ -3195,13 +3200,46 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
3195
3200
  });
3196
3201
  });
3197
3202
 
3198
- describe("layerOf — classify trust layer from proxy headers", () => {
3199
- // Hub binds 127.0.0.1:1939; only trusted forwarders (cloudflared,
3200
- // tailscaled-serve, tailscaled-funnel) reach the listener. Spoofing isn't
3201
- // a concern. layerOf inspects the headers each forwarder injects.
3203
+ describe("layerOf — classify trust layer from proxy headers + peer (item E / #526)", () => {
3204
+ // Proxy headers (cloudflared, tailscale serve/funnel) take precedence. When
3205
+ // absent, the PEER ADDRESS is the loopback discriminator — header-absence is
3206
+ // no longer a loopback signal (#526). The peer-address is the 2nd arg.
3207
+
3208
+ test("no proxy headers + loopback peer (127.0.0.1) → loopback (on-box CLI)", () => {
3209
+ expect(layerOf(req("/"), "127.0.0.1")).toBe("loopback");
3210
+ });
3202
3211
 
3203
- test("no proxy headers loopback (direct localhost call)", () => {
3204
- expect(layerOf(req("/"))).toBe("loopback");
3212
+ test("no proxy headers + IPv6 loopback peer (::1) loopback", () => {
3213
+ expect(layerOf(req("/"), "::1")).toBe("loopback");
3214
+ });
3215
+
3216
+ test("no proxy headers + IPv4-mapped IPv6 loopback (::ffff:127.0.0.1) → loopback", () => {
3217
+ expect(layerOf(req("/"), "::ffff:127.0.0.1")).toBe("loopback");
3218
+ });
3219
+
3220
+ // THE FIX: a header-absent NON-loopback peer (the 0.0.0.0-bind direct-network
3221
+ // case) must NOT be classified loopback — it would bypass the
3222
+ // publicExposure:loopback 404-cloak. Fail to public (least-trusted).
3223
+ test("no proxy headers + non-loopback peer → public (NOT loopback) [#526]", () => {
3224
+ expect(layerOf(req("/"), "203.0.113.7")).toBe("public");
3225
+ expect(layerOf(req("/"), "10.0.0.5")).toBe("public");
3226
+ expect(layerOf(req("/"), "fd00::1234")).toBe("public");
3227
+ });
3228
+
3229
+ // Fail-closed: an unknown peer (no Server threaded — null/undefined) is NOT
3230
+ // loopback. A direct unit call to the fetch fn with no server lands here.
3231
+ test("no proxy headers + unknown peer (null/undefined) → public (fail closed)", () => {
3232
+ expect(layerOf(req("/"), null)).toBe("public");
3233
+ expect(layerOf(req("/"), undefined)).toBe("public");
3234
+ expect(layerOf(req("/"))).toBe("public");
3235
+ });
3236
+
3237
+ // Headers still win over peer address — a tailnet/public forwarder sets the
3238
+ // header and the peer (the local tailscaled/cloudflared) is loopback, but the
3239
+ // header is authoritative.
3240
+ test("Tailscale-User-Login → tailnet even from a loopback peer", () => {
3241
+ const r = req("/", { headers: { "Tailscale-User-Login": "alice@example.com" } });
3242
+ expect(layerOf(r, "127.0.0.1")).toBe("tailnet");
3205
3243
  });
3206
3244
 
3207
3245
  test("Tailscale-User-Login → tailnet (authed via tailscale serve)", () => {
@@ -3266,6 +3304,11 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
3266
3304
  return { port: server.port as number, stop: () => server.stop(true) };
3267
3305
  }
3268
3306
 
3307
+ // Fake Bun Server handle exposing only `requestIP` (item E / #526) so the
3308
+ // fetch fn can resolve the peer address. The on-box CLI caller connects from
3309
+ // 127.0.0.1; a network peer on a 0.0.0.0 bind connects from its real IP.
3310
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
3311
+
3269
3312
  test("publicExposure: loopback + tailnet header → 404 (gate hides the route)", async () => {
3270
3313
  const h = makeHarness();
3271
3314
  const upstream = startUpstream("loopback-only");
@@ -3326,7 +3369,7 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
3326
3369
  }
3327
3370
  });
3328
3371
 
3329
- test("publicExposure: loopback + no headers → reaches upstream (loopback layer)", async () => {
3372
+ test("publicExposure: loopback + no headers + loopback peer → reaches upstream (loopback layer)", async () => {
3330
3373
  const h = makeHarness();
3331
3374
  const upstream = startUpstream("loopback-only");
3332
3375
  try {
@@ -3346,7 +3389,8 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
3346
3389
  h.manifestPath,
3347
3390
  );
3348
3391
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3349
- const res = await fetcher(req("/loopback-only/health"));
3392
+ // On-box CLI caller: 127.0.0.1 peer, no proxy headers → loopback layer.
3393
+ const res = await fetcher(req("/loopback-only/health"), fakeServer("127.0.0.1"));
3350
3394
  expect(res.status).toBe(200);
3351
3395
  const body = (await res.json()) as { tag: string };
3352
3396
  expect(body.tag).toBe("loopback-only");
@@ -3356,6 +3400,37 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
3356
3400
  }
3357
3401
  });
3358
3402
 
3403
+ // Item E / #526 — the core fix. On a 0.0.0.0 bind a network peer reaches the
3404
+ // listener with NO proxy headers; it must NOT be treated as loopback, so the
3405
+ // loopback-exposure cloak still fires (404) rather than leaking the route.
3406
+ test("publicExposure: loopback + no headers + NON-loopback peer → 404 (cloak fires) [#526]", async () => {
3407
+ const h = makeHarness();
3408
+ const upstream = startUpstream("loopback-only");
3409
+ try {
3410
+ writeManifest(
3411
+ {
3412
+ services: [
3413
+ {
3414
+ name: "loopback-only",
3415
+ port: upstream.port,
3416
+ paths: ["/loopback-only"],
3417
+ health: "/loopback-only/health",
3418
+ version: "0.1.0",
3419
+ publicExposure: "loopback",
3420
+ },
3421
+ ],
3422
+ },
3423
+ h.manifestPath,
3424
+ );
3425
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3426
+ const res = await fetcher(req("/loopback-only/health"), fakeServer("203.0.113.9"));
3427
+ expect(res.status).toBe(404);
3428
+ } finally {
3429
+ upstream.stop();
3430
+ h.cleanup();
3431
+ }
3432
+ });
3433
+
3359
3434
  test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
3360
3435
  const h = makeHarness();
3361
3436
  const upstream = startUpstream("allowed");
@@ -3514,6 +3589,10 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
3514
3589
  return { port: server.port as number, stop: () => server.stop(true) };
3515
3590
  }
3516
3591
 
3592
+ // Item E / #526 — fake Bun Server handle exposing `requestIP` for the peer-
3593
+ // address discriminator (see the proxyToService block for the rationale).
3594
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
3595
+
3517
3596
  test("vault publicExposure: loopback + tailnet header → 404", async () => {
3518
3597
  const h = makeHarness();
3519
3598
  const upstream = startVaultUpstream("vault-private");
@@ -3545,7 +3624,7 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
3545
3624
  }
3546
3625
  });
3547
3626
 
3548
- test("vault publicExposure: loopback + no headers → reaches vault backend", async () => {
3627
+ test("vault publicExposure: loopback + no headers + loopback peer → reaches vault backend", async () => {
3549
3628
  const h = makeHarness();
3550
3629
  const upstream = startVaultUpstream("vault-private");
3551
3630
  try {
@@ -3565,7 +3644,7 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
3565
3644
  h.manifestPath,
3566
3645
  );
3567
3646
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3568
- const res = await fetcher(req("/vault/private/health"));
3647
+ const res = await fetcher(req("/vault/private/health"), fakeServer("127.0.0.1"));
3569
3648
  expect(res.status).toBe(200);
3570
3649
  const body = (await res.json()) as { tag: string };
3571
3650
  expect(body.tag).toBe("vault-private");
@@ -3575,6 +3654,36 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
3575
3654
  }
3576
3655
  });
3577
3656
 
3657
+ // Item E / #526 — vault-path symmetry: a header-absent NON-loopback peer on a
3658
+ // 0.0.0.0 bind must NOT reach a loopback-exposed vault (cloak fires).
3659
+ test("vault publicExposure: loopback + no headers + NON-loopback peer → 404 [#526]", async () => {
3660
+ const h = makeHarness();
3661
+ const upstream = startVaultUpstream("vault-private");
3662
+ try {
3663
+ writeManifest(
3664
+ {
3665
+ services: [
3666
+ {
3667
+ name: "parachute-vault-private",
3668
+ port: upstream.port,
3669
+ paths: ["/vault/private"],
3670
+ health: "/vault/private/health",
3671
+ version: "0.4.0",
3672
+ publicExposure: "loopback",
3673
+ },
3674
+ ],
3675
+ },
3676
+ h.manifestPath,
3677
+ );
3678
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3679
+ const res = await fetcher(req("/vault/private/health"), fakeServer("198.51.100.4"));
3680
+ expect(res.status).toBe(404);
3681
+ } finally {
3682
+ upstream.stop();
3683
+ h.cleanup();
3684
+ }
3685
+ });
3686
+
3578
3687
  test("vault publicExposure: allowed + tailnet header → reaches backend", async () => {
3579
3688
  const h = makeHarness();
3580
3689
  const upstream = startVaultUpstream("vault-public");
@@ -4185,6 +4294,9 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
4185
4294
  const friend = await createUser(db, "friend", "friend-password-123", {
4186
4295
  assignedVaults,
4187
4296
  allowMulti: true,
4297
+ // Item F (#469): the friend mints a token only after rotating the temp
4298
+ // password; an unrotated friend is force-redirected before any mint.
4299
+ passwordChanged: true,
4188
4300
  });
4189
4301
  const session = createSession(db, { userId: friend.id });
4190
4302
  const csrf = generateCsrfToken();
@@ -4227,6 +4339,63 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
4227
4339
  }
4228
4340
  });
4229
4341
 
4342
+ // Item F (#469) routed e2e — an assigned but unrotated friend is force-
4343
+ // redirected to the change-password rail before any mint, through the real
4344
+ // dispatch.
4345
+ test("unrotated friend is force-change-gated, routed through hubFetch (item F / #469)", async () => {
4346
+ // As of hub#469 the broad per-request gate (forceChangePasswordGate) is the
4347
+ // choke point and intercepts BEFORE the per-route mint handler. A browser
4348
+ // request (Accept: text/html) is 302'd to the change-password rail; an
4349
+ // API-style POST without that header is 403 force_change_password. Both
4350
+ // prove the unrotated friend can't parlay the temp password into a mint.
4351
+ const h = makeHarness();
4352
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4353
+ const db = openHubDb(hubDbPath(h.dir));
4354
+ rotateSigningKey(db);
4355
+ await createUser(db, "operator", "operator-password-123");
4356
+ const friend = await createUser(db, "friend", "friend-password-123", {
4357
+ assignedVaults: ["work"],
4358
+ allowMulti: true,
4359
+ passwordChanged: false, // not yet rotated
4360
+ });
4361
+ const session = createSession(db, { userId: friend.id });
4362
+ const csrf = generateCsrfToken();
4363
+ const cookie = `${buildSessionCookie(session.id, 3600, { secure: false })}; ${
4364
+ buildCsrfCookie(csrf, { secure: false }).split(";")[0]
4365
+ }`;
4366
+ const fetcher = hubFetch(h.dir, {
4367
+ getDb: () => db,
4368
+ manifestPath: h.manifestPath,
4369
+ issuer: "https://hub.test",
4370
+ });
4371
+ try {
4372
+ // The mint is a POST (non-GET) → the gate rejects with 403
4373
+ // force_change_password regardless of Accept, per the spec ("redirect
4374
+ // browser GETs, reject non-GET / API-style requests with 403"). A 302 on
4375
+ // a POST wouldn't usefully re-issue the mint anyway.
4376
+ const apiRes = await fetcher(
4377
+ req("/account/vault-token/work", {
4378
+ method: "POST",
4379
+ headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
4380
+ body: postBody(csrf, "read"),
4381
+ }),
4382
+ );
4383
+ expect(apiRes.status).toBe(403);
4384
+ expect(((await apiRes.json()) as { error: string }).error).toBe("force_change_password");
4385
+
4386
+ // The friend's browser GET of the account home IS bounced to the
4387
+ // change-password rail — the surface they'd navigate to is gated.
4388
+ const browserRes = await fetcher(
4389
+ req("/account/", { headers: { cookie, accept: "text/html" } }),
4390
+ );
4391
+ expect(browserRes.status).toBe(302);
4392
+ expect(browserRes.headers.get("location")).toBe("/account/change-password");
4393
+ } finally {
4394
+ db.close();
4395
+ h.cleanup();
4396
+ }
4397
+ });
4398
+
4230
4399
  test("unassigned vault → 403, no token, routed through hubFetch", async () => {
4231
4400
  const h = makeHarness();
4232
4401
  writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
@@ -4269,6 +4438,73 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
4269
4438
  });
4270
4439
  });
4271
4440
 
4441
+ // Item D (#450) routed e2e — exercise the knownVaultNames threading from
4442
+ // services.json → hubFetch → handleApiMintToken (hub-server.ts dispatch),
4443
+ // which the unit-level handler tests can't cover. A `vault:<typo>:admin` mint
4444
+ // for an unregistered vault → 400 through the full stack; a known vault → 200.
4445
+ describe("POST /api/auth/mint-token — vault-existence threading (routed end-to-end, item D)", () => {
4446
+ const ISSUER = "https://hub.test";
4447
+
4448
+ async function seedMint(
4449
+ h: Harness,
4450
+ vaultNames: string[],
4451
+ ): Promise<{ db: ReturnType<typeof openHubDb>; bearer: string }> {
4452
+ const db = openHubDb(hubDbPath(h.dir));
4453
+ rotateSigningKey(db); // mint needs an active signing key
4454
+ const owner = await createUser(db, "owner", "owner-password-123");
4455
+ // The default operator scope-set carries parachute:host:admin, which mints
4456
+ // vault:<name>:admin (canGrant rule 2).
4457
+ const op = await mintOperatorToken(db, owner.id, { issuer: ISSUER });
4458
+ writeManifest({ services: vaultNames.map((n) => vaultEntry(n)) }, h.manifestPath);
4459
+ return { db, bearer: op.token };
4460
+ }
4461
+
4462
+ function mintReq(scope: string, bearer: string): Request {
4463
+ return req("/api/auth/mint-token", {
4464
+ method: "POST",
4465
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
4466
+ body: JSON.stringify({ scope }),
4467
+ });
4468
+ }
4469
+
4470
+ test("vault:<typo>:admin for an unregistered vault → 400 (knownVaultNames from services.json)", async () => {
4471
+ const h = makeHarness();
4472
+ const { db, bearer } = await seedMint(h, ["work", "default"]);
4473
+ try {
4474
+ const res = await hubFetch(h.dir, {
4475
+ getDb: () => db,
4476
+ manifestPath: h.manifestPath,
4477
+ issuer: ISSUER,
4478
+ })(mintReq("vault:typo:admin", bearer));
4479
+ expect(res.status).toBe(400);
4480
+ const body = (await res.json()) as { error: string; error_description: string };
4481
+ expect(body.error).toBe("invalid_scope");
4482
+ expect(body.error_description).toContain("typo");
4483
+ } finally {
4484
+ db.close();
4485
+ h.cleanup();
4486
+ }
4487
+ });
4488
+
4489
+ test("vault:<name>:admin for a REGISTERED vault → 200 (proves the gate isn't over-blocking)", async () => {
4490
+ const h = makeHarness();
4491
+ const { db, bearer } = await seedMint(h, ["work", "default"]);
4492
+ try {
4493
+ const res = await hubFetch(h.dir, {
4494
+ getDb: () => db,
4495
+ manifestPath: h.manifestPath,
4496
+ issuer: ISSUER,
4497
+ })(mintReq("vault:work:admin", bearer));
4498
+ expect(res.status).toBe(200);
4499
+ const body = (await res.json()) as { scope: string };
4500
+ expect(body.scope).toBe("vault:work:admin");
4501
+ } finally {
4502
+ db.close();
4503
+ h.cleanup();
4504
+ }
4505
+ });
4506
+ });
4507
+
4272
4508
  describe("hubFetch routing — /api/hub/upgrade (D4 SPA hub-upgrade)", () => {
4273
4509
  test("POST /api/hub/upgrade dispatches to the handler (401 without bearer, NOT 404)", async () => {
4274
4510
  const h = makeHarness();
@@ -4332,3 +4568,382 @@ describe("hubFetch routing — /api/hub/upgrade (D4 SPA hub-upgrade)", () => {
4332
4568
  }
4333
4569
  });
4334
4570
  });
4571
+
4572
+ // Per-request force-change-password enforcement (P0-1 / hub#469). The redirect
4573
+ // at /login was never enough: a signed-in user holding an admin-set temp
4574
+ // password (`password_changed=false`) could navigate DIRECTLY to /account/* or
4575
+ // a per-vault proxy URL and operate indefinitely on the un-rotated secret.
4576
+ // These tests pin the broad per-request gate: pre-rotation users are bounced
4577
+ // off every /account/* surface AND the per-vault proxy, EXCEPT the rotation/exit
4578
+ // path (/account/change-password + /logout); after rotation all surfaces open.
4579
+ describe("force-change-password per-request gate (#469)", () => {
4580
+ // Seed a signed-in user with a chosen password_changed flag and return a
4581
+ // ready-to-use session cookie. Mirrors the seedFriend helpers in the
4582
+ // account-vault-{token,admin-token} suites.
4583
+ async function seedUser(
4584
+ h: Harness,
4585
+ db: ReturnType<typeof openHubDb>,
4586
+ opts: { passwordChanged: boolean; assignedVaults?: string[] },
4587
+ ): Promise<{ cookie: string }> {
4588
+ const { SESSION_TTL_MS } = await import("../sessions.ts");
4589
+ const user = await createUser(db, "friend", "temp-pw", {
4590
+ allowMulti: true,
4591
+ passwordChanged: opts.passwordChanged,
4592
+ assignedVaults: opts.assignedVaults ?? [],
4593
+ });
4594
+ const session = createSession(db, { userId: user.id });
4595
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
4596
+ return { cookie };
4597
+ }
4598
+
4599
+ test("(a) pre-rotation browser GET /account/ is 302'd to change-password", async () => {
4600
+ const h = makeHarness();
4601
+ try {
4602
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4603
+ const db = openHubDb(hubDbPath(h.dir));
4604
+ try {
4605
+ const { cookie } = await seedUser(h, db, {
4606
+ passwordChanged: false,
4607
+ assignedVaults: ["work"],
4608
+ });
4609
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4610
+ req("/account/", { headers: { cookie, accept: "text/html" } }),
4611
+ );
4612
+ expect(res.status).toBe(302);
4613
+ expect(res.headers.get("location")).toBe("/account/change-password");
4614
+ } finally {
4615
+ db.close();
4616
+ }
4617
+ } finally {
4618
+ h.cleanup();
4619
+ }
4620
+ });
4621
+
4622
+ test("(a) pre-rotation API POST /account/vault-token/<name> is 403 force_change_password", async () => {
4623
+ const h = makeHarness();
4624
+ try {
4625
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4626
+ const db = openHubDb(hubDbPath(h.dir));
4627
+ try {
4628
+ const { cookie } = await seedUser(h, db, {
4629
+ passwordChanged: false,
4630
+ assignedVaults: ["work"],
4631
+ });
4632
+ // No Accept: text/html → treated as an API client → 403 JSON, not a 302.
4633
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4634
+ req("/account/vault-token/work", { method: "POST", headers: { cookie } }),
4635
+ );
4636
+ expect(res.status).toBe(403);
4637
+ const body = (await res.json()) as { error: string };
4638
+ expect(body.error).toBe("force_change_password");
4639
+ } finally {
4640
+ db.close();
4641
+ }
4642
+ } finally {
4643
+ h.cleanup();
4644
+ }
4645
+ });
4646
+
4647
+ test("(a) pre-rotation browser GET of a per-vault proxy URL is 302'd to change-password", async () => {
4648
+ const h = makeHarness();
4649
+ try {
4650
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4651
+ const db = openHubDb(hubDbPath(h.dir));
4652
+ try {
4653
+ const { cookie } = await seedUser(h, db, {
4654
+ passwordChanged: false,
4655
+ assignedVaults: ["work"],
4656
+ });
4657
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4658
+ req("/vault/work/notes/abc", { headers: { cookie, accept: "text/html" } }),
4659
+ );
4660
+ // Gated BEFORE the proxy ever runs — so this is the gate's 302, not a
4661
+ // proxy 404/502.
4662
+ expect(res.status).toBe(302);
4663
+ expect(res.headers.get("location")).toBe("/account/change-password");
4664
+ } finally {
4665
+ db.close();
4666
+ }
4667
+ } finally {
4668
+ h.cleanup();
4669
+ }
4670
+ });
4671
+
4672
+ test("(b) pre-rotation user CAN still reach /account/change-password (GET)", async () => {
4673
+ const h = makeHarness();
4674
+ try {
4675
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4676
+ const db = openHubDb(hubDbPath(h.dir));
4677
+ try {
4678
+ const { cookie } = await seedUser(h, db, {
4679
+ passwordChanged: false,
4680
+ assignedVaults: ["work"],
4681
+ });
4682
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4683
+ req("/account/change-password", { headers: { cookie, accept: "text/html" } }),
4684
+ );
4685
+ // Reaches the real handler (200 form render) — NOT bounced to itself.
4686
+ expect(res.status).toBe(200);
4687
+ const body = await res.text();
4688
+ expect(body.toLowerCase()).toContain("password");
4689
+ } finally {
4690
+ db.close();
4691
+ }
4692
+ } finally {
4693
+ h.cleanup();
4694
+ }
4695
+ });
4696
+
4697
+ test("(b) pre-rotation user CAN still reach /logout (POST)", async () => {
4698
+ const h = makeHarness();
4699
+ try {
4700
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4701
+ const db = openHubDb(hubDbPath(h.dir));
4702
+ try {
4703
+ const { cookie } = await seedUser(h, db, {
4704
+ passwordChanged: false,
4705
+ assignedVaults: ["work"],
4706
+ });
4707
+ // CSRF cookie + matching field so the logout POST passes its own gate;
4708
+ // the point is the force-change gate does NOT intercept /logout.
4709
+ const csrf = generateCsrfToken();
4710
+ const form = new URLSearchParams();
4711
+ form.set("__csrf", csrf);
4712
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4713
+ req("/logout", {
4714
+ method: "POST",
4715
+ headers: {
4716
+ cookie: `${cookie}; ${buildCsrfCookie(csrf)}`,
4717
+ "content-type": "application/x-www-form-urlencoded",
4718
+ },
4719
+ body: form.toString(),
4720
+ }),
4721
+ );
4722
+ // Logout succeeds (302 to /) — it is NOT the force-change 302 to
4723
+ // /account/change-password and NOT a 403.
4724
+ expect(res.status).toBe(302);
4725
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4726
+ expect(res.status).not.toBe(403);
4727
+ } finally {
4728
+ db.close();
4729
+ }
4730
+ } finally {
4731
+ h.cleanup();
4732
+ }
4733
+ });
4734
+
4735
+ test("(c) after rotation, /account/ is reachable (not gated)", async () => {
4736
+ const h = makeHarness();
4737
+ try {
4738
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4739
+ const db = openHubDb(hubDbPath(h.dir));
4740
+ try {
4741
+ const { cookie } = await seedUser(h, db, {
4742
+ passwordChanged: true,
4743
+ assignedVaults: ["work"],
4744
+ });
4745
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4746
+ req("/account/", { headers: { cookie, accept: "text/html" } }),
4747
+ );
4748
+ // Account home renders — not bounced.
4749
+ expect(res.status).toBe(200);
4750
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4751
+ } finally {
4752
+ db.close();
4753
+ }
4754
+ } finally {
4755
+ h.cleanup();
4756
+ }
4757
+ });
4758
+
4759
+ test("(c) after rotation, a per-vault proxy URL is NOT gated (reaches the proxy)", async () => {
4760
+ const h = makeHarness();
4761
+ try {
4762
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4763
+ const db = openHubDb(hubDbPath(h.dir));
4764
+ try {
4765
+ const { cookie } = await seedUser(h, db, {
4766
+ passwordChanged: true,
4767
+ assignedVaults: ["work"],
4768
+ });
4769
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4770
+ req("/vault/work/notes/abc", { headers: { cookie, accept: "text/html" } }),
4771
+ );
4772
+ // No upstream listening → the proxy returns a 404/502, NOT the gate's
4773
+ // 302→change-password / 403. The point is the gate let it through.
4774
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4775
+ const body = res.status === 403 ? ((await res.json()) as { error?: string }) : null;
4776
+ expect(body?.error).not.toBe("force_change_password");
4777
+ } finally {
4778
+ db.close();
4779
+ }
4780
+ } finally {
4781
+ h.cleanup();
4782
+ }
4783
+ });
4784
+
4785
+ test("an UNAUTHENTICATED per-vault proxy request is NOT gated (no hub session)", async () => {
4786
+ const h = makeHarness();
4787
+ try {
4788
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4789
+ const db = openHubDb(hubDbPath(h.dir));
4790
+ try {
4791
+ // No cookie at all — the common Notes/MCP case carrying its own bearer.
4792
+ // forceChangePasswordGate returns null (no session) → proxy handles it.
4793
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4794
+ req("/vault/work/notes/abc", { headers: { accept: "text/html" } }),
4795
+ );
4796
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4797
+ expect(res.status).not.toBe(403);
4798
+ } finally {
4799
+ db.close();
4800
+ }
4801
+ } finally {
4802
+ h.cleanup();
4803
+ }
4804
+ });
4805
+
4806
+ test("(b) pre-rotation user CAN POST /account/change-password (the rotation action itself)", async () => {
4807
+ // The exempt POST: a pre-rotation user submitting their new password must
4808
+ // NOT be intercepted by the gate — it's the only way out of force-change.
4809
+ // `/account/change-password` is dispatched ABOVE the gate, so it never
4810
+ // reaches the choke point. We assert the POST reaches its own handler (it
4811
+ // fails CSRF here → its OWN 400/403, NOT the gate's 302→change-password).
4812
+ const h = makeHarness();
4813
+ try {
4814
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4815
+ const db = openHubDb(hubDbPath(h.dir));
4816
+ try {
4817
+ const { cookie } = await seedUser(h, db, {
4818
+ passwordChanged: false,
4819
+ assignedVaults: ["work"],
4820
+ });
4821
+ const csrf = generateCsrfToken();
4822
+ const form = new URLSearchParams();
4823
+ form.set("__csrf", csrf);
4824
+ form.set("current_password", "temp-pw");
4825
+ form.set("new_password", "rotated-password-123");
4826
+ form.set("new_password_confirm", "rotated-password-123");
4827
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4828
+ req("/account/change-password", {
4829
+ method: "POST",
4830
+ headers: {
4831
+ cookie: `${cookie}; ${buildCsrfCookie(csrf)}`,
4832
+ "content-type": "application/x-www-form-urlencoded",
4833
+ accept: "text/html",
4834
+ },
4835
+ body: form.toString(),
4836
+ }),
4837
+ );
4838
+ // Reaches its own handler (303 back to /account/ on success, or its own
4839
+ // form re-render). The point: it is NOT the gate's 302 to
4840
+ // change-password and NOT the gate's 403 force_change_password.
4841
+ expect(res.status).not.toBe(403);
4842
+ if (res.status === 302 || res.status === 303) {
4843
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4844
+ }
4845
+ // And the rotation actually took: the user's flag flipped to true.
4846
+ const { getUserById } = await import("../users.ts");
4847
+ const friend = getUserById(
4848
+ db,
4849
+ db.query<{ id: string }, []>("SELECT id FROM users WHERE username = 'friend'").get()
4850
+ ?.id ?? "",
4851
+ );
4852
+ expect(friend?.passwordChanged).toBe(true);
4853
+ } finally {
4854
+ db.close();
4855
+ }
4856
+ } finally {
4857
+ h.cleanup();
4858
+ }
4859
+ });
4860
+
4861
+ test("(a) bare /account (no trailing slash) is gated on the FIRST hop for a pre-rotation user", async () => {
4862
+ // The bare `/account` 301s to `/account/`; without an explicit match it
4863
+ // would slip past `startsWith("/account/")` and only be gated on the second
4864
+ // hop. The gate must intercept the first request.
4865
+ const h = makeHarness();
4866
+ try {
4867
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4868
+ const db = openHubDb(hubDbPath(h.dir));
4869
+ try {
4870
+ const { cookie } = await seedUser(h, db, {
4871
+ passwordChanged: false,
4872
+ assignedVaults: ["work"],
4873
+ });
4874
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4875
+ req("/account", { headers: { cookie, accept: "text/html" } }),
4876
+ );
4877
+ // Gated → 302 to change-password, NOT the 301 → /account/.
4878
+ expect(res.status).toBe(302);
4879
+ expect(res.headers.get("location")).toBe("/account/change-password");
4880
+ } finally {
4881
+ db.close();
4882
+ }
4883
+ } finally {
4884
+ h.cleanup();
4885
+ }
4886
+ });
4887
+
4888
+ test("(a) pre-rotation session at /oauth/authorize is 302'd to change-password (no auth code issued)", async () => {
4889
+ // The important one: a signed-in pre-rotation user must not ride the
4890
+ // consent flow to an auth code → /oauth/token exchange for a vault token
4891
+ // WITHOUT rotating. The gate intercepts /oauth/authorize before any client
4892
+ // validation or code issuance, so no `code=` redirect can be produced.
4893
+ const h = makeHarness();
4894
+ try {
4895
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4896
+ const db = openHubDb(hubDbPath(h.dir));
4897
+ try {
4898
+ const { cookie } = await seedUser(h, db, {
4899
+ passwordChanged: false,
4900
+ assignedVaults: ["work"],
4901
+ });
4902
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4903
+ req(
4904
+ "/oauth/authorize?client_id=x&response_type=code&redirect_uri=https://app.example/cb&scope=vault:work:read",
4905
+ { headers: { cookie, accept: "text/html" } },
4906
+ ),
4907
+ );
4908
+ expect(res.status).toBe(302);
4909
+ const location = res.headers.get("location") ?? "";
4910
+ expect(location).toBe("/account/change-password");
4911
+ // No auth code was issued — the redirect is to the rotation rail, not a
4912
+ // client callback carrying a `code=`.
4913
+ expect(location).not.toContain("code=");
4914
+ expect(location).not.toContain("app.example");
4915
+ } finally {
4916
+ db.close();
4917
+ }
4918
+ } finally {
4919
+ h.cleanup();
4920
+ }
4921
+ });
4922
+
4923
+ test("(c) after rotation, /oauth/authorize is NOT gated (reaches the oauth handler)", async () => {
4924
+ const h = makeHarness();
4925
+ try {
4926
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4927
+ const db = openHubDb(hubDbPath(h.dir));
4928
+ try {
4929
+ const { cookie } = await seedUser(h, db, {
4930
+ passwordChanged: true,
4931
+ assignedVaults: ["work"],
4932
+ });
4933
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4934
+ req(
4935
+ "/oauth/authorize?client_id=x&response_type=code&redirect_uri=https://app.example/cb&scope=vault:work:read",
4936
+ { headers: { cookie, accept: "text/html" } },
4937
+ ),
4938
+ );
4939
+ // Reaches the real authorize handler (login/consent/error) — NOT bounced
4940
+ // to the change-password rail by the gate.
4941
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4942
+ } finally {
4943
+ db.close();
4944
+ }
4945
+ } finally {
4946
+ h.cleanup();
4947
+ }
4948
+ });
4949
+ });