@openparachute/hub 0.6.5-rc.8 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
hubFetch,
|
|
14
14
|
layerOf,
|
|
15
15
|
parseArgs,
|
|
16
|
+
resolveClientIp,
|
|
16
17
|
} from "../hub-server.ts";
|
|
17
18
|
import { setNotesRedirectDisabled } from "../hub-settings.ts";
|
|
18
19
|
import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
@@ -85,20 +86,26 @@ function vaultEntry(name: string): ServiceEntry {
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
describe("hubFetch routing", () => {
|
|
88
|
-
|
|
89
|
+
// Admin-shell IA (R1): the bare `/` page redirects into the single coherent
|
|
90
|
+
// admin shell at `/admin`. The old discovery-page content moved into the
|
|
91
|
+
// shell's Home overview. Only the bare `/` redirects — `/hub.html` still
|
|
92
|
+
// serves the discovery page (static expose file + explicit-`.html` bookmarks).
|
|
93
|
+
test("/ redirects (302) to /admin (admin-shell IA)", async () => {
|
|
89
94
|
const h = makeHarness();
|
|
90
95
|
try {
|
|
96
|
+
// No DB → exercises the redirect on the static-fallback path. The
|
|
97
|
+
// redirect sits above the static hub.html serve, so it fires regardless
|
|
98
|
+
// of whether a disk file exists.
|
|
91
99
|
writeFileSync(join(h.dir, "hub.html"), "<html><body>hi</body></html>");
|
|
92
100
|
const res = await hubFetch(h.dir)(req("/"));
|
|
93
|
-
expect(res.status).toBe(
|
|
94
|
-
expect(res.headers.get("
|
|
95
|
-
expect(await res.text()).toContain("<html>");
|
|
101
|
+
expect(res.status).toBe(302);
|
|
102
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
96
103
|
} finally {
|
|
97
104
|
h.cleanup();
|
|
98
105
|
}
|
|
99
106
|
});
|
|
100
107
|
|
|
101
|
-
test("/hub.html serves the
|
|
108
|
+
test("/hub.html still serves the discovery page (no DB → static fallback)", async () => {
|
|
102
109
|
const h = makeHarness();
|
|
103
110
|
try {
|
|
104
111
|
writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
|
|
@@ -173,11 +180,14 @@ describe("hubFetch routing", () => {
|
|
|
173
180
|
}
|
|
174
181
|
});
|
|
175
182
|
|
|
176
|
-
test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
|
|
183
|
+
test("/hub.html renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
|
|
177
184
|
// The dynamic path takes over from the static disk file the moment a
|
|
178
185
|
// DB is configured. With no session cookie, we still render — just
|
|
179
186
|
// with the "Sign in" affordance.
|
|
180
187
|
//
|
|
188
|
+
// Targets `/hub.html` (not `/`): bare `/` now 302-redirects to the admin
|
|
189
|
+
// shell (R1). `/hub.html` still serves the dynamic discovery page.
|
|
190
|
+
//
|
|
181
191
|
// hub#259 rc.6: requires an admin row to bypass the fresh-hub
|
|
182
192
|
// funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
|
|
183
193
|
// test continues to exercise the signed-out-but-setup-done branch.
|
|
@@ -198,7 +208,7 @@ describe("hubFetch routing", () => {
|
|
|
198
208
|
const res = await hubFetch(h.dir, {
|
|
199
209
|
getDb: () => db,
|
|
200
210
|
manifestPath: h.manifestPath,
|
|
201
|
-
})(req("/"));
|
|
211
|
+
})(req("/hub.html"));
|
|
202
212
|
expect(res.status).toBe(200);
|
|
203
213
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
204
214
|
const body = await res.text();
|
|
@@ -213,10 +223,11 @@ describe("hubFetch routing", () => {
|
|
|
213
223
|
}
|
|
214
224
|
});
|
|
215
225
|
|
|
216
|
-
test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
|
|
226
|
+
test("/hub.html renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
|
|
217
227
|
// Same wizard-funnel bypass as the signed-out test above — seed a
|
|
218
228
|
// vault row and pass an explicit manifestPath so CI doesn't fall back
|
|
219
|
-
// to ~/.parachute/services.json.
|
|
229
|
+
// to ~/.parachute/services.json. Targets `/hub.html` (bare `/` now
|
|
230
|
+
// redirects to the admin shell, R1).
|
|
220
231
|
const h = makeHarness();
|
|
221
232
|
try {
|
|
222
233
|
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
@@ -232,7 +243,7 @@ describe("hubFetch routing", () => {
|
|
|
232
243
|
const res = await hubFetch(h.dir, {
|
|
233
244
|
getDb: () => db,
|
|
234
245
|
manifestPath: h.manifestPath,
|
|
235
|
-
})(req("/", { headers: { cookie } }));
|
|
246
|
+
})(req("/hub.html", { headers: { cookie } }));
|
|
236
247
|
expect(res.status).toBe(200);
|
|
237
248
|
const body = await res.text();
|
|
238
249
|
expect(body).toContain("Signed in as");
|
|
@@ -249,6 +260,36 @@ describe("hubFetch routing", () => {
|
|
|
249
260
|
}
|
|
250
261
|
});
|
|
251
262
|
|
|
263
|
+
test("/ → /admin even with a DB + admin + vault (setup complete; not the wizard funnel)", async () => {
|
|
264
|
+
// Distinguishes the admin-shell redirect (302 → /admin) from the fresh-hub
|
|
265
|
+
// wizard funnel (302 → /admin/setup). With admin + vault seeded, the wizard
|
|
266
|
+
// funnel is bypassed and the bare `/` lands on the admin shell. Also proves
|
|
267
|
+
// the redirect fires on the DB-configured path, not just the static
|
|
268
|
+
// fallback. `/hub.html` under the same setup still renders the discovery
|
|
269
|
+
// page (asserted separately above) — only bare `/` redirects.
|
|
270
|
+
const h = makeHarness();
|
|
271
|
+
try {
|
|
272
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
273
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
274
|
+
try {
|
|
275
|
+
const { createUser } = await import("../users.ts");
|
|
276
|
+
await createUser(db, "owner", "pw");
|
|
277
|
+
const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
|
|
278
|
+
const rootRes = await handler(req("/"));
|
|
279
|
+
expect(rootRes.status).toBe(302);
|
|
280
|
+
expect(rootRes.headers.get("location")).toBe("/admin");
|
|
281
|
+
// /hub.html under the same setup-complete state still serves discovery.
|
|
282
|
+
const hubHtmlRes = await handler(req("/hub.html"));
|
|
283
|
+
expect(hubHtmlRes.status).toBe(200);
|
|
284
|
+
expect(hubHtmlRes.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
285
|
+
} finally {
|
|
286
|
+
db.close();
|
|
287
|
+
}
|
|
288
|
+
} finally {
|
|
289
|
+
h.cleanup();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
252
293
|
test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
|
|
253
294
|
const h = makeHarness();
|
|
254
295
|
try {
|
|
@@ -648,8 +689,9 @@ describe("hubFetch routing", () => {
|
|
|
648
689
|
test("missing hub.html returns 404 rather than crashing", async () => {
|
|
649
690
|
const h = makeHarness();
|
|
650
691
|
try {
|
|
651
|
-
// dir exists but no files in it
|
|
652
|
-
|
|
692
|
+
// dir exists but no files in it. Targets `/hub.html` directly — bare `/`
|
|
693
|
+
// now 302-redirects to the admin shell before reaching the static serve.
|
|
694
|
+
const res = await hubFetch(h.dir)(req("/hub.html"));
|
|
653
695
|
expect(res.status).toBe(404);
|
|
654
696
|
} finally {
|
|
655
697
|
h.cleanup();
|
|
@@ -932,23 +974,28 @@ describe("hubFetch routing", () => {
|
|
|
932
974
|
// 301-redirect to the new /admin/* mount. Tests cover every entry in the
|
|
933
975
|
// dispatch — operator bookmarks landing on any of these still work.
|
|
934
976
|
|
|
935
|
-
|
|
977
|
+
// B5 (2026-06-09 hub-module-boundary): the legacy `/vault[/new]` 301s point
|
|
978
|
+
// DIRECTLY at vault's daemon-level admin surface — not at /admin/vaults,
|
|
979
|
+
// whose SPA route is now just a feature-detected forwarder. Pointing at the
|
|
980
|
+
// final target avoids a redirect → SPA-load → client-side-forward chain.
|
|
981
|
+
|
|
982
|
+
test("301: /vault → /vault/admin/ (direct — no chain through /admin/vaults)", async () => {
|
|
936
983
|
const h = makeHarness();
|
|
937
984
|
try {
|
|
938
985
|
const res = await hubFetch(h.dir)(req("/vault"));
|
|
939
986
|
expect(res.status).toBe(301);
|
|
940
|
-
expect(res.headers.get("location")).toBe("/admin/
|
|
987
|
+
expect(res.headers.get("location")).toBe("/vault/admin/");
|
|
941
988
|
} finally {
|
|
942
989
|
h.cleanup();
|
|
943
990
|
}
|
|
944
991
|
});
|
|
945
992
|
|
|
946
|
-
test("301: /vault/new → /admin/
|
|
993
|
+
test("301: /vault/new → /vault/admin/ (create relocated into vault's surface)", async () => {
|
|
947
994
|
const h = makeHarness();
|
|
948
995
|
try {
|
|
949
996
|
const res = await hubFetch(h.dir)(req("/vault/new"));
|
|
950
997
|
expect(res.status).toBe(301);
|
|
951
|
-
expect(res.headers.get("location")).toBe("/admin/
|
|
998
|
+
expect(res.headers.get("location")).toBe("/vault/admin/");
|
|
952
999
|
} finally {
|
|
953
1000
|
h.cleanup();
|
|
954
1001
|
}
|
|
@@ -959,7 +1006,7 @@ describe("hubFetch routing", () => {
|
|
|
959
1006
|
try {
|
|
960
1007
|
const res = await hubFetch(h.dir)(req("/vault?next=foo"));
|
|
961
1008
|
expect(res.status).toBe(301);
|
|
962
|
-
expect(res.headers.get("location")).toBe("/admin
|
|
1009
|
+
expect(res.headers.get("location")).toBe("/vault/admin/?next=foo");
|
|
963
1010
|
} finally {
|
|
964
1011
|
h.cleanup();
|
|
965
1012
|
}
|
|
@@ -1622,7 +1669,7 @@ describe("hubFetch routing", () => {
|
|
|
1622
1669
|
}
|
|
1623
1670
|
});
|
|
1624
1671
|
|
|
1625
|
-
test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
|
|
1672
|
+
test("live Bun.serve round-trip: /, /hub.html and /.well-known resolve", async () => {
|
|
1626
1673
|
const h = makeHarness();
|
|
1627
1674
|
try {
|
|
1628
1675
|
writeFileSync(join(h.dir, "hub.html"), "<html>live</html>");
|
|
@@ -1634,9 +1681,15 @@ describe("hubFetch routing", () => {
|
|
|
1634
1681
|
});
|
|
1635
1682
|
try {
|
|
1636
1683
|
const base = `http://127.0.0.1:${server.port}`;
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1684
|
+
// Bare `/` 302-redirects into the admin shell (R1). `redirect: "manual"`
|
|
1685
|
+
// so we observe the redirect itself rather than following it to /admin.
|
|
1686
|
+
const r1 = await fetch(`${base}/`, { redirect: "manual" });
|
|
1687
|
+
expect(r1.status).toBe(302);
|
|
1688
|
+
expect(r1.headers.get("location")).toBe("/admin");
|
|
1689
|
+
// The discovery page still serves at /hub.html.
|
|
1690
|
+
const rHub = await fetch(`${base}/hub.html`);
|
|
1691
|
+
expect(rHub.status).toBe(200);
|
|
1692
|
+
expect(await rHub.text()).toBe("<html>live</html>");
|
|
1640
1693
|
const r2 = await fetch(`${base}/.well-known/parachute.json`);
|
|
1641
1694
|
expect(r2.headers.get("content-type")).toBe("application/json");
|
|
1642
1695
|
expect(await r2.json()).toEqual({ vaults: [], services: [] });
|
|
@@ -4051,6 +4104,208 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
4051
4104
|
});
|
|
4052
4105
|
});
|
|
4053
4106
|
|
|
4107
|
+
describe("hubFetch /vault/admin daemon-level mount (B-route, hub-module-boundary)", () => {
|
|
4108
|
+
// /vault/admin + /vault/admin/* route to the vault MODULE's daemon
|
|
4109
|
+
// (resolved via findServiceByShort, not findVaultUpstream) — the
|
|
4110
|
+
// daemon-level multi-vault admin surface, not an instance path. "admin"
|
|
4111
|
+
// is a reserved vault name (B2h) so no instance can ever claim the mount.
|
|
4112
|
+
|
|
4113
|
+
/** Upstream that ECHOES the request path, so the tests can pin both which
|
|
4114
|
+
* daemon got the request and that the FULL path was forwarded (vault's
|
|
4115
|
+
* row declares no stripPrefix). */
|
|
4116
|
+
function startEchoUpstream(tag: string): { port: number; stop: () => void } {
|
|
4117
|
+
const server = Bun.serve({
|
|
4118
|
+
port: 0,
|
|
4119
|
+
hostname: "127.0.0.1",
|
|
4120
|
+
fetch: (r) =>
|
|
4121
|
+
new Response(JSON.stringify({ tag, path: new URL(r.url).pathname }), {
|
|
4122
|
+
status: 200,
|
|
4123
|
+
headers: { "content-type": "application/json" },
|
|
4124
|
+
}),
|
|
4125
|
+
});
|
|
4126
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
4130
|
+
|
|
4131
|
+
test("/vault/admin and /vault/admin/* route to the vault module's daemon port with the FULL path", async () => {
|
|
4132
|
+
const h = makeHarness();
|
|
4133
|
+
const upstream = startEchoUpstream("vault-daemon");
|
|
4134
|
+
try {
|
|
4135
|
+
writeManifest(
|
|
4136
|
+
{
|
|
4137
|
+
services: [
|
|
4138
|
+
{
|
|
4139
|
+
// The canonical self-registered row name — findServiceByShort
|
|
4140
|
+
// resolves `parachute-vault` → short "vault".
|
|
4141
|
+
name: "parachute-vault",
|
|
4142
|
+
port: upstream.port,
|
|
4143
|
+
paths: ["/vault/default"],
|
|
4144
|
+
health: "/vault/default/health",
|
|
4145
|
+
version: "0.5.0",
|
|
4146
|
+
},
|
|
4147
|
+
],
|
|
4148
|
+
},
|
|
4149
|
+
h.manifestPath,
|
|
4150
|
+
);
|
|
4151
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4152
|
+
for (const path of ["/vault/admin", "/vault/admin/", "/vault/admin/assets/app.js"]) {
|
|
4153
|
+
const res = await fetcher(req(path));
|
|
4154
|
+
expect(res.status).toBe(200);
|
|
4155
|
+
const body = (await res.json()) as { tag: string; path: string };
|
|
4156
|
+
expect(body.tag).toBe("vault-daemon");
|
|
4157
|
+
// Full path forwarded — no prefix strip (vault routes /vault/admin itself).
|
|
4158
|
+
expect(body.path).toBe(path);
|
|
4159
|
+
}
|
|
4160
|
+
} finally {
|
|
4161
|
+
upstream.stop();
|
|
4162
|
+
h.cleanup();
|
|
4163
|
+
}
|
|
4164
|
+
});
|
|
4165
|
+
|
|
4166
|
+
test("/vault/admin is NOT captured by a per-instance mount — daemon-level route wins", async () => {
|
|
4167
|
+
// A pathological vault row mounted at the bare `/vault` would prefix-match
|
|
4168
|
+
// `/vault/admin` in findVaultUpstream; the daemon-level branch runs FIRST
|
|
4169
|
+
// so the per-vault proxy never sees the path (and no consumer can
|
|
4170
|
+
// fabricate a phantom instance named "admin"). With distinct upstream
|
|
4171
|
+
// ports we can tell exactly which one served the request.
|
|
4172
|
+
const h = makeHarness();
|
|
4173
|
+
const daemonUpstream = startEchoUpstream("vault-daemon");
|
|
4174
|
+
const instanceUpstream = startEchoUpstream("vault-instance");
|
|
4175
|
+
try {
|
|
4176
|
+
writeManifest(
|
|
4177
|
+
{
|
|
4178
|
+
services: [
|
|
4179
|
+
{
|
|
4180
|
+
name: "parachute-vault",
|
|
4181
|
+
port: daemonUpstream.port,
|
|
4182
|
+
paths: ["/vault/default"],
|
|
4183
|
+
health: "/vault/default/health",
|
|
4184
|
+
version: "0.5.0",
|
|
4185
|
+
},
|
|
4186
|
+
{
|
|
4187
|
+
// Pathological-but-representable bare mount on a second row.
|
|
4188
|
+
name: "parachute-vault-bare",
|
|
4189
|
+
port: instanceUpstream.port,
|
|
4190
|
+
paths: ["/vault"],
|
|
4191
|
+
health: "/vault/health",
|
|
4192
|
+
version: "0.5.0",
|
|
4193
|
+
},
|
|
4194
|
+
],
|
|
4195
|
+
},
|
|
4196
|
+
h.manifestPath,
|
|
4197
|
+
);
|
|
4198
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4199
|
+
const res = await fetcher(req("/vault/admin/"));
|
|
4200
|
+
expect(res.status).toBe(200);
|
|
4201
|
+
const body = (await res.json()) as { tag: string };
|
|
4202
|
+
expect(body.tag).toBe("vault-daemon");
|
|
4203
|
+
} finally {
|
|
4204
|
+
daemonUpstream.stop();
|
|
4205
|
+
instanceUpstream.stop();
|
|
4206
|
+
h.cleanup();
|
|
4207
|
+
}
|
|
4208
|
+
});
|
|
4209
|
+
|
|
4210
|
+
test('a vault instance named "adminx" still routes per-instance (exact-segment match only)', async () => {
|
|
4211
|
+
const h = makeHarness();
|
|
4212
|
+
const upstream = startEchoUpstream("vault-daemon");
|
|
4213
|
+
try {
|
|
4214
|
+
writeManifest(
|
|
4215
|
+
{
|
|
4216
|
+
services: [
|
|
4217
|
+
{
|
|
4218
|
+
name: "parachute-vault",
|
|
4219
|
+
port: upstream.port,
|
|
4220
|
+
paths: ["/vault/adminx"],
|
|
4221
|
+
health: "/vault/adminx/health",
|
|
4222
|
+
version: "0.5.0",
|
|
4223
|
+
},
|
|
4224
|
+
],
|
|
4225
|
+
},
|
|
4226
|
+
h.manifestPath,
|
|
4227
|
+
);
|
|
4228
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4229
|
+
// Per-instance path — proxied through the per-vault dispatch (full
|
|
4230
|
+
// path forwarded; vault rows don't strip).
|
|
4231
|
+
const res = await fetcher(req("/vault/adminx/health"));
|
|
4232
|
+
expect(res.status).toBe(200);
|
|
4233
|
+
const body = (await res.json()) as { path: string };
|
|
4234
|
+
expect(body.path).toBe("/vault/adminx/health");
|
|
4235
|
+
} finally {
|
|
4236
|
+
upstream.stop();
|
|
4237
|
+
h.cleanup();
|
|
4238
|
+
}
|
|
4239
|
+
});
|
|
4240
|
+
|
|
4241
|
+
test("404 cleanly when no vault module is installed", async () => {
|
|
4242
|
+
const h = makeHarness();
|
|
4243
|
+
try {
|
|
4244
|
+
writeManifest(
|
|
4245
|
+
{
|
|
4246
|
+
services: [
|
|
4247
|
+
{
|
|
4248
|
+
// Some other module installed — must not capture /vault/admin.
|
|
4249
|
+
name: "parachute-scribe",
|
|
4250
|
+
port: 1942,
|
|
4251
|
+
paths: ["/scribe"],
|
|
4252
|
+
health: "/scribe/health",
|
|
4253
|
+
version: "0.1.0",
|
|
4254
|
+
},
|
|
4255
|
+
],
|
|
4256
|
+
},
|
|
4257
|
+
h.manifestPath,
|
|
4258
|
+
);
|
|
4259
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4260
|
+
const res = await fetcher(req("/vault/admin/"));
|
|
4261
|
+
expect(res.status).toBe(404);
|
|
4262
|
+
} finally {
|
|
4263
|
+
h.cleanup();
|
|
4264
|
+
}
|
|
4265
|
+
});
|
|
4266
|
+
|
|
4267
|
+
test('publicExposure: "loopback" on the vault row cloaks /vault/admin from tailnet/public (same gate as proxyToVault)', async () => {
|
|
4268
|
+
const h = makeHarness();
|
|
4269
|
+
const upstream = startEchoUpstream("vault-daemon");
|
|
4270
|
+
try {
|
|
4271
|
+
writeManifest(
|
|
4272
|
+
{
|
|
4273
|
+
services: [
|
|
4274
|
+
{
|
|
4275
|
+
name: "parachute-vault",
|
|
4276
|
+
port: upstream.port,
|
|
4277
|
+
paths: ["/vault/default"],
|
|
4278
|
+
health: "/vault/default/health",
|
|
4279
|
+
version: "0.5.0",
|
|
4280
|
+
publicExposure: "loopback",
|
|
4281
|
+
},
|
|
4282
|
+
],
|
|
4283
|
+
},
|
|
4284
|
+
h.manifestPath,
|
|
4285
|
+
);
|
|
4286
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4287
|
+
// Tailnet caller → cloaked.
|
|
4288
|
+
const tailnet = await fetcher(
|
|
4289
|
+
req("/vault/admin/", { headers: { "Tailscale-User-Login": "alice@example.com" } }),
|
|
4290
|
+
);
|
|
4291
|
+
expect(tailnet.status).toBe(404);
|
|
4292
|
+
// Public caller → cloaked.
|
|
4293
|
+
const pub = await fetcher(req("/vault/admin/", { headers: { "CF-Ray": "abc123" } }));
|
|
4294
|
+
expect(pub.status).toBe(404);
|
|
4295
|
+
// Header-absent NON-loopback peer (0.0.0.0 bind) → cloaked (#526 posture).
|
|
4296
|
+
const direct = await fetcher(req("/vault/admin/"), fakeServer("198.51.100.4"));
|
|
4297
|
+
expect(direct.status).toBe(404);
|
|
4298
|
+
// Loopback peer → reaches the daemon.
|
|
4299
|
+
const loop = await fetcher(req("/vault/admin/"), fakeServer("127.0.0.1"));
|
|
4300
|
+
expect(loop.status).toBe(200);
|
|
4301
|
+
expect(((await loop.json()) as { tag: string }).tag).toBe("vault-daemon");
|
|
4302
|
+
} finally {
|
|
4303
|
+
upstream.stop();
|
|
4304
|
+
h.cleanup();
|
|
4305
|
+
}
|
|
4306
|
+
});
|
|
4307
|
+
});
|
|
4308
|
+
|
|
4054
4309
|
/** Find a port that no one is listening on by binding briefly and releasing. */
|
|
4055
4310
|
async function pickClosedPort(): Promise<number> {
|
|
4056
4311
|
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
|
|
@@ -5004,6 +5259,50 @@ describe("force-change-password per-request gate (#469)", () => {
|
|
|
5004
5259
|
}
|
|
5005
5260
|
});
|
|
5006
5261
|
|
|
5262
|
+
test("(a) pre-rotation browser GET /vault/admin/ is 302'd to change-password (B-route gate parity)", async () => {
|
|
5263
|
+
// The daemon-level /vault/admin mount applies the SAME per-request
|
|
5264
|
+
// force-change-password gate as the per-vault proxy — a pre-rotation
|
|
5265
|
+
// session can't reach the multi-vault admin surface on an un-rotated
|
|
5266
|
+
// temp password either.
|
|
5267
|
+
const h = makeHarness();
|
|
5268
|
+
try {
|
|
5269
|
+
writeManifest(
|
|
5270
|
+
{
|
|
5271
|
+
services: [
|
|
5272
|
+
{
|
|
5273
|
+
// Canonical row name so findServiceByShort would resolve it if
|
|
5274
|
+
// the request ever got past the gate.
|
|
5275
|
+
name: "parachute-vault",
|
|
5276
|
+
port: 1940,
|
|
5277
|
+
paths: ["/vault/work"],
|
|
5278
|
+
health: "/vault/work/health",
|
|
5279
|
+
version: "0.5.0",
|
|
5280
|
+
},
|
|
5281
|
+
],
|
|
5282
|
+
},
|
|
5283
|
+
h.manifestPath,
|
|
5284
|
+
);
|
|
5285
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
5286
|
+
try {
|
|
5287
|
+
const { cookie } = await seedUser(h, db, {
|
|
5288
|
+
passwordChanged: false,
|
|
5289
|
+
assignedVaults: ["work"],
|
|
5290
|
+
});
|
|
5291
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
5292
|
+
req("/vault/admin/", { headers: { cookie, accept: "text/html" } }),
|
|
5293
|
+
);
|
|
5294
|
+
// Gated BEFORE proxyToVaultAdmin ever runs — the gate's 302, not a
|
|
5295
|
+
// proxy 404/502.
|
|
5296
|
+
expect(res.status).toBe(302);
|
|
5297
|
+
expect(res.headers.get("location")).toBe("/account/change-password");
|
|
5298
|
+
} finally {
|
|
5299
|
+
db.close();
|
|
5300
|
+
}
|
|
5301
|
+
} finally {
|
|
5302
|
+
h.cleanup();
|
|
5303
|
+
}
|
|
5304
|
+
});
|
|
5305
|
+
|
|
5007
5306
|
test("(b) pre-rotation user CAN still reach /account/change-password (GET)", async () => {
|
|
5008
5307
|
const h = makeHarness();
|
|
5009
5308
|
try {
|
|
@@ -5282,3 +5581,268 @@ describe("force-change-password per-request gate (#469)", () => {
|
|
|
5282
5581
|
}
|
|
5283
5582
|
});
|
|
5284
5583
|
});
|
|
5584
|
+
|
|
5585
|
+
describe("substrate trust headers — X-Parachute-Layer / X-Parachute-Client-IP (H2)", () => {
|
|
5586
|
+
// The hub stamps the layerOf classification + resolved client IP on every
|
|
5587
|
+
// request it forwards to a module upstream, STRIPPING any inbound
|
|
5588
|
+
// occurrences first (a public client must not be able to inject a forged
|
|
5589
|
+
// "loopback" trust signal past the proxy). Surface-runtime design §10.
|
|
5590
|
+
|
|
5591
|
+
function startEchoUpstream(): { port: number; stop: () => void } {
|
|
5592
|
+
const server = Bun.serve({
|
|
5593
|
+
port: 0,
|
|
5594
|
+
hostname: "127.0.0.1",
|
|
5595
|
+
fetch: (req) =>
|
|
5596
|
+
new Response(
|
|
5597
|
+
JSON.stringify({
|
|
5598
|
+
layer: req.headers.get("x-parachute-layer"),
|
|
5599
|
+
clientIp: req.headers.get("x-parachute-client-ip"),
|
|
5600
|
+
}),
|
|
5601
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
5602
|
+
),
|
|
5603
|
+
});
|
|
5604
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
5608
|
+
|
|
5609
|
+
function writeEchoService(h: Harness, port: number): void {
|
|
5610
|
+
writeManifest(
|
|
5611
|
+
{
|
|
5612
|
+
services: [
|
|
5613
|
+
{
|
|
5614
|
+
name: "echo-svc",
|
|
5615
|
+
port,
|
|
5616
|
+
paths: ["/echo-svc"],
|
|
5617
|
+
health: "/echo-svc/health",
|
|
5618
|
+
version: "0.1.0",
|
|
5619
|
+
},
|
|
5620
|
+
],
|
|
5621
|
+
},
|
|
5622
|
+
h.manifestPath,
|
|
5623
|
+
);
|
|
5624
|
+
}
|
|
5625
|
+
|
|
5626
|
+
test("loopback direct-to-hub → stamped loopback + peer IP (generic proxy)", async () => {
|
|
5627
|
+
const h = makeHarness();
|
|
5628
|
+
const upstream = startEchoUpstream();
|
|
5629
|
+
try {
|
|
5630
|
+
writeEchoService(h, upstream.port);
|
|
5631
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5632
|
+
const res = await fetcher(req("/echo-svc/x"), fakeServer("127.0.0.1"));
|
|
5633
|
+
expect(res.status).toBe(200);
|
|
5634
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5635
|
+
expect(body.layer).toBe("loopback");
|
|
5636
|
+
expect(body.clientIp).toBe("127.0.0.1");
|
|
5637
|
+
} finally {
|
|
5638
|
+
upstream.stop();
|
|
5639
|
+
h.cleanup();
|
|
5640
|
+
}
|
|
5641
|
+
});
|
|
5642
|
+
|
|
5643
|
+
test("tailnet request → stamped tailnet + X-Forwarded-For client IP", async () => {
|
|
5644
|
+
const h = makeHarness();
|
|
5645
|
+
const upstream = startEchoUpstream();
|
|
5646
|
+
try {
|
|
5647
|
+
writeEchoService(h, upstream.port);
|
|
5648
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5649
|
+
const res = await fetcher(
|
|
5650
|
+
req("/echo-svc/x", {
|
|
5651
|
+
headers: {
|
|
5652
|
+
"tailscale-user-login": "aaron@example.com",
|
|
5653
|
+
"x-forwarded-for": "100.64.0.7",
|
|
5654
|
+
},
|
|
5655
|
+
}),
|
|
5656
|
+
fakeServer("127.0.0.1"),
|
|
5657
|
+
);
|
|
5658
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5659
|
+
expect(body.layer).toBe("tailnet");
|
|
5660
|
+
expect(body.clientIp).toBe("100.64.0.7");
|
|
5661
|
+
} finally {
|
|
5662
|
+
upstream.stop();
|
|
5663
|
+
h.cleanup();
|
|
5664
|
+
}
|
|
5665
|
+
});
|
|
5666
|
+
|
|
5667
|
+
test("public (cloudflared) request → stamped public + CF-Connecting-IP", async () => {
|
|
5668
|
+
const h = makeHarness();
|
|
5669
|
+
const upstream = startEchoUpstream();
|
|
5670
|
+
try {
|
|
5671
|
+
writeEchoService(h, upstream.port);
|
|
5672
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5673
|
+
const res = await fetcher(
|
|
5674
|
+
req("/echo-svc/x", {
|
|
5675
|
+
headers: { "cf-ray": "8abc123", "cf-connecting-ip": "203.0.113.9" },
|
|
5676
|
+
}),
|
|
5677
|
+
fakeServer("127.0.0.1"),
|
|
5678
|
+
);
|
|
5679
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5680
|
+
expect(body.layer).toBe("public");
|
|
5681
|
+
expect(body.clientIp).toBe("203.0.113.9");
|
|
5682
|
+
} finally {
|
|
5683
|
+
upstream.stop();
|
|
5684
|
+
h.cleanup();
|
|
5685
|
+
}
|
|
5686
|
+
});
|
|
5687
|
+
|
|
5688
|
+
test("inbound spoof is STRIPPED — public peer injecting loopback headers gets re-stamped", async () => {
|
|
5689
|
+
// The attack H2's strip exists for: a direct network peer (0.0.0.0 bind)
|
|
5690
|
+
// sends X-Parachute-Layer: loopback + a forged client IP. The hub must
|
|
5691
|
+
// strip both and stamp its own fail-closed classification.
|
|
5692
|
+
const h = makeHarness();
|
|
5693
|
+
const upstream = startEchoUpstream();
|
|
5694
|
+
try {
|
|
5695
|
+
writeEchoService(h, upstream.port);
|
|
5696
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5697
|
+
const res = await fetcher(
|
|
5698
|
+
req("/echo-svc/x", {
|
|
5699
|
+
headers: {
|
|
5700
|
+
"x-parachute-layer": "loopback",
|
|
5701
|
+
"x-parachute-client-ip": "127.0.0.1",
|
|
5702
|
+
},
|
|
5703
|
+
}),
|
|
5704
|
+
fakeServer("198.51.100.20"),
|
|
5705
|
+
);
|
|
5706
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5707
|
+
expect(body.layer).toBe("public"); // header-absent non-loopback peer → public
|
|
5708
|
+
expect(body.clientIp).toBe("198.51.100.20"); // peer address, not the forgery
|
|
5709
|
+
} finally {
|
|
5710
|
+
upstream.stop();
|
|
5711
|
+
h.cleanup();
|
|
5712
|
+
}
|
|
5713
|
+
});
|
|
5714
|
+
|
|
5715
|
+
test("direct caller injecting CF-Connecting-IP: layer stamp stays sound (public), client IP is best-effort attribution", async () => {
|
|
5716
|
+
// Pins the documented property (resolveClientIp's "Known limitation"):
|
|
5717
|
+
// a DIRECT caller can spoof the forwarded-IP headers and misattribute
|
|
5718
|
+
// its own address — X-Parachute-Client-IP reflects the injected value —
|
|
5719
|
+
// but it CANNOT spoof the LAYER: the CF header's presence classifies the
|
|
5720
|
+
// request "public" regardless of the peer (here even a loopback one),
|
|
5721
|
+
// so spoofing only ever moves the trust signal DOWN. Layer is the
|
|
5722
|
+
// security signal; client IP is attribution.
|
|
5723
|
+
const h = makeHarness();
|
|
5724
|
+
const upstream = startEchoUpstream();
|
|
5725
|
+
try {
|
|
5726
|
+
writeEchoService(h, upstream.port);
|
|
5727
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5728
|
+
const res = await fetcher(
|
|
5729
|
+
req("/echo-svc/x", {
|
|
5730
|
+
headers: { "cf-connecting-ip": "203.0.113.50" },
|
|
5731
|
+
}),
|
|
5732
|
+
fakeServer("127.0.0.1"),
|
|
5733
|
+
);
|
|
5734
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5735
|
+
expect(body.layer).toBe("public"); // CF header presence → public, not loopback
|
|
5736
|
+
expect(body.clientIp).toBe("203.0.113.50"); // injected value — best-effort by design
|
|
5737
|
+
} finally {
|
|
5738
|
+
upstream.stop();
|
|
5739
|
+
h.cleanup();
|
|
5740
|
+
}
|
|
5741
|
+
});
|
|
5742
|
+
|
|
5743
|
+
test("unknown peer (no Server threaded) → fail-closed public, header for client IP omitted", async () => {
|
|
5744
|
+
const h = makeHarness();
|
|
5745
|
+
const upstream = startEchoUpstream();
|
|
5746
|
+
try {
|
|
5747
|
+
writeEchoService(h, upstream.port);
|
|
5748
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5749
|
+
// No second arg — peerAddr resolves null; layer fails closed to public.
|
|
5750
|
+
const res = await fetcher(req("/echo-svc/x"));
|
|
5751
|
+
const body = (await res.json()) as { layer: string; clientIp: string | null };
|
|
5752
|
+
expect(body.layer).toBe("public");
|
|
5753
|
+
expect(body.clientIp).toBeNull();
|
|
5754
|
+
} finally {
|
|
5755
|
+
upstream.stop();
|
|
5756
|
+
h.cleanup();
|
|
5757
|
+
}
|
|
5758
|
+
});
|
|
5759
|
+
|
|
5760
|
+
test("per-vault proxy stamps the same headers (shared proxyRequest path)", async () => {
|
|
5761
|
+
const h = makeHarness();
|
|
5762
|
+
const upstream = startEchoUpstream();
|
|
5763
|
+
try {
|
|
5764
|
+
writeManifest(
|
|
5765
|
+
{
|
|
5766
|
+
services: [
|
|
5767
|
+
{
|
|
5768
|
+
name: "parachute-vault-default",
|
|
5769
|
+
port: upstream.port,
|
|
5770
|
+
paths: ["/vault/default"],
|
|
5771
|
+
health: "/health",
|
|
5772
|
+
version: "0.4.0",
|
|
5773
|
+
},
|
|
5774
|
+
],
|
|
5775
|
+
},
|
|
5776
|
+
h.manifestPath,
|
|
5777
|
+
);
|
|
5778
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5779
|
+
const res = await fetcher(req("/vault/default/api/notes"), fakeServer("127.0.0.1"));
|
|
5780
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5781
|
+
expect(body.layer).toBe("loopback");
|
|
5782
|
+
expect(body.clientIp).toBe("127.0.0.1");
|
|
5783
|
+
} finally {
|
|
5784
|
+
upstream.stop();
|
|
5785
|
+
h.cleanup();
|
|
5786
|
+
}
|
|
5787
|
+
});
|
|
5788
|
+
|
|
5789
|
+
test("/vault/admin route stamps the same headers (proxyToVaultAdmin path)", async () => {
|
|
5790
|
+
const h = makeHarness();
|
|
5791
|
+
const upstream = startEchoUpstream();
|
|
5792
|
+
try {
|
|
5793
|
+
writeManifest(
|
|
5794
|
+
{
|
|
5795
|
+
services: [
|
|
5796
|
+
{
|
|
5797
|
+
name: "parachute-vault",
|
|
5798
|
+
port: upstream.port,
|
|
5799
|
+
paths: ["/vault/default"],
|
|
5800
|
+
health: "/health",
|
|
5801
|
+
version: "0.5.0",
|
|
5802
|
+
},
|
|
5803
|
+
],
|
|
5804
|
+
},
|
|
5805
|
+
h.manifestPath,
|
|
5806
|
+
);
|
|
5807
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5808
|
+
const res = await fetcher(
|
|
5809
|
+
req("/vault/admin/", {
|
|
5810
|
+
headers: { "tailscale-user-login": "aaron@example.com" },
|
|
5811
|
+
}),
|
|
5812
|
+
fakeServer("127.0.0.1"),
|
|
5813
|
+
);
|
|
5814
|
+
const body = (await res.json()) as { layer: string };
|
|
5815
|
+
expect(body.layer).toBe("tailnet");
|
|
5816
|
+
} finally {
|
|
5817
|
+
upstream.stop();
|
|
5818
|
+
h.cleanup();
|
|
5819
|
+
}
|
|
5820
|
+
});
|
|
5821
|
+
});
|
|
5822
|
+
|
|
5823
|
+
describe("resolveClientIp (H2)", () => {
|
|
5824
|
+
test("CF-Connecting-IP wins over X-Forwarded-For and peer", () => {
|
|
5825
|
+
const r = req("/", {
|
|
5826
|
+
headers: { "cf-connecting-ip": "203.0.113.1", "x-forwarded-for": "10.0.0.1" },
|
|
5827
|
+
});
|
|
5828
|
+
expect(resolveClientIp(r, "127.0.0.1")).toBe("203.0.113.1");
|
|
5829
|
+
});
|
|
5830
|
+
|
|
5831
|
+
test("X-Forwarded-For first hop wins over peer", () => {
|
|
5832
|
+
const r = req("/", { headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2" } });
|
|
5833
|
+
expect(resolveClientIp(r, "127.0.0.1")).toBe("10.0.0.1");
|
|
5834
|
+
});
|
|
5835
|
+
|
|
5836
|
+
test("falls back to the peer address", () => {
|
|
5837
|
+
expect(resolveClientIp(req("/"), "198.51.100.7")).toBe("198.51.100.7");
|
|
5838
|
+
});
|
|
5839
|
+
|
|
5840
|
+
test("null when nothing resolves", () => {
|
|
5841
|
+
expect(resolveClientIp(req("/"), null)).toBeNull();
|
|
5842
|
+
});
|
|
5843
|
+
|
|
5844
|
+
test("whitespace-only header values are treated as absent", () => {
|
|
5845
|
+
const r = req("/", { headers: { "x-forwarded-for": " " } });
|
|
5846
|
+
expect(resolveClientIp(r, null)).toBeNull();
|
|
5847
|
+
});
|
|
5848
|
+
});
|