@openparachute/hub 0.3.0-rc.1 → 0.5.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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
7
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
8
|
+
import { findVaultUpstream, hubFetch } from "../hub-server.ts";
|
|
9
|
+
import { pidPath } from "../process-state.ts";
|
|
10
|
+
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
11
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
6
12
|
|
|
7
13
|
interface Harness {
|
|
8
14
|
dir: string;
|
|
15
|
+
manifestPath: string;
|
|
9
16
|
cleanup: () => void;
|
|
10
17
|
}
|
|
11
18
|
|
|
12
19
|
function makeHarness(): Harness {
|
|
13
20
|
const dir = mkdtempSync(join(tmpdir(), "pcli-hub-server-"));
|
|
14
|
-
return {
|
|
21
|
+
return {
|
|
22
|
+
dir,
|
|
23
|
+
manifestPath: join(dir, "services.json"),
|
|
24
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
25
|
+
};
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
function req(path: string, init?: RequestInit): Request {
|
|
18
29
|
return new Request(`http://127.0.0.1/${path.replace(/^\//, "")}`, init);
|
|
19
30
|
}
|
|
20
31
|
|
|
32
|
+
function mkdirIfMissing(dir: string): void {
|
|
33
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function vaultEntry(name: string): ServiceEntry {
|
|
37
|
+
return {
|
|
38
|
+
name: `parachute-vault-${name}`,
|
|
39
|
+
port: 1940,
|
|
40
|
+
paths: [`/vault/${name}`],
|
|
41
|
+
health: "/health",
|
|
42
|
+
version: "0.4.0",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
describe("hubFetch routing", () => {
|
|
22
47
|
test("/ serves hub.html with text/html content-type", async () => {
|
|
23
48
|
const h = makeHarness();
|
|
24
49
|
try {
|
|
25
50
|
writeFileSync(join(h.dir, "hub.html"), "<html><body>hi</body></html>");
|
|
26
|
-
const res = hubFetch(h.dir)(req("/"));
|
|
51
|
+
const res = await hubFetch(h.dir)(req("/"));
|
|
27
52
|
expect(res.status).toBe(200);
|
|
28
53
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
29
54
|
expect(await res.text()).toContain("<html>");
|
|
@@ -36,7 +61,7 @@ describe("hubFetch routing", () => {
|
|
|
36
61
|
const h = makeHarness();
|
|
37
62
|
try {
|
|
38
63
|
writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
|
|
39
|
-
const res = hubFetch(h.dir)(req("/hub.html"));
|
|
64
|
+
const res = await hubFetch(h.dir)(req("/hub.html"));
|
|
40
65
|
expect(res.status).toBe(200);
|
|
41
66
|
expect(await res.text()).toBe("<html>x</html>");
|
|
42
67
|
} finally {
|
|
@@ -44,14 +69,18 @@ describe("hubFetch routing", () => {
|
|
|
44
69
|
}
|
|
45
70
|
});
|
|
46
71
|
|
|
47
|
-
test("/.well-known/parachute.json
|
|
72
|
+
test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
|
|
48
73
|
const h = makeHarness();
|
|
49
74
|
try {
|
|
50
|
-
|
|
51
|
-
const res = hubFetch(h.dir)(
|
|
75
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
76
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
77
|
+
req("/.well-known/parachute.json"),
|
|
78
|
+
);
|
|
52
79
|
expect(res.status).toBe(200);
|
|
53
80
|
expect(res.headers.get("content-type")).toBe("application/json");
|
|
54
|
-
|
|
81
|
+
const body = (await res.json()) as { vaults: Array<{ name: string; url: string }> };
|
|
82
|
+
expect(body.vaults).toHaveLength(1);
|
|
83
|
+
expect(body.vaults[0]?.name).toBe("default");
|
|
55
84
|
} finally {
|
|
56
85
|
h.cleanup();
|
|
57
86
|
}
|
|
@@ -65,8 +94,10 @@ describe("hubFetch routing", () => {
|
|
|
65
94
|
test("/.well-known/parachute.json includes wildcard CORS headers on GET", async () => {
|
|
66
95
|
const h = makeHarness();
|
|
67
96
|
try {
|
|
68
|
-
|
|
69
|
-
const res = hubFetch(h.dir)(
|
|
97
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
98
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
99
|
+
req("/.well-known/parachute.json"),
|
|
100
|
+
);
|
|
70
101
|
expect(res.status).toBe(200);
|
|
71
102
|
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
72
103
|
expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
|
|
@@ -78,8 +109,10 @@ describe("hubFetch routing", () => {
|
|
|
78
109
|
test("OPTIONS preflight on /.well-known/parachute.json returns 204 + CORS", async () => {
|
|
79
110
|
const h = makeHarness();
|
|
80
111
|
try {
|
|
81
|
-
// Note: no
|
|
82
|
-
const res = hubFetch(h.dir
|
|
112
|
+
// Note: no services.json on disk — preflight must not depend on it.
|
|
113
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
114
|
+
req("/.well-known/parachute.json", { method: "OPTIONS" }),
|
|
115
|
+
);
|
|
83
116
|
expect(res.status).toBe(204);
|
|
84
117
|
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
85
118
|
expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
|
|
@@ -88,14 +121,165 @@ describe("hubFetch routing", () => {
|
|
|
88
121
|
}
|
|
89
122
|
});
|
|
90
123
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
124
|
+
// The dispatch from team-lead specifically: a fresh hub install has no
|
|
125
|
+
// expose run yet, but `parachute vault create` writes services.json. The
|
|
126
|
+
// well-known doc must reflect that vault on the *next* GET — no expose,
|
|
127
|
+
// no parachute.json on disk.
|
|
128
|
+
test("/.well-known/parachute.json works on a fresh hub (services.json only, no expose run)", async () => {
|
|
94
129
|
const h = makeHarness();
|
|
95
130
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
131
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
132
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
133
|
+
req("/.well-known/parachute.json"),
|
|
134
|
+
);
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
const body = (await res.json()) as {
|
|
137
|
+
vaults: Array<{ name: string }>;
|
|
138
|
+
services: Array<{ name: string }>;
|
|
139
|
+
};
|
|
140
|
+
expect(body.vaults.map((v) => v.name)).toEqual(["default"]);
|
|
141
|
+
expect(body.services.map((s) => s.name)).toEqual(["parachute-vault-default"]);
|
|
142
|
+
} finally {
|
|
143
|
+
h.cleanup();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// hub#158: each vault entry's module.json:managementUrl rides through to
|
|
148
|
+
// the well-known doc. The SPA reads it to decide whether to render a
|
|
149
|
+
// "Manage" link on the row.
|
|
150
|
+
test("/.well-known/parachute.json surfaces managementUrl from the vault module manifest", async () => {
|
|
151
|
+
const h = makeHarness();
|
|
152
|
+
try {
|
|
153
|
+
const entryWithInstallDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake" };
|
|
154
|
+
writeManifest({ services: [entryWithInstallDir] }, h.manifestPath);
|
|
155
|
+
const res = await hubFetch(h.dir, {
|
|
156
|
+
manifestPath: h.manifestPath,
|
|
157
|
+
// Stand in for module-manifest.readModuleManifest — production reads
|
|
158
|
+
// <installDir>/.parachute/module.json off disk.
|
|
159
|
+
readModuleManifest: async () => ({
|
|
160
|
+
name: "vault",
|
|
161
|
+
manifestName: "parachute-vault",
|
|
162
|
+
kind: "api",
|
|
163
|
+
port: 1940,
|
|
164
|
+
paths: ["/vault/default"],
|
|
165
|
+
health: "/health",
|
|
166
|
+
managementUrl: "/admin",
|
|
167
|
+
}),
|
|
168
|
+
})(req("/.well-known/parachute.json"));
|
|
169
|
+
expect(res.status).toBe(200);
|
|
170
|
+
const body = (await res.json()) as {
|
|
171
|
+
vaults: Array<{ name: string; managementUrl?: string }>;
|
|
172
|
+
};
|
|
173
|
+
expect(body.vaults).toHaveLength(1);
|
|
174
|
+
expect(body.vaults[0]?.managementUrl).toBe("/admin");
|
|
175
|
+
} finally {
|
|
176
|
+
h.cleanup();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("/.well-known/parachute.json omits managementUrl when manifest has none", async () => {
|
|
181
|
+
const h = makeHarness();
|
|
182
|
+
try {
|
|
183
|
+
const entryWithInstallDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake" };
|
|
184
|
+
writeManifest({ services: [entryWithInstallDir] }, h.manifestPath);
|
|
185
|
+
const res = await hubFetch(h.dir, {
|
|
186
|
+
manifestPath: h.manifestPath,
|
|
187
|
+
readModuleManifest: async () => null,
|
|
188
|
+
})(req("/.well-known/parachute.json"));
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
const body = (await res.json()) as { vaults: Array<{ managementUrl?: string }> };
|
|
191
|
+
expect(body.vaults[0]).not.toHaveProperty("managementUrl");
|
|
192
|
+
} finally {
|
|
193
|
+
h.cleanup();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// The bug this PR fixes: `parachute vault create techne` updates
|
|
198
|
+
// services.json but the old code only re-derived parachute.json on
|
|
199
|
+
// `parachute expose`. With the dynamic build, the second GET reflects
|
|
200
|
+
// the new vault without any other action.
|
|
201
|
+
test("services.json change is reflected on the next GET (no restart, no expose)", async () => {
|
|
202
|
+
const h = makeHarness();
|
|
203
|
+
try {
|
|
204
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
205
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
206
|
+
|
|
207
|
+
const before = (await (await fetcher(req("/.well-known/parachute.json"))).json()) as {
|
|
208
|
+
vaults: Array<{ name: string }>;
|
|
209
|
+
};
|
|
210
|
+
expect(before.vaults.map((v) => v.name)).toEqual(["default"]);
|
|
211
|
+
|
|
212
|
+
writeManifest({ services: [vaultEntry("default"), vaultEntry("techne")] }, h.manifestPath);
|
|
213
|
+
|
|
214
|
+
const after = (await (await fetcher(req("/.well-known/parachute.json"))).json()) as {
|
|
215
|
+
vaults: Array<{ name: string }>;
|
|
216
|
+
};
|
|
217
|
+
expect(after.vaults.map((v) => v.name)).toEqual(["default", "techne"]);
|
|
218
|
+
} finally {
|
|
219
|
+
h.cleanup();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("missing services.json yields an empty doc with CORS, not a 404", async () => {
|
|
224
|
+
// No expose, no `parachute vault create` yet — readManifest returns
|
|
225
|
+
// {services: []}, so the doc is well-formed-but-empty rather than a
|
|
226
|
+
// network-error-looking 404.
|
|
227
|
+
const h = makeHarness();
|
|
228
|
+
try {
|
|
229
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
230
|
+
req("/.well-known/parachute.json"),
|
|
231
|
+
);
|
|
232
|
+
expect(res.status).toBe(200);
|
|
98
233
|
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
234
|
+
expect(await res.json()).toEqual({ vaults: [], services: [] });
|
|
235
|
+
} finally {
|
|
236
|
+
h.cleanup();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("canonicalOrigin uses configured issuer when present", async () => {
|
|
241
|
+
const h = makeHarness();
|
|
242
|
+
try {
|
|
243
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
244
|
+
const res = await hubFetch(h.dir, {
|
|
245
|
+
manifestPath: h.manifestPath,
|
|
246
|
+
issuer: "https://hub.example",
|
|
247
|
+
})(req("/.well-known/parachute.json"));
|
|
248
|
+
const body = (await res.json()) as { vaults: Array<{ url: string }> };
|
|
249
|
+
expect(body.vaults[0]?.url).toBe("https://hub.example/vault/default");
|
|
250
|
+
} finally {
|
|
251
|
+
h.cleanup();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Same fallback shape as the OAuth handlers: when the hub isn't started
|
|
256
|
+
// with `--issuer` (local dev, direct loopback hit), use the request's own
|
|
257
|
+
// origin so the doc is still self-consistent.
|
|
258
|
+
test("canonicalOrigin falls back to the request origin when no issuer is configured", async () => {
|
|
259
|
+
const h = makeHarness();
|
|
260
|
+
try {
|
|
261
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
262
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
263
|
+
new Request("http://127.0.0.1:1939/.well-known/parachute.json"),
|
|
264
|
+
);
|
|
265
|
+
const body = (await res.json()) as { vaults: Array<{ url: string }> };
|
|
266
|
+
expect(body.vaults[0]?.url).toBe("http://127.0.0.1:1939/vault/default");
|
|
267
|
+
} finally {
|
|
268
|
+
h.cleanup();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("malformed services.json returns 500 + CORS, not a crash", async () => {
|
|
273
|
+
const h = makeHarness();
|
|
274
|
+
try {
|
|
275
|
+
writeFileSync(h.manifestPath, "{ not json");
|
|
276
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
277
|
+
req("/.well-known/parachute.json"),
|
|
278
|
+
);
|
|
279
|
+
expect(res.status).toBe(500);
|
|
280
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
281
|
+
const body = (await res.json()) as { error: string };
|
|
282
|
+
expect(body.error).toContain("well-known build failed");
|
|
99
283
|
} finally {
|
|
100
284
|
h.cleanup();
|
|
101
285
|
}
|
|
@@ -105,7 +289,7 @@ describe("hubFetch routing", () => {
|
|
|
105
289
|
const h = makeHarness();
|
|
106
290
|
try {
|
|
107
291
|
writeFileSync(join(h.dir, "hub.html"), "<html/>");
|
|
108
|
-
const res = hubFetch(h.dir)(req("/nope"));
|
|
292
|
+
const res = await hubFetch(h.dir)(req("/nope"));
|
|
109
293
|
expect(res.status).toBe(404);
|
|
110
294
|
} finally {
|
|
111
295
|
h.cleanup();
|
|
@@ -116,18 +300,350 @@ describe("hubFetch routing", () => {
|
|
|
116
300
|
const h = makeHarness();
|
|
117
301
|
try {
|
|
118
302
|
// dir exists but no files in it
|
|
119
|
-
const res = hubFetch(h.dir)(req("/"));
|
|
303
|
+
const res = await hubFetch(h.dir)(req("/"));
|
|
120
304
|
expect(res.status).toBe(404);
|
|
121
305
|
} finally {
|
|
122
306
|
h.cleanup();
|
|
123
307
|
}
|
|
124
308
|
});
|
|
125
309
|
|
|
126
|
-
test("
|
|
310
|
+
test("/.well-known/jwks.json returns the JWKS from the live db", async () => {
|
|
127
311
|
const h = makeHarness();
|
|
128
312
|
try {
|
|
129
|
-
const
|
|
130
|
-
|
|
313
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
314
|
+
try {
|
|
315
|
+
const k = rotateSigningKey(db);
|
|
316
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/.well-known/jwks.json"));
|
|
317
|
+
expect(res.status).toBe(200);
|
|
318
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
319
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
320
|
+
const body = (await res.json()) as { keys: Array<{ kid: string; alg: string; n: string }> };
|
|
321
|
+
expect(body.keys.length).toBe(1);
|
|
322
|
+
expect(body.keys[0]?.kid).toBe(k.kid);
|
|
323
|
+
expect(body.keys[0]?.alg).toBe("RS256");
|
|
324
|
+
expect(body.keys[0]?.n.length).toBeGreaterThan(0);
|
|
325
|
+
} finally {
|
|
326
|
+
db.close();
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
h.cleanup();
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("OPTIONS preflight on /.well-known/jwks.json returns 204 + CORS without touching the db", async () => {
|
|
334
|
+
const h = makeHarness();
|
|
335
|
+
try {
|
|
336
|
+
// Pass a getDb that throws — preflight must not invoke it.
|
|
337
|
+
const res = await hubFetch(h.dir, {
|
|
338
|
+
getDb: () => {
|
|
339
|
+
throw new Error("getDb should not be called for OPTIONS");
|
|
340
|
+
},
|
|
341
|
+
})(req("/.well-known/jwks.json", { method: "OPTIONS" }));
|
|
342
|
+
expect(res.status).toBe(204);
|
|
343
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
344
|
+
expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
|
|
345
|
+
} finally {
|
|
346
|
+
h.cleanup();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("/.well-known/jwks.json returns 503 + CORS when db is not configured", async () => {
|
|
351
|
+
const h = makeHarness();
|
|
352
|
+
try {
|
|
353
|
+
const res = await hubFetch(h.dir)(req("/.well-known/jwks.json"));
|
|
354
|
+
expect(res.status).toBe(503);
|
|
355
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
356
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
357
|
+
} finally {
|
|
358
|
+
h.cleanup();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("/.well-known/jwks.json on an empty db returns {keys: []}", async () => {
|
|
363
|
+
const h = makeHarness();
|
|
364
|
+
try {
|
|
365
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
366
|
+
try {
|
|
367
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/.well-known/jwks.json"));
|
|
368
|
+
expect(res.status).toBe(200);
|
|
369
|
+
expect(await res.json()).toEqual({ keys: [] });
|
|
370
|
+
} finally {
|
|
371
|
+
db.close();
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
h.cleanup();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("/.well-known/oauth-authorization-server returns RFC 8414 metadata + CORS", async () => {
|
|
379
|
+
const h = makeHarness();
|
|
380
|
+
try {
|
|
381
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
382
|
+
try {
|
|
383
|
+
const res = await hubFetch(h.dir, {
|
|
384
|
+
getDb: () => db,
|
|
385
|
+
issuer: "https://hub.example",
|
|
386
|
+
})(req("/.well-known/oauth-authorization-server"));
|
|
387
|
+
expect(res.status).toBe(200);
|
|
388
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
389
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
390
|
+
expect(body.issuer).toBe("https://hub.example");
|
|
391
|
+
expect(body.authorization_endpoint).toBe("https://hub.example/oauth/authorize");
|
|
392
|
+
expect(body.code_challenge_methods_supported).toEqual(["S256"]);
|
|
393
|
+
} finally {
|
|
394
|
+
db.close();
|
|
395
|
+
}
|
|
396
|
+
} finally {
|
|
397
|
+
h.cleanup();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// SPA mounts after hub#168-realignment:
|
|
402
|
+
// /vault — primary (vault list, NewVault, etc.)
|
|
403
|
+
// /hub — back-compat (only /hub/permissions; /hub/vaults* is 301'd
|
|
404
|
+
// below, /hub/ root falls through to the SPA's 404 route)
|
|
405
|
+
//
|
|
406
|
+
// Same bundle at both mounts. Build base is /vault/, so asset URLs are
|
|
407
|
+
// origin-absolute and resolve regardless of which mount served the HTML.
|
|
408
|
+
|
|
409
|
+
test("/vault serves index.html when the SPA bundle exists", async () => {
|
|
410
|
+
const h = makeHarness();
|
|
411
|
+
try {
|
|
412
|
+
const dist = join(h.dir, "dist");
|
|
413
|
+
mkdirIfMissing(dist);
|
|
414
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
415
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
416
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
417
|
+
req("/vault"),
|
|
418
|
+
);
|
|
419
|
+
expect(res.status).toBe(200);
|
|
420
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
421
|
+
expect(await res.text()).toContain("<div id=root>");
|
|
422
|
+
} finally {
|
|
423
|
+
h.cleanup();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("/vault/new (client-side route, no matching vault) falls back to index.html", async () => {
|
|
428
|
+
// Routing-order check: `new` isn't a known vault, so proxyToVault
|
|
429
|
+
// returns undefined and we fall through to the SPA shell. The router
|
|
430
|
+
// takes over client-side and renders the NewVault form.
|
|
431
|
+
const h = makeHarness();
|
|
432
|
+
try {
|
|
433
|
+
const dist = join(h.dir, "dist");
|
|
434
|
+
mkdirIfMissing(dist);
|
|
435
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
436
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
437
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
438
|
+
req("/vault/new"),
|
|
439
|
+
);
|
|
440
|
+
expect(res.status).toBe(200);
|
|
441
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
442
|
+
expect(await res.text()).toContain("<div id=root>");
|
|
443
|
+
} finally {
|
|
444
|
+
h.cleanup();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("/vault/assets/*.js is served with the matching content-type", async () => {
|
|
449
|
+
const h = makeHarness();
|
|
450
|
+
try {
|
|
451
|
+
const dist = join(h.dir, "dist");
|
|
452
|
+
const assets = join(dist, "assets");
|
|
453
|
+
mkdirIfMissing(dist);
|
|
454
|
+
mkdirIfMissing(assets);
|
|
455
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html>");
|
|
456
|
+
writeFileSync(join(assets, "main.js"), "console.log('hi');");
|
|
457
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
458
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
459
|
+
req("/vault/assets/main.js"),
|
|
460
|
+
);
|
|
461
|
+
expect(res.status).toBe(200);
|
|
462
|
+
expect(res.headers.get("content-type")).toBe("application/javascript; charset=utf-8");
|
|
463
|
+
expect(await res.text()).toContain("console.log");
|
|
464
|
+
} finally {
|
|
465
|
+
h.cleanup();
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("/vault/* returns 503 with build hint when dist is missing", async () => {
|
|
470
|
+
const h = makeHarness();
|
|
471
|
+
try {
|
|
472
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
473
|
+
const res = await hubFetch(h.dir, {
|
|
474
|
+
spaDistDir: join(h.dir, "missing"),
|
|
475
|
+
manifestPath: h.manifestPath,
|
|
476
|
+
})(req("/vault"));
|
|
477
|
+
expect(res.status).toBe(503);
|
|
478
|
+
expect(await res.text()).toContain("bun run build");
|
|
479
|
+
} finally {
|
|
480
|
+
h.cleanup();
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("/vault rejects non-GET methods with 405", async () => {
|
|
485
|
+
const h = makeHarness();
|
|
486
|
+
try {
|
|
487
|
+
const dist = join(h.dir, "dist");
|
|
488
|
+
mkdirIfMissing(dist);
|
|
489
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html>");
|
|
490
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/vault", { method: "POST" }));
|
|
491
|
+
expect(res.status).toBe(405);
|
|
492
|
+
} finally {
|
|
493
|
+
h.cleanup();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("/hub/permissions serves the SPA shell (back-compat mount)", async () => {
|
|
498
|
+
const h = makeHarness();
|
|
499
|
+
try {
|
|
500
|
+
const dist = join(h.dir, "dist");
|
|
501
|
+
mkdirIfMissing(dist);
|
|
502
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
503
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/hub/permissions"));
|
|
504
|
+
expect(res.status).toBe(200);
|
|
505
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
506
|
+
expect(await res.text()).toContain("<div id=root>");
|
|
507
|
+
} finally {
|
|
508
|
+
h.cleanup();
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("/hub/* returns 503 with build hint when dist is missing", async () => {
|
|
513
|
+
const h = makeHarness();
|
|
514
|
+
try {
|
|
515
|
+
const res = await hubFetch(h.dir, { spaDistDir: join(h.dir, "missing") })(
|
|
516
|
+
req("/hub/permissions"),
|
|
517
|
+
);
|
|
518
|
+
expect(res.status).toBe(503);
|
|
519
|
+
expect(await res.text()).toContain("bun run build");
|
|
520
|
+
} finally {
|
|
521
|
+
h.cleanup();
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("/hub rejects non-GET methods with 405", async () => {
|
|
526
|
+
const h = makeHarness();
|
|
527
|
+
try {
|
|
528
|
+
const dist = join(h.dir, "dist");
|
|
529
|
+
mkdirIfMissing(dist);
|
|
530
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html>");
|
|
531
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(
|
|
532
|
+
req("/hub/permissions", { method: "POST" }),
|
|
533
|
+
);
|
|
534
|
+
expect(res.status).toBe(405);
|
|
535
|
+
} finally {
|
|
536
|
+
h.cleanup();
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("/hub/vaults issues a 301 redirect to /vault", async () => {
|
|
541
|
+
// Back-compat for bookmarks. The exact mount path used to be /hub/vaults
|
|
542
|
+
// before the realignment; permanent redirect keeps stale URLs working.
|
|
543
|
+
const h = makeHarness();
|
|
544
|
+
try {
|
|
545
|
+
const res = await hubFetch(h.dir)(req("/hub/vaults"));
|
|
546
|
+
expect(res.status).toBe(301);
|
|
547
|
+
expect(res.headers.get("location")).toBe("/vault");
|
|
548
|
+
} finally {
|
|
549
|
+
h.cleanup();
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("/hub/vaults/new redirects to /vault/new", async () => {
|
|
554
|
+
const h = makeHarness();
|
|
555
|
+
try {
|
|
556
|
+
const res = await hubFetch(h.dir)(req("/hub/vaults/new"));
|
|
557
|
+
expect(res.status).toBe(301);
|
|
558
|
+
expect(res.headers.get("location")).toBe("/vault/new");
|
|
559
|
+
} finally {
|
|
560
|
+
h.cleanup();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("/hub/vaults/* preserves the query string in the redirect", async () => {
|
|
565
|
+
const h = makeHarness();
|
|
566
|
+
try {
|
|
567
|
+
const res = await hubFetch(h.dir)(req("/hub/vaults/foo?bar=1&baz=2"));
|
|
568
|
+
expect(res.status).toBe(301);
|
|
569
|
+
expect(res.headers.get("location")).toBe("/vault/foo?bar=1&baz=2");
|
|
570
|
+
} finally {
|
|
571
|
+
h.cleanup();
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("/oauth/authorize without configured db returns 503", async () => {
|
|
576
|
+
const h = makeHarness();
|
|
577
|
+
try {
|
|
578
|
+
const res = await hubFetch(h.dir)(req("/oauth/authorize?client_id=x"));
|
|
579
|
+
expect(res.status).toBe(503);
|
|
580
|
+
} finally {
|
|
581
|
+
h.cleanup();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("every DB-dependent route returns 503 when getDb is absent (closes #139)", async () => {
|
|
586
|
+
const h = makeHarness();
|
|
587
|
+
try {
|
|
588
|
+
const fetch = hubFetch(h.dir);
|
|
589
|
+
const cases: Array<[string, RequestInit]> = [
|
|
590
|
+
["/oauth/token", { method: "POST" }],
|
|
591
|
+
["/oauth/register", { method: "POST" }],
|
|
592
|
+
["/oauth/revoke", { method: "POST" }],
|
|
593
|
+
["/vaults", { method: "POST" }],
|
|
594
|
+
["/admin/login", { method: "POST" }],
|
|
595
|
+
["/admin/logout", { method: "POST" }],
|
|
596
|
+
["/admin/config", { method: "GET" }],
|
|
597
|
+
["/admin/config/example", { method: "POST" }],
|
|
598
|
+
["/admin/host-admin-token", { method: "GET" }],
|
|
599
|
+
];
|
|
600
|
+
for (const [path, init] of cases) {
|
|
601
|
+
const res = await fetch(req(path, init));
|
|
602
|
+
expect(res.status).toBe(503);
|
|
603
|
+
}
|
|
604
|
+
} finally {
|
|
605
|
+
h.cleanup();
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("/oauth/token rejects non-POST with 405", async () => {
|
|
610
|
+
const h = makeHarness();
|
|
611
|
+
try {
|
|
612
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
613
|
+
try {
|
|
614
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
615
|
+
req("/oauth/token", { method: "GET" }),
|
|
616
|
+
);
|
|
617
|
+
expect(res.status).toBe(405);
|
|
618
|
+
} finally {
|
|
619
|
+
db.close();
|
|
620
|
+
}
|
|
621
|
+
} finally {
|
|
622
|
+
h.cleanup();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("/oauth/register accepts POST with JSON body", async () => {
|
|
627
|
+
const h = makeHarness();
|
|
628
|
+
try {
|
|
629
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
630
|
+
try {
|
|
631
|
+
const res = await hubFetch(h.dir, {
|
|
632
|
+
getDb: () => db,
|
|
633
|
+
issuer: "https://hub.example",
|
|
634
|
+
})(
|
|
635
|
+
req("/oauth/register", {
|
|
636
|
+
method: "POST",
|
|
637
|
+
headers: { "content-type": "application/json" },
|
|
638
|
+
body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
|
|
639
|
+
}),
|
|
640
|
+
);
|
|
641
|
+
expect(res.status).toBe(201);
|
|
642
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
643
|
+
expect(typeof body.client_id).toBe("string");
|
|
644
|
+
} finally {
|
|
645
|
+
db.close();
|
|
646
|
+
}
|
|
131
647
|
} finally {
|
|
132
648
|
h.cleanup();
|
|
133
649
|
}
|
|
@@ -137,8 +653,12 @@ describe("hubFetch routing", () => {
|
|
|
137
653
|
const h = makeHarness();
|
|
138
654
|
try {
|
|
139
655
|
writeFileSync(join(h.dir, "hub.html"), "<html>live</html>");
|
|
140
|
-
|
|
141
|
-
const server = Bun.serve({
|
|
656
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
657
|
+
const server = Bun.serve({
|
|
658
|
+
port: 0,
|
|
659
|
+
hostname: "127.0.0.1",
|
|
660
|
+
fetch: hubFetch(h.dir, { manifestPath: h.manifestPath }),
|
|
661
|
+
});
|
|
142
662
|
try {
|
|
143
663
|
const base = `http://127.0.0.1:${server.port}`;
|
|
144
664
|
const r1 = await fetch(`${base}/`);
|
|
@@ -146,7 +666,7 @@ describe("hubFetch routing", () => {
|
|
|
146
666
|
expect(await r1.text()).toBe("<html>live</html>");
|
|
147
667
|
const r2 = await fetch(`${base}/.well-known/parachute.json`);
|
|
148
668
|
expect(r2.headers.get("content-type")).toBe("application/json");
|
|
149
|
-
expect(await r2.json()).toEqual({ services: [] });
|
|
669
|
+
expect(await r2.json()).toEqual({ vaults: [], services: [] });
|
|
150
670
|
} finally {
|
|
151
671
|
server.stop(true);
|
|
152
672
|
}
|
|
@@ -155,3 +675,441 @@ describe("hubFetch routing", () => {
|
|
|
155
675
|
}
|
|
156
676
|
});
|
|
157
677
|
});
|
|
678
|
+
|
|
679
|
+
describe("findVaultUpstream (#144)", () => {
|
|
680
|
+
test("matches a single-path vault on its exact mount", () => {
|
|
681
|
+
const services: ServiceEntry[] = [vaultEntry("default")];
|
|
682
|
+
const m = findVaultUpstream(services, "/vault/default");
|
|
683
|
+
expect(m?.mount).toBe("/vault/default");
|
|
684
|
+
expect(m?.port).toBe(1940);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("matches a vault on any descendant pathname", () => {
|
|
688
|
+
const services: ServiceEntry[] = [vaultEntry("default")];
|
|
689
|
+
expect(findVaultUpstream(services, "/vault/default/health")?.mount).toBe("/vault/default");
|
|
690
|
+
expect(findVaultUpstream(services, "/vault/default/notes/abc")?.mount).toBe("/vault/default");
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("returns undefined when no vault claims the path", () => {
|
|
694
|
+
const services: ServiceEntry[] = [vaultEntry("default")];
|
|
695
|
+
expect(findVaultUpstream(services, "/vault/missing")).toBeUndefined();
|
|
696
|
+
expect(findVaultUpstream(services, "/vault/missing/health")).toBeUndefined();
|
|
697
|
+
expect(findVaultUpstream(services, "/notes/foo")).toBeUndefined();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("non-vault services are ignored even when their path begins with /vault/", () => {
|
|
701
|
+
const odd: ServiceEntry = {
|
|
702
|
+
name: "parachute-vaultkeeper", // not a vault — see isVaultEntry
|
|
703
|
+
port: 9999,
|
|
704
|
+
paths: ["/vault/keeper"],
|
|
705
|
+
health: "/health",
|
|
706
|
+
version: "0.0.1",
|
|
707
|
+
};
|
|
708
|
+
expect(findVaultUpstream([odd], "/vault/keeper")).toBeUndefined();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("multi-path single ServiceEntry — both paths route to the same backend", () => {
|
|
712
|
+
// Post-#179/vault#208: one parachute-vault backend hosts every instance,
|
|
713
|
+
// expressed as a single ServiceEntry with multiple paths. The lookup must
|
|
714
|
+
// pick the matching path for each request.
|
|
715
|
+
const multi: ServiceEntry = {
|
|
716
|
+
name: "parachute-vault",
|
|
717
|
+
port: 1940,
|
|
718
|
+
paths: ["/vault/default", "/vault/techne"],
|
|
719
|
+
health: "/vault/default/health",
|
|
720
|
+
version: "0.4.0",
|
|
721
|
+
};
|
|
722
|
+
expect(findVaultUpstream([multi], "/vault/default/notes")?.mount).toBe("/vault/default");
|
|
723
|
+
expect(findVaultUpstream([multi], "/vault/techne/notes")?.mount).toBe("/vault/techne");
|
|
724
|
+
expect(findVaultUpstream([multi], "/vault/other")).toBeUndefined();
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("longest mount wins on overlapping prefixes", () => {
|
|
728
|
+
// Pathological but representable: a vault claims `/vault` AND another
|
|
729
|
+
// claims `/vault/inner`. Request for `/vault/inner/x` should pick the
|
|
730
|
+
// more specific mount.
|
|
731
|
+
const a: ServiceEntry = {
|
|
732
|
+
name: "parachute-vault",
|
|
733
|
+
port: 1940,
|
|
734
|
+
paths: ["/vault"],
|
|
735
|
+
health: "/health",
|
|
736
|
+
version: "0.4.0",
|
|
737
|
+
};
|
|
738
|
+
const b: ServiceEntry = {
|
|
739
|
+
name: "parachute-vault-inner",
|
|
740
|
+
port: 1941,
|
|
741
|
+
paths: ["/vault/inner"],
|
|
742
|
+
health: "/health",
|
|
743
|
+
version: "0.4.0",
|
|
744
|
+
};
|
|
745
|
+
const m = findVaultUpstream([a, b], "/vault/inner/x");
|
|
746
|
+
expect(m?.mount).toBe("/vault/inner");
|
|
747
|
+
expect(m?.port).toBe(1941);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
752
|
+
// The bug: tailscale serve config is built once at expose-time, so a vault
|
|
753
|
+
// created later was unreachable on the tailnet (404 from the `/` fallback)
|
|
754
|
+
// until the user re-ran `parachute expose`. The fix puts a single `/vault/`
|
|
755
|
+
// tailscale mount → hub, and hub picks the specific vault per request.
|
|
756
|
+
// These tests verify the hub-side picker works with a real upstream.
|
|
757
|
+
|
|
758
|
+
function startUpstream(replyTag: string): { port: number; stop: () => void } {
|
|
759
|
+
const server = Bun.serve({
|
|
760
|
+
port: 0,
|
|
761
|
+
hostname: "127.0.0.1",
|
|
762
|
+
fetch: async (req) => {
|
|
763
|
+
const u = new URL(req.url);
|
|
764
|
+
// Echo enough metadata for tests to verify path + method + body
|
|
765
|
+
// arrive intact end-to-end.
|
|
766
|
+
const body = req.body ? await req.text() : "";
|
|
767
|
+
return new Response(
|
|
768
|
+
JSON.stringify({
|
|
769
|
+
tag: replyTag,
|
|
770
|
+
method: req.method,
|
|
771
|
+
pathname: u.pathname,
|
|
772
|
+
search: u.search,
|
|
773
|
+
body,
|
|
774
|
+
}),
|
|
775
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
776
|
+
);
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
// server.port is `number | undefined` in Bun's types, but `Bun.serve()`
|
|
780
|
+
// returns synchronously with the bound port — non-null assertion is safe.
|
|
781
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
test("proxies a /vault/<name>/* request to the matching upstream", async () => {
|
|
785
|
+
const h = makeHarness();
|
|
786
|
+
const upstream = startUpstream("default-vault");
|
|
787
|
+
try {
|
|
788
|
+
writeManifest(
|
|
789
|
+
{
|
|
790
|
+
services: [
|
|
791
|
+
{
|
|
792
|
+
name: "parachute-vault",
|
|
793
|
+
port: upstream.port,
|
|
794
|
+
paths: ["/vault/default"],
|
|
795
|
+
health: "/vault/default/health",
|
|
796
|
+
version: "0.4.0",
|
|
797
|
+
},
|
|
798
|
+
],
|
|
799
|
+
},
|
|
800
|
+
h.manifestPath,
|
|
801
|
+
);
|
|
802
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
803
|
+
const res = await fetcher(req("/vault/default/health?ok=1"));
|
|
804
|
+
expect(res.status).toBe(200);
|
|
805
|
+
const body = (await res.json()) as { tag: string; pathname: string; search: string };
|
|
806
|
+
expect(body.tag).toBe("default-vault");
|
|
807
|
+
// Path is preserved end-to-end — vault since paraclaw#18 expects requests
|
|
808
|
+
// at `/vault/<name>/...` rather than stripped.
|
|
809
|
+
expect(body.pathname).toBe("/vault/default/health");
|
|
810
|
+
expect(body.search).toBe("?ok=1");
|
|
811
|
+
} finally {
|
|
812
|
+
upstream.stop();
|
|
813
|
+
h.cleanup();
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("a freshly-added vault is routable on the very next request, no restart", async () => {
|
|
818
|
+
// The whole reason hub#144 exists: `parachute vault create techne` writes
|
|
819
|
+
// services.json but the user shouldn't need to re-expose to reach the new
|
|
820
|
+
// vault. Read-on-each-request makes this work.
|
|
821
|
+
const h = makeHarness();
|
|
822
|
+
const u1 = startUpstream("default-vault");
|
|
823
|
+
const u2 = startUpstream("techne-vault");
|
|
824
|
+
try {
|
|
825
|
+
writeManifest(
|
|
826
|
+
{
|
|
827
|
+
services: [
|
|
828
|
+
{
|
|
829
|
+
name: "parachute-vault",
|
|
830
|
+
port: u1.port,
|
|
831
|
+
paths: ["/vault/default"],
|
|
832
|
+
health: "/vault/default/health",
|
|
833
|
+
version: "0.4.0",
|
|
834
|
+
},
|
|
835
|
+
],
|
|
836
|
+
},
|
|
837
|
+
h.manifestPath,
|
|
838
|
+
);
|
|
839
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
840
|
+
|
|
841
|
+
// Before: /vault/techne 404s — no entry yet.
|
|
842
|
+
const before = await fetcher(req("/vault/techne/health"));
|
|
843
|
+
expect(before.status).toBe(404);
|
|
844
|
+
|
|
845
|
+
// Simulate `parachute vault create techne` — multi-path single
|
|
846
|
+
// ServiceEntry shape is what vault writes today.
|
|
847
|
+
writeManifest(
|
|
848
|
+
{
|
|
849
|
+
services: [
|
|
850
|
+
{
|
|
851
|
+
name: "parachute-vault",
|
|
852
|
+
port: u2.port,
|
|
853
|
+
paths: ["/vault/default", "/vault/techne"],
|
|
854
|
+
health: "/vault/default/health",
|
|
855
|
+
version: "0.4.0",
|
|
856
|
+
},
|
|
857
|
+
],
|
|
858
|
+
},
|
|
859
|
+
h.manifestPath,
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
// After: same hubFetch instance, no restart, /vault/techne is reachable.
|
|
863
|
+
const after = await fetcher(req("/vault/techne/health"));
|
|
864
|
+
expect(after.status).toBe(200);
|
|
865
|
+
const body = (await after.json()) as { tag: string; pathname: string };
|
|
866
|
+
expect(body.tag).toBe("techne-vault");
|
|
867
|
+
expect(body.pathname).toBe("/vault/techne/health");
|
|
868
|
+
} finally {
|
|
869
|
+
u1.stop();
|
|
870
|
+
u2.stop();
|
|
871
|
+
h.cleanup();
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test("a removed vault returns 404 from the hub on the next request", async () => {
|
|
876
|
+
const h = makeHarness();
|
|
877
|
+
const upstream = startUpstream("default-vault");
|
|
878
|
+
try {
|
|
879
|
+
writeManifest(
|
|
880
|
+
{
|
|
881
|
+
services: [
|
|
882
|
+
{
|
|
883
|
+
name: "parachute-vault",
|
|
884
|
+
port: upstream.port,
|
|
885
|
+
paths: ["/vault/default"],
|
|
886
|
+
health: "/vault/default/health",
|
|
887
|
+
version: "0.4.0",
|
|
888
|
+
},
|
|
889
|
+
],
|
|
890
|
+
},
|
|
891
|
+
h.manifestPath,
|
|
892
|
+
);
|
|
893
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
894
|
+
expect((await fetcher(req("/vault/default/health"))).status).toBe(200);
|
|
895
|
+
|
|
896
|
+
// Vault detached — services.json no longer mentions it.
|
|
897
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
898
|
+
const after = await fetcher(req("/vault/default/health"));
|
|
899
|
+
expect(after.status).toBe(404);
|
|
900
|
+
} finally {
|
|
901
|
+
upstream.stop();
|
|
902
|
+
h.cleanup();
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
test("preserves method + body for POSTs", async () => {
|
|
907
|
+
const h = makeHarness();
|
|
908
|
+
const upstream = startUpstream("default-vault");
|
|
909
|
+
try {
|
|
910
|
+
writeManifest(
|
|
911
|
+
{
|
|
912
|
+
services: [
|
|
913
|
+
{
|
|
914
|
+
name: "parachute-vault",
|
|
915
|
+
port: upstream.port,
|
|
916
|
+
paths: ["/vault/default"],
|
|
917
|
+
health: "/vault/default/health",
|
|
918
|
+
version: "0.4.0",
|
|
919
|
+
},
|
|
920
|
+
],
|
|
921
|
+
},
|
|
922
|
+
h.manifestPath,
|
|
923
|
+
);
|
|
924
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
925
|
+
const res = await fetcher(
|
|
926
|
+
req("/vault/default/notes", {
|
|
927
|
+
method: "POST",
|
|
928
|
+
headers: { "content-type": "application/json" },
|
|
929
|
+
body: JSON.stringify({ content: "hello" }),
|
|
930
|
+
}),
|
|
931
|
+
);
|
|
932
|
+
expect(res.status).toBe(200);
|
|
933
|
+
const body = (await res.json()) as { method: string; body: string };
|
|
934
|
+
expect(body.method).toBe("POST");
|
|
935
|
+
expect(JSON.parse(body.body)).toEqual({ content: "hello" });
|
|
936
|
+
} finally {
|
|
937
|
+
upstream.stop();
|
|
938
|
+
h.cleanup();
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("returns 502 when the matching vault upstream is unreachable", async () => {
|
|
943
|
+
// Vault is in services.json but the port has nothing listening — vault
|
|
944
|
+
// crashed, port shifted, or the user is mid-restart. We owe the caller a
|
|
945
|
+
// useful error instead of a hang or a silent 404.
|
|
946
|
+
const h = makeHarness();
|
|
947
|
+
try {
|
|
948
|
+
writeManifest(
|
|
949
|
+
{
|
|
950
|
+
services: [
|
|
951
|
+
{
|
|
952
|
+
name: "parachute-vault",
|
|
953
|
+
// Bind a port + immediately release it so the proxy gets ECONNREFUSED.
|
|
954
|
+
port: await pickClosedPort(),
|
|
955
|
+
paths: ["/vault/default"],
|
|
956
|
+
health: "/vault/default/health",
|
|
957
|
+
version: "0.4.0",
|
|
958
|
+
},
|
|
959
|
+
],
|
|
960
|
+
},
|
|
961
|
+
h.manifestPath,
|
|
962
|
+
);
|
|
963
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
964
|
+
const res = await fetcher(req("/vault/default/health"));
|
|
965
|
+
expect(res.status).toBe(502);
|
|
966
|
+
const body = (await res.json()) as { error: string };
|
|
967
|
+
expect(body.error).toContain("vault upstream unreachable");
|
|
968
|
+
} finally {
|
|
969
|
+
h.cleanup();
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
test("non-vault path inside /vault/ namespace falls through to 404", async () => {
|
|
974
|
+
// `/vault/keeper` belongs to no installed service — no longest-prefix
|
|
975
|
+
// match, no proxy attempt, hub answers with the generic 404.
|
|
976
|
+
const h = makeHarness();
|
|
977
|
+
const upstream = startUpstream("default-vault");
|
|
978
|
+
try {
|
|
979
|
+
writeManifest(
|
|
980
|
+
{
|
|
981
|
+
services: [
|
|
982
|
+
{
|
|
983
|
+
name: "parachute-vault",
|
|
984
|
+
port: upstream.port,
|
|
985
|
+
paths: ["/vault/default"],
|
|
986
|
+
health: "/vault/default/health",
|
|
987
|
+
version: "0.4.0",
|
|
988
|
+
},
|
|
989
|
+
],
|
|
990
|
+
},
|
|
991
|
+
h.manifestPath,
|
|
992
|
+
);
|
|
993
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
994
|
+
const res = await fetcher(req("/vault/keeper/health"));
|
|
995
|
+
expect(res.status).toBe(404);
|
|
996
|
+
} finally {
|
|
997
|
+
upstream.stop();
|
|
998
|
+
h.cleanup();
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test("single-segment /vault/<name> picks proxy when registered, SPA shell when not", async () => {
|
|
1003
|
+
// Two cases share one fixture so the contrast is explicit:
|
|
1004
|
+
// - `/vault/default` is registered → proxy answers (200, JSON tag).
|
|
1005
|
+
// - `/vault/nonexistent` has no match → falls through to the SPA
|
|
1006
|
+
// shell (200, text/html). The SPA's :name route renders client-side
|
|
1007
|
+
// and shows a 404 in-app, but at the wire it's the shell.
|
|
1008
|
+
// This is the routing-order seam #173 introduced — proxy is consulted
|
|
1009
|
+
// before the SPA fallback, and the fallback only triggers when no
|
|
1010
|
+
// vault claims the path.
|
|
1011
|
+
const h = makeHarness();
|
|
1012
|
+
const upstream = startUpstream("default-vault");
|
|
1013
|
+
try {
|
|
1014
|
+
const dist = join(h.dir, "dist");
|
|
1015
|
+
mkdirIfMissing(dist);
|
|
1016
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
1017
|
+
writeManifest(
|
|
1018
|
+
{
|
|
1019
|
+
services: [
|
|
1020
|
+
{
|
|
1021
|
+
name: "parachute-vault",
|
|
1022
|
+
port: upstream.port,
|
|
1023
|
+
paths: ["/vault/default"],
|
|
1024
|
+
health: "/vault/default/health",
|
|
1025
|
+
version: "0.4.0",
|
|
1026
|
+
},
|
|
1027
|
+
],
|
|
1028
|
+
},
|
|
1029
|
+
h.manifestPath,
|
|
1030
|
+
);
|
|
1031
|
+
const fetcher = hubFetch(h.dir, {
|
|
1032
|
+
spaDistDir: dist,
|
|
1033
|
+
manifestPath: h.manifestPath,
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const proxied = await fetcher(req("/vault/default"));
|
|
1037
|
+
expect(proxied.status).toBe(200);
|
|
1038
|
+
expect(proxied.headers.get("content-type")).toContain("application/json");
|
|
1039
|
+
const body = (await proxied.json()) as { tag: string; pathname: string };
|
|
1040
|
+
expect(body.tag).toBe("default-vault");
|
|
1041
|
+
expect(body.pathname).toBe("/vault/default");
|
|
1042
|
+
|
|
1043
|
+
const shelled = await fetcher(req("/vault/nonexistent"));
|
|
1044
|
+
expect(shelled.status).toBe(200);
|
|
1045
|
+
expect(shelled.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
1046
|
+
expect(await shelled.text()).toContain("<div id=root>");
|
|
1047
|
+
} finally {
|
|
1048
|
+
upstream.stop();
|
|
1049
|
+
h.cleanup();
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
/** Find a port that no one is listening on by binding briefly and releasing. */
|
|
1055
|
+
async function pickClosedPort(): Promise<number> {
|
|
1056
|
+
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
|
|
1057
|
+
const port = s.port as number;
|
|
1058
|
+
s.stop(true);
|
|
1059
|
+
return port;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const HUB_SERVER_PATH = fileURLToPath(new URL("../hub-server.ts", import.meta.url));
|
|
1063
|
+
|
|
1064
|
+
async function pollUntil(check: () => boolean, timeoutMs = 3000): Promise<boolean> {
|
|
1065
|
+
const deadline = Date.now() + timeoutMs;
|
|
1066
|
+
while (Date.now() < deadline) {
|
|
1067
|
+
if (check()) return true;
|
|
1068
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
1069
|
+
}
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
describe("hub-server.ts startup PID/port registration (#148)", () => {
|
|
1074
|
+
test("manual `bun src/hub-server.ts` writes hub.pid and hub.port; SIGTERM clears them", async () => {
|
|
1075
|
+
const port = await pickClosedPort();
|
|
1076
|
+
const configDir = mkdtempSync(join(tmpdir(), "pcli-hub-startup-"));
|
|
1077
|
+
const wellKnownDir = join(configDir, "well-known");
|
|
1078
|
+
const dbPath = join(configDir, "hub.db");
|
|
1079
|
+
|
|
1080
|
+
const proc = Bun.spawn(
|
|
1081
|
+
[
|
|
1082
|
+
process.execPath,
|
|
1083
|
+
HUB_SERVER_PATH,
|
|
1084
|
+
"--port",
|
|
1085
|
+
String(port),
|
|
1086
|
+
"--well-known-dir",
|
|
1087
|
+
wellKnownDir,
|
|
1088
|
+
"--db",
|
|
1089
|
+
dbPath,
|
|
1090
|
+
],
|
|
1091
|
+
{
|
|
1092
|
+
stdout: "pipe",
|
|
1093
|
+
stderr: "pipe",
|
|
1094
|
+
env: { ...process.env, PARACHUTE_HOME: configDir },
|
|
1095
|
+
},
|
|
1096
|
+
);
|
|
1097
|
+
const pidFile = pidPath(HUB_SVC, configDir);
|
|
1098
|
+
const portFile = hubPortPath(configDir);
|
|
1099
|
+
try {
|
|
1100
|
+
const ready = await pollUntil(() => existsSync(pidFile) && existsSync(portFile));
|
|
1101
|
+
expect(ready).toBe(true);
|
|
1102
|
+
expect(Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10)).toBe(proc.pid);
|
|
1103
|
+
expect(Number.parseInt(readFileSync(portFile, "utf8").trim(), 10)).toBe(port);
|
|
1104
|
+
proc.kill("SIGTERM");
|
|
1105
|
+
await proc.exited;
|
|
1106
|
+
// After SIGTERM the cleanup handler should have rm'd both files —
|
|
1107
|
+
// proves manual starts also play nice with `parachute expose` teardown.
|
|
1108
|
+
expect(existsSync(pidFile)).toBe(false);
|
|
1109
|
+
expect(existsSync(portFile)).toBe(false);
|
|
1110
|
+
} finally {
|
|
1111
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
1112
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
1113
|
+
}
|
|
1114
|
+
}, 10_000);
|
|
1115
|
+
});
|