@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.
Files changed (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. 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 { hubFetch } from "../hub-server.ts";
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 { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
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 serves JSON with application/json", async () => {
72
+ test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
48
73
  const h = makeHarness();
49
74
  try {
50
- writeFileSync(join(h.dir, "parachute.json"), '{"vaults":[]}\n');
51
- const res = hubFetch(h.dir)(req("/.well-known/parachute.json"));
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
- expect(await res.text()).toBe('{"vaults":[]}\n');
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
- writeFileSync(join(h.dir, "parachute.json"), '{"vaults":[]}\n');
69
- const res = hubFetch(h.dir)(req("/.well-known/parachute.json"));
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 parachute.json on disk — preflight must not depend on it.
82
- const res = hubFetch(h.dir)(req("/.well-known/parachute.json", { method: "OPTIONS" }));
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
- test("missing parachute.json still returns CORS headers on the 404", async () => {
92
- // If the response 404s without CORS, the browser treats it as a network
93
- // error and the consumer can't even tell the server is reachable.
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
- const res = hubFetch(h.dir)(req("/.well-known/parachute.json"));
97
- expect(res.status).toBe(404);
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("missing parachute.json returns 404 rather than crashing", async () => {
310
+ test("/.well-known/jwks.json returns the JWKS from the live db", async () => {
127
311
  const h = makeHarness();
128
312
  try {
129
- const res = hubFetch(h.dir)(req("/.well-known/parachute.json"));
130
- expect(res.status).toBe(404);
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
- writeFileSync(join(h.dir, "parachute.json"), '{"services":[]}');
141
- const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: hubFetch(h.dir) });
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
+ });