@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -469,10 +469,16 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
469
469
|
expect(interactiveCalled).toBe(false);
|
|
470
470
|
expect(cloudflareCalled).toBe(false);
|
|
471
471
|
const joined = logs.join("\n");
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
)
|
|
472
|
+
// Post 2026-05-27 cloudflared-URL refresh: the install hint moved
|
|
473
|
+
// off apt-get / dnf / developers.cloudflare.com (all unreliable —
|
|
474
|
+
// Aaron hit `No match for argument: cloudflared` on AL2023 and
|
|
475
|
+
// 404s from the docs URL on the same box) onto the static binary
|
|
476
|
+
// from GitHub releases.
|
|
477
|
+
expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
|
|
478
|
+
expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
|
|
479
|
+
expect(joined).not.toContain("developers.cloudflare.com");
|
|
480
|
+
expect(joined).not.toContain("pkg.cloudflare.com");
|
|
481
|
+
expect(joined).not.toContain("sudo dnf install cloudflared");
|
|
476
482
|
} finally {
|
|
477
483
|
env.cleanup();
|
|
478
484
|
}
|
|
@@ -126,7 +126,11 @@ describe("exposePublicAutoPick — neither ready", () => {
|
|
|
126
126
|
expect(code).toBe(1);
|
|
127
127
|
const joined = logs.join("\n");
|
|
128
128
|
expect(joined).toContain("tailscale.com/download");
|
|
129
|
-
|
|
129
|
+
// Post 2026-05-27 cloudflared-URL refresh: the install hint now points
|
|
130
|
+
// at GitHub releases (developers.cloudflare.com / pkg.cloudflare.com
|
|
131
|
+
// both returned HTML/404 on Aaron's fresh AL2023 EC2 box).
|
|
132
|
+
expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
|
|
133
|
+
expect(joined).not.toContain("developers.cloudflare.com");
|
|
130
134
|
expect(joined).toContain("--skip-provider-check");
|
|
131
135
|
});
|
|
132
136
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { exposePublic, exposeTailnet } from "../commands/expose.ts";
|
|
6
|
+
import { readEnvFileValues } from "../env-file.ts";
|
|
6
7
|
import { readExposeState, writeExposeState } from "../expose-state.ts";
|
|
7
8
|
import type { EnsureHubOpts, HubSpawner, StopHubOpts } from "../hub-control.ts";
|
|
8
9
|
import { writePid } from "../process-state.ts";
|
|
@@ -696,6 +697,53 @@ describe("expose tailnet off", () => {
|
|
|
696
697
|
}
|
|
697
698
|
});
|
|
698
699
|
|
|
700
|
+
test("clears the persisted PARACHUTE_HUB_ORIGIN from vault/.env on teardown", async () => {
|
|
701
|
+
// OAuth issuer-mismatch fix: `expose up` persisted the public origin into
|
|
702
|
+
// vault/.env so the daemon validates `iss` against it. With exposure gone,
|
|
703
|
+
// a local-only hub mints loopback-`iss` tokens, so a stale public origin
|
|
704
|
+
// left in `.env` would itself cause the mismatch on the next daemon boot.
|
|
705
|
+
// Tearing it down reverts vault to its loopback default.
|
|
706
|
+
const h = makeHarness();
|
|
707
|
+
try {
|
|
708
|
+
writeExposeState(
|
|
709
|
+
{
|
|
710
|
+
version: 1,
|
|
711
|
+
layer: "tailnet",
|
|
712
|
+
mode: "path",
|
|
713
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
714
|
+
port: 443,
|
|
715
|
+
funnel: false,
|
|
716
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://127.0.0.1:1939", service: "hub" }],
|
|
717
|
+
hubOrigin: "https://parachute.taildf9ce2.ts.net",
|
|
718
|
+
},
|
|
719
|
+
h.statePath,
|
|
720
|
+
);
|
|
721
|
+
mkdirSync(join(h.configDir, "vault"), { recursive: true });
|
|
722
|
+
writeFileSync(
|
|
723
|
+
join(h.configDir, "vault", ".env"),
|
|
724
|
+
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://parachute.taildf9ce2.ts.net\n",
|
|
725
|
+
);
|
|
726
|
+
const { runner } = makeRunner();
|
|
727
|
+
const code = await exposeTailnet("off", {
|
|
728
|
+
runner,
|
|
729
|
+
statePath: h.statePath,
|
|
730
|
+
wellKnownPath: h.wellKnownPath,
|
|
731
|
+
hubPath: h.hubPath,
|
|
732
|
+
wellKnownDir: h.wellKnownDir,
|
|
733
|
+
configDir: h.configDir,
|
|
734
|
+
skipHub: true,
|
|
735
|
+
log: () => {},
|
|
736
|
+
});
|
|
737
|
+
expect(code).toBe(0);
|
|
738
|
+
const values = readEnvFileValues(join(h.configDir, "vault", ".env"));
|
|
739
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
740
|
+
// Sibling keys preserved.
|
|
741
|
+
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
742
|
+
} finally {
|
|
743
|
+
h.cleanup();
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
699
747
|
test("leaves state in place on teardown failure", async () => {
|
|
700
748
|
const h = makeHarness();
|
|
701
749
|
try {
|
|
@@ -961,7 +1009,9 @@ describe("expose public up", () => {
|
|
|
961
1009
|
});
|
|
962
1010
|
expect(code).toBe(0);
|
|
963
1011
|
const joined = logs.join("\n");
|
|
964
|
-
|
|
1012
|
+
// hub#473: real hub-login 2FA. The warning now recommends the real
|
|
1013
|
+
// `parachute auth 2fa enroll` path.
|
|
1014
|
+
expect(joined).toContain("/login is now reachable on the public internet");
|
|
965
1015
|
expect(joined).toContain("parachute auth 2fa enroll");
|
|
966
1016
|
// /login pointer uses the canonical https://<fqdn> origin.
|
|
967
1017
|
expect(joined).toContain("https://parachute.taildf9ce2.ts.net/login");
|
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
6
7
|
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
7
8
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
8
9
|
import {
|
|
@@ -16,7 +17,9 @@ import { setNotesRedirectDisabled } from "../hub-settings.ts";
|
|
|
16
17
|
import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
17
18
|
import { pidPath } from "../process-state.ts";
|
|
18
19
|
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
20
|
+
import { buildSessionCookie, createSession } from "../sessions.ts";
|
|
19
21
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
22
|
+
import type { ModuleState, Supervisor } from "../supervisor.ts";
|
|
20
23
|
import { createUser } from "../users.ts";
|
|
21
24
|
|
|
22
25
|
interface Harness {
|
|
@@ -42,6 +45,33 @@ function mkdirIfMissing(dir: string): void {
|
|
|
42
45
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Minimal stub of the Supervisor surface that proxyRequest's classifier
|
|
50
|
+
* reads from. We don't drive real Bun.spawn — just hand back hand-crafted
|
|
51
|
+
* ModuleState values so the test can pin "vault is starting" / "vault has
|
|
52
|
+
* been running for a minute" exactly. See `supervisor.test.ts` for the
|
|
53
|
+
* real lifecycle tests.
|
|
54
|
+
*/
|
|
55
|
+
function stubSupervisor(states: ModuleState[]): Supervisor {
|
|
56
|
+
return {
|
|
57
|
+
list: () => states,
|
|
58
|
+
get: (short: string) => states.find((s) => s.short === short),
|
|
59
|
+
start: async () => {
|
|
60
|
+
throw new Error("not implemented");
|
|
61
|
+
},
|
|
62
|
+
stop: async () => undefined,
|
|
63
|
+
restart: async () => undefined,
|
|
64
|
+
} as unknown as Supervisor;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
|
|
68
|
+
return {
|
|
69
|
+
status: "running",
|
|
70
|
+
restartsInWindow: 0,
|
|
71
|
+
...partial,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
function vaultEntry(name: string): ServiceEntry {
|
|
46
76
|
return {
|
|
47
77
|
name: `parachute-vault-${name}`,
|
|
@@ -1912,10 +1942,12 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1912
1942
|
}
|
|
1913
1943
|
});
|
|
1914
1944
|
|
|
1915
|
-
test("returns 502 when the matching vault upstream is unreachable", async () => {
|
|
1945
|
+
test("returns 502 with persistent-state JSON when the matching vault upstream is unreachable", async () => {
|
|
1916
1946
|
// Vault is in services.json but the port has nothing listening — vault
|
|
1917
1947
|
// crashed, port shifted, or the user is mid-restart. We owe the caller a
|
|
1918
|
-
// useful error instead of a hang or a silent 404.
|
|
1948
|
+
// useful error instead of a hang or a silent 404. No supervisor +
|
|
1949
|
+
// no pidfile → classifier returns "persistent" → 502 + admin_url
|
|
1950
|
+
// (hub#443 boot-readiness gating).
|
|
1919
1951
|
const h = makeHarness();
|
|
1920
1952
|
try {
|
|
1921
1953
|
writeManifest(
|
|
@@ -1934,10 +1966,261 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1934
1966
|
h.manifestPath,
|
|
1935
1967
|
);
|
|
1936
1968
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1937
|
-
const res = await fetcher(
|
|
1969
|
+
const res = await fetcher(
|
|
1970
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
1971
|
+
);
|
|
1972
|
+
expect(res.status).toBe(502);
|
|
1973
|
+
const body = (await res.json()) as {
|
|
1974
|
+
error: string;
|
|
1975
|
+
error_type: string;
|
|
1976
|
+
admin_url?: string;
|
|
1977
|
+
service: string;
|
|
1978
|
+
};
|
|
1979
|
+
expect(body.error_type).toBe("upstream_unreachable");
|
|
1980
|
+
expect(body.error).toBe("upstream_unreachable");
|
|
1981
|
+
expect(body.admin_url).toBe("/admin/modules");
|
|
1982
|
+
expect(body.service).toBe("vault");
|
|
1983
|
+
} finally {
|
|
1984
|
+
h.cleanup();
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
test("transient state (supervisor reports starting) → 503 + Retry-After when fetch fails", async () => {
|
|
1989
|
+
// Supervisor says vault is `starting` — the loopback port hasn't bound
|
|
1990
|
+
// yet, fetch fails with ECONNREFUSED. Classifier returns "transient",
|
|
1991
|
+
// proxy responds 503 with a Retry-After hint instead of 502.
|
|
1992
|
+
const h = makeHarness();
|
|
1993
|
+
try {
|
|
1994
|
+
writeManifest(
|
|
1995
|
+
{
|
|
1996
|
+
services: [
|
|
1997
|
+
{
|
|
1998
|
+
name: "parachute-vault",
|
|
1999
|
+
port: await pickClosedPort(),
|
|
2000
|
+
paths: ["/vault/default"],
|
|
2001
|
+
health: "/vault/default/health",
|
|
2002
|
+
version: "0.4.0",
|
|
2003
|
+
},
|
|
2004
|
+
],
|
|
2005
|
+
},
|
|
2006
|
+
h.manifestPath,
|
|
2007
|
+
);
|
|
2008
|
+
const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
|
|
2009
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2010
|
+
const res = await fetcher(
|
|
2011
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
2012
|
+
);
|
|
2013
|
+
expect(res.status).toBe(503);
|
|
2014
|
+
expect(res.headers.get("retry-after")).toBe("2");
|
|
2015
|
+
const body = (await res.json()) as {
|
|
2016
|
+
error_type: string;
|
|
2017
|
+
retry_after_ms: number;
|
|
2018
|
+
max_attempts: number;
|
|
2019
|
+
admin_url?: string;
|
|
2020
|
+
};
|
|
2021
|
+
expect(body.error_type).toBe("upstream_starting");
|
|
2022
|
+
expect(body.retry_after_ms).toBe(2000);
|
|
2023
|
+
expect(body.max_attempts).toBe(5);
|
|
2024
|
+
// Transient JSON MUST NOT carry an admin link.
|
|
2025
|
+
expect(body.admin_url).toBeUndefined();
|
|
2026
|
+
} finally {
|
|
2027
|
+
h.cleanup();
|
|
2028
|
+
}
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
test("transient state + Accept: text/html → 503 HTML page with auto-refresh + poll, no admin link", async () => {
|
|
2032
|
+
const h = makeHarness();
|
|
2033
|
+
try {
|
|
2034
|
+
writeManifest(
|
|
2035
|
+
{
|
|
2036
|
+
services: [
|
|
2037
|
+
{
|
|
2038
|
+
name: "parachute-vault",
|
|
2039
|
+
port: await pickClosedPort(),
|
|
2040
|
+
paths: ["/vault/default"],
|
|
2041
|
+
health: "/vault/default/health",
|
|
2042
|
+
version: "0.4.0",
|
|
2043
|
+
},
|
|
2044
|
+
],
|
|
2045
|
+
},
|
|
2046
|
+
h.manifestPath,
|
|
2047
|
+
);
|
|
2048
|
+
const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
|
|
2049
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2050
|
+
const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
|
|
2051
|
+
expect(res.status).toBe(503);
|
|
2052
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
2053
|
+
const text = await res.text();
|
|
2054
|
+
expect(text).toContain(`<meta http-equiv="refresh"`);
|
|
2055
|
+
expect(text).toContain("/api/ready");
|
|
2056
|
+
expect(text).toContain("Just a moment");
|
|
2057
|
+
// Aaron design (d): transient page has no admin link.
|
|
2058
|
+
expect(text).not.toContain("/admin/modules");
|
|
2059
|
+
} finally {
|
|
2060
|
+
h.cleanup();
|
|
2061
|
+
}
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
test("persistent state + Accept: text/html → 502 HTML page with admin link, no auto-refresh", async () => {
|
|
2065
|
+
const h = makeHarness();
|
|
2066
|
+
try {
|
|
2067
|
+
writeManifest(
|
|
2068
|
+
{
|
|
2069
|
+
services: [
|
|
2070
|
+
{
|
|
2071
|
+
name: "parachute-vault",
|
|
2072
|
+
port: await pickClosedPort(),
|
|
2073
|
+
paths: ["/vault/default"],
|
|
2074
|
+
health: "/vault/default/health",
|
|
2075
|
+
version: "0.4.0",
|
|
2076
|
+
},
|
|
2077
|
+
],
|
|
2078
|
+
},
|
|
2079
|
+
h.manifestPath,
|
|
2080
|
+
);
|
|
2081
|
+
const supervisor = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
|
|
2082
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2083
|
+
const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
|
|
2084
|
+
expect(res.status).toBe(502);
|
|
2085
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
2086
|
+
expect(res.headers.get("retry-after")).toBeNull();
|
|
2087
|
+
const text = await res.text();
|
|
2088
|
+
expect(text).toContain("Module unreachable");
|
|
2089
|
+
expect(text).toContain("/admin/modules");
|
|
2090
|
+
expect(text).not.toContain(`http-equiv="refresh"`);
|
|
2091
|
+
} finally {
|
|
2092
|
+
h.cleanup();
|
|
2093
|
+
}
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
test("supervisor running-but-fresh-startedAt → transient classification", async () => {
|
|
2097
|
+
// Supervisor says vault is running, but startedAt is recent enough that
|
|
2098
|
+
// we trust the boot-window heuristic over the failed fetch.
|
|
2099
|
+
const h = makeHarness();
|
|
2100
|
+
try {
|
|
2101
|
+
writeManifest(
|
|
2102
|
+
{
|
|
2103
|
+
services: [
|
|
2104
|
+
{
|
|
2105
|
+
name: "parachute-vault",
|
|
2106
|
+
port: await pickClosedPort(),
|
|
2107
|
+
paths: ["/vault/default"],
|
|
2108
|
+
health: "/vault/default/health",
|
|
2109
|
+
version: "0.4.0",
|
|
2110
|
+
},
|
|
2111
|
+
],
|
|
2112
|
+
},
|
|
2113
|
+
h.manifestPath,
|
|
2114
|
+
);
|
|
2115
|
+
const supervisor = stubSupervisor([
|
|
2116
|
+
moduleState({
|
|
2117
|
+
short: "vault",
|
|
2118
|
+
status: "running",
|
|
2119
|
+
startedAt: new Date(Date.now() - 5_000).toISOString(),
|
|
2120
|
+
}),
|
|
2121
|
+
]);
|
|
2122
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2123
|
+
const res = await fetcher(
|
|
2124
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
2125
|
+
);
|
|
2126
|
+
expect(res.status).toBe(503);
|
|
2127
|
+
const body = (await res.json()) as { error_type: string };
|
|
2128
|
+
expect(body.error_type).toBe("upstream_starting");
|
|
2129
|
+
} finally {
|
|
2130
|
+
h.cleanup();
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
test("supervisor running-but-old-startedAt → persistent (boot window expired)", async () => {
|
|
2135
|
+
const h = makeHarness();
|
|
2136
|
+
try {
|
|
2137
|
+
writeManifest(
|
|
2138
|
+
{
|
|
2139
|
+
services: [
|
|
2140
|
+
{
|
|
2141
|
+
name: "parachute-vault",
|
|
2142
|
+
port: await pickClosedPort(),
|
|
2143
|
+
paths: ["/vault/default"],
|
|
2144
|
+
health: "/vault/default/health",
|
|
2145
|
+
version: "0.4.0",
|
|
2146
|
+
},
|
|
2147
|
+
],
|
|
2148
|
+
},
|
|
2149
|
+
h.manifestPath,
|
|
2150
|
+
);
|
|
2151
|
+
const supervisor = stubSupervisor([
|
|
2152
|
+
moduleState({
|
|
2153
|
+
short: "vault",
|
|
2154
|
+
status: "running",
|
|
2155
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
2156
|
+
}),
|
|
2157
|
+
]);
|
|
2158
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2159
|
+
const res = await fetcher(
|
|
2160
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
2161
|
+
);
|
|
1938
2162
|
expect(res.status).toBe(502);
|
|
1939
|
-
const body = (await res.json()) as {
|
|
1940
|
-
expect(body.
|
|
2163
|
+
const body = (await res.json()) as { error_type: string };
|
|
2164
|
+
expect(body.error_type).toBe("upstream_unreachable");
|
|
2165
|
+
} finally {
|
|
2166
|
+
h.cleanup();
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
test("non-vault upstream (scribe) classified through supervisor when starting", async () => {
|
|
2171
|
+
// Same logic as vault, but exercising the generic /<svc>/* dispatch path.
|
|
2172
|
+
const h = makeHarness();
|
|
2173
|
+
try {
|
|
2174
|
+
writeManifest(
|
|
2175
|
+
{
|
|
2176
|
+
services: [
|
|
2177
|
+
{
|
|
2178
|
+
name: "scribe",
|
|
2179
|
+
port: await pickClosedPort(),
|
|
2180
|
+
paths: ["/scribe"],
|
|
2181
|
+
health: "/scribe/health",
|
|
2182
|
+
version: "0.1.0",
|
|
2183
|
+
},
|
|
2184
|
+
],
|
|
2185
|
+
},
|
|
2186
|
+
h.manifestPath,
|
|
2187
|
+
);
|
|
2188
|
+
const supervisor = stubSupervisor([moduleState({ short: "scribe", status: "starting" })]);
|
|
2189
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2190
|
+
const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
|
|
2191
|
+
expect(res.status).toBe(503);
|
|
2192
|
+
const body = (await res.json()) as { error_type: string; service: string };
|
|
2193
|
+
expect(body.error_type).toBe("upstream_starting");
|
|
2194
|
+
expect(body.service).toBe("scribe");
|
|
2195
|
+
} finally {
|
|
2196
|
+
h.cleanup();
|
|
2197
|
+
}
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
test("/api/ready returns supervisor view + is reachable pre-admin", async () => {
|
|
2201
|
+
// hub#443 endpoint is public + pre-admin (it has to be — the page that
|
|
2202
|
+
// polls it is itself served pre-auth when modules are still booting).
|
|
2203
|
+
const h = makeHarness();
|
|
2204
|
+
try {
|
|
2205
|
+
const supervisor = stubSupervisor([
|
|
2206
|
+
moduleState({ short: "vault", status: "starting" }),
|
|
2207
|
+
moduleState({
|
|
2208
|
+
short: "scribe",
|
|
2209
|
+
status: "running",
|
|
2210
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
2211
|
+
}),
|
|
2212
|
+
]);
|
|
2213
|
+
const fetcher = hubFetch(h.dir, { supervisor });
|
|
2214
|
+
const res = await fetcher(req("/api/ready"));
|
|
2215
|
+
expect(res.status).toBe(200);
|
|
2216
|
+
const body = (await res.json()) as {
|
|
2217
|
+
ready: boolean;
|
|
2218
|
+
ready_modules: string[];
|
|
2219
|
+
transient_modules: string[];
|
|
2220
|
+
};
|
|
2221
|
+
expect(body.ready).toBe(false);
|
|
2222
|
+
expect(body.transient_modules).toEqual(["vault"]);
|
|
2223
|
+
expect(body.ready_modules).toEqual(["scribe"]);
|
|
1941
2224
|
} finally {
|
|
1942
2225
|
h.cleanup();
|
|
1943
2226
|
}
|
|
@@ -2562,9 +2845,10 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
2562
2845
|
}
|
|
2563
2846
|
});
|
|
2564
2847
|
|
|
2565
|
-
test("returns 502 when the matching upstream is unreachable", async () => {
|
|
2848
|
+
test("returns 502 with persistent-state JSON when the matching upstream is unreachable", async () => {
|
|
2566
2849
|
// Service is in services.json but the port has nothing listening — same
|
|
2567
|
-
// shape as the vault-unreachable test
|
|
2850
|
+
// shape as the vault-unreachable test (hub#443 boot-readiness gating).
|
|
2851
|
+
// No supervisor + no pidfile → persistent → 502 with admin_url.
|
|
2568
2852
|
const h = makeHarness();
|
|
2569
2853
|
try {
|
|
2570
2854
|
writeManifest(
|
|
@@ -2582,10 +2866,17 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
2582
2866
|
h.manifestPath,
|
|
2583
2867
|
);
|
|
2584
2868
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2585
|
-
const res = await fetcher(req("/scribe/health"));
|
|
2869
|
+
const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
|
|
2586
2870
|
expect(res.status).toBe(502);
|
|
2587
|
-
const body = (await res.json()) as {
|
|
2588
|
-
|
|
2871
|
+
const body = (await res.json()) as {
|
|
2872
|
+
error: string;
|
|
2873
|
+
error_type: string;
|
|
2874
|
+
admin_url?: string;
|
|
2875
|
+
service: string;
|
|
2876
|
+
};
|
|
2877
|
+
expect(body.error_type).toBe("upstream_unreachable");
|
|
2878
|
+
expect(body.admin_url).toBe("/admin/modules");
|
|
2879
|
+
expect(body.service).toBe("scribe");
|
|
2589
2880
|
} finally {
|
|
2590
2881
|
h.cleanup();
|
|
2591
2882
|
}
|
|
@@ -3882,3 +4173,98 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3882
4173
|
}
|
|
3883
4174
|
});
|
|
3884
4175
|
});
|
|
4176
|
+
|
|
4177
|
+
describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to-end)", () => {
|
|
4178
|
+
// Drive the real dispatch (`hubFetch`) so the route wiring + precedence
|
|
4179
|
+
// (the `/account/vault-token/` prefix must win over `/account/` and the
|
|
4180
|
+
// SPA catch-all) is exercised, not just the handler in isolation.
|
|
4181
|
+
async function seed(h: Harness, assignedVaults: string[]) {
|
|
4182
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4183
|
+
rotateSigningKey(db); // mint needs an active signing key
|
|
4184
|
+
await createUser(db, "operator", "operator-password-123");
|
|
4185
|
+
const friend = await createUser(db, "friend", "friend-password-123", {
|
|
4186
|
+
assignedVaults,
|
|
4187
|
+
allowMulti: true,
|
|
4188
|
+
});
|
|
4189
|
+
const session = createSession(db, { userId: friend.id });
|
|
4190
|
+
const csrf = generateCsrfToken();
|
|
4191
|
+
const cookie = `${buildSessionCookie(session.id, 3600, { secure: false })}; ${
|
|
4192
|
+
buildCsrfCookie(csrf, { secure: false }).split(";")[0]
|
|
4193
|
+
}`;
|
|
4194
|
+
return { db, friendId: friend.id, cookie, csrf };
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
function postBody(csrf: string, verb: string): string {
|
|
4198
|
+
const b = new URLSearchParams();
|
|
4199
|
+
b.set("__csrf", csrf);
|
|
4200
|
+
b.set("verb", verb);
|
|
4201
|
+
return b.toString();
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
test("assigned vault → 200 with a token banner, routed through hubFetch", async () => {
|
|
4205
|
+
const h = makeHarness();
|
|
4206
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4207
|
+
const { db, cookie, csrf } = await seed(h, ["work"]);
|
|
4208
|
+
try {
|
|
4209
|
+
const res = await hubFetch(h.dir, {
|
|
4210
|
+
getDb: () => db,
|
|
4211
|
+
manifestPath: h.manifestPath,
|
|
4212
|
+
issuer: "https://hub.test",
|
|
4213
|
+
})(
|
|
4214
|
+
req("/account/vault-token/work", {
|
|
4215
|
+
method: "POST",
|
|
4216
|
+
headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
|
|
4217
|
+
body: postBody(csrf, "read"),
|
|
4218
|
+
}),
|
|
4219
|
+
);
|
|
4220
|
+
expect(res.status).toBe(200);
|
|
4221
|
+
const html = await res.text();
|
|
4222
|
+
expect(html).toContain('data-testid="minted-token-banner"');
|
|
4223
|
+
expect(html).toContain("vault:work:read");
|
|
4224
|
+
} finally {
|
|
4225
|
+
db.close();
|
|
4226
|
+
h.cleanup();
|
|
4227
|
+
}
|
|
4228
|
+
});
|
|
4229
|
+
|
|
4230
|
+
test("unassigned vault → 403, no token, routed through hubFetch", async () => {
|
|
4231
|
+
const h = makeHarness();
|
|
4232
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4233
|
+
const { db, cookie, csrf } = await seed(h, ["work"]);
|
|
4234
|
+
try {
|
|
4235
|
+
const res = await hubFetch(h.dir, {
|
|
4236
|
+
getDb: () => db,
|
|
4237
|
+
manifestPath: h.manifestPath,
|
|
4238
|
+
issuer: "https://hub.test",
|
|
4239
|
+
})(
|
|
4240
|
+
req("/account/vault-token/other", {
|
|
4241
|
+
method: "POST",
|
|
4242
|
+
headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
|
|
4243
|
+
body: postBody(csrf, "read"),
|
|
4244
|
+
}),
|
|
4245
|
+
);
|
|
4246
|
+
expect(res.status).toBe(403);
|
|
4247
|
+
const html = await res.text();
|
|
4248
|
+
expect(html).not.toContain('data-testid="minted-token-banner"');
|
|
4249
|
+
} finally {
|
|
4250
|
+
db.close();
|
|
4251
|
+
h.cleanup();
|
|
4252
|
+
}
|
|
4253
|
+
});
|
|
4254
|
+
|
|
4255
|
+
test("GET on the mint path → 405 (POST-only)", async () => {
|
|
4256
|
+
const h = makeHarness();
|
|
4257
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4258
|
+
const { db } = await seed(h, ["work"]);
|
|
4259
|
+
try {
|
|
4260
|
+
const res = await hubFetch(h.dir, {
|
|
4261
|
+
getDb: () => db,
|
|
4262
|
+
manifestPath: h.manifestPath,
|
|
4263
|
+
})(req("/account/vault-token/work"));
|
|
4264
|
+
expect(res.status).toBe(405);
|
|
4265
|
+
} finally {
|
|
4266
|
+
db.close();
|
|
4267
|
+
h.cleanup();
|
|
4268
|
+
}
|
|
4269
|
+
});
|
|
4270
|
+
});
|