@openparachute/hub 0.6.3 → 0.6.4-rc.10
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 +1 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- 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, {
|
|
534
|
-
|
|
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 {
|
|
@@ -1625,6 +1630,139 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1625
1630
|
}
|
|
1626
1631
|
});
|
|
1627
1632
|
|
|
1633
|
+
// #525: bare `/vault/<name>` POST (no `/mcp` suffix) half-connects MCP
|
|
1634
|
+
// clients. The fix 308-redirects the bare-path POST to `<mount>/mcp` BEFORE
|
|
1635
|
+
// proxying, so a compliant client re-POSTs to the right endpoint and clients
|
|
1636
|
+
// that don't follow redirects at least get an actionable Location + JSON body
|
|
1637
|
+
// (vs the old opaque vault 405).
|
|
1638
|
+
test("POST to bare /vault/<name> 308-redirects to /vault/<name>/mcp with an actionable body (#525)", async () => {
|
|
1639
|
+
const h = makeHarness();
|
|
1640
|
+
try {
|
|
1641
|
+
writeManifest({ services: [vaultEntry("aaron")] }, h.manifestPath);
|
|
1642
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1643
|
+
const res = await fetcher(
|
|
1644
|
+
req("/vault/aaron", {
|
|
1645
|
+
method: "POST",
|
|
1646
|
+
headers: { "content-type": "application/json" },
|
|
1647
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1 }),
|
|
1648
|
+
}),
|
|
1649
|
+
);
|
|
1650
|
+
expect(res.status).toBe(308);
|
|
1651
|
+
expect(res.headers.get("location")).toBe("/vault/aaron/mcp");
|
|
1652
|
+
// 308 is permanently cacheable by default — no-store prevents a cached
|
|
1653
|
+
// redirect outliving a remount.
|
|
1654
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
1655
|
+
const body = (await res.json()) as { error: string; mcp_url: string; message: string };
|
|
1656
|
+
expect(body.error).toBe("missing_mcp_suffix");
|
|
1657
|
+
expect(body.mcp_url).toBe("/vault/aaron/mcp");
|
|
1658
|
+
expect(body.message).toContain("/vault/aaron/mcp");
|
|
1659
|
+
} finally {
|
|
1660
|
+
h.cleanup();
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
test("bare-path 308 honors a trailing-slash mount: POST /vault/default → /vault/default/mcp (#525)", async () => {
|
|
1665
|
+
// Mounts in services.json sometimes carry a trailing slash (#197). The
|
|
1666
|
+
// redirect target must be built from the *normalized* mount so it stays
|
|
1667
|
+
// `/vault/default/mcp`, never `/vault/default//mcp`.
|
|
1668
|
+
const h = makeHarness();
|
|
1669
|
+
try {
|
|
1670
|
+
writeManifest(
|
|
1671
|
+
{
|
|
1672
|
+
services: [
|
|
1673
|
+
{
|
|
1674
|
+
name: "parachute-vault-default",
|
|
1675
|
+
port: 1940,
|
|
1676
|
+
paths: ["/vault/default/"],
|
|
1677
|
+
health: "/health",
|
|
1678
|
+
version: "0.4.0",
|
|
1679
|
+
},
|
|
1680
|
+
],
|
|
1681
|
+
},
|
|
1682
|
+
h.manifestPath,
|
|
1683
|
+
);
|
|
1684
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1685
|
+
const res = await fetcher(req("/vault/default", { method: "POST" }));
|
|
1686
|
+
expect(res.status).toBe(308);
|
|
1687
|
+
expect(res.headers.get("location")).toBe("/vault/default/mcp");
|
|
1688
|
+
} finally {
|
|
1689
|
+
h.cleanup();
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
test("GET to bare /vault/<name> is NOT redirected — proxies through untouched (#525)", async () => {
|
|
1694
|
+
// Only POST (the MCP transport verb) is caught. A browser GET to the bare
|
|
1695
|
+
// path keeps its existing proxy behavior so we don't break any bare-path
|
|
1696
|
+
// GET surface.
|
|
1697
|
+
const h = makeHarness();
|
|
1698
|
+
const upstream = startUpstream("bare-get");
|
|
1699
|
+
try {
|
|
1700
|
+
writeManifest(
|
|
1701
|
+
{
|
|
1702
|
+
services: [
|
|
1703
|
+
{
|
|
1704
|
+
name: "parachute-vault-aaron",
|
|
1705
|
+
port: upstream.port,
|
|
1706
|
+
paths: ["/vault/aaron"],
|
|
1707
|
+
health: "/health",
|
|
1708
|
+
version: "0.4.0",
|
|
1709
|
+
},
|
|
1710
|
+
],
|
|
1711
|
+
},
|
|
1712
|
+
h.manifestPath,
|
|
1713
|
+
);
|
|
1714
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1715
|
+
const res = await fetcher(req("/vault/aaron", { method: "GET" }));
|
|
1716
|
+
expect(res.status).toBe(200);
|
|
1717
|
+
const body = (await res.json()) as { tag: string; method: string; pathname: string };
|
|
1718
|
+
expect(body.tag).toBe("bare-get");
|
|
1719
|
+
expect(body.method).toBe("GET");
|
|
1720
|
+
expect(body.pathname).toBe("/vault/aaron");
|
|
1721
|
+
} finally {
|
|
1722
|
+
upstream.stop();
|
|
1723
|
+
h.cleanup();
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
test("POST to the canonical /vault/<name>/mcp sub-path is NOT redirected — proxies through (#525)", async () => {
|
|
1728
|
+
// The real MCP endpoint must keep working: a POST that already carries the
|
|
1729
|
+
// `/mcp` suffix is a sub-path (not the exact bare mount) and proxies
|
|
1730
|
+
// straight to the vault backend.
|
|
1731
|
+
const h = makeHarness();
|
|
1732
|
+
const upstream = startUpstream("mcp-post");
|
|
1733
|
+
try {
|
|
1734
|
+
writeManifest(
|
|
1735
|
+
{
|
|
1736
|
+
services: [
|
|
1737
|
+
{
|
|
1738
|
+
name: "parachute-vault-aaron",
|
|
1739
|
+
port: upstream.port,
|
|
1740
|
+
paths: ["/vault/aaron"],
|
|
1741
|
+
health: "/health",
|
|
1742
|
+
version: "0.4.0",
|
|
1743
|
+
},
|
|
1744
|
+
],
|
|
1745
|
+
},
|
|
1746
|
+
h.manifestPath,
|
|
1747
|
+
);
|
|
1748
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1749
|
+
const res = await fetcher(
|
|
1750
|
+
req("/vault/aaron/mcp", {
|
|
1751
|
+
method: "POST",
|
|
1752
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 2 }),
|
|
1753
|
+
}),
|
|
1754
|
+
);
|
|
1755
|
+
expect(res.status).toBe(200);
|
|
1756
|
+
const body = (await res.json()) as { tag: string; method: string; pathname: string };
|
|
1757
|
+
expect(body.tag).toBe("mcp-post");
|
|
1758
|
+
expect(body.method).toBe("POST");
|
|
1759
|
+
expect(body.pathname).toBe("/vault/aaron/mcp");
|
|
1760
|
+
} finally {
|
|
1761
|
+
upstream.stop();
|
|
1762
|
+
h.cleanup();
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1628
1766
|
test("synthesizes X-Forwarded-Proto when edge didn't set it (direct HTTPS to hub)", async () => {
|
|
1629
1767
|
// Non-Render shape: hub bound directly to https (e.g. local TLS or a
|
|
1630
1768
|
// proxy that doesn't set X-Forwarded-Proto). isHttpsRequest sees the
|
|
@@ -3195,13 +3333,46 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
3195
3333
|
});
|
|
3196
3334
|
});
|
|
3197
3335
|
|
|
3198
|
-
describe("layerOf — classify trust layer from proxy headers", () => {
|
|
3199
|
-
//
|
|
3200
|
-
//
|
|
3201
|
-
// a
|
|
3336
|
+
describe("layerOf — classify trust layer from proxy headers + peer (item E / #526)", () => {
|
|
3337
|
+
// Proxy headers (cloudflared, tailscale serve/funnel) take precedence. When
|
|
3338
|
+
// absent, the PEER ADDRESS is the loopback discriminator — header-absence is
|
|
3339
|
+
// no longer a loopback signal (#526). The peer-address is the 2nd arg.
|
|
3202
3340
|
|
|
3203
|
-
test("no proxy headers → loopback (
|
|
3204
|
-
expect(layerOf(req("/"))).toBe("loopback");
|
|
3341
|
+
test("no proxy headers + loopback peer (127.0.0.1) → loopback (on-box CLI)", () => {
|
|
3342
|
+
expect(layerOf(req("/"), "127.0.0.1")).toBe("loopback");
|
|
3343
|
+
});
|
|
3344
|
+
|
|
3345
|
+
test("no proxy headers + IPv6 loopback peer (::1) → loopback", () => {
|
|
3346
|
+
expect(layerOf(req("/"), "::1")).toBe("loopback");
|
|
3347
|
+
});
|
|
3348
|
+
|
|
3349
|
+
test("no proxy headers + IPv4-mapped IPv6 loopback (::ffff:127.0.0.1) → loopback", () => {
|
|
3350
|
+
expect(layerOf(req("/"), "::ffff:127.0.0.1")).toBe("loopback");
|
|
3351
|
+
});
|
|
3352
|
+
|
|
3353
|
+
// THE FIX: a header-absent NON-loopback peer (the 0.0.0.0-bind direct-network
|
|
3354
|
+
// case) must NOT be classified loopback — it would bypass the
|
|
3355
|
+
// publicExposure:loopback 404-cloak. Fail to public (least-trusted).
|
|
3356
|
+
test("no proxy headers + non-loopback peer → public (NOT loopback) [#526]", () => {
|
|
3357
|
+
expect(layerOf(req("/"), "203.0.113.7")).toBe("public");
|
|
3358
|
+
expect(layerOf(req("/"), "10.0.0.5")).toBe("public");
|
|
3359
|
+
expect(layerOf(req("/"), "fd00::1234")).toBe("public");
|
|
3360
|
+
});
|
|
3361
|
+
|
|
3362
|
+
// Fail-closed: an unknown peer (no Server threaded — null/undefined) is NOT
|
|
3363
|
+
// loopback. A direct unit call to the fetch fn with no server lands here.
|
|
3364
|
+
test("no proxy headers + unknown peer (null/undefined) → public (fail closed)", () => {
|
|
3365
|
+
expect(layerOf(req("/"), null)).toBe("public");
|
|
3366
|
+
expect(layerOf(req("/"), undefined)).toBe("public");
|
|
3367
|
+
expect(layerOf(req("/"))).toBe("public");
|
|
3368
|
+
});
|
|
3369
|
+
|
|
3370
|
+
// Headers still win over peer address — a tailnet/public forwarder sets the
|
|
3371
|
+
// header and the peer (the local tailscaled/cloudflared) is loopback, but the
|
|
3372
|
+
// header is authoritative.
|
|
3373
|
+
test("Tailscale-User-Login → tailnet even from a loopback peer", () => {
|
|
3374
|
+
const r = req("/", { headers: { "Tailscale-User-Login": "alice@example.com" } });
|
|
3375
|
+
expect(layerOf(r, "127.0.0.1")).toBe("tailnet");
|
|
3205
3376
|
});
|
|
3206
3377
|
|
|
3207
3378
|
test("Tailscale-User-Login → tailnet (authed via tailscale serve)", () => {
|
|
@@ -3266,6 +3437,11 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3266
3437
|
return { port: server.port as number, stop: () => server.stop(true) };
|
|
3267
3438
|
}
|
|
3268
3439
|
|
|
3440
|
+
// Fake Bun Server handle exposing only `requestIP` (item E / #526) so the
|
|
3441
|
+
// fetch fn can resolve the peer address. The on-box CLI caller connects from
|
|
3442
|
+
// 127.0.0.1; a network peer on a 0.0.0.0 bind connects from its real IP.
|
|
3443
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
3444
|
+
|
|
3269
3445
|
test("publicExposure: loopback + tailnet header → 404 (gate hides the route)", async () => {
|
|
3270
3446
|
const h = makeHarness();
|
|
3271
3447
|
const upstream = startUpstream("loopback-only");
|
|
@@ -3326,7 +3502,7 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3326
3502
|
}
|
|
3327
3503
|
});
|
|
3328
3504
|
|
|
3329
|
-
test("publicExposure: loopback + no headers → reaches upstream (loopback layer)", async () => {
|
|
3505
|
+
test("publicExposure: loopback + no headers + loopback peer → reaches upstream (loopback layer)", async () => {
|
|
3330
3506
|
const h = makeHarness();
|
|
3331
3507
|
const upstream = startUpstream("loopback-only");
|
|
3332
3508
|
try {
|
|
@@ -3346,7 +3522,8 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3346
3522
|
h.manifestPath,
|
|
3347
3523
|
);
|
|
3348
3524
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3349
|
-
|
|
3525
|
+
// On-box CLI caller: 127.0.0.1 peer, no proxy headers → loopback layer.
|
|
3526
|
+
const res = await fetcher(req("/loopback-only/health"), fakeServer("127.0.0.1"));
|
|
3350
3527
|
expect(res.status).toBe(200);
|
|
3351
3528
|
const body = (await res.json()) as { tag: string };
|
|
3352
3529
|
expect(body.tag).toBe("loopback-only");
|
|
@@ -3356,6 +3533,37 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3356
3533
|
}
|
|
3357
3534
|
});
|
|
3358
3535
|
|
|
3536
|
+
// Item E / #526 — the core fix. On a 0.0.0.0 bind a network peer reaches the
|
|
3537
|
+
// listener with NO proxy headers; it must NOT be treated as loopback, so the
|
|
3538
|
+
// loopback-exposure cloak still fires (404) rather than leaking the route.
|
|
3539
|
+
test("publicExposure: loopback + no headers + NON-loopback peer → 404 (cloak fires) [#526]", async () => {
|
|
3540
|
+
const h = makeHarness();
|
|
3541
|
+
const upstream = startUpstream("loopback-only");
|
|
3542
|
+
try {
|
|
3543
|
+
writeManifest(
|
|
3544
|
+
{
|
|
3545
|
+
services: [
|
|
3546
|
+
{
|
|
3547
|
+
name: "loopback-only",
|
|
3548
|
+
port: upstream.port,
|
|
3549
|
+
paths: ["/loopback-only"],
|
|
3550
|
+
health: "/loopback-only/health",
|
|
3551
|
+
version: "0.1.0",
|
|
3552
|
+
publicExposure: "loopback",
|
|
3553
|
+
},
|
|
3554
|
+
],
|
|
3555
|
+
},
|
|
3556
|
+
h.manifestPath,
|
|
3557
|
+
);
|
|
3558
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3559
|
+
const res = await fetcher(req("/loopback-only/health"), fakeServer("203.0.113.9"));
|
|
3560
|
+
expect(res.status).toBe(404);
|
|
3561
|
+
} finally {
|
|
3562
|
+
upstream.stop();
|
|
3563
|
+
h.cleanup();
|
|
3564
|
+
}
|
|
3565
|
+
});
|
|
3566
|
+
|
|
3359
3567
|
test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
|
|
3360
3568
|
const h = makeHarness();
|
|
3361
3569
|
const upstream = startUpstream("allowed");
|
|
@@ -3514,6 +3722,10 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
3514
3722
|
return { port: server.port as number, stop: () => server.stop(true) };
|
|
3515
3723
|
}
|
|
3516
3724
|
|
|
3725
|
+
// Item E / #526 — fake Bun Server handle exposing `requestIP` for the peer-
|
|
3726
|
+
// address discriminator (see the proxyToService block for the rationale).
|
|
3727
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
3728
|
+
|
|
3517
3729
|
test("vault publicExposure: loopback + tailnet header → 404", async () => {
|
|
3518
3730
|
const h = makeHarness();
|
|
3519
3731
|
const upstream = startVaultUpstream("vault-private");
|
|
@@ -3545,7 +3757,7 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
3545
3757
|
}
|
|
3546
3758
|
});
|
|
3547
3759
|
|
|
3548
|
-
test("vault publicExposure: loopback + no headers → reaches vault backend", async () => {
|
|
3760
|
+
test("vault publicExposure: loopback + no headers + loopback peer → reaches vault backend", async () => {
|
|
3549
3761
|
const h = makeHarness();
|
|
3550
3762
|
const upstream = startVaultUpstream("vault-private");
|
|
3551
3763
|
try {
|
|
@@ -3565,7 +3777,7 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
3565
3777
|
h.manifestPath,
|
|
3566
3778
|
);
|
|
3567
3779
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3568
|
-
const res = await fetcher(req("/vault/private/health"));
|
|
3780
|
+
const res = await fetcher(req("/vault/private/health"), fakeServer("127.0.0.1"));
|
|
3569
3781
|
expect(res.status).toBe(200);
|
|
3570
3782
|
const body = (await res.json()) as { tag: string };
|
|
3571
3783
|
expect(body.tag).toBe("vault-private");
|
|
@@ -3575,6 +3787,36 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
3575
3787
|
}
|
|
3576
3788
|
});
|
|
3577
3789
|
|
|
3790
|
+
// Item E / #526 — vault-path symmetry: a header-absent NON-loopback peer on a
|
|
3791
|
+
// 0.0.0.0 bind must NOT reach a loopback-exposed vault (cloak fires).
|
|
3792
|
+
test("vault publicExposure: loopback + no headers + NON-loopback peer → 404 [#526]", async () => {
|
|
3793
|
+
const h = makeHarness();
|
|
3794
|
+
const upstream = startVaultUpstream("vault-private");
|
|
3795
|
+
try {
|
|
3796
|
+
writeManifest(
|
|
3797
|
+
{
|
|
3798
|
+
services: [
|
|
3799
|
+
{
|
|
3800
|
+
name: "parachute-vault-private",
|
|
3801
|
+
port: upstream.port,
|
|
3802
|
+
paths: ["/vault/private"],
|
|
3803
|
+
health: "/vault/private/health",
|
|
3804
|
+
version: "0.4.0",
|
|
3805
|
+
publicExposure: "loopback",
|
|
3806
|
+
},
|
|
3807
|
+
],
|
|
3808
|
+
},
|
|
3809
|
+
h.manifestPath,
|
|
3810
|
+
);
|
|
3811
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3812
|
+
const res = await fetcher(req("/vault/private/health"), fakeServer("198.51.100.4"));
|
|
3813
|
+
expect(res.status).toBe(404);
|
|
3814
|
+
} finally {
|
|
3815
|
+
upstream.stop();
|
|
3816
|
+
h.cleanup();
|
|
3817
|
+
}
|
|
3818
|
+
});
|
|
3819
|
+
|
|
3578
3820
|
test("vault publicExposure: allowed + tailnet header → reaches backend", async () => {
|
|
3579
3821
|
const h = makeHarness();
|
|
3580
3822
|
const upstream = startVaultUpstream("vault-public");
|
|
@@ -4185,6 +4427,9 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
|
|
|
4185
4427
|
const friend = await createUser(db, "friend", "friend-password-123", {
|
|
4186
4428
|
assignedVaults,
|
|
4187
4429
|
allowMulti: true,
|
|
4430
|
+
// Item F (#469): the friend mints a token only after rotating the temp
|
|
4431
|
+
// password; an unrotated friend is force-redirected before any mint.
|
|
4432
|
+
passwordChanged: true,
|
|
4188
4433
|
});
|
|
4189
4434
|
const session = createSession(db, { userId: friend.id });
|
|
4190
4435
|
const csrf = generateCsrfToken();
|
|
@@ -4227,6 +4472,63 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
|
|
|
4227
4472
|
}
|
|
4228
4473
|
});
|
|
4229
4474
|
|
|
4475
|
+
// Item F (#469) routed e2e — an assigned but unrotated friend is force-
|
|
4476
|
+
// redirected to the change-password rail before any mint, through the real
|
|
4477
|
+
// dispatch.
|
|
4478
|
+
test("unrotated friend is force-change-gated, routed through hubFetch (item F / #469)", async () => {
|
|
4479
|
+
// As of hub#469 the broad per-request gate (forceChangePasswordGate) is the
|
|
4480
|
+
// choke point and intercepts BEFORE the per-route mint handler. A browser
|
|
4481
|
+
// request (Accept: text/html) is 302'd to the change-password rail; an
|
|
4482
|
+
// API-style POST without that header is 403 force_change_password. Both
|
|
4483
|
+
// prove the unrotated friend can't parlay the temp password into a mint.
|
|
4484
|
+
const h = makeHarness();
|
|
4485
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4486
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4487
|
+
rotateSigningKey(db);
|
|
4488
|
+
await createUser(db, "operator", "operator-password-123");
|
|
4489
|
+
const friend = await createUser(db, "friend", "friend-password-123", {
|
|
4490
|
+
assignedVaults: ["work"],
|
|
4491
|
+
allowMulti: true,
|
|
4492
|
+
passwordChanged: false, // not yet rotated
|
|
4493
|
+
});
|
|
4494
|
+
const session = createSession(db, { userId: friend.id });
|
|
4495
|
+
const csrf = generateCsrfToken();
|
|
4496
|
+
const cookie = `${buildSessionCookie(session.id, 3600, { secure: false })}; ${
|
|
4497
|
+
buildCsrfCookie(csrf, { secure: false }).split(";")[0]
|
|
4498
|
+
}`;
|
|
4499
|
+
const fetcher = hubFetch(h.dir, {
|
|
4500
|
+
getDb: () => db,
|
|
4501
|
+
manifestPath: h.manifestPath,
|
|
4502
|
+
issuer: "https://hub.test",
|
|
4503
|
+
});
|
|
4504
|
+
try {
|
|
4505
|
+
// The mint is a POST (non-GET) → the gate rejects with 403
|
|
4506
|
+
// force_change_password regardless of Accept, per the spec ("redirect
|
|
4507
|
+
// browser GETs, reject non-GET / API-style requests with 403"). A 302 on
|
|
4508
|
+
// a POST wouldn't usefully re-issue the mint anyway.
|
|
4509
|
+
const apiRes = await fetcher(
|
|
4510
|
+
req("/account/vault-token/work", {
|
|
4511
|
+
method: "POST",
|
|
4512
|
+
headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
|
|
4513
|
+
body: postBody(csrf, "read"),
|
|
4514
|
+
}),
|
|
4515
|
+
);
|
|
4516
|
+
expect(apiRes.status).toBe(403);
|
|
4517
|
+
expect(((await apiRes.json()) as { error: string }).error).toBe("force_change_password");
|
|
4518
|
+
|
|
4519
|
+
// The friend's browser GET of the account home IS bounced to the
|
|
4520
|
+
// change-password rail — the surface they'd navigate to is gated.
|
|
4521
|
+
const browserRes = await fetcher(
|
|
4522
|
+
req("/account/", { headers: { cookie, accept: "text/html" } }),
|
|
4523
|
+
);
|
|
4524
|
+
expect(browserRes.status).toBe(302);
|
|
4525
|
+
expect(browserRes.headers.get("location")).toBe("/account/change-password");
|
|
4526
|
+
} finally {
|
|
4527
|
+
db.close();
|
|
4528
|
+
h.cleanup();
|
|
4529
|
+
}
|
|
4530
|
+
});
|
|
4531
|
+
|
|
4230
4532
|
test("unassigned vault → 403, no token, routed through hubFetch", async () => {
|
|
4231
4533
|
const h = makeHarness();
|
|
4232
4534
|
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
@@ -4269,6 +4571,73 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
|
|
|
4269
4571
|
});
|
|
4270
4572
|
});
|
|
4271
4573
|
|
|
4574
|
+
// Item D (#450) routed e2e — exercise the knownVaultNames threading from
|
|
4575
|
+
// services.json → hubFetch → handleApiMintToken (hub-server.ts dispatch),
|
|
4576
|
+
// which the unit-level handler tests can't cover. A `vault:<typo>:admin` mint
|
|
4577
|
+
// for an unregistered vault → 400 through the full stack; a known vault → 200.
|
|
4578
|
+
describe("POST /api/auth/mint-token — vault-existence threading (routed end-to-end, item D)", () => {
|
|
4579
|
+
const ISSUER = "https://hub.test";
|
|
4580
|
+
|
|
4581
|
+
async function seedMint(
|
|
4582
|
+
h: Harness,
|
|
4583
|
+
vaultNames: string[],
|
|
4584
|
+
): Promise<{ db: ReturnType<typeof openHubDb>; bearer: string }> {
|
|
4585
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4586
|
+
rotateSigningKey(db); // mint needs an active signing key
|
|
4587
|
+
const owner = await createUser(db, "owner", "owner-password-123");
|
|
4588
|
+
// The default operator scope-set carries parachute:host:admin, which mints
|
|
4589
|
+
// vault:<name>:admin (canGrant rule 2).
|
|
4590
|
+
const op = await mintOperatorToken(db, owner.id, { issuer: ISSUER });
|
|
4591
|
+
writeManifest({ services: vaultNames.map((n) => vaultEntry(n)) }, h.manifestPath);
|
|
4592
|
+
return { db, bearer: op.token };
|
|
4593
|
+
}
|
|
4594
|
+
|
|
4595
|
+
function mintReq(scope: string, bearer: string): Request {
|
|
4596
|
+
return req("/api/auth/mint-token", {
|
|
4597
|
+
method: "POST",
|
|
4598
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
4599
|
+
body: JSON.stringify({ scope }),
|
|
4600
|
+
});
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4603
|
+
test("vault:<typo>:admin for an unregistered vault → 400 (knownVaultNames from services.json)", async () => {
|
|
4604
|
+
const h = makeHarness();
|
|
4605
|
+
const { db, bearer } = await seedMint(h, ["work", "default"]);
|
|
4606
|
+
try {
|
|
4607
|
+
const res = await hubFetch(h.dir, {
|
|
4608
|
+
getDb: () => db,
|
|
4609
|
+
manifestPath: h.manifestPath,
|
|
4610
|
+
issuer: ISSUER,
|
|
4611
|
+
})(mintReq("vault:typo:admin", bearer));
|
|
4612
|
+
expect(res.status).toBe(400);
|
|
4613
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
4614
|
+
expect(body.error).toBe("invalid_scope");
|
|
4615
|
+
expect(body.error_description).toContain("typo");
|
|
4616
|
+
} finally {
|
|
4617
|
+
db.close();
|
|
4618
|
+
h.cleanup();
|
|
4619
|
+
}
|
|
4620
|
+
});
|
|
4621
|
+
|
|
4622
|
+
test("vault:<name>:admin for a REGISTERED vault → 200 (proves the gate isn't over-blocking)", async () => {
|
|
4623
|
+
const h = makeHarness();
|
|
4624
|
+
const { db, bearer } = await seedMint(h, ["work", "default"]);
|
|
4625
|
+
try {
|
|
4626
|
+
const res = await hubFetch(h.dir, {
|
|
4627
|
+
getDb: () => db,
|
|
4628
|
+
manifestPath: h.manifestPath,
|
|
4629
|
+
issuer: ISSUER,
|
|
4630
|
+
})(mintReq("vault:work:admin", bearer));
|
|
4631
|
+
expect(res.status).toBe(200);
|
|
4632
|
+
const body = (await res.json()) as { scope: string };
|
|
4633
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
4634
|
+
} finally {
|
|
4635
|
+
db.close();
|
|
4636
|
+
h.cleanup();
|
|
4637
|
+
}
|
|
4638
|
+
});
|
|
4639
|
+
});
|
|
4640
|
+
|
|
4272
4641
|
describe("hubFetch routing — /api/hub/upgrade (D4 SPA hub-upgrade)", () => {
|
|
4273
4642
|
test("POST /api/hub/upgrade dispatches to the handler (401 without bearer, NOT 404)", async () => {
|
|
4274
4643
|
const h = makeHarness();
|
|
@@ -4332,3 +4701,382 @@ describe("hubFetch routing — /api/hub/upgrade (D4 SPA hub-upgrade)", () => {
|
|
|
4332
4701
|
}
|
|
4333
4702
|
});
|
|
4334
4703
|
});
|
|
4704
|
+
|
|
4705
|
+
// Per-request force-change-password enforcement (P0-1 / hub#469). The redirect
|
|
4706
|
+
// at /login was never enough: a signed-in user holding an admin-set temp
|
|
4707
|
+
// password (`password_changed=false`) could navigate DIRECTLY to /account/* or
|
|
4708
|
+
// a per-vault proxy URL and operate indefinitely on the un-rotated secret.
|
|
4709
|
+
// These tests pin the broad per-request gate: pre-rotation users are bounced
|
|
4710
|
+
// off every /account/* surface AND the per-vault proxy, EXCEPT the rotation/exit
|
|
4711
|
+
// path (/account/change-password + /logout); after rotation all surfaces open.
|
|
4712
|
+
describe("force-change-password per-request gate (#469)", () => {
|
|
4713
|
+
// Seed a signed-in user with a chosen password_changed flag and return a
|
|
4714
|
+
// ready-to-use session cookie. Mirrors the seedFriend helpers in the
|
|
4715
|
+
// account-vault-{token,admin-token} suites.
|
|
4716
|
+
async function seedUser(
|
|
4717
|
+
h: Harness,
|
|
4718
|
+
db: ReturnType<typeof openHubDb>,
|
|
4719
|
+
opts: { passwordChanged: boolean; assignedVaults?: string[] },
|
|
4720
|
+
): Promise<{ cookie: string }> {
|
|
4721
|
+
const { SESSION_TTL_MS } = await import("../sessions.ts");
|
|
4722
|
+
const user = await createUser(db, "friend", "temp-pw", {
|
|
4723
|
+
allowMulti: true,
|
|
4724
|
+
passwordChanged: opts.passwordChanged,
|
|
4725
|
+
assignedVaults: opts.assignedVaults ?? [],
|
|
4726
|
+
});
|
|
4727
|
+
const session = createSession(db, { userId: user.id });
|
|
4728
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
4729
|
+
return { cookie };
|
|
4730
|
+
}
|
|
4731
|
+
|
|
4732
|
+
test("(a) pre-rotation browser GET /account/ is 302'd to change-password", async () => {
|
|
4733
|
+
const h = makeHarness();
|
|
4734
|
+
try {
|
|
4735
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4736
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4737
|
+
try {
|
|
4738
|
+
const { cookie } = await seedUser(h, db, {
|
|
4739
|
+
passwordChanged: false,
|
|
4740
|
+
assignedVaults: ["work"],
|
|
4741
|
+
});
|
|
4742
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4743
|
+
req("/account/", { headers: { cookie, accept: "text/html" } }),
|
|
4744
|
+
);
|
|
4745
|
+
expect(res.status).toBe(302);
|
|
4746
|
+
expect(res.headers.get("location")).toBe("/account/change-password");
|
|
4747
|
+
} finally {
|
|
4748
|
+
db.close();
|
|
4749
|
+
}
|
|
4750
|
+
} finally {
|
|
4751
|
+
h.cleanup();
|
|
4752
|
+
}
|
|
4753
|
+
});
|
|
4754
|
+
|
|
4755
|
+
test("(a) pre-rotation API POST /account/vault-token/<name> is 403 force_change_password", async () => {
|
|
4756
|
+
const h = makeHarness();
|
|
4757
|
+
try {
|
|
4758
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4759
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4760
|
+
try {
|
|
4761
|
+
const { cookie } = await seedUser(h, db, {
|
|
4762
|
+
passwordChanged: false,
|
|
4763
|
+
assignedVaults: ["work"],
|
|
4764
|
+
});
|
|
4765
|
+
// No Accept: text/html → treated as an API client → 403 JSON, not a 302.
|
|
4766
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4767
|
+
req("/account/vault-token/work", { method: "POST", headers: { cookie } }),
|
|
4768
|
+
);
|
|
4769
|
+
expect(res.status).toBe(403);
|
|
4770
|
+
const body = (await res.json()) as { error: string };
|
|
4771
|
+
expect(body.error).toBe("force_change_password");
|
|
4772
|
+
} finally {
|
|
4773
|
+
db.close();
|
|
4774
|
+
}
|
|
4775
|
+
} finally {
|
|
4776
|
+
h.cleanup();
|
|
4777
|
+
}
|
|
4778
|
+
});
|
|
4779
|
+
|
|
4780
|
+
test("(a) pre-rotation browser GET of a per-vault proxy URL is 302'd to change-password", async () => {
|
|
4781
|
+
const h = makeHarness();
|
|
4782
|
+
try {
|
|
4783
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4784
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4785
|
+
try {
|
|
4786
|
+
const { cookie } = await seedUser(h, db, {
|
|
4787
|
+
passwordChanged: false,
|
|
4788
|
+
assignedVaults: ["work"],
|
|
4789
|
+
});
|
|
4790
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4791
|
+
req("/vault/work/notes/abc", { headers: { cookie, accept: "text/html" } }),
|
|
4792
|
+
);
|
|
4793
|
+
// Gated BEFORE the proxy ever runs — so this is the gate's 302, not a
|
|
4794
|
+
// proxy 404/502.
|
|
4795
|
+
expect(res.status).toBe(302);
|
|
4796
|
+
expect(res.headers.get("location")).toBe("/account/change-password");
|
|
4797
|
+
} finally {
|
|
4798
|
+
db.close();
|
|
4799
|
+
}
|
|
4800
|
+
} finally {
|
|
4801
|
+
h.cleanup();
|
|
4802
|
+
}
|
|
4803
|
+
});
|
|
4804
|
+
|
|
4805
|
+
test("(b) pre-rotation user CAN still reach /account/change-password (GET)", async () => {
|
|
4806
|
+
const h = makeHarness();
|
|
4807
|
+
try {
|
|
4808
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4809
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4810
|
+
try {
|
|
4811
|
+
const { cookie } = await seedUser(h, db, {
|
|
4812
|
+
passwordChanged: false,
|
|
4813
|
+
assignedVaults: ["work"],
|
|
4814
|
+
});
|
|
4815
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4816
|
+
req("/account/change-password", { headers: { cookie, accept: "text/html" } }),
|
|
4817
|
+
);
|
|
4818
|
+
// Reaches the real handler (200 form render) — NOT bounced to itself.
|
|
4819
|
+
expect(res.status).toBe(200);
|
|
4820
|
+
const body = await res.text();
|
|
4821
|
+
expect(body.toLowerCase()).toContain("password");
|
|
4822
|
+
} finally {
|
|
4823
|
+
db.close();
|
|
4824
|
+
}
|
|
4825
|
+
} finally {
|
|
4826
|
+
h.cleanup();
|
|
4827
|
+
}
|
|
4828
|
+
});
|
|
4829
|
+
|
|
4830
|
+
test("(b) pre-rotation user CAN still reach /logout (POST)", async () => {
|
|
4831
|
+
const h = makeHarness();
|
|
4832
|
+
try {
|
|
4833
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4834
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4835
|
+
try {
|
|
4836
|
+
const { cookie } = await seedUser(h, db, {
|
|
4837
|
+
passwordChanged: false,
|
|
4838
|
+
assignedVaults: ["work"],
|
|
4839
|
+
});
|
|
4840
|
+
// CSRF cookie + matching field so the logout POST passes its own gate;
|
|
4841
|
+
// the point is the force-change gate does NOT intercept /logout.
|
|
4842
|
+
const csrf = generateCsrfToken();
|
|
4843
|
+
const form = new URLSearchParams();
|
|
4844
|
+
form.set("__csrf", csrf);
|
|
4845
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4846
|
+
req("/logout", {
|
|
4847
|
+
method: "POST",
|
|
4848
|
+
headers: {
|
|
4849
|
+
cookie: `${cookie}; ${buildCsrfCookie(csrf)}`,
|
|
4850
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4851
|
+
},
|
|
4852
|
+
body: form.toString(),
|
|
4853
|
+
}),
|
|
4854
|
+
);
|
|
4855
|
+
// Logout succeeds (302 to /) — it is NOT the force-change 302 to
|
|
4856
|
+
// /account/change-password and NOT a 403.
|
|
4857
|
+
expect(res.status).toBe(302);
|
|
4858
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4859
|
+
expect(res.status).not.toBe(403);
|
|
4860
|
+
} finally {
|
|
4861
|
+
db.close();
|
|
4862
|
+
}
|
|
4863
|
+
} finally {
|
|
4864
|
+
h.cleanup();
|
|
4865
|
+
}
|
|
4866
|
+
});
|
|
4867
|
+
|
|
4868
|
+
test("(c) after rotation, /account/ is reachable (not gated)", async () => {
|
|
4869
|
+
const h = makeHarness();
|
|
4870
|
+
try {
|
|
4871
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4872
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4873
|
+
try {
|
|
4874
|
+
const { cookie } = await seedUser(h, db, {
|
|
4875
|
+
passwordChanged: true,
|
|
4876
|
+
assignedVaults: ["work"],
|
|
4877
|
+
});
|
|
4878
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4879
|
+
req("/account/", { headers: { cookie, accept: "text/html" } }),
|
|
4880
|
+
);
|
|
4881
|
+
// Account home renders — not bounced.
|
|
4882
|
+
expect(res.status).toBe(200);
|
|
4883
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4884
|
+
} finally {
|
|
4885
|
+
db.close();
|
|
4886
|
+
}
|
|
4887
|
+
} finally {
|
|
4888
|
+
h.cleanup();
|
|
4889
|
+
}
|
|
4890
|
+
});
|
|
4891
|
+
|
|
4892
|
+
test("(c) after rotation, a per-vault proxy URL is NOT gated (reaches the proxy)", async () => {
|
|
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: true,
|
|
4900
|
+
assignedVaults: ["work"],
|
|
4901
|
+
});
|
|
4902
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4903
|
+
req("/vault/work/notes/abc", { headers: { cookie, accept: "text/html" } }),
|
|
4904
|
+
);
|
|
4905
|
+
// No upstream listening → the proxy returns a 404/502, NOT the gate's
|
|
4906
|
+
// 302→change-password / 403. The point is the gate let it through.
|
|
4907
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4908
|
+
const body = res.status === 403 ? ((await res.json()) as { error?: string }) : null;
|
|
4909
|
+
expect(body?.error).not.toBe("force_change_password");
|
|
4910
|
+
} finally {
|
|
4911
|
+
db.close();
|
|
4912
|
+
}
|
|
4913
|
+
} finally {
|
|
4914
|
+
h.cleanup();
|
|
4915
|
+
}
|
|
4916
|
+
});
|
|
4917
|
+
|
|
4918
|
+
test("an UNAUTHENTICATED per-vault proxy request is NOT gated (no hub session)", async () => {
|
|
4919
|
+
const h = makeHarness();
|
|
4920
|
+
try {
|
|
4921
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4922
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4923
|
+
try {
|
|
4924
|
+
// No cookie at all — the common Notes/MCP case carrying its own bearer.
|
|
4925
|
+
// forceChangePasswordGate returns null (no session) → proxy handles it.
|
|
4926
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4927
|
+
req("/vault/work/notes/abc", { headers: { accept: "text/html" } }),
|
|
4928
|
+
);
|
|
4929
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4930
|
+
expect(res.status).not.toBe(403);
|
|
4931
|
+
} finally {
|
|
4932
|
+
db.close();
|
|
4933
|
+
}
|
|
4934
|
+
} finally {
|
|
4935
|
+
h.cleanup();
|
|
4936
|
+
}
|
|
4937
|
+
});
|
|
4938
|
+
|
|
4939
|
+
test("(b) pre-rotation user CAN POST /account/change-password (the rotation action itself)", async () => {
|
|
4940
|
+
// The exempt POST: a pre-rotation user submitting their new password must
|
|
4941
|
+
// NOT be intercepted by the gate — it's the only way out of force-change.
|
|
4942
|
+
// `/account/change-password` is dispatched ABOVE the gate, so it never
|
|
4943
|
+
// reaches the choke point. We assert the POST reaches its own handler (it
|
|
4944
|
+
// fails CSRF here → its OWN 400/403, NOT the gate's 302→change-password).
|
|
4945
|
+
const h = makeHarness();
|
|
4946
|
+
try {
|
|
4947
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4948
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4949
|
+
try {
|
|
4950
|
+
const { cookie } = await seedUser(h, db, {
|
|
4951
|
+
passwordChanged: false,
|
|
4952
|
+
assignedVaults: ["work"],
|
|
4953
|
+
});
|
|
4954
|
+
const csrf = generateCsrfToken();
|
|
4955
|
+
const form = new URLSearchParams();
|
|
4956
|
+
form.set("__csrf", csrf);
|
|
4957
|
+
form.set("current_password", "temp-pw");
|
|
4958
|
+
form.set("new_password", "rotated-password-123");
|
|
4959
|
+
form.set("new_password_confirm", "rotated-password-123");
|
|
4960
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4961
|
+
req("/account/change-password", {
|
|
4962
|
+
method: "POST",
|
|
4963
|
+
headers: {
|
|
4964
|
+
cookie: `${cookie}; ${buildCsrfCookie(csrf)}`,
|
|
4965
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4966
|
+
accept: "text/html",
|
|
4967
|
+
},
|
|
4968
|
+
body: form.toString(),
|
|
4969
|
+
}),
|
|
4970
|
+
);
|
|
4971
|
+
// Reaches its own handler (303 back to /account/ on success, or its own
|
|
4972
|
+
// form re-render). The point: it is NOT the gate's 302 to
|
|
4973
|
+
// change-password and NOT the gate's 403 force_change_password.
|
|
4974
|
+
expect(res.status).not.toBe(403);
|
|
4975
|
+
if (res.status === 302 || res.status === 303) {
|
|
4976
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4977
|
+
}
|
|
4978
|
+
// And the rotation actually took: the user's flag flipped to true.
|
|
4979
|
+
const { getUserById } = await import("../users.ts");
|
|
4980
|
+
const friend = getUserById(
|
|
4981
|
+
db,
|
|
4982
|
+
db.query<{ id: string }, []>("SELECT id FROM users WHERE username = 'friend'").get()
|
|
4983
|
+
?.id ?? "",
|
|
4984
|
+
);
|
|
4985
|
+
expect(friend?.passwordChanged).toBe(true);
|
|
4986
|
+
} finally {
|
|
4987
|
+
db.close();
|
|
4988
|
+
}
|
|
4989
|
+
} finally {
|
|
4990
|
+
h.cleanup();
|
|
4991
|
+
}
|
|
4992
|
+
});
|
|
4993
|
+
|
|
4994
|
+
test("(a) bare /account (no trailing slash) is gated on the FIRST hop for a pre-rotation user", async () => {
|
|
4995
|
+
// The bare `/account` 301s to `/account/`; without an explicit match it
|
|
4996
|
+
// would slip past `startsWith("/account/")` and only be gated on the second
|
|
4997
|
+
// hop. The gate must intercept the first request.
|
|
4998
|
+
const h = makeHarness();
|
|
4999
|
+
try {
|
|
5000
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
5001
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
5002
|
+
try {
|
|
5003
|
+
const { cookie } = await seedUser(h, db, {
|
|
5004
|
+
passwordChanged: false,
|
|
5005
|
+
assignedVaults: ["work"],
|
|
5006
|
+
});
|
|
5007
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
5008
|
+
req("/account", { headers: { cookie, accept: "text/html" } }),
|
|
5009
|
+
);
|
|
5010
|
+
// Gated → 302 to change-password, NOT the 301 → /account/.
|
|
5011
|
+
expect(res.status).toBe(302);
|
|
5012
|
+
expect(res.headers.get("location")).toBe("/account/change-password");
|
|
5013
|
+
} finally {
|
|
5014
|
+
db.close();
|
|
5015
|
+
}
|
|
5016
|
+
} finally {
|
|
5017
|
+
h.cleanup();
|
|
5018
|
+
}
|
|
5019
|
+
});
|
|
5020
|
+
|
|
5021
|
+
test("(a) pre-rotation session at /oauth/authorize is 302'd to change-password (no auth code issued)", async () => {
|
|
5022
|
+
// The important one: a signed-in pre-rotation user must not ride the
|
|
5023
|
+
// consent flow to an auth code → /oauth/token exchange for a vault token
|
|
5024
|
+
// WITHOUT rotating. The gate intercepts /oauth/authorize before any client
|
|
5025
|
+
// validation or code issuance, so no `code=` redirect can be produced.
|
|
5026
|
+
const h = makeHarness();
|
|
5027
|
+
try {
|
|
5028
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
5029
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
5030
|
+
try {
|
|
5031
|
+
const { cookie } = await seedUser(h, db, {
|
|
5032
|
+
passwordChanged: false,
|
|
5033
|
+
assignedVaults: ["work"],
|
|
5034
|
+
});
|
|
5035
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
5036
|
+
req(
|
|
5037
|
+
"/oauth/authorize?client_id=x&response_type=code&redirect_uri=https://app.example/cb&scope=vault:work:read",
|
|
5038
|
+
{ headers: { cookie, accept: "text/html" } },
|
|
5039
|
+
),
|
|
5040
|
+
);
|
|
5041
|
+
expect(res.status).toBe(302);
|
|
5042
|
+
const location = res.headers.get("location") ?? "";
|
|
5043
|
+
expect(location).toBe("/account/change-password");
|
|
5044
|
+
// No auth code was issued — the redirect is to the rotation rail, not a
|
|
5045
|
+
// client callback carrying a `code=`.
|
|
5046
|
+
expect(location).not.toContain("code=");
|
|
5047
|
+
expect(location).not.toContain("app.example");
|
|
5048
|
+
} finally {
|
|
5049
|
+
db.close();
|
|
5050
|
+
}
|
|
5051
|
+
} finally {
|
|
5052
|
+
h.cleanup();
|
|
5053
|
+
}
|
|
5054
|
+
});
|
|
5055
|
+
|
|
5056
|
+
test("(c) after rotation, /oauth/authorize is NOT gated (reaches the oauth handler)", async () => {
|
|
5057
|
+
const h = makeHarness();
|
|
5058
|
+
try {
|
|
5059
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
5060
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
5061
|
+
try {
|
|
5062
|
+
const { cookie } = await seedUser(h, db, {
|
|
5063
|
+
passwordChanged: true,
|
|
5064
|
+
assignedVaults: ["work"],
|
|
5065
|
+
});
|
|
5066
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
5067
|
+
req(
|
|
5068
|
+
"/oauth/authorize?client_id=x&response_type=code&redirect_uri=https://app.example/cb&scope=vault:work:read",
|
|
5069
|
+
{ headers: { cookie, accept: "text/html" } },
|
|
5070
|
+
),
|
|
5071
|
+
);
|
|
5072
|
+
// Reaches the real authorize handler (login/consent/error) — NOT bounced
|
|
5073
|
+
// to the change-password rail by the gate.
|
|
5074
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
5075
|
+
} finally {
|
|
5076
|
+
db.close();
|
|
5077
|
+
}
|
|
5078
|
+
} finally {
|
|
5079
|
+
h.cleanup();
|
|
5080
|
+
}
|
|
5081
|
+
});
|
|
5082
|
+
});
|