@openparachute/hub 0.5.13 → 0.5.14-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/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- 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 +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- 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 +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- 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 +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- 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 +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -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-Dzrbe6EP.js +0 -61
|
@@ -17,6 +17,7 @@ import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
|
17
17
|
import { pidPath } from "../process-state.ts";
|
|
18
18
|
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
19
19
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
20
|
+
import type { ModuleState, Supervisor } from "../supervisor.ts";
|
|
20
21
|
import { createUser } from "../users.ts";
|
|
21
22
|
|
|
22
23
|
interface Harness {
|
|
@@ -42,6 +43,33 @@ function mkdirIfMissing(dir: string): void {
|
|
|
42
43
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Minimal stub of the Supervisor surface that proxyRequest's classifier
|
|
48
|
+
* reads from. We don't drive real Bun.spawn — just hand back hand-crafted
|
|
49
|
+
* ModuleState values so the test can pin "vault is starting" / "vault has
|
|
50
|
+
* been running for a minute" exactly. See `supervisor.test.ts` for the
|
|
51
|
+
* real lifecycle tests.
|
|
52
|
+
*/
|
|
53
|
+
function stubSupervisor(states: ModuleState[]): Supervisor {
|
|
54
|
+
return {
|
|
55
|
+
list: () => states,
|
|
56
|
+
get: (short: string) => states.find((s) => s.short === short),
|
|
57
|
+
start: async () => {
|
|
58
|
+
throw new Error("not implemented");
|
|
59
|
+
},
|
|
60
|
+
stop: async () => undefined,
|
|
61
|
+
restart: async () => undefined,
|
|
62
|
+
} as unknown as Supervisor;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
|
|
66
|
+
return {
|
|
67
|
+
status: "running",
|
|
68
|
+
restartsInWindow: 0,
|
|
69
|
+
...partial,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
function vaultEntry(name: string): ServiceEntry {
|
|
46
74
|
return {
|
|
47
75
|
name: `parachute-vault-${name}`,
|
|
@@ -995,22 +1023,22 @@ describe("hubFetch routing", () => {
|
|
|
995
1023
|
});
|
|
996
1024
|
|
|
997
1025
|
// Notes-as-app migration Phase 2 (parachute-app design doc §16).
|
|
998
|
-
// `/notes/*` 301-redirects to `/
|
|
1026
|
+
// `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land
|
|
999
1027
|
// on the apps-hosted Notes. Tested with no DB (the migration-default
|
|
1000
1028
|
// path — absent DB or absent row means redirect-on).
|
|
1001
|
-
test("301: /notes/ → /
|
|
1029
|
+
test("301: /notes/ → /surface/notes/", async () => {
|
|
1002
1030
|
clearNotesRedirectLogState();
|
|
1003
1031
|
const h = makeHarness();
|
|
1004
1032
|
try {
|
|
1005
1033
|
const res = await hubFetch(h.dir)(req("/notes/"));
|
|
1006
1034
|
expect(res.status).toBe(301);
|
|
1007
|
-
expect(res.headers.get("location")).toBe("/
|
|
1035
|
+
expect(res.headers.get("location")).toBe("/surface/notes/");
|
|
1008
1036
|
} finally {
|
|
1009
1037
|
h.cleanup();
|
|
1010
1038
|
}
|
|
1011
1039
|
});
|
|
1012
1040
|
|
|
1013
|
-
test("301: bare /notes → /
|
|
1041
|
+
test("301: bare /notes → /surface/notes", async () => {
|
|
1014
1042
|
// The bare-prefix form (no trailing slash) is the path browsers land
|
|
1015
1043
|
// on when an operator types `https://hub.example/notes` directly.
|
|
1016
1044
|
clearNotesRedirectLogState();
|
|
@@ -1018,7 +1046,7 @@ describe("hubFetch routing", () => {
|
|
|
1018
1046
|
try {
|
|
1019
1047
|
const res = await hubFetch(h.dir)(req("/notes"));
|
|
1020
1048
|
expect(res.status).toBe(301);
|
|
1021
|
-
expect(res.headers.get("location")).toBe("/
|
|
1049
|
+
expect(res.headers.get("location")).toBe("/surface/notes");
|
|
1022
1050
|
} finally {
|
|
1023
1051
|
h.cleanup();
|
|
1024
1052
|
}
|
|
@@ -1030,7 +1058,7 @@ describe("hubFetch routing", () => {
|
|
|
1030
1058
|
try {
|
|
1031
1059
|
const res = await hubFetch(h.dir)(req("/notes/some/path?q=1&n=2"));
|
|
1032
1060
|
expect(res.status).toBe(301);
|
|
1033
|
-
expect(res.headers.get("location")).toBe("/
|
|
1061
|
+
expect(res.headers.get("location")).toBe("/surface/notes/some/path?q=1&n=2");
|
|
1034
1062
|
} finally {
|
|
1035
1063
|
h.cleanup();
|
|
1036
1064
|
}
|
|
@@ -1912,10 +1940,12 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1912
1940
|
}
|
|
1913
1941
|
});
|
|
1914
1942
|
|
|
1915
|
-
test("returns 502 when the matching vault upstream is unreachable", async () => {
|
|
1943
|
+
test("returns 502 with persistent-state JSON when the matching vault upstream is unreachable", async () => {
|
|
1916
1944
|
// Vault is in services.json but the port has nothing listening — vault
|
|
1917
1945
|
// 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.
|
|
1946
|
+
// useful error instead of a hang or a silent 404. No supervisor +
|
|
1947
|
+
// no pidfile → classifier returns "persistent" → 502 + admin_url
|
|
1948
|
+
// (hub#443 boot-readiness gating).
|
|
1919
1949
|
const h = makeHarness();
|
|
1920
1950
|
try {
|
|
1921
1951
|
writeManifest(
|
|
@@ -1934,10 +1964,261 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1934
1964
|
h.manifestPath,
|
|
1935
1965
|
);
|
|
1936
1966
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1937
|
-
const res = await fetcher(
|
|
1967
|
+
const res = await fetcher(
|
|
1968
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
1969
|
+
);
|
|
1970
|
+
expect(res.status).toBe(502);
|
|
1971
|
+
const body = (await res.json()) as {
|
|
1972
|
+
error: string;
|
|
1973
|
+
error_type: string;
|
|
1974
|
+
admin_url?: string;
|
|
1975
|
+
service: string;
|
|
1976
|
+
};
|
|
1977
|
+
expect(body.error_type).toBe("upstream_unreachable");
|
|
1978
|
+
expect(body.error).toBe("upstream_unreachable");
|
|
1979
|
+
expect(body.admin_url).toBe("/admin/modules");
|
|
1980
|
+
expect(body.service).toBe("vault");
|
|
1981
|
+
} finally {
|
|
1982
|
+
h.cleanup();
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
test("transient state (supervisor reports starting) → 503 + Retry-After when fetch fails", async () => {
|
|
1987
|
+
// Supervisor says vault is `starting` — the loopback port hasn't bound
|
|
1988
|
+
// yet, fetch fails with ECONNREFUSED. Classifier returns "transient",
|
|
1989
|
+
// proxy responds 503 with a Retry-After hint instead of 502.
|
|
1990
|
+
const h = makeHarness();
|
|
1991
|
+
try {
|
|
1992
|
+
writeManifest(
|
|
1993
|
+
{
|
|
1994
|
+
services: [
|
|
1995
|
+
{
|
|
1996
|
+
name: "parachute-vault",
|
|
1997
|
+
port: await pickClosedPort(),
|
|
1998
|
+
paths: ["/vault/default"],
|
|
1999
|
+
health: "/vault/default/health",
|
|
2000
|
+
version: "0.4.0",
|
|
2001
|
+
},
|
|
2002
|
+
],
|
|
2003
|
+
},
|
|
2004
|
+
h.manifestPath,
|
|
2005
|
+
);
|
|
2006
|
+
const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
|
|
2007
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2008
|
+
const res = await fetcher(
|
|
2009
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
2010
|
+
);
|
|
2011
|
+
expect(res.status).toBe(503);
|
|
2012
|
+
expect(res.headers.get("retry-after")).toBe("2");
|
|
2013
|
+
const body = (await res.json()) as {
|
|
2014
|
+
error_type: string;
|
|
2015
|
+
retry_after_ms: number;
|
|
2016
|
+
max_attempts: number;
|
|
2017
|
+
admin_url?: string;
|
|
2018
|
+
};
|
|
2019
|
+
expect(body.error_type).toBe("upstream_starting");
|
|
2020
|
+
expect(body.retry_after_ms).toBe(2000);
|
|
2021
|
+
expect(body.max_attempts).toBe(5);
|
|
2022
|
+
// Transient JSON MUST NOT carry an admin link.
|
|
2023
|
+
expect(body.admin_url).toBeUndefined();
|
|
2024
|
+
} finally {
|
|
2025
|
+
h.cleanup();
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
test("transient state + Accept: text/html → 503 HTML page with auto-refresh + poll, no admin link", async () => {
|
|
2030
|
+
const h = makeHarness();
|
|
2031
|
+
try {
|
|
2032
|
+
writeManifest(
|
|
2033
|
+
{
|
|
2034
|
+
services: [
|
|
2035
|
+
{
|
|
2036
|
+
name: "parachute-vault",
|
|
2037
|
+
port: await pickClosedPort(),
|
|
2038
|
+
paths: ["/vault/default"],
|
|
2039
|
+
health: "/vault/default/health",
|
|
2040
|
+
version: "0.4.0",
|
|
2041
|
+
},
|
|
2042
|
+
],
|
|
2043
|
+
},
|
|
2044
|
+
h.manifestPath,
|
|
2045
|
+
);
|
|
2046
|
+
const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
|
|
2047
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2048
|
+
const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
|
|
2049
|
+
expect(res.status).toBe(503);
|
|
2050
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
2051
|
+
const text = await res.text();
|
|
2052
|
+
expect(text).toContain(`<meta http-equiv="refresh"`);
|
|
2053
|
+
expect(text).toContain("/api/ready");
|
|
2054
|
+
expect(text).toContain("Just a moment");
|
|
2055
|
+
// Aaron design (d): transient page has no admin link.
|
|
2056
|
+
expect(text).not.toContain("/admin/modules");
|
|
2057
|
+
} finally {
|
|
2058
|
+
h.cleanup();
|
|
2059
|
+
}
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
test("persistent state + Accept: text/html → 502 HTML page with admin link, no auto-refresh", async () => {
|
|
2063
|
+
const h = makeHarness();
|
|
2064
|
+
try {
|
|
2065
|
+
writeManifest(
|
|
2066
|
+
{
|
|
2067
|
+
services: [
|
|
2068
|
+
{
|
|
2069
|
+
name: "parachute-vault",
|
|
2070
|
+
port: await pickClosedPort(),
|
|
2071
|
+
paths: ["/vault/default"],
|
|
2072
|
+
health: "/vault/default/health",
|
|
2073
|
+
version: "0.4.0",
|
|
2074
|
+
},
|
|
2075
|
+
],
|
|
2076
|
+
},
|
|
2077
|
+
h.manifestPath,
|
|
2078
|
+
);
|
|
2079
|
+
const supervisor = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
|
|
2080
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2081
|
+
const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
|
|
1938
2082
|
expect(res.status).toBe(502);
|
|
1939
|
-
|
|
1940
|
-
expect(
|
|
2083
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
2084
|
+
expect(res.headers.get("retry-after")).toBeNull();
|
|
2085
|
+
const text = await res.text();
|
|
2086
|
+
expect(text).toContain("Module unreachable");
|
|
2087
|
+
expect(text).toContain("/admin/modules");
|
|
2088
|
+
expect(text).not.toContain(`http-equiv="refresh"`);
|
|
2089
|
+
} finally {
|
|
2090
|
+
h.cleanup();
|
|
2091
|
+
}
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
test("supervisor running-but-fresh-startedAt → transient classification", async () => {
|
|
2095
|
+
// Supervisor says vault is running, but startedAt is recent enough that
|
|
2096
|
+
// we trust the boot-window heuristic over the failed fetch.
|
|
2097
|
+
const h = makeHarness();
|
|
2098
|
+
try {
|
|
2099
|
+
writeManifest(
|
|
2100
|
+
{
|
|
2101
|
+
services: [
|
|
2102
|
+
{
|
|
2103
|
+
name: "parachute-vault",
|
|
2104
|
+
port: await pickClosedPort(),
|
|
2105
|
+
paths: ["/vault/default"],
|
|
2106
|
+
health: "/vault/default/health",
|
|
2107
|
+
version: "0.4.0",
|
|
2108
|
+
},
|
|
2109
|
+
],
|
|
2110
|
+
},
|
|
2111
|
+
h.manifestPath,
|
|
2112
|
+
);
|
|
2113
|
+
const supervisor = stubSupervisor([
|
|
2114
|
+
moduleState({
|
|
2115
|
+
short: "vault",
|
|
2116
|
+
status: "running",
|
|
2117
|
+
startedAt: new Date(Date.now() - 5_000).toISOString(),
|
|
2118
|
+
}),
|
|
2119
|
+
]);
|
|
2120
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2121
|
+
const res = await fetcher(
|
|
2122
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
2123
|
+
);
|
|
2124
|
+
expect(res.status).toBe(503);
|
|
2125
|
+
const body = (await res.json()) as { error_type: string };
|
|
2126
|
+
expect(body.error_type).toBe("upstream_starting");
|
|
2127
|
+
} finally {
|
|
2128
|
+
h.cleanup();
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
test("supervisor running-but-old-startedAt → persistent (boot window expired)", async () => {
|
|
2133
|
+
const h = makeHarness();
|
|
2134
|
+
try {
|
|
2135
|
+
writeManifest(
|
|
2136
|
+
{
|
|
2137
|
+
services: [
|
|
2138
|
+
{
|
|
2139
|
+
name: "parachute-vault",
|
|
2140
|
+
port: await pickClosedPort(),
|
|
2141
|
+
paths: ["/vault/default"],
|
|
2142
|
+
health: "/vault/default/health",
|
|
2143
|
+
version: "0.4.0",
|
|
2144
|
+
},
|
|
2145
|
+
],
|
|
2146
|
+
},
|
|
2147
|
+
h.manifestPath,
|
|
2148
|
+
);
|
|
2149
|
+
const supervisor = stubSupervisor([
|
|
2150
|
+
moduleState({
|
|
2151
|
+
short: "vault",
|
|
2152
|
+
status: "running",
|
|
2153
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
2154
|
+
}),
|
|
2155
|
+
]);
|
|
2156
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2157
|
+
const res = await fetcher(
|
|
2158
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
2159
|
+
);
|
|
2160
|
+
expect(res.status).toBe(502);
|
|
2161
|
+
const body = (await res.json()) as { error_type: string };
|
|
2162
|
+
expect(body.error_type).toBe("upstream_unreachable");
|
|
2163
|
+
} finally {
|
|
2164
|
+
h.cleanup();
|
|
2165
|
+
}
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
test("non-vault upstream (scribe) classified through supervisor when starting", async () => {
|
|
2169
|
+
// Same logic as vault, but exercising the generic /<svc>/* dispatch path.
|
|
2170
|
+
const h = makeHarness();
|
|
2171
|
+
try {
|
|
2172
|
+
writeManifest(
|
|
2173
|
+
{
|
|
2174
|
+
services: [
|
|
2175
|
+
{
|
|
2176
|
+
name: "scribe",
|
|
2177
|
+
port: await pickClosedPort(),
|
|
2178
|
+
paths: ["/scribe"],
|
|
2179
|
+
health: "/scribe/health",
|
|
2180
|
+
version: "0.1.0",
|
|
2181
|
+
},
|
|
2182
|
+
],
|
|
2183
|
+
},
|
|
2184
|
+
h.manifestPath,
|
|
2185
|
+
);
|
|
2186
|
+
const supervisor = stubSupervisor([moduleState({ short: "scribe", status: "starting" })]);
|
|
2187
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2188
|
+
const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
|
|
2189
|
+
expect(res.status).toBe(503);
|
|
2190
|
+
const body = (await res.json()) as { error_type: string; service: string };
|
|
2191
|
+
expect(body.error_type).toBe("upstream_starting");
|
|
2192
|
+
expect(body.service).toBe("scribe");
|
|
2193
|
+
} finally {
|
|
2194
|
+
h.cleanup();
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
test("/api/ready returns supervisor view + is reachable pre-admin", async () => {
|
|
2199
|
+
// hub#443 endpoint is public + pre-admin (it has to be — the page that
|
|
2200
|
+
// polls it is itself served pre-auth when modules are still booting).
|
|
2201
|
+
const h = makeHarness();
|
|
2202
|
+
try {
|
|
2203
|
+
const supervisor = stubSupervisor([
|
|
2204
|
+
moduleState({ short: "vault", status: "starting" }),
|
|
2205
|
+
moduleState({
|
|
2206
|
+
short: "scribe",
|
|
2207
|
+
status: "running",
|
|
2208
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
2209
|
+
}),
|
|
2210
|
+
]);
|
|
2211
|
+
const fetcher = hubFetch(h.dir, { supervisor });
|
|
2212
|
+
const res = await fetcher(req("/api/ready"));
|
|
2213
|
+
expect(res.status).toBe(200);
|
|
2214
|
+
const body = (await res.json()) as {
|
|
2215
|
+
ready: boolean;
|
|
2216
|
+
ready_modules: string[];
|
|
2217
|
+
transient_modules: string[];
|
|
2218
|
+
};
|
|
2219
|
+
expect(body.ready).toBe(false);
|
|
2220
|
+
expect(body.transient_modules).toEqual(["vault"]);
|
|
2221
|
+
expect(body.ready_modules).toEqual(["scribe"]);
|
|
1941
2222
|
} finally {
|
|
1942
2223
|
h.cleanup();
|
|
1943
2224
|
}
|
|
@@ -2240,7 +2521,7 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
2240
2521
|
// motivator for the `--mount` strip in notes-serve.ts).
|
|
2241
2522
|
//
|
|
2242
2523
|
// Post-parachute-app §16 Phase 2 the `/notes/*` path 301-redirects to
|
|
2243
|
-
// `/
|
|
2524
|
+
// `/surface/notes/*` by default. This test pins the notes-as-module legacy
|
|
2244
2525
|
// path (notes-daemon still serving its own mount); set the opt-out
|
|
2245
2526
|
// flag so the dispatch falls through to the generic proxy.
|
|
2246
2527
|
const h = makeHarness();
|
|
@@ -2562,9 +2843,10 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
2562
2843
|
}
|
|
2563
2844
|
});
|
|
2564
2845
|
|
|
2565
|
-
test("returns 502 when the matching upstream is unreachable", async () => {
|
|
2846
|
+
test("returns 502 with persistent-state JSON when the matching upstream is unreachable", async () => {
|
|
2566
2847
|
// Service is in services.json but the port has nothing listening — same
|
|
2567
|
-
// shape as the vault-unreachable test
|
|
2848
|
+
// shape as the vault-unreachable test (hub#443 boot-readiness gating).
|
|
2849
|
+
// No supervisor + no pidfile → persistent → 502 with admin_url.
|
|
2568
2850
|
const h = makeHarness();
|
|
2569
2851
|
try {
|
|
2570
2852
|
writeManifest(
|
|
@@ -2582,10 +2864,17 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
2582
2864
|
h.manifestPath,
|
|
2583
2865
|
);
|
|
2584
2866
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2585
|
-
const res = await fetcher(req("/scribe/health"));
|
|
2867
|
+
const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
|
|
2586
2868
|
expect(res.status).toBe(502);
|
|
2587
|
-
const body = (await res.json()) as {
|
|
2588
|
-
|
|
2869
|
+
const body = (await res.json()) as {
|
|
2870
|
+
error: string;
|
|
2871
|
+
error_type: string;
|
|
2872
|
+
admin_url?: string;
|
|
2873
|
+
service: string;
|
|
2874
|
+
};
|
|
2875
|
+
expect(body.error_type).toBe("upstream_unreachable");
|
|
2876
|
+
expect(body.admin_url).toBe("/admin/modules");
|
|
2877
|
+
expect(body.service).toBe("scribe");
|
|
2589
2878
|
} finally {
|
|
2590
2879
|
h.cleanup();
|
|
2591
2880
|
}
|
|
@@ -3453,7 +3742,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3453
3742
|
// Pins the proxy-side wiring of the chrome strip from
|
|
3454
3743
|
// `parachute-patterns/patterns/design-system.md` §7 — every proxied
|
|
3455
3744
|
// text/html response gets the strip injected after the first `<body>`,
|
|
3456
|
-
// with opt-outs for `/
|
|
3745
|
+
// with opt-outs for `/surface/notes/*` (the Notes PWA owns its own chrome).
|
|
3457
3746
|
// The pure rewrite + opt-out logic is covered in chrome-strip.test.ts;
|
|
3458
3747
|
// here we exercise the dispatch integration end-to-end through hubFetch.
|
|
3459
3748
|
|
|
@@ -3608,7 +3897,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3608
3897
|
}
|
|
3609
3898
|
});
|
|
3610
3899
|
|
|
3611
|
-
test("does NOT inject chrome on /
|
|
3900
|
+
test("does NOT inject chrome on /surface/notes/* (Notes PWA owns its own chrome)", async () => {
|
|
3612
3901
|
const h = makeHarness();
|
|
3613
3902
|
const upstream = startHtmlUpstream("<html><body><h1>Notes</h1></body></html>");
|
|
3614
3903
|
try {
|
|
@@ -3616,10 +3905,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3616
3905
|
{
|
|
3617
3906
|
services: [
|
|
3618
3907
|
{
|
|
3619
|
-
name: "parachute-
|
|
3908
|
+
name: "parachute-surface",
|
|
3620
3909
|
port: upstream.port,
|
|
3621
|
-
paths: ["/
|
|
3622
|
-
health: "/
|
|
3910
|
+
paths: ["/surface"],
|
|
3911
|
+
health: "/surface/health",
|
|
3623
3912
|
version: "0.1.0",
|
|
3624
3913
|
},
|
|
3625
3914
|
],
|
|
@@ -3627,7 +3916,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3627
3916
|
h.manifestPath,
|
|
3628
3917
|
);
|
|
3629
3918
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3630
|
-
const res = await fetcher(req("/
|
|
3919
|
+
const res = await fetcher(req("/surface/notes/"));
|
|
3631
3920
|
expect(res.status).toBe(200);
|
|
3632
3921
|
const body = await res.text();
|
|
3633
3922
|
expect(body).toBe("<html><body><h1>Notes</h1></body></html>");
|
|
@@ -3638,7 +3927,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3638
3927
|
}
|
|
3639
3928
|
});
|
|
3640
3929
|
|
|
3641
|
-
test("DOES inject chrome on /
|
|
3930
|
+
test("DOES inject chrome on /surface/admin/* (parachute-app admin, not Notes)", async () => {
|
|
3642
3931
|
const h = makeHarness();
|
|
3643
3932
|
const upstream = startHtmlUpstream("<html><body>app admin</body></html>");
|
|
3644
3933
|
try {
|
|
@@ -3646,10 +3935,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3646
3935
|
{
|
|
3647
3936
|
services: [
|
|
3648
3937
|
{
|
|
3649
|
-
name: "parachute-
|
|
3938
|
+
name: "parachute-surface",
|
|
3650
3939
|
port: upstream.port,
|
|
3651
|
-
paths: ["/
|
|
3652
|
-
health: "/
|
|
3940
|
+
paths: ["/surface"],
|
|
3941
|
+
health: "/surface/health",
|
|
3653
3942
|
version: "0.1.0",
|
|
3654
3943
|
},
|
|
3655
3944
|
],
|
|
@@ -3657,7 +3946,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3657
3946
|
h.manifestPath,
|
|
3658
3947
|
);
|
|
3659
3948
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3660
|
-
const res = await fetcher(req("/
|
|
3949
|
+
const res = await fetcher(req("/surface/admin/"));
|
|
3661
3950
|
expect(res.status).toBe(200);
|
|
3662
3951
|
const body = await res.text();
|
|
3663
3952
|
expect(body).toContain("pc-chrome");
|
|
@@ -3668,7 +3957,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3668
3957
|
}
|
|
3669
3958
|
});
|
|
3670
3959
|
|
|
3671
|
-
test("does NOT inject on /
|
|
3960
|
+
test("does NOT inject on /surface/notes/ sub-paths (asset requests)", async () => {
|
|
3672
3961
|
const h = makeHarness();
|
|
3673
3962
|
const upstream = startHtmlUpstream("<html><body>asset shell</body></html>");
|
|
3674
3963
|
try {
|
|
@@ -3676,10 +3965,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3676
3965
|
{
|
|
3677
3966
|
services: [
|
|
3678
3967
|
{
|
|
3679
|
-
name: "parachute-
|
|
3968
|
+
name: "parachute-surface",
|
|
3680
3969
|
port: upstream.port,
|
|
3681
|
-
paths: ["/
|
|
3682
|
-
health: "/
|
|
3970
|
+
paths: ["/surface"],
|
|
3971
|
+
health: "/surface/health",
|
|
3683
3972
|
version: "0.1.0",
|
|
3684
3973
|
},
|
|
3685
3974
|
],
|
|
@@ -3687,7 +3976,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3687
3976
|
h.manifestPath,
|
|
3688
3977
|
);
|
|
3689
3978
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3690
|
-
const res = await fetcher(req("/
|
|
3979
|
+
const res = await fetcher(req("/surface/notes/index.html"));
|
|
3691
3980
|
expect(res.status).toBe(200);
|
|
3692
3981
|
const body = await res.text();
|
|
3693
3982
|
expect(body).not.toContain("pc-chrome");
|
|
@@ -189,6 +189,17 @@ describe("renderHub — signed-in indicator (rc.13)", () => {
|
|
|
189
189
|
expect(html).not.toContain('href="/login?next=/"');
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
test("signed-in indicator carries an Account breadcrumb to /account/", () => {
|
|
193
|
+
// Onboarding discoverability: a signed-in friend needs a single link
|
|
194
|
+
// to the self-service /account/ home (change password, their vault).
|
|
195
|
+
// Applies to admins too — harmless for them.
|
|
196
|
+
const html = renderHub({
|
|
197
|
+
session: { displayName: "aaron", csrfToken: "csrf-token-xyz" },
|
|
198
|
+
});
|
|
199
|
+
expect(html).toContain('href="/account/"');
|
|
200
|
+
expect(html).toContain("Account");
|
|
201
|
+
});
|
|
202
|
+
|
|
192
203
|
test("displayName with HTML special chars is escaped", () => {
|
|
193
204
|
// Username field allows alphanumerics historically, but the
|
|
194
205
|
// displayName field on the wire is forward-compatible with profile
|