@openparachute/hub 0.6.5-rc.7 → 0.7.0
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 +34 -0
- 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.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-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-db-liveness.test.ts +12 -7
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- 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/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-db-liveness.ts +33 -17
- package/src/hub-server.ts +354 -61
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- 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/web/ui/dist/assets/index-C-XzMVqN.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
|
@@ -85,20 +85,26 @@ function vaultEntry(name: string): ServiceEntry {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
describe("hubFetch routing", () => {
|
|
88
|
-
|
|
88
|
+
// Admin-shell IA (R1): the bare `/` page redirects into the single coherent
|
|
89
|
+
// admin shell at `/admin`. The old discovery-page content moved into the
|
|
90
|
+
// shell's Home overview. Only the bare `/` redirects — `/hub.html` still
|
|
91
|
+
// serves the discovery page (static expose file + explicit-`.html` bookmarks).
|
|
92
|
+
test("/ redirects (302) to /admin (admin-shell IA)", async () => {
|
|
89
93
|
const h = makeHarness();
|
|
90
94
|
try {
|
|
95
|
+
// No DB → exercises the redirect on the static-fallback path. The
|
|
96
|
+
// redirect sits above the static hub.html serve, so it fires regardless
|
|
97
|
+
// of whether a disk file exists.
|
|
91
98
|
writeFileSync(join(h.dir, "hub.html"), "<html><body>hi</body></html>");
|
|
92
99
|
const res = await hubFetch(h.dir)(req("/"));
|
|
93
|
-
expect(res.status).toBe(
|
|
94
|
-
expect(res.headers.get("
|
|
95
|
-
expect(await res.text()).toContain("<html>");
|
|
100
|
+
expect(res.status).toBe(302);
|
|
101
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
96
102
|
} finally {
|
|
97
103
|
h.cleanup();
|
|
98
104
|
}
|
|
99
105
|
});
|
|
100
106
|
|
|
101
|
-
test("/hub.html serves the
|
|
107
|
+
test("/hub.html still serves the discovery page (no DB → static fallback)", async () => {
|
|
102
108
|
const h = makeHarness();
|
|
103
109
|
try {
|
|
104
110
|
writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
|
|
@@ -173,11 +179,14 @@ describe("hubFetch routing", () => {
|
|
|
173
179
|
}
|
|
174
180
|
});
|
|
175
181
|
|
|
176
|
-
test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
|
|
182
|
+
test("/hub.html renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
|
|
177
183
|
// The dynamic path takes over from the static disk file the moment a
|
|
178
184
|
// DB is configured. With no session cookie, we still render — just
|
|
179
185
|
// with the "Sign in" affordance.
|
|
180
186
|
//
|
|
187
|
+
// Targets `/hub.html` (not `/`): bare `/` now 302-redirects to the admin
|
|
188
|
+
// shell (R1). `/hub.html` still serves the dynamic discovery page.
|
|
189
|
+
//
|
|
181
190
|
// hub#259 rc.6: requires an admin row to bypass the fresh-hub
|
|
182
191
|
// funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
|
|
183
192
|
// test continues to exercise the signed-out-but-setup-done branch.
|
|
@@ -198,7 +207,7 @@ describe("hubFetch routing", () => {
|
|
|
198
207
|
const res = await hubFetch(h.dir, {
|
|
199
208
|
getDb: () => db,
|
|
200
209
|
manifestPath: h.manifestPath,
|
|
201
|
-
})(req("/"));
|
|
210
|
+
})(req("/hub.html"));
|
|
202
211
|
expect(res.status).toBe(200);
|
|
203
212
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
204
213
|
const body = await res.text();
|
|
@@ -213,10 +222,11 @@ describe("hubFetch routing", () => {
|
|
|
213
222
|
}
|
|
214
223
|
});
|
|
215
224
|
|
|
216
|
-
test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
|
|
225
|
+
test("/hub.html renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
|
|
217
226
|
// Same wizard-funnel bypass as the signed-out test above — seed a
|
|
218
227
|
// vault row and pass an explicit manifestPath so CI doesn't fall back
|
|
219
|
-
// to ~/.parachute/services.json.
|
|
228
|
+
// to ~/.parachute/services.json. Targets `/hub.html` (bare `/` now
|
|
229
|
+
// redirects to the admin shell, R1).
|
|
220
230
|
const h = makeHarness();
|
|
221
231
|
try {
|
|
222
232
|
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
@@ -232,7 +242,7 @@ describe("hubFetch routing", () => {
|
|
|
232
242
|
const res = await hubFetch(h.dir, {
|
|
233
243
|
getDb: () => db,
|
|
234
244
|
manifestPath: h.manifestPath,
|
|
235
|
-
})(req("/", { headers: { cookie } }));
|
|
245
|
+
})(req("/hub.html", { headers: { cookie } }));
|
|
236
246
|
expect(res.status).toBe(200);
|
|
237
247
|
const body = await res.text();
|
|
238
248
|
expect(body).toContain("Signed in as");
|
|
@@ -249,6 +259,36 @@ describe("hubFetch routing", () => {
|
|
|
249
259
|
}
|
|
250
260
|
});
|
|
251
261
|
|
|
262
|
+
test("/ → /admin even with a DB + admin + vault (setup complete; not the wizard funnel)", async () => {
|
|
263
|
+
// Distinguishes the admin-shell redirect (302 → /admin) from the fresh-hub
|
|
264
|
+
// wizard funnel (302 → /admin/setup). With admin + vault seeded, the wizard
|
|
265
|
+
// funnel is bypassed and the bare `/` lands on the admin shell. Also proves
|
|
266
|
+
// the redirect fires on the DB-configured path, not just the static
|
|
267
|
+
// fallback. `/hub.html` under the same setup still renders the discovery
|
|
268
|
+
// page (asserted separately above) — only bare `/` redirects.
|
|
269
|
+
const h = makeHarness();
|
|
270
|
+
try {
|
|
271
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
272
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
273
|
+
try {
|
|
274
|
+
const { createUser } = await import("../users.ts");
|
|
275
|
+
await createUser(db, "owner", "pw");
|
|
276
|
+
const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
|
|
277
|
+
const rootRes = await handler(req("/"));
|
|
278
|
+
expect(rootRes.status).toBe(302);
|
|
279
|
+
expect(rootRes.headers.get("location")).toBe("/admin");
|
|
280
|
+
// /hub.html under the same setup-complete state still serves discovery.
|
|
281
|
+
const hubHtmlRes = await handler(req("/hub.html"));
|
|
282
|
+
expect(hubHtmlRes.status).toBe(200);
|
|
283
|
+
expect(hubHtmlRes.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
284
|
+
} finally {
|
|
285
|
+
db.close();
|
|
286
|
+
}
|
|
287
|
+
} finally {
|
|
288
|
+
h.cleanup();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
252
292
|
test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
|
|
253
293
|
const h = makeHarness();
|
|
254
294
|
try {
|
|
@@ -648,8 +688,9 @@ describe("hubFetch routing", () => {
|
|
|
648
688
|
test("missing hub.html returns 404 rather than crashing", async () => {
|
|
649
689
|
const h = makeHarness();
|
|
650
690
|
try {
|
|
651
|
-
// dir exists but no files in it
|
|
652
|
-
|
|
691
|
+
// dir exists but no files in it. Targets `/hub.html` directly — bare `/`
|
|
692
|
+
// now 302-redirects to the admin shell before reaching the static serve.
|
|
693
|
+
const res = await hubFetch(h.dir)(req("/hub.html"));
|
|
653
694
|
expect(res.status).toBe(404);
|
|
654
695
|
} finally {
|
|
655
696
|
h.cleanup();
|
|
@@ -932,23 +973,28 @@ describe("hubFetch routing", () => {
|
|
|
932
973
|
// 301-redirect to the new /admin/* mount. Tests cover every entry in the
|
|
933
974
|
// dispatch — operator bookmarks landing on any of these still work.
|
|
934
975
|
|
|
935
|
-
|
|
976
|
+
// B5 (2026-06-09 hub-module-boundary): the legacy `/vault[/new]` 301s point
|
|
977
|
+
// DIRECTLY at vault's daemon-level admin surface — not at /admin/vaults,
|
|
978
|
+
// whose SPA route is now just a feature-detected forwarder. Pointing at the
|
|
979
|
+
// final target avoids a redirect → SPA-load → client-side-forward chain.
|
|
980
|
+
|
|
981
|
+
test("301: /vault → /vault/admin/ (direct — no chain through /admin/vaults)", async () => {
|
|
936
982
|
const h = makeHarness();
|
|
937
983
|
try {
|
|
938
984
|
const res = await hubFetch(h.dir)(req("/vault"));
|
|
939
985
|
expect(res.status).toBe(301);
|
|
940
|
-
expect(res.headers.get("location")).toBe("/admin/
|
|
986
|
+
expect(res.headers.get("location")).toBe("/vault/admin/");
|
|
941
987
|
} finally {
|
|
942
988
|
h.cleanup();
|
|
943
989
|
}
|
|
944
990
|
});
|
|
945
991
|
|
|
946
|
-
test("301: /vault/new → /admin/
|
|
992
|
+
test("301: /vault/new → /vault/admin/ (create relocated into vault's surface)", async () => {
|
|
947
993
|
const h = makeHarness();
|
|
948
994
|
try {
|
|
949
995
|
const res = await hubFetch(h.dir)(req("/vault/new"));
|
|
950
996
|
expect(res.status).toBe(301);
|
|
951
|
-
expect(res.headers.get("location")).toBe("/admin/
|
|
997
|
+
expect(res.headers.get("location")).toBe("/vault/admin/");
|
|
952
998
|
} finally {
|
|
953
999
|
h.cleanup();
|
|
954
1000
|
}
|
|
@@ -959,7 +1005,7 @@ describe("hubFetch routing", () => {
|
|
|
959
1005
|
try {
|
|
960
1006
|
const res = await hubFetch(h.dir)(req("/vault?next=foo"));
|
|
961
1007
|
expect(res.status).toBe(301);
|
|
962
|
-
expect(res.headers.get("location")).toBe("/admin
|
|
1008
|
+
expect(res.headers.get("location")).toBe("/vault/admin/?next=foo");
|
|
963
1009
|
} finally {
|
|
964
1010
|
h.cleanup();
|
|
965
1011
|
}
|
|
@@ -1622,7 +1668,7 @@ describe("hubFetch routing", () => {
|
|
|
1622
1668
|
}
|
|
1623
1669
|
});
|
|
1624
1670
|
|
|
1625
|
-
test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
|
|
1671
|
+
test("live Bun.serve round-trip: /, /hub.html and /.well-known resolve", async () => {
|
|
1626
1672
|
const h = makeHarness();
|
|
1627
1673
|
try {
|
|
1628
1674
|
writeFileSync(join(h.dir, "hub.html"), "<html>live</html>");
|
|
@@ -1634,9 +1680,15 @@ describe("hubFetch routing", () => {
|
|
|
1634
1680
|
});
|
|
1635
1681
|
try {
|
|
1636
1682
|
const base = `http://127.0.0.1:${server.port}`;
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1683
|
+
// Bare `/` 302-redirects into the admin shell (R1). `redirect: "manual"`
|
|
1684
|
+
// so we observe the redirect itself rather than following it to /admin.
|
|
1685
|
+
const r1 = await fetch(`${base}/`, { redirect: "manual" });
|
|
1686
|
+
expect(r1.status).toBe(302);
|
|
1687
|
+
expect(r1.headers.get("location")).toBe("/admin");
|
|
1688
|
+
// The discovery page still serves at /hub.html.
|
|
1689
|
+
const rHub = await fetch(`${base}/hub.html`);
|
|
1690
|
+
expect(rHub.status).toBe(200);
|
|
1691
|
+
expect(await rHub.text()).toBe("<html>live</html>");
|
|
1640
1692
|
const r2 = await fetch(`${base}/.well-known/parachute.json`);
|
|
1641
1693
|
expect(r2.headers.get("content-type")).toBe("application/json");
|
|
1642
1694
|
expect(await r2.json()).toEqual({ vaults: [], services: [] });
|
|
@@ -4051,6 +4103,208 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
4051
4103
|
});
|
|
4052
4104
|
});
|
|
4053
4105
|
|
|
4106
|
+
describe("hubFetch /vault/admin daemon-level mount (B-route, hub-module-boundary)", () => {
|
|
4107
|
+
// /vault/admin + /vault/admin/* route to the vault MODULE's daemon
|
|
4108
|
+
// (resolved via findServiceByShort, not findVaultUpstream) — the
|
|
4109
|
+
// daemon-level multi-vault admin surface, not an instance path. "admin"
|
|
4110
|
+
// is a reserved vault name (B2h) so no instance can ever claim the mount.
|
|
4111
|
+
|
|
4112
|
+
/** Upstream that ECHOES the request path, so the tests can pin both which
|
|
4113
|
+
* daemon got the request and that the FULL path was forwarded (vault's
|
|
4114
|
+
* row declares no stripPrefix). */
|
|
4115
|
+
function startEchoUpstream(tag: string): { port: number; stop: () => void } {
|
|
4116
|
+
const server = Bun.serve({
|
|
4117
|
+
port: 0,
|
|
4118
|
+
hostname: "127.0.0.1",
|
|
4119
|
+
fetch: (r) =>
|
|
4120
|
+
new Response(JSON.stringify({ tag, path: new URL(r.url).pathname }), {
|
|
4121
|
+
status: 200,
|
|
4122
|
+
headers: { "content-type": "application/json" },
|
|
4123
|
+
}),
|
|
4124
|
+
});
|
|
4125
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
4129
|
+
|
|
4130
|
+
test("/vault/admin and /vault/admin/* route to the vault module's daemon port with the FULL path", async () => {
|
|
4131
|
+
const h = makeHarness();
|
|
4132
|
+
const upstream = startEchoUpstream("vault-daemon");
|
|
4133
|
+
try {
|
|
4134
|
+
writeManifest(
|
|
4135
|
+
{
|
|
4136
|
+
services: [
|
|
4137
|
+
{
|
|
4138
|
+
// The canonical self-registered row name — findServiceByShort
|
|
4139
|
+
// resolves `parachute-vault` → short "vault".
|
|
4140
|
+
name: "parachute-vault",
|
|
4141
|
+
port: upstream.port,
|
|
4142
|
+
paths: ["/vault/default"],
|
|
4143
|
+
health: "/vault/default/health",
|
|
4144
|
+
version: "0.5.0",
|
|
4145
|
+
},
|
|
4146
|
+
],
|
|
4147
|
+
},
|
|
4148
|
+
h.manifestPath,
|
|
4149
|
+
);
|
|
4150
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4151
|
+
for (const path of ["/vault/admin", "/vault/admin/", "/vault/admin/assets/app.js"]) {
|
|
4152
|
+
const res = await fetcher(req(path));
|
|
4153
|
+
expect(res.status).toBe(200);
|
|
4154
|
+
const body = (await res.json()) as { tag: string; path: string };
|
|
4155
|
+
expect(body.tag).toBe("vault-daemon");
|
|
4156
|
+
// Full path forwarded — no prefix strip (vault routes /vault/admin itself).
|
|
4157
|
+
expect(body.path).toBe(path);
|
|
4158
|
+
}
|
|
4159
|
+
} finally {
|
|
4160
|
+
upstream.stop();
|
|
4161
|
+
h.cleanup();
|
|
4162
|
+
}
|
|
4163
|
+
});
|
|
4164
|
+
|
|
4165
|
+
test("/vault/admin is NOT captured by a per-instance mount — daemon-level route wins", async () => {
|
|
4166
|
+
// A pathological vault row mounted at the bare `/vault` would prefix-match
|
|
4167
|
+
// `/vault/admin` in findVaultUpstream; the daemon-level branch runs FIRST
|
|
4168
|
+
// so the per-vault proxy never sees the path (and no consumer can
|
|
4169
|
+
// fabricate a phantom instance named "admin"). With distinct upstream
|
|
4170
|
+
// ports we can tell exactly which one served the request.
|
|
4171
|
+
const h = makeHarness();
|
|
4172
|
+
const daemonUpstream = startEchoUpstream("vault-daemon");
|
|
4173
|
+
const instanceUpstream = startEchoUpstream("vault-instance");
|
|
4174
|
+
try {
|
|
4175
|
+
writeManifest(
|
|
4176
|
+
{
|
|
4177
|
+
services: [
|
|
4178
|
+
{
|
|
4179
|
+
name: "parachute-vault",
|
|
4180
|
+
port: daemonUpstream.port,
|
|
4181
|
+
paths: ["/vault/default"],
|
|
4182
|
+
health: "/vault/default/health",
|
|
4183
|
+
version: "0.5.0",
|
|
4184
|
+
},
|
|
4185
|
+
{
|
|
4186
|
+
// Pathological-but-representable bare mount on a second row.
|
|
4187
|
+
name: "parachute-vault-bare",
|
|
4188
|
+
port: instanceUpstream.port,
|
|
4189
|
+
paths: ["/vault"],
|
|
4190
|
+
health: "/vault/health",
|
|
4191
|
+
version: "0.5.0",
|
|
4192
|
+
},
|
|
4193
|
+
],
|
|
4194
|
+
},
|
|
4195
|
+
h.manifestPath,
|
|
4196
|
+
);
|
|
4197
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4198
|
+
const res = await fetcher(req("/vault/admin/"));
|
|
4199
|
+
expect(res.status).toBe(200);
|
|
4200
|
+
const body = (await res.json()) as { tag: string };
|
|
4201
|
+
expect(body.tag).toBe("vault-daemon");
|
|
4202
|
+
} finally {
|
|
4203
|
+
daemonUpstream.stop();
|
|
4204
|
+
instanceUpstream.stop();
|
|
4205
|
+
h.cleanup();
|
|
4206
|
+
}
|
|
4207
|
+
});
|
|
4208
|
+
|
|
4209
|
+
test('a vault instance named "adminx" still routes per-instance (exact-segment match only)', async () => {
|
|
4210
|
+
const h = makeHarness();
|
|
4211
|
+
const upstream = startEchoUpstream("vault-daemon");
|
|
4212
|
+
try {
|
|
4213
|
+
writeManifest(
|
|
4214
|
+
{
|
|
4215
|
+
services: [
|
|
4216
|
+
{
|
|
4217
|
+
name: "parachute-vault",
|
|
4218
|
+
port: upstream.port,
|
|
4219
|
+
paths: ["/vault/adminx"],
|
|
4220
|
+
health: "/vault/adminx/health",
|
|
4221
|
+
version: "0.5.0",
|
|
4222
|
+
},
|
|
4223
|
+
],
|
|
4224
|
+
},
|
|
4225
|
+
h.manifestPath,
|
|
4226
|
+
);
|
|
4227
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4228
|
+
// Per-instance path — proxied through the per-vault dispatch (full
|
|
4229
|
+
// path forwarded; vault rows don't strip).
|
|
4230
|
+
const res = await fetcher(req("/vault/adminx/health"));
|
|
4231
|
+
expect(res.status).toBe(200);
|
|
4232
|
+
const body = (await res.json()) as { path: string };
|
|
4233
|
+
expect(body.path).toBe("/vault/adminx/health");
|
|
4234
|
+
} finally {
|
|
4235
|
+
upstream.stop();
|
|
4236
|
+
h.cleanup();
|
|
4237
|
+
}
|
|
4238
|
+
});
|
|
4239
|
+
|
|
4240
|
+
test("404 cleanly when no vault module is installed", async () => {
|
|
4241
|
+
const h = makeHarness();
|
|
4242
|
+
try {
|
|
4243
|
+
writeManifest(
|
|
4244
|
+
{
|
|
4245
|
+
services: [
|
|
4246
|
+
{
|
|
4247
|
+
// Some other module installed — must not capture /vault/admin.
|
|
4248
|
+
name: "parachute-scribe",
|
|
4249
|
+
port: 1942,
|
|
4250
|
+
paths: ["/scribe"],
|
|
4251
|
+
health: "/scribe/health",
|
|
4252
|
+
version: "0.1.0",
|
|
4253
|
+
},
|
|
4254
|
+
],
|
|
4255
|
+
},
|
|
4256
|
+
h.manifestPath,
|
|
4257
|
+
);
|
|
4258
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4259
|
+
const res = await fetcher(req("/vault/admin/"));
|
|
4260
|
+
expect(res.status).toBe(404);
|
|
4261
|
+
} finally {
|
|
4262
|
+
h.cleanup();
|
|
4263
|
+
}
|
|
4264
|
+
});
|
|
4265
|
+
|
|
4266
|
+
test('publicExposure: "loopback" on the vault row cloaks /vault/admin from tailnet/public (same gate as proxyToVault)', async () => {
|
|
4267
|
+
const h = makeHarness();
|
|
4268
|
+
const upstream = startEchoUpstream("vault-daemon");
|
|
4269
|
+
try {
|
|
4270
|
+
writeManifest(
|
|
4271
|
+
{
|
|
4272
|
+
services: [
|
|
4273
|
+
{
|
|
4274
|
+
name: "parachute-vault",
|
|
4275
|
+
port: upstream.port,
|
|
4276
|
+
paths: ["/vault/default"],
|
|
4277
|
+
health: "/vault/default/health",
|
|
4278
|
+
version: "0.5.0",
|
|
4279
|
+
publicExposure: "loopback",
|
|
4280
|
+
},
|
|
4281
|
+
],
|
|
4282
|
+
},
|
|
4283
|
+
h.manifestPath,
|
|
4284
|
+
);
|
|
4285
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
4286
|
+
// Tailnet caller → cloaked.
|
|
4287
|
+
const tailnet = await fetcher(
|
|
4288
|
+
req("/vault/admin/", { headers: { "Tailscale-User-Login": "alice@example.com" } }),
|
|
4289
|
+
);
|
|
4290
|
+
expect(tailnet.status).toBe(404);
|
|
4291
|
+
// Public caller → cloaked.
|
|
4292
|
+
const pub = await fetcher(req("/vault/admin/", { headers: { "CF-Ray": "abc123" } }));
|
|
4293
|
+
expect(pub.status).toBe(404);
|
|
4294
|
+
// Header-absent NON-loopback peer (0.0.0.0 bind) → cloaked (#526 posture).
|
|
4295
|
+
const direct = await fetcher(req("/vault/admin/"), fakeServer("198.51.100.4"));
|
|
4296
|
+
expect(direct.status).toBe(404);
|
|
4297
|
+
// Loopback peer → reaches the daemon.
|
|
4298
|
+
const loop = await fetcher(req("/vault/admin/"), fakeServer("127.0.0.1"));
|
|
4299
|
+
expect(loop.status).toBe(200);
|
|
4300
|
+
expect(((await loop.json()) as { tag: string }).tag).toBe("vault-daemon");
|
|
4301
|
+
} finally {
|
|
4302
|
+
upstream.stop();
|
|
4303
|
+
h.cleanup();
|
|
4304
|
+
}
|
|
4305
|
+
});
|
|
4306
|
+
});
|
|
4307
|
+
|
|
4054
4308
|
/** Find a port that no one is listening on by binding briefly and releasing. */
|
|
4055
4309
|
async function pickClosedPort(): Promise<number> {
|
|
4056
4310
|
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
|
|
@@ -5004,6 +5258,50 @@ describe("force-change-password per-request gate (#469)", () => {
|
|
|
5004
5258
|
}
|
|
5005
5259
|
});
|
|
5006
5260
|
|
|
5261
|
+
test("(a) pre-rotation browser GET /vault/admin/ is 302'd to change-password (B-route gate parity)", async () => {
|
|
5262
|
+
// The daemon-level /vault/admin mount applies the SAME per-request
|
|
5263
|
+
// force-change-password gate as the per-vault proxy — a pre-rotation
|
|
5264
|
+
// session can't reach the multi-vault admin surface on an un-rotated
|
|
5265
|
+
// temp password either.
|
|
5266
|
+
const h = makeHarness();
|
|
5267
|
+
try {
|
|
5268
|
+
writeManifest(
|
|
5269
|
+
{
|
|
5270
|
+
services: [
|
|
5271
|
+
{
|
|
5272
|
+
// Canonical row name so findServiceByShort would resolve it if
|
|
5273
|
+
// the request ever got past the gate.
|
|
5274
|
+
name: "parachute-vault",
|
|
5275
|
+
port: 1940,
|
|
5276
|
+
paths: ["/vault/work"],
|
|
5277
|
+
health: "/vault/work/health",
|
|
5278
|
+
version: "0.5.0",
|
|
5279
|
+
},
|
|
5280
|
+
],
|
|
5281
|
+
},
|
|
5282
|
+
h.manifestPath,
|
|
5283
|
+
);
|
|
5284
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
5285
|
+
try {
|
|
5286
|
+
const { cookie } = await seedUser(h, db, {
|
|
5287
|
+
passwordChanged: false,
|
|
5288
|
+
assignedVaults: ["work"],
|
|
5289
|
+
});
|
|
5290
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
5291
|
+
req("/vault/admin/", { headers: { cookie, accept: "text/html" } }),
|
|
5292
|
+
);
|
|
5293
|
+
// Gated BEFORE proxyToVaultAdmin ever runs — the gate's 302, not a
|
|
5294
|
+
// proxy 404/502.
|
|
5295
|
+
expect(res.status).toBe(302);
|
|
5296
|
+
expect(res.headers.get("location")).toBe("/account/change-password");
|
|
5297
|
+
} finally {
|
|
5298
|
+
db.close();
|
|
5299
|
+
}
|
|
5300
|
+
} finally {
|
|
5301
|
+
h.cleanup();
|
|
5302
|
+
}
|
|
5303
|
+
});
|
|
5304
|
+
|
|
5007
5305
|
test("(b) pre-rotation user CAN still reach /account/change-password (GET)", async () => {
|
|
5008
5306
|
const h = makeHarness();
|
|
5009
5307
|
try {
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
issueInvite,
|
|
28
28
|
listInvites,
|
|
29
29
|
revokeInvite,
|
|
30
|
+
revokeInvitesForVault,
|
|
30
31
|
} from "../invites.ts";
|
|
31
32
|
import { createUser } from "../users.ts";
|
|
32
33
|
|
|
@@ -183,6 +184,32 @@ describe("revokeInvite", () => {
|
|
|
183
184
|
});
|
|
184
185
|
});
|
|
185
186
|
|
|
187
|
+
describe("revokeInvitesForVault (B1 cascade step)", () => {
|
|
188
|
+
test("a NULL-vault_name invite (redeemer-named flow) is NOT revoked by any vault's cascade", async () => {
|
|
189
|
+
// The cascade invalidates invites PINNED to the deleted vault — an
|
|
190
|
+
// unpinned invite (vault_name NULL: the redeemer names their own vault)
|
|
191
|
+
// can't resurrect a specific name, so it must survive every vault's
|
|
192
|
+
// delete. SQL `vault_name = ?` never matches NULL; pin that boundary.
|
|
193
|
+
const { db, adminId, cleanup } = await makeDb();
|
|
194
|
+
try {
|
|
195
|
+
const unpinned = issueInvite(db, { createdBy: adminId }); // vault_name NULL
|
|
196
|
+
const pinned = issueInvite(db, { createdBy: adminId, vaultName: "work" });
|
|
197
|
+
|
|
198
|
+
expect(revokeInvitesForVault(db, "work")).toBe(1);
|
|
199
|
+
// The pinned invite is revoked; the unpinned one rides on untouched.
|
|
200
|
+
expect(findInviteByRawToken(db, pinned.rawToken)?.revokedAt).not.toBeNull();
|
|
201
|
+
expect(findInviteByRawToken(db, unpinned.rawToken)?.revokedAt).toBeNull();
|
|
202
|
+
|
|
203
|
+
// A second sweep (or another vault's sweep) finds nothing more.
|
|
204
|
+
expect(revokeInvitesForVault(db, "work")).toBe(0);
|
|
205
|
+
expect(revokeInvitesForVault(db, "other")).toBe(0);
|
|
206
|
+
expect(findInviteByRawToken(db, unpinned.rawToken)?.revokedAt).toBeNull();
|
|
207
|
+
} finally {
|
|
208
|
+
cleanup();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
186
213
|
describe("inviteStatus / listInvites", () => {
|
|
187
214
|
test("derives pending / redeemed / expired / revoked", async () => {
|
|
188
215
|
const { db, adminId, cleanup } = await makeDb();
|