@openparachute/hub 0.5.7 → 0.5.10-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -9,6 +9,7 @@ import { findServiceUpstream, findVaultUpstream, hubFetch, layerOf } from "../hu
9
9
  import { pidPath } from "../process-state.ts";
10
10
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
11
11
  import { rotateSigningKey } from "../signing-keys.ts";
12
+ import { createUser } from "../users.ts";
12
13
 
13
14
  interface Harness {
14
15
  dir: string;
@@ -57,7 +58,7 @@ describe("hubFetch routing", () => {
57
58
  }
58
59
  });
59
60
 
60
- test("/hub.html serves the same file as /", async () => {
61
+ test("/hub.html serves the same file as / (no DB → static fallback)", async () => {
61
62
  const h = makeHarness();
62
63
  try {
63
64
  writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
@@ -69,6 +70,64 @@ describe("hubFetch routing", () => {
69
70
  }
70
71
  });
71
72
 
73
+ test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
74
+ // The dynamic path takes over from the static disk file the moment a
75
+ // DB is configured. With no session cookie, we still render — just
76
+ // with the "Sign in" affordance.
77
+ //
78
+ // hub#259 rc.6: requires an admin row to bypass the fresh-hub
79
+ // funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
80
+ // test continues to exercise the signed-out-but-setup-done branch.
81
+ const h = makeHarness();
82
+ try {
83
+ const db = openHubDb(hubDbPath(h.dir));
84
+ try {
85
+ const { createUser } = await import("../users.ts");
86
+ await createUser(db, "owner", "pw");
87
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
88
+ expect(res.status).toBe(200);
89
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
90
+ const body = await res.text();
91
+ expect(body).toContain('class="auth-indicator"');
92
+ expect(body).toContain("Sign in");
93
+ expect(body).not.toContain("Signed in as");
94
+ } finally {
95
+ db.close();
96
+ }
97
+ } finally {
98
+ h.cleanup();
99
+ }
100
+ });
101
+
102
+ test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
103
+ const h = makeHarness();
104
+ try {
105
+ const db = openHubDb(hubDbPath(h.dir));
106
+ try {
107
+ const { createUser } = await import("../users.ts");
108
+ const { createSession, buildSessionCookie, SESSION_TTL_MS } = await import(
109
+ "../sessions.ts"
110
+ );
111
+ const user = await createUser(db, "aaron", "pw");
112
+ const session = createSession(db, { userId: user.id });
113
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
114
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/", { headers: { cookie } }));
115
+ expect(res.status).toBe(200);
116
+ const body = await res.text();
117
+ expect(body).toContain("Signed in as");
118
+ expect(body).toContain("aaron");
119
+ expect(body).toContain('action="/logout"');
120
+ expect(body).toContain('name="__csrf"');
121
+ // CSRF cookie was minted on the response (no prior cookie present).
122
+ expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_csrf=");
123
+ } finally {
124
+ db.close();
125
+ }
126
+ } finally {
127
+ h.cleanup();
128
+ }
129
+ });
130
+
72
131
  test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
73
132
  const h = makeHarness();
74
133
  try {
@@ -106,6 +165,29 @@ describe("hubFetch routing", () => {
106
165
  }
107
166
  });
108
167
 
168
+ test("/.well-known/parachute.json sets cache-control: no-store (hub#268 Item 1)", async () => {
169
+ // The discovery page (/) fetches this doc and renders Service tiles
170
+ // from it. Without no-store, the browser HTTP cache returns the
171
+ // stale services list the next time the operator clicks back to /
172
+ // after installing a module via /admin/modules. The doc is built
173
+ // per-request anyway, so giving up cacheability has no real cost.
174
+ const h = makeHarness();
175
+ try {
176
+ writeManifest({ services: [] }, h.manifestPath);
177
+ const getRes = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
178
+ req("/.well-known/parachute.json"),
179
+ );
180
+ expect(getRes.headers.get("cache-control")).toBe("no-store");
181
+ // Preflight gets the same header (same corsHeaders object).
182
+ const optRes = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
183
+ req("/.well-known/parachute.json", { method: "OPTIONS" }),
184
+ );
185
+ expect(optRes.headers.get("cache-control")).toBe("no-store");
186
+ } finally {
187
+ h.cleanup();
188
+ }
189
+ });
190
+
109
191
  test("OPTIONS preflight on /.well-known/parachute.json returns 204 + CORS", async () => {
110
192
  const h = makeHarness();
111
193
  try {
@@ -194,6 +276,105 @@ describe("hubFetch routing", () => {
194
276
  }
195
277
  });
196
278
 
279
+ // Phase D consumer-side: each non-vault service entry's
280
+ // module.json:uiUrl + displayName ride through to doc.services. The
281
+ // discovery page (`/`) reads them to render data-driven Service tiles.
282
+ test("/.well-known/parachute.json surfaces uiUrl + displayName from non-vault module manifests", async () => {
283
+ const h = makeHarness();
284
+ try {
285
+ const notesEntry: ServiceEntry = {
286
+ name: "parachute-notes",
287
+ port: 5173,
288
+ paths: ["/notes"],
289
+ health: "/health",
290
+ version: "0.0.1",
291
+ installDir: "/fake/notes",
292
+ };
293
+ writeManifest({ services: [notesEntry] }, h.manifestPath);
294
+ const res = await hubFetch(h.dir, {
295
+ manifestPath: h.manifestPath,
296
+ readModuleManifest: async () => ({
297
+ name: "notes",
298
+ manifestName: "parachute-notes",
299
+ kind: "frontend",
300
+ port: 5173,
301
+ paths: ["/notes"],
302
+ health: "/health",
303
+ uiUrl: "/notes",
304
+ displayName: "Notes",
305
+ }),
306
+ })(req("/.well-known/parachute.json"));
307
+ expect(res.status).toBe(200);
308
+ const body = (await res.json()) as {
309
+ services: Array<{ name: string; uiUrl?: string; displayName?: string }>;
310
+ };
311
+ const svc = body.services.find((s) => s.name === "parachute-notes");
312
+ expect(svc?.uiUrl).toMatch(/\/notes$/);
313
+ expect(svc?.displayName).toBe("Notes");
314
+ } finally {
315
+ h.cleanup();
316
+ }
317
+ });
318
+
319
+ test("/.well-known/parachute.json omits uiUrl when the non-vault manifest has none", async () => {
320
+ const h = makeHarness();
321
+ try {
322
+ const notesEntry: ServiceEntry = {
323
+ name: "parachute-notes",
324
+ port: 5173,
325
+ paths: ["/notes"],
326
+ health: "/health",
327
+ version: "0.0.1",
328
+ installDir: "/fake/notes",
329
+ };
330
+ writeManifest({ services: [notesEntry] }, h.manifestPath);
331
+ const res = await hubFetch(h.dir, {
332
+ manifestPath: h.manifestPath,
333
+ readModuleManifest: async () => ({
334
+ name: "notes",
335
+ manifestName: "parachute-notes",
336
+ kind: "frontend",
337
+ port: 5173,
338
+ paths: ["/notes"],
339
+ health: "/health",
340
+ // no uiUrl declared — discovery page will skip the tile.
341
+ }),
342
+ })(req("/.well-known/parachute.json"));
343
+ const body = (await res.json()) as { services: Array<{ uiUrl?: string }> };
344
+ expect(body.services[0]).not.toHaveProperty("uiUrl");
345
+ } finally {
346
+ h.cleanup();
347
+ }
348
+ });
349
+
350
+ test("/.well-known/parachute.json: uiUrl resolver is skipped for vault entries (loadManagementUrls handles vault)", async () => {
351
+ const h = makeHarness();
352
+ try {
353
+ const vaultWithDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake/vault" };
354
+ writeManifest({ services: [vaultWithDir] }, h.manifestPath);
355
+ // The fake module.json declares uiUrl, but vault is supposed to be
356
+ // skipped by loadServiceUiMetadata (it has its own managementUrl
357
+ // path). So doc.services[vault] should NOT carry uiUrl.
358
+ const res = await hubFetch(h.dir, {
359
+ manifestPath: h.manifestPath,
360
+ readModuleManifest: async () => ({
361
+ name: "vault",
362
+ manifestName: "parachute-vault",
363
+ kind: "api",
364
+ port: 1940,
365
+ paths: ["/vault/default"],
366
+ health: "/health",
367
+ uiUrl: "/should-be-ignored",
368
+ }),
369
+ })(req("/.well-known/parachute.json"));
370
+ const body = (await res.json()) as { services: Array<{ name: string; uiUrl?: string }> };
371
+ const vaultSvc = body.services.find((s) => s.name === "parachute-vault");
372
+ expect(vaultSvc).not.toHaveProperty("uiUrl");
373
+ } finally {
374
+ h.cleanup();
375
+ }
376
+ });
377
+
197
378
  // The bug this PR fixes: `parachute vault create techne` updates
198
379
  // services.json but the old code only re-derived parachute.json on
199
380
  // `parachute expose`. With the dynamic build, the second GET reflects
@@ -398,15 +579,12 @@ describe("hubFetch routing", () => {
398
579
  }
399
580
  });
400
581
 
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.
582
+ // SPA mount after hub#231: single `/admin/*` mount serves vault
583
+ // provisioning + permissions + tokens. Pre-rename `/vault` and `/hub/*`
584
+ // SPA URLs are 301-redirected; the per-vault content proxy at
585
+ // `/vault/<name>/*` stays where it is.
408
586
 
409
- test("/vault serves index.html when the SPA bundle exists", async () => {
587
+ test("/admin/vaults serves the SPA shell when the bundle exists", async () => {
410
588
  const h = makeHarness();
411
589
  try {
412
590
  const dist = join(h.dir, "dist");
@@ -414,7 +592,7 @@ describe("hubFetch routing", () => {
414
592
  writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
415
593
  writeManifest({ services: [] }, h.manifestPath);
416
594
  const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
417
- req("/vault"),
595
+ req("/admin/vaults"),
418
596
  );
419
597
  expect(res.status).toBe(200);
420
598
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
@@ -424,10 +602,7 @@ describe("hubFetch routing", () => {
424
602
  }
425
603
  });
426
604
 
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.
605
+ test("/admin/vaults/new serves the SPA shell (client-side route)", async () => {
431
606
  const h = makeHarness();
432
607
  try {
433
608
  const dist = join(h.dir, "dist");
@@ -435,7 +610,7 @@ describe("hubFetch routing", () => {
435
610
  writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
436
611
  writeManifest({ services: [] }, h.manifestPath);
437
612
  const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
438
- req("/vault/new"),
613
+ req("/admin/vaults/new"),
439
614
  );
440
615
  expect(res.status).toBe(200);
441
616
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
@@ -445,7 +620,34 @@ describe("hubFetch routing", () => {
445
620
  }
446
621
  });
447
622
 
448
- test("/vault/assets/*.js is served with the matching content-type", async () => {
623
+ test("/admin/permissions serves the SPA shell", async () => {
624
+ const h = makeHarness();
625
+ try {
626
+ const dist = join(h.dir, "dist");
627
+ mkdirIfMissing(dist);
628
+ writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
629
+ const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/permissions"));
630
+ expect(res.status).toBe(200);
631
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
632
+ } finally {
633
+ h.cleanup();
634
+ }
635
+ });
636
+
637
+ test("/admin/tokens serves the SPA shell", async () => {
638
+ const h = makeHarness();
639
+ try {
640
+ const dist = join(h.dir, "dist");
641
+ mkdirIfMissing(dist);
642
+ writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
643
+ const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/tokens"));
644
+ expect(res.status).toBe(200);
645
+ } finally {
646
+ h.cleanup();
647
+ }
648
+ });
649
+
650
+ test("/admin/assets/*.js is served with the matching content-type", async () => {
449
651
  const h = makeHarness();
450
652
  try {
451
653
  const dist = join(h.dir, "dist");
@@ -456,7 +658,7 @@ describe("hubFetch routing", () => {
456
658
  writeFileSync(join(assets, "main.js"), "console.log('hi');");
457
659
  writeManifest({ services: [] }, h.manifestPath);
458
660
  const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
459
- req("/vault/assets/main.js"),
661
+ req("/admin/assets/main.js"),
460
662
  );
461
663
  expect(res.status).toBe(200);
462
664
  expect(res.headers.get("content-type")).toBe("application/javascript; charset=utf-8");
@@ -466,14 +668,14 @@ describe("hubFetch routing", () => {
466
668
  }
467
669
  });
468
670
 
469
- test("/vault/* returns 503 with build hint when dist is missing", async () => {
671
+ test("/admin/* returns 503 with build hint when dist is missing", async () => {
470
672
  const h = makeHarness();
471
673
  try {
472
674
  writeManifest({ services: [] }, h.manifestPath);
473
675
  const res = await hubFetch(h.dir, {
474
676
  spaDistDir: join(h.dir, "missing"),
475
677
  manifestPath: h.manifestPath,
476
- })(req("/vault"));
678
+ })(req("/admin/vaults"));
477
679
  expect(res.status).toBe(503);
478
680
  expect(await res.text()).toContain("bun run build");
479
681
  } finally {
@@ -481,125 +683,251 @@ describe("hubFetch routing", () => {
481
683
  }
482
684
  });
483
685
 
484
- test("/vault rejects non-GET methods with 405", async () => {
686
+ test("/admin/vaults rejects non-GET methods with 405", async () => {
485
687
  const h = makeHarness();
486
688
  try {
487
689
  const dist = join(h.dir, "dist");
488
690
  mkdirIfMissing(dist);
489
691
  writeFileSync(join(dist, "index.html"), "<!doctype html>");
490
- const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/vault", { method: "POST" }));
692
+ const res = await hubFetch(h.dir, { spaDistDir: dist })(
693
+ req("/admin/vaults", { method: "POST" }),
694
+ );
491
695
  expect(res.status).toBe(405);
492
696
  } finally {
493
697
  h.cleanup();
494
698
  }
495
699
  });
496
700
 
497
- test("/hub/permissions serves the SPA shell (back-compat mount)", async () => {
701
+ // 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
702
+ // 301-redirect to the new /admin/* mount. Tests cover every entry in the
703
+ // dispatch — operator bookmarks landing on any of these still work.
704
+
705
+ test("301: /vault → /admin/vaults", async () => {
498
706
  const h = makeHarness();
499
707
  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>");
708
+ const res = await hubFetch(h.dir)(req("/vault"));
709
+ expect(res.status).toBe(301);
710
+ expect(res.headers.get("location")).toBe("/admin/vaults");
507
711
  } finally {
508
712
  h.cleanup();
509
713
  }
510
714
  });
511
715
 
512
- test("/hub/* returns 503 with build hint when dist is missing", async () => {
716
+ test("301: /vault/new /admin/vaults/new", async () => {
513
717
  const h = makeHarness();
514
718
  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");
719
+ const res = await hubFetch(h.dir)(req("/vault/new"));
720
+ expect(res.status).toBe(301);
721
+ expect(res.headers.get("location")).toBe("/admin/vaults/new");
520
722
  } finally {
521
723
  h.cleanup();
522
724
  }
523
725
  });
524
726
 
525
- test("/hub rejects non-GET methods with 405", async () => {
727
+ test("301: /vault preserves query string", async () => {
526
728
  const h = makeHarness();
527
729
  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);
730
+ const res = await hubFetch(h.dir)(req("/vault?next=foo"));
731
+ expect(res.status).toBe(301);
732
+ expect(res.headers.get("location")).toBe("/admin/vaults?next=foo");
535
733
  } finally {
536
734
  h.cleanup();
537
735
  }
538
736
  });
539
737
 
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.
738
+ test("301: /hub/vaults /admin/vaults (chain through the rename)", async () => {
739
+ // The /hub/vaults redirect predates #231 it used to land at /vault.
740
+ // Now it lands at the final /admin/vaults so old bookmarks don't bounce
741
+ // through two redirects.
543
742
  const h = makeHarness();
544
743
  try {
545
744
  const res = await hubFetch(h.dir)(req("/hub/vaults"));
546
745
  expect(res.status).toBe(301);
547
- expect(res.headers.get("location")).toBe("/vault");
746
+ expect(res.headers.get("location")).toBe("/admin/vaults");
548
747
  } finally {
549
748
  h.cleanup();
550
749
  }
551
750
  });
552
751
 
553
- test("/hub/vaults/new redirects to /vault/new", async () => {
752
+ test("301: /hub/vaults/new /admin/vaults/new", async () => {
554
753
  const h = makeHarness();
555
754
  try {
556
755
  const res = await hubFetch(h.dir)(req("/hub/vaults/new"));
557
756
  expect(res.status).toBe(301);
558
- expect(res.headers.get("location")).toBe("/vault/new");
757
+ expect(res.headers.get("location")).toBe("/admin/vaults/new");
559
758
  } finally {
560
759
  h.cleanup();
561
760
  }
562
761
  });
563
762
 
564
- test("/hub/vaults/* preserves the query string in the redirect", async () => {
763
+ test("301: /hub/vaults/* preserves the query string", async () => {
565
764
  const h = makeHarness();
566
765
  try {
567
766
  const res = await hubFetch(h.dir)(req("/hub/vaults/foo?bar=1&baz=2"));
568
767
  expect(res.status).toBe(301);
569
- expect(res.headers.get("location")).toBe("/vault/foo?bar=1&baz=2");
768
+ expect(res.headers.get("location")).toBe("/admin/vaults/foo?bar=1&baz=2");
769
+ } finally {
770
+ h.cleanup();
771
+ }
772
+ });
773
+
774
+ test("301: /hub/permissions → /admin/permissions", async () => {
775
+ const h = makeHarness();
776
+ try {
777
+ const res = await hubFetch(h.dir)(req("/hub/permissions"));
778
+ expect(res.status).toBe(301);
779
+ expect(res.headers.get("location")).toBe("/admin/permissions");
780
+ } finally {
781
+ h.cleanup();
782
+ }
783
+ });
784
+
785
+ test("301: /hub/tokens → /admin/tokens", async () => {
786
+ const h = makeHarness();
787
+ try {
788
+ const res = await hubFetch(h.dir)(req("/hub/tokens"));
789
+ expect(res.status).toBe(301);
790
+ expect(res.headers.get("location")).toBe("/admin/tokens");
791
+ } finally {
792
+ h.cleanup();
793
+ }
794
+ });
795
+
796
+ test("301: /hub bare → /admin/vaults", async () => {
797
+ const h = makeHarness();
798
+ try {
799
+ const res = await hubFetch(h.dir)(req("/hub"));
800
+ expect(res.status).toBe(301);
801
+ expect(res.headers.get("location")).toBe("/admin/vaults");
802
+ } finally {
803
+ h.cleanup();
804
+ }
805
+ });
806
+
807
+ // Login surface rename redirects (auth-UX cleanup): /admin/login and
808
+ // /admin/logout 301 to /login and /logout. Path-only test — the
809
+ // handlers themselves are exercised through the existing
810
+ // handleAdminLoginGet/Post + handleAdminLogoutPost test files.
811
+ test("301: /admin/login → /login", async () => {
812
+ const h = makeHarness();
813
+ try {
814
+ const res = await hubFetch(h.dir)(req("/admin/login"));
815
+ expect(res.status).toBe(301);
816
+ expect(res.headers.get("location")).toBe("/login");
817
+ } finally {
818
+ h.cleanup();
819
+ }
820
+ });
821
+
822
+ test("301: /admin/login preserves the next= query param", async () => {
823
+ const h = makeHarness();
824
+ try {
825
+ const res = await hubFetch(h.dir)(req("/admin/login?next=/admin/permissions"));
826
+ expect(res.status).toBe(301);
827
+ expect(res.headers.get("location")).toBe("/login?next=/admin/permissions");
828
+ } finally {
829
+ h.cleanup();
830
+ }
831
+ });
832
+
833
+ test("301: /admin/config → /admin/vaults (legacy server-rendered portal retired)", async () => {
834
+ const h = makeHarness();
835
+ try {
836
+ const res = await hubFetch(h.dir)(req("/admin/config"));
837
+ expect(res.status).toBe(301);
838
+ expect(res.headers.get("location")).toBe("/admin/vaults");
839
+ } finally {
840
+ h.cleanup();
841
+ }
842
+ });
843
+
844
+ test("301: /admin/config/<name> → /admin/vaults", async () => {
845
+ const h = makeHarness();
846
+ try {
847
+ const res = await hubFetch(h.dir)(req("/admin/config/vault"));
848
+ expect(res.status).toBe(301);
849
+ expect(res.headers.get("location")).toBe("/admin/vaults");
850
+ } finally {
851
+ h.cleanup();
852
+ }
853
+ });
854
+
855
+ test("301: /admin/logout → /logout", async () => {
856
+ const h = makeHarness();
857
+ try {
858
+ const res = await hubFetch(h.dir)(req("/admin/logout"));
859
+ expect(res.status).toBe(301);
860
+ expect(res.headers.get("location")).toBe("/logout");
861
+ } finally {
862
+ h.cleanup();
863
+ }
864
+ });
865
+
866
+ test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
867
+ const h = makeHarness();
868
+ try {
869
+ writeManifest({ services: [] }, h.manifestPath);
870
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
871
+ req("/hub/unknown-thing"),
872
+ );
873
+ expect(res.status).toBe(404);
570
874
  } finally {
571
875
  h.cleanup();
572
876
  }
573
877
  });
574
878
 
575
- test("/oauth/authorize without configured db returns 503", async () => {
879
+ test("/oauth/authorize without configured db returns 503 JSON", async () => {
576
880
  const h = makeHarness();
577
881
  try {
578
882
  const res = await hubFetch(h.dir)(req("/oauth/authorize?client_id=x"));
579
883
  expect(res.status).toBe(503);
884
+ const body = (await res.json()) as { error: string; error_description: string };
885
+ expect(body.error).toBe("service_unavailable");
886
+ expect(body.error_description).toBe("hub db not configured");
580
887
  } finally {
581
888
  h.cleanup();
582
889
  }
583
890
  });
584
891
 
585
- test("every DB-dependent route returns 503 when getDb is absent (closes #139)", async () => {
892
+ test("every DB-dependent route returns 503 when getDb is absent (closes #139, JSON shape closes #227)", async () => {
586
893
  const h = makeHarness();
587
894
  try {
588
895
  const fetch = hubFetch(h.dir);
896
+ // Every DB-dependent guard returns the same JSON 503 shape
897
+ // (`service_unavailable`) so consumers don't branch on content-type to
898
+ // extract the message. The pattern was already canonical on
899
+ // /api/auth/* (hub#215, #226) and was extended to all guards in
900
+ // hub#227.
589
901
  const cases: Array<[string, RequestInit]> = [
902
+ ["/oauth/authorize?client_id=x", { method: "GET" }],
903
+ ["/oauth/authorize/approve", { method: "POST" }],
590
904
  ["/oauth/token", { method: "POST" }],
591
905
  ["/oauth/register", { method: "POST" }],
592
906
  ["/oauth/revoke", { method: "POST" }],
593
907
  ["/vaults", { method: "POST" }],
594
- ["/admin/login", { method: "POST" }],
595
- ["/admin/logout", { method: "POST" }],
596
- ["/admin/config", { method: "GET" }],
597
- ["/admin/config/example", { method: "POST" }],
908
+ // /login + /logout canonical names since the auth-UX rename;
909
+ // /admin/login + /admin/logout 301-redirect to here (separate
910
+ // tests pin the redirects themselves).
911
+ ["/login", { method: "POST" }],
912
+ ["/logout", { method: "POST" }],
598
913
  ["/admin/host-admin-token", { method: "GET" }],
914
+ ["/admin/vault-admin-token/demo", { method: "GET" }],
915
+ ["/api/me", { method: "GET" }],
916
+ ["/api/auth/mint-token", { method: "POST" }],
917
+ ["/api/auth/revoke-token", { method: "POST" }],
918
+ ["/api/auth/tokens", { method: "GET" }],
919
+ ["/api/grants", { method: "GET" }],
920
+ ["/api/grants/client-x", { method: "DELETE" }],
921
+ ["/api/oauth/clients/client-x", { method: "GET" }],
922
+ ["/api/oauth/clients/client-x/approve", { method: "POST" }],
599
923
  ];
600
924
  for (const [path, init] of cases) {
601
925
  const res = await fetch(req(path, init));
602
926
  expect(res.status).toBe(503);
927
+ expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
928
+ const body = (await res.json()) as { error: string; error_description: string };
929
+ expect(body.error).toBe("service_unavailable");
930
+ expect(body.error_description).toBe("hub db not configured");
603
931
  }
604
932
  } finally {
605
933
  h.cleanup();
@@ -611,6 +939,11 @@ describe("hubFetch routing", () => {
611
939
  try {
612
940
  const db = openHubDb(hubDbPath(h.dir));
613
941
  try {
942
+ // Seed an admin so the pre-admin setup gate (hub#258) doesn't
943
+ // 503 the request before the OAuth method-allow check runs.
944
+ // OAuth routing semantics are what this test pins; the setup
945
+ // gate has its own coverage in src/__tests__/setup-gate.test.ts.
946
+ await createUser(db, "owner", "pw");
614
947
  const res = await hubFetch(h.dir, { getDb: () => db })(
615
948
  req("/oauth/token", { method: "GET" }),
616
949
  );
@@ -628,6 +961,7 @@ describe("hubFetch routing", () => {
628
961
  try {
629
962
  const db = openHubDb(hubDbPath(h.dir));
630
963
  try {
964
+ await createUser(db, "owner", "pw");
631
965
  const res = await hubFetch(h.dir, {
632
966
  getDb: () => db,
633
967
  issuer: "https://hub.example",
@@ -649,6 +983,191 @@ describe("hubFetch routing", () => {
649
983
  }
650
984
  });
651
985
 
986
+ // Platform health check (hub#258). Returns 200 JSON regardless of DB
987
+ // state — Render et al. poll this every few seconds and a transient DB
988
+ // open shouldn't cascade into a restart loop. The body advertises the
989
+ // running version so a deploy verifier can confirm the rolled-out
990
+ // image is the one it expected.
991
+ test("/health returns 200 JSON without invoking the db", async () => {
992
+ const h = makeHarness();
993
+ try {
994
+ const res = await hubFetch(h.dir, {
995
+ getDb: () => {
996
+ throw new Error("getDb must not be called by /health");
997
+ },
998
+ })(req("/health"));
999
+ expect(res.status).toBe(200);
1000
+ expect(res.headers.get("content-type")).toContain("application/json");
1001
+ expect(res.headers.get("cache-control")).toBe("no-store");
1002
+ const body = (await res.json()) as Record<string, unknown>;
1003
+ expect(body.status).toBe("ok");
1004
+ expect(body.service).toBe("parachute-hub");
1005
+ expect(typeof body.version).toBe("string");
1006
+ } finally {
1007
+ h.cleanup();
1008
+ }
1009
+ });
1010
+
1011
+ // First-boot setup wizard (hub#259, expanding hub#258's static
1012
+ // placeholder). When no admin exists, GET /admin/setup renders the
1013
+ // wizard's account-step form. Once admin + vault both exist, it 301s
1014
+ // to /login so a stale bookmark still lands somewhere useful. With
1015
+ // admin but no vault, the wizard resumes at the vault step.
1016
+ test("/admin/setup renders the wizard's account form when no admin exists", async () => {
1017
+ const h = makeHarness();
1018
+ try {
1019
+ const db = openHubDb(hubDbPath(h.dir));
1020
+ try {
1021
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
1022
+ expect(res.status).toBe(200);
1023
+ expect(res.headers.get("content-type")).toContain("text/html");
1024
+ const body = await res.text();
1025
+ expect(body).toContain('action="/admin/setup/account"');
1026
+ // Env-var seed path is still surfaced as the alt-path disclosure.
1027
+ expect(body).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
1028
+ } finally {
1029
+ db.close();
1030
+ }
1031
+ } finally {
1032
+ h.cleanup();
1033
+ }
1034
+ });
1035
+
1036
+ test("/admin/setup 301s to /login once admin + vault + expose mode all exist (hub#259, hub#268)", async () => {
1037
+ const h = makeHarness();
1038
+ try {
1039
+ const db = openHubDb(hubDbPath(h.dir));
1040
+ try {
1041
+ await createUser(db, "owner", "pw");
1042
+ // Seed the vault entry + expose-mode answer so the wizard's
1043
+ // state derives as "done" and the GET 301s. Without expose
1044
+ // (hub#268 Item 2) the wizard would resume on the expose step.
1045
+ const { writeManifest } = await import("../services-manifest.ts");
1046
+ const { setSetting } = await import("../hub-settings.ts");
1047
+ const { join } = await import("node:path");
1048
+ writeManifest(
1049
+ {
1050
+ services: [
1051
+ {
1052
+ name: "parachute-vault",
1053
+ version: "0.1.0",
1054
+ port: 1940,
1055
+ paths: ["/vault/default"],
1056
+ health: "/health",
1057
+ },
1058
+ ],
1059
+ },
1060
+ join(h.dir, "services.json"),
1061
+ );
1062
+ setSetting(db, "setup_expose_mode", "localhost");
1063
+ const res = await hubFetch(h.dir, {
1064
+ getDb: () => db,
1065
+ manifestPath: join(h.dir, "services.json"),
1066
+ })(req("/admin/setup"));
1067
+ expect(res.status).toBe(301);
1068
+ expect(res.headers.get("location")).toBe("/login");
1069
+ } finally {
1070
+ db.close();
1071
+ }
1072
+ } finally {
1073
+ h.cleanup();
1074
+ }
1075
+ });
1076
+
1077
+ // Pre-admin lockout (hub#258). When no admin row exists, operator-
1078
+ // facing surfaces (admin/api/login) 503 with a JSON body pointing at
1079
+ // /admin/setup. Public surfaces (health, well-known, /, oauth, vault,
1080
+ // /admin/setup itself) stay open so the container is reachable and
1081
+ // OAuth third parties aren't held hostage by admin onboarding.
1082
+ test("pre-admin lockout: /admin/vaults returns 503 setup_required", async () => {
1083
+ const h = makeHarness();
1084
+ try {
1085
+ const db = openHubDb(hubDbPath(h.dir));
1086
+ try {
1087
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/vaults"));
1088
+ expect(res.status).toBe(503);
1089
+ const body = (await res.json()) as Record<string, unknown>;
1090
+ expect(body.error).toBe("setup_required");
1091
+ expect(body.setup_url).toBe("/admin/setup");
1092
+ } finally {
1093
+ db.close();
1094
+ }
1095
+ } finally {
1096
+ h.cleanup();
1097
+ }
1098
+ });
1099
+
1100
+ test("pre-admin lockout: /api/me returns 503 setup_required", async () => {
1101
+ const h = makeHarness();
1102
+ try {
1103
+ const db = openHubDb(hubDbPath(h.dir));
1104
+ try {
1105
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
1106
+ expect(res.status).toBe(503);
1107
+ const body = (await res.json()) as Record<string, unknown>;
1108
+ expect(body.error).toBe("setup_required");
1109
+ } finally {
1110
+ db.close();
1111
+ }
1112
+ } finally {
1113
+ h.cleanup();
1114
+ }
1115
+ });
1116
+
1117
+ test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
1118
+ const h = makeHarness();
1119
+ try {
1120
+ writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
1121
+ writeManifest({ services: [] }, h.manifestPath);
1122
+ const db = openHubDb(hubDbPath(h.dir));
1123
+ try {
1124
+ const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
1125
+ // /login gated
1126
+ const loginRes = await handler(req("/login"));
1127
+ expect(loginRes.status).toBe(503);
1128
+ // /admin/setup open
1129
+ const setupRes = await handler(req("/admin/setup"));
1130
+ expect(setupRes.status).toBe(200);
1131
+ // /health open
1132
+ const healthRes = await handler(req("/health"));
1133
+ expect(healthRes.status).toBe(200);
1134
+ // / funnels to the wizard (hub#259 rc.6 fix for Bug 2 — the
1135
+ // static portal pre-setup is useless; redirect to setup).
1136
+ const rootRes = await handler(req("/"));
1137
+ expect(rootRes.status).toBe(302);
1138
+ expect(rootRes.headers.get("location")).toBe("/admin/setup");
1139
+ // /.well-known/parachute.json open
1140
+ const wkRes = await handler(req("/.well-known/parachute.json"));
1141
+ expect(wkRes.status).toBe(200);
1142
+ } finally {
1143
+ db.close();
1144
+ }
1145
+ } finally {
1146
+ h.cleanup();
1147
+ }
1148
+ });
1149
+
1150
+ test("pre-admin lockout falls away once an admin exists", async () => {
1151
+ const h = makeHarness();
1152
+ try {
1153
+ const db = openHubDb(hubDbPath(h.dir));
1154
+ try {
1155
+ // Before: /api/me 503s under the lockout.
1156
+ const before = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
1157
+ expect(before.status).toBe(503);
1158
+ // After seeding an admin: dispatch resumes normal handling.
1159
+ await createUser(db, "owner", "pw");
1160
+ const after = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
1161
+ // /api/me with no session returns `hasSession: false` 200, not 503.
1162
+ expect(after.status).toBe(200);
1163
+ } finally {
1164
+ db.close();
1165
+ }
1166
+ } finally {
1167
+ h.cleanup();
1168
+ }
1169
+ });
1170
+
652
1171
  test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
653
1172
  const h = makeHarness();
654
1173
  try {
@@ -1024,15 +1543,15 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1024
1543
  }
1025
1544
  });
1026
1545
 
1027
- test("single-segment /vault/<name> picks proxy when registered, SPA shell when not", async () => {
1546
+ test("single-segment /vault/<name> picks proxy when registered, 404 when not", async () => {
1028
1547
  // Two cases share one fixture so the contrast is explicit:
1029
1548
  // - `/vault/default` is registered → proxy answers (200, JSON tag).
1030
- // - `/vault/nonexistent` has no match → falls through to the SPA
1031
- // shell (200, text/html). The SPA's :name route renders client-side
1032
- // and shows a 404 in-app, but at the wire it's the shell.
1549
+ // - `/vault/nonexistent` has no match → 404 directly (no SPA-shell
1550
+ // fallback under /vault since hub#231 moved the admin SPA to
1551
+ // /admin/*; the /vault/<name>/* slot is now exclusively the
1552
+ // per-vault content proxy).
1033
1553
  // This is the routing-order seam #173 introduced — proxy is consulted
1034
- // before the SPA fallback, and the fallback only triggers when no
1035
- // vault claims the path.
1554
+ // before the 404; the SPA fallback that used to live here is gone.
1036
1555
  const h = makeHarness();
1037
1556
  const upstream = startUpstream("default-vault");
1038
1557
  try {
@@ -1065,10 +1584,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1065
1584
  expect(body.tag).toBe("default-vault");
1066
1585
  expect(body.pathname).toBe("/vault/default");
1067
1586
 
1068
- const shelled = await fetcher(req("/vault/nonexistent"));
1069
- expect(shelled.status).toBe(200);
1070
- expect(shelled.headers.get("content-type")).toBe("text/html; charset=utf-8");
1071
- expect(await shelled.text()).toContain("<div id=root>");
1587
+ const notFound = await fetcher(req("/vault/nonexistent"));
1588
+ expect(notFound.status).toBe(404);
1072
1589
  } finally {
1073
1590
  upstream.stop();
1074
1591
  h.cleanup();