@openparachute/hub 0.5.7 → 0.5.10-rc.2

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 (69) 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-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +526 -67
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. 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,58 @@ 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
+ const h = makeHarness();
78
+ try {
79
+ const db = openHubDb(hubDbPath(h.dir));
80
+ try {
81
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
82
+ expect(res.status).toBe(200);
83
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
84
+ const body = await res.text();
85
+ expect(body).toContain('class="auth-indicator"');
86
+ expect(body).toContain("Sign in");
87
+ expect(body).not.toContain("Signed in as");
88
+ } finally {
89
+ db.close();
90
+ }
91
+ } finally {
92
+ h.cleanup();
93
+ }
94
+ });
95
+
96
+ test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
97
+ const h = makeHarness();
98
+ try {
99
+ const db = openHubDb(hubDbPath(h.dir));
100
+ try {
101
+ const { createUser } = await import("../users.ts");
102
+ const { createSession, buildSessionCookie, SESSION_TTL_MS } = await import(
103
+ "../sessions.ts"
104
+ );
105
+ const user = await createUser(db, "aaron", "pw");
106
+ const session = createSession(db, { userId: user.id });
107
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
108
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/", { headers: { cookie } }));
109
+ expect(res.status).toBe(200);
110
+ const body = await res.text();
111
+ expect(body).toContain("Signed in as");
112
+ expect(body).toContain("aaron");
113
+ expect(body).toContain('action="/logout"');
114
+ expect(body).toContain('name="__csrf"');
115
+ // CSRF cookie was minted on the response (no prior cookie present).
116
+ expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_csrf=");
117
+ } finally {
118
+ db.close();
119
+ }
120
+ } finally {
121
+ h.cleanup();
122
+ }
123
+ });
124
+
72
125
  test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
73
126
  const h = makeHarness();
74
127
  try {
@@ -194,6 +247,105 @@ describe("hubFetch routing", () => {
194
247
  }
195
248
  });
196
249
 
250
+ // Phase D consumer-side: each non-vault service entry's
251
+ // module.json:uiUrl + displayName ride through to doc.services. The
252
+ // discovery page (`/`) reads them to render data-driven Service tiles.
253
+ test("/.well-known/parachute.json surfaces uiUrl + displayName from non-vault module manifests", async () => {
254
+ const h = makeHarness();
255
+ try {
256
+ const notesEntry: ServiceEntry = {
257
+ name: "parachute-notes",
258
+ port: 5173,
259
+ paths: ["/notes"],
260
+ health: "/health",
261
+ version: "0.0.1",
262
+ installDir: "/fake/notes",
263
+ };
264
+ writeManifest({ services: [notesEntry] }, h.manifestPath);
265
+ const res = await hubFetch(h.dir, {
266
+ manifestPath: h.manifestPath,
267
+ readModuleManifest: async () => ({
268
+ name: "notes",
269
+ manifestName: "parachute-notes",
270
+ kind: "frontend",
271
+ port: 5173,
272
+ paths: ["/notes"],
273
+ health: "/health",
274
+ uiUrl: "/notes",
275
+ displayName: "Notes",
276
+ }),
277
+ })(req("/.well-known/parachute.json"));
278
+ expect(res.status).toBe(200);
279
+ const body = (await res.json()) as {
280
+ services: Array<{ name: string; uiUrl?: string; displayName?: string }>;
281
+ };
282
+ const svc = body.services.find((s) => s.name === "parachute-notes");
283
+ expect(svc?.uiUrl).toMatch(/\/notes$/);
284
+ expect(svc?.displayName).toBe("Notes");
285
+ } finally {
286
+ h.cleanup();
287
+ }
288
+ });
289
+
290
+ test("/.well-known/parachute.json omits uiUrl when the non-vault manifest has none", async () => {
291
+ const h = makeHarness();
292
+ try {
293
+ const notesEntry: ServiceEntry = {
294
+ name: "parachute-notes",
295
+ port: 5173,
296
+ paths: ["/notes"],
297
+ health: "/health",
298
+ version: "0.0.1",
299
+ installDir: "/fake/notes",
300
+ };
301
+ writeManifest({ services: [notesEntry] }, h.manifestPath);
302
+ const res = await hubFetch(h.dir, {
303
+ manifestPath: h.manifestPath,
304
+ readModuleManifest: async () => ({
305
+ name: "notes",
306
+ manifestName: "parachute-notes",
307
+ kind: "frontend",
308
+ port: 5173,
309
+ paths: ["/notes"],
310
+ health: "/health",
311
+ // no uiUrl declared — discovery page will skip the tile.
312
+ }),
313
+ })(req("/.well-known/parachute.json"));
314
+ const body = (await res.json()) as { services: Array<{ uiUrl?: string }> };
315
+ expect(body.services[0]).not.toHaveProperty("uiUrl");
316
+ } finally {
317
+ h.cleanup();
318
+ }
319
+ });
320
+
321
+ test("/.well-known/parachute.json: uiUrl resolver is skipped for vault entries (loadManagementUrls handles vault)", async () => {
322
+ const h = makeHarness();
323
+ try {
324
+ const vaultWithDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake/vault" };
325
+ writeManifest({ services: [vaultWithDir] }, h.manifestPath);
326
+ // The fake module.json declares uiUrl, but vault is supposed to be
327
+ // skipped by loadServiceUiMetadata (it has its own managementUrl
328
+ // path). So doc.services[vault] should NOT carry uiUrl.
329
+ const res = await hubFetch(h.dir, {
330
+ manifestPath: h.manifestPath,
331
+ readModuleManifest: async () => ({
332
+ name: "vault",
333
+ manifestName: "parachute-vault",
334
+ kind: "api",
335
+ port: 1940,
336
+ paths: ["/vault/default"],
337
+ health: "/health",
338
+ uiUrl: "/should-be-ignored",
339
+ }),
340
+ })(req("/.well-known/parachute.json"));
341
+ const body = (await res.json()) as { services: Array<{ name: string; uiUrl?: string }> };
342
+ const vaultSvc = body.services.find((s) => s.name === "parachute-vault");
343
+ expect(vaultSvc).not.toHaveProperty("uiUrl");
344
+ } finally {
345
+ h.cleanup();
346
+ }
347
+ });
348
+
197
349
  // The bug this PR fixes: `parachute vault create techne` updates
198
350
  // services.json but the old code only re-derived parachute.json on
199
351
  // `parachute expose`. With the dynamic build, the second GET reflects
@@ -398,15 +550,12 @@ describe("hubFetch routing", () => {
398
550
  }
399
551
  });
400
552
 
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.
553
+ // SPA mount after hub#231: single `/admin/*` mount serves vault
554
+ // provisioning + permissions + tokens. Pre-rename `/vault` and `/hub/*`
555
+ // SPA URLs are 301-redirected; the per-vault content proxy at
556
+ // `/vault/<name>/*` stays where it is.
408
557
 
409
- test("/vault serves index.html when the SPA bundle exists", async () => {
558
+ test("/admin/vaults serves the SPA shell when the bundle exists", async () => {
410
559
  const h = makeHarness();
411
560
  try {
412
561
  const dist = join(h.dir, "dist");
@@ -414,7 +563,7 @@ describe("hubFetch routing", () => {
414
563
  writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
415
564
  writeManifest({ services: [] }, h.manifestPath);
416
565
  const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
417
- req("/vault"),
566
+ req("/admin/vaults"),
418
567
  );
419
568
  expect(res.status).toBe(200);
420
569
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
@@ -424,10 +573,7 @@ describe("hubFetch routing", () => {
424
573
  }
425
574
  });
426
575
 
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.
576
+ test("/admin/vaults/new serves the SPA shell (client-side route)", async () => {
431
577
  const h = makeHarness();
432
578
  try {
433
579
  const dist = join(h.dir, "dist");
@@ -435,7 +581,7 @@ describe("hubFetch routing", () => {
435
581
  writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
436
582
  writeManifest({ services: [] }, h.manifestPath);
437
583
  const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
438
- req("/vault/new"),
584
+ req("/admin/vaults/new"),
439
585
  );
440
586
  expect(res.status).toBe(200);
441
587
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
@@ -445,7 +591,34 @@ describe("hubFetch routing", () => {
445
591
  }
446
592
  });
447
593
 
448
- test("/vault/assets/*.js is served with the matching content-type", async () => {
594
+ test("/admin/permissions serves the SPA shell", async () => {
595
+ const h = makeHarness();
596
+ try {
597
+ const dist = join(h.dir, "dist");
598
+ mkdirIfMissing(dist);
599
+ writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
600
+ const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/permissions"));
601
+ expect(res.status).toBe(200);
602
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
603
+ } finally {
604
+ h.cleanup();
605
+ }
606
+ });
607
+
608
+ test("/admin/tokens serves the SPA shell", async () => {
609
+ const h = makeHarness();
610
+ try {
611
+ const dist = join(h.dir, "dist");
612
+ mkdirIfMissing(dist);
613
+ writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
614
+ const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/tokens"));
615
+ expect(res.status).toBe(200);
616
+ } finally {
617
+ h.cleanup();
618
+ }
619
+ });
620
+
621
+ test("/admin/assets/*.js is served with the matching content-type", async () => {
449
622
  const h = makeHarness();
450
623
  try {
451
624
  const dist = join(h.dir, "dist");
@@ -456,7 +629,7 @@ describe("hubFetch routing", () => {
456
629
  writeFileSync(join(assets, "main.js"), "console.log('hi');");
457
630
  writeManifest({ services: [] }, h.manifestPath);
458
631
  const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
459
- req("/vault/assets/main.js"),
632
+ req("/admin/assets/main.js"),
460
633
  );
461
634
  expect(res.status).toBe(200);
462
635
  expect(res.headers.get("content-type")).toBe("application/javascript; charset=utf-8");
@@ -466,14 +639,14 @@ describe("hubFetch routing", () => {
466
639
  }
467
640
  });
468
641
 
469
- test("/vault/* returns 503 with build hint when dist is missing", async () => {
642
+ test("/admin/* returns 503 with build hint when dist is missing", async () => {
470
643
  const h = makeHarness();
471
644
  try {
472
645
  writeManifest({ services: [] }, h.manifestPath);
473
646
  const res = await hubFetch(h.dir, {
474
647
  spaDistDir: join(h.dir, "missing"),
475
648
  manifestPath: h.manifestPath,
476
- })(req("/vault"));
649
+ })(req("/admin/vaults"));
477
650
  expect(res.status).toBe(503);
478
651
  expect(await res.text()).toContain("bun run build");
479
652
  } finally {
@@ -481,125 +654,251 @@ describe("hubFetch routing", () => {
481
654
  }
482
655
  });
483
656
 
484
- test("/vault rejects non-GET methods with 405", async () => {
657
+ test("/admin/vaults rejects non-GET methods with 405", async () => {
485
658
  const h = makeHarness();
486
659
  try {
487
660
  const dist = join(h.dir, "dist");
488
661
  mkdirIfMissing(dist);
489
662
  writeFileSync(join(dist, "index.html"), "<!doctype html>");
490
- const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/vault", { method: "POST" }));
663
+ const res = await hubFetch(h.dir, { spaDistDir: dist })(
664
+ req("/admin/vaults", { method: "POST" }),
665
+ );
491
666
  expect(res.status).toBe(405);
492
667
  } finally {
493
668
  h.cleanup();
494
669
  }
495
670
  });
496
671
 
497
- test("/hub/permissions serves the SPA shell (back-compat mount)", async () => {
672
+ // 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
673
+ // 301-redirect to the new /admin/* mount. Tests cover every entry in the
674
+ // dispatch — operator bookmarks landing on any of these still work.
675
+
676
+ test("301: /vault → /admin/vaults", async () => {
498
677
  const h = makeHarness();
499
678
  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>");
679
+ const res = await hubFetch(h.dir)(req("/vault"));
680
+ expect(res.status).toBe(301);
681
+ expect(res.headers.get("location")).toBe("/admin/vaults");
507
682
  } finally {
508
683
  h.cleanup();
509
684
  }
510
685
  });
511
686
 
512
- test("/hub/* returns 503 with build hint when dist is missing", async () => {
687
+ test("301: /vault/new /admin/vaults/new", async () => {
513
688
  const h = makeHarness();
514
689
  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");
690
+ const res = await hubFetch(h.dir)(req("/vault/new"));
691
+ expect(res.status).toBe(301);
692
+ expect(res.headers.get("location")).toBe("/admin/vaults/new");
520
693
  } finally {
521
694
  h.cleanup();
522
695
  }
523
696
  });
524
697
 
525
- test("/hub rejects non-GET methods with 405", async () => {
698
+ test("301: /vault preserves query string", async () => {
526
699
  const h = makeHarness();
527
700
  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);
701
+ const res = await hubFetch(h.dir)(req("/vault?next=foo"));
702
+ expect(res.status).toBe(301);
703
+ expect(res.headers.get("location")).toBe("/admin/vaults?next=foo");
535
704
  } finally {
536
705
  h.cleanup();
537
706
  }
538
707
  });
539
708
 
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.
709
+ test("301: /hub/vaults /admin/vaults (chain through the rename)", async () => {
710
+ // The /hub/vaults redirect predates #231 it used to land at /vault.
711
+ // Now it lands at the final /admin/vaults so old bookmarks don't bounce
712
+ // through two redirects.
543
713
  const h = makeHarness();
544
714
  try {
545
715
  const res = await hubFetch(h.dir)(req("/hub/vaults"));
546
716
  expect(res.status).toBe(301);
547
- expect(res.headers.get("location")).toBe("/vault");
717
+ expect(res.headers.get("location")).toBe("/admin/vaults");
548
718
  } finally {
549
719
  h.cleanup();
550
720
  }
551
721
  });
552
722
 
553
- test("/hub/vaults/new redirects to /vault/new", async () => {
723
+ test("301: /hub/vaults/new /admin/vaults/new", async () => {
554
724
  const h = makeHarness();
555
725
  try {
556
726
  const res = await hubFetch(h.dir)(req("/hub/vaults/new"));
557
727
  expect(res.status).toBe(301);
558
- expect(res.headers.get("location")).toBe("/vault/new");
728
+ expect(res.headers.get("location")).toBe("/admin/vaults/new");
559
729
  } finally {
560
730
  h.cleanup();
561
731
  }
562
732
  });
563
733
 
564
- test("/hub/vaults/* preserves the query string in the redirect", async () => {
734
+ test("301: /hub/vaults/* preserves the query string", async () => {
565
735
  const h = makeHarness();
566
736
  try {
567
737
  const res = await hubFetch(h.dir)(req("/hub/vaults/foo?bar=1&baz=2"));
568
738
  expect(res.status).toBe(301);
569
- expect(res.headers.get("location")).toBe("/vault/foo?bar=1&baz=2");
739
+ expect(res.headers.get("location")).toBe("/admin/vaults/foo?bar=1&baz=2");
740
+ } finally {
741
+ h.cleanup();
742
+ }
743
+ });
744
+
745
+ test("301: /hub/permissions → /admin/permissions", async () => {
746
+ const h = makeHarness();
747
+ try {
748
+ const res = await hubFetch(h.dir)(req("/hub/permissions"));
749
+ expect(res.status).toBe(301);
750
+ expect(res.headers.get("location")).toBe("/admin/permissions");
751
+ } finally {
752
+ h.cleanup();
753
+ }
754
+ });
755
+
756
+ test("301: /hub/tokens → /admin/tokens", async () => {
757
+ const h = makeHarness();
758
+ try {
759
+ const res = await hubFetch(h.dir)(req("/hub/tokens"));
760
+ expect(res.status).toBe(301);
761
+ expect(res.headers.get("location")).toBe("/admin/tokens");
762
+ } finally {
763
+ h.cleanup();
764
+ }
765
+ });
766
+
767
+ test("301: /hub bare → /admin/vaults", async () => {
768
+ const h = makeHarness();
769
+ try {
770
+ const res = await hubFetch(h.dir)(req("/hub"));
771
+ expect(res.status).toBe(301);
772
+ expect(res.headers.get("location")).toBe("/admin/vaults");
773
+ } finally {
774
+ h.cleanup();
775
+ }
776
+ });
777
+
778
+ // Login surface rename redirects (auth-UX cleanup): /admin/login and
779
+ // /admin/logout 301 to /login and /logout. Path-only test — the
780
+ // handlers themselves are exercised through the existing
781
+ // handleAdminLoginGet/Post + handleAdminLogoutPost test files.
782
+ test("301: /admin/login → /login", async () => {
783
+ const h = makeHarness();
784
+ try {
785
+ const res = await hubFetch(h.dir)(req("/admin/login"));
786
+ expect(res.status).toBe(301);
787
+ expect(res.headers.get("location")).toBe("/login");
788
+ } finally {
789
+ h.cleanup();
790
+ }
791
+ });
792
+
793
+ test("301: /admin/login preserves the next= query param", async () => {
794
+ const h = makeHarness();
795
+ try {
796
+ const res = await hubFetch(h.dir)(req("/admin/login?next=/admin/permissions"));
797
+ expect(res.status).toBe(301);
798
+ expect(res.headers.get("location")).toBe("/login?next=/admin/permissions");
799
+ } finally {
800
+ h.cleanup();
801
+ }
802
+ });
803
+
804
+ test("301: /admin/config → /admin/vaults (legacy server-rendered portal retired)", async () => {
805
+ const h = makeHarness();
806
+ try {
807
+ const res = await hubFetch(h.dir)(req("/admin/config"));
808
+ expect(res.status).toBe(301);
809
+ expect(res.headers.get("location")).toBe("/admin/vaults");
810
+ } finally {
811
+ h.cleanup();
812
+ }
813
+ });
814
+
815
+ test("301: /admin/config/<name> → /admin/vaults", async () => {
816
+ const h = makeHarness();
817
+ try {
818
+ const res = await hubFetch(h.dir)(req("/admin/config/vault"));
819
+ expect(res.status).toBe(301);
820
+ expect(res.headers.get("location")).toBe("/admin/vaults");
570
821
  } finally {
571
822
  h.cleanup();
572
823
  }
573
824
  });
574
825
 
575
- test("/oauth/authorize without configured db returns 503", async () => {
826
+ test("301: /admin/logout /logout", async () => {
827
+ const h = makeHarness();
828
+ try {
829
+ const res = await hubFetch(h.dir)(req("/admin/logout"));
830
+ expect(res.status).toBe(301);
831
+ expect(res.headers.get("location")).toBe("/logout");
832
+ } finally {
833
+ h.cleanup();
834
+ }
835
+ });
836
+
837
+ test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
838
+ const h = makeHarness();
839
+ try {
840
+ writeManifest({ services: [] }, h.manifestPath);
841
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
842
+ req("/hub/unknown-thing"),
843
+ );
844
+ expect(res.status).toBe(404);
845
+ } finally {
846
+ h.cleanup();
847
+ }
848
+ });
849
+
850
+ test("/oauth/authorize without configured db returns 503 JSON", async () => {
576
851
  const h = makeHarness();
577
852
  try {
578
853
  const res = await hubFetch(h.dir)(req("/oauth/authorize?client_id=x"));
579
854
  expect(res.status).toBe(503);
855
+ const body = (await res.json()) as { error: string; error_description: string };
856
+ expect(body.error).toBe("service_unavailable");
857
+ expect(body.error_description).toBe("hub db not configured");
580
858
  } finally {
581
859
  h.cleanup();
582
860
  }
583
861
  });
584
862
 
585
- test("every DB-dependent route returns 503 when getDb is absent (closes #139)", async () => {
863
+ test("every DB-dependent route returns 503 when getDb is absent (closes #139, JSON shape closes #227)", async () => {
586
864
  const h = makeHarness();
587
865
  try {
588
866
  const fetch = hubFetch(h.dir);
867
+ // Every DB-dependent guard returns the same JSON 503 shape
868
+ // (`service_unavailable`) so consumers don't branch on content-type to
869
+ // extract the message. The pattern was already canonical on
870
+ // /api/auth/* (hub#215, #226) and was extended to all guards in
871
+ // hub#227.
589
872
  const cases: Array<[string, RequestInit]> = [
873
+ ["/oauth/authorize?client_id=x", { method: "GET" }],
874
+ ["/oauth/authorize/approve", { method: "POST" }],
590
875
  ["/oauth/token", { method: "POST" }],
591
876
  ["/oauth/register", { method: "POST" }],
592
877
  ["/oauth/revoke", { method: "POST" }],
593
878
  ["/vaults", { method: "POST" }],
594
- ["/admin/login", { method: "POST" }],
595
- ["/admin/logout", { method: "POST" }],
596
- ["/admin/config", { method: "GET" }],
597
- ["/admin/config/example", { method: "POST" }],
879
+ // /login + /logout canonical names since the auth-UX rename;
880
+ // /admin/login + /admin/logout 301-redirect to here (separate
881
+ // tests pin the redirects themselves).
882
+ ["/login", { method: "POST" }],
883
+ ["/logout", { method: "POST" }],
598
884
  ["/admin/host-admin-token", { method: "GET" }],
885
+ ["/admin/vault-admin-token/demo", { method: "GET" }],
886
+ ["/api/me", { method: "GET" }],
887
+ ["/api/auth/mint-token", { method: "POST" }],
888
+ ["/api/auth/revoke-token", { method: "POST" }],
889
+ ["/api/auth/tokens", { method: "GET" }],
890
+ ["/api/grants", { method: "GET" }],
891
+ ["/api/grants/client-x", { method: "DELETE" }],
892
+ ["/api/oauth/clients/client-x", { method: "GET" }],
893
+ ["/api/oauth/clients/client-x/approve", { method: "POST" }],
599
894
  ];
600
895
  for (const [path, init] of cases) {
601
896
  const res = await fetch(req(path, init));
602
897
  expect(res.status).toBe(503);
898
+ expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
899
+ const body = (await res.json()) as { error: string; error_description: string };
900
+ expect(body.error).toBe("service_unavailable");
901
+ expect(body.error_description).toBe("hub db not configured");
603
902
  }
604
903
  } finally {
605
904
  h.cleanup();
@@ -611,6 +910,11 @@ describe("hubFetch routing", () => {
611
910
  try {
612
911
  const db = openHubDb(hubDbPath(h.dir));
613
912
  try {
913
+ // Seed an admin so the pre-admin setup gate (hub#258) doesn't
914
+ // 503 the request before the OAuth method-allow check runs.
915
+ // OAuth routing semantics are what this test pins; the setup
916
+ // gate has its own coverage in src/__tests__/setup-gate.test.ts.
917
+ await createUser(db, "owner", "pw");
614
918
  const res = await hubFetch(h.dir, { getDb: () => db })(
615
919
  req("/oauth/token", { method: "GET" }),
616
920
  );
@@ -628,6 +932,7 @@ describe("hubFetch routing", () => {
628
932
  try {
629
933
  const db = openHubDb(hubDbPath(h.dir));
630
934
  try {
935
+ await createUser(db, "owner", "pw");
631
936
  const res = await hubFetch(h.dir, {
632
937
  getDb: () => db,
633
938
  issuer: "https://hub.example",
@@ -649,6 +954,162 @@ describe("hubFetch routing", () => {
649
954
  }
650
955
  });
651
956
 
957
+ // Platform health check (hub#258). Returns 200 JSON regardless of DB
958
+ // state — Render et al. poll this every few seconds and a transient DB
959
+ // open shouldn't cascade into a restart loop. The body advertises the
960
+ // running version so a deploy verifier can confirm the rolled-out
961
+ // image is the one it expected.
962
+ test("/health returns 200 JSON without invoking the db", async () => {
963
+ const h = makeHarness();
964
+ try {
965
+ const res = await hubFetch(h.dir, {
966
+ getDb: () => {
967
+ throw new Error("getDb must not be called by /health");
968
+ },
969
+ })(req("/health"));
970
+ expect(res.status).toBe(200);
971
+ expect(res.headers.get("content-type")).toContain("application/json");
972
+ expect(res.headers.get("cache-control")).toBe("no-store");
973
+ const body = (await res.json()) as Record<string, unknown>;
974
+ expect(body.status).toBe("ok");
975
+ expect(body.service).toBe("parachute-hub");
976
+ expect(typeof body.version).toBe("string");
977
+ } finally {
978
+ h.cleanup();
979
+ }
980
+ });
981
+
982
+ // First-boot setup placeholder (hub#258). When no admin exists the page
983
+ // is the bootstrap onboarding surface; once an admin exists it 301s to
984
+ // /login so a stale bookmark still lands somewhere useful.
985
+ test("/admin/setup renders placeholder HTML when no admin exists", async () => {
986
+ const h = makeHarness();
987
+ try {
988
+ const db = openHubDb(hubDbPath(h.dir));
989
+ try {
990
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
991
+ expect(res.status).toBe(200);
992
+ expect(res.headers.get("content-type")).toContain("text/html");
993
+ const body = await res.text();
994
+ expect(body).toContain("first-boot setup");
995
+ expect(body).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
996
+ } finally {
997
+ db.close();
998
+ }
999
+ } finally {
1000
+ h.cleanup();
1001
+ }
1002
+ });
1003
+
1004
+ test("/admin/setup 301s to /login when an admin already exists", async () => {
1005
+ const h = makeHarness();
1006
+ try {
1007
+ const db = openHubDb(hubDbPath(h.dir));
1008
+ try {
1009
+ await createUser(db, "owner", "pw");
1010
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
1011
+ expect(res.status).toBe(301);
1012
+ expect(res.headers.get("location")).toBe("/login");
1013
+ } finally {
1014
+ db.close();
1015
+ }
1016
+ } finally {
1017
+ h.cleanup();
1018
+ }
1019
+ });
1020
+
1021
+ // Pre-admin lockout (hub#258). When no admin row exists, operator-
1022
+ // facing surfaces (admin/api/login) 503 with a JSON body pointing at
1023
+ // /admin/setup. Public surfaces (health, well-known, /, oauth, vault,
1024
+ // /admin/setup itself) stay open so the container is reachable and
1025
+ // OAuth third parties aren't held hostage by admin onboarding.
1026
+ test("pre-admin lockout: /admin/vaults returns 503 setup_required", async () => {
1027
+ const h = makeHarness();
1028
+ try {
1029
+ const db = openHubDb(hubDbPath(h.dir));
1030
+ try {
1031
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/vaults"));
1032
+ expect(res.status).toBe(503);
1033
+ const body = (await res.json()) as Record<string, unknown>;
1034
+ expect(body.error).toBe("setup_required");
1035
+ expect(body.setup_url).toBe("/admin/setup");
1036
+ } finally {
1037
+ db.close();
1038
+ }
1039
+ } finally {
1040
+ h.cleanup();
1041
+ }
1042
+ });
1043
+
1044
+ test("pre-admin lockout: /api/me returns 503 setup_required", async () => {
1045
+ const h = makeHarness();
1046
+ try {
1047
+ const db = openHubDb(hubDbPath(h.dir));
1048
+ try {
1049
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
1050
+ expect(res.status).toBe(503);
1051
+ const body = (await res.json()) as Record<string, unknown>;
1052
+ expect(body.error).toBe("setup_required");
1053
+ } finally {
1054
+ db.close();
1055
+ }
1056
+ } finally {
1057
+ h.cleanup();
1058
+ }
1059
+ });
1060
+
1061
+ test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open", async () => {
1062
+ const h = makeHarness();
1063
+ try {
1064
+ writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
1065
+ writeManifest({ services: [] }, h.manifestPath);
1066
+ const db = openHubDb(hubDbPath(h.dir));
1067
+ try {
1068
+ const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
1069
+ // /login gated
1070
+ const loginRes = await handler(req("/login"));
1071
+ expect(loginRes.status).toBe(503);
1072
+ // /admin/setup open
1073
+ const setupRes = await handler(req("/admin/setup"));
1074
+ expect(setupRes.status).toBe(200);
1075
+ // /health open
1076
+ const healthRes = await handler(req("/health"));
1077
+ expect(healthRes.status).toBe(200);
1078
+ // / open
1079
+ const rootRes = await handler(req("/"));
1080
+ expect(rootRes.status).toBe(200);
1081
+ // /.well-known/parachute.json open
1082
+ const wkRes = await handler(req("/.well-known/parachute.json"));
1083
+ expect(wkRes.status).toBe(200);
1084
+ } finally {
1085
+ db.close();
1086
+ }
1087
+ } finally {
1088
+ h.cleanup();
1089
+ }
1090
+ });
1091
+
1092
+ test("pre-admin lockout falls away once an admin exists", async () => {
1093
+ const h = makeHarness();
1094
+ try {
1095
+ const db = openHubDb(hubDbPath(h.dir));
1096
+ try {
1097
+ // Before: /api/me 503s under the lockout.
1098
+ const before = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
1099
+ expect(before.status).toBe(503);
1100
+ // After seeding an admin: dispatch resumes normal handling.
1101
+ await createUser(db, "owner", "pw");
1102
+ const after = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
1103
+ // /api/me with no session returns `hasSession: false` 200, not 503.
1104
+ expect(after.status).toBe(200);
1105
+ } finally {
1106
+ db.close();
1107
+ }
1108
+ } finally {
1109
+ h.cleanup();
1110
+ }
1111
+ });
1112
+
652
1113
  test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
653
1114
  const h = makeHarness();
654
1115
  try {
@@ -1024,15 +1485,15 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1024
1485
  }
1025
1486
  });
1026
1487
 
1027
- test("single-segment /vault/<name> picks proxy when registered, SPA shell when not", async () => {
1488
+ test("single-segment /vault/<name> picks proxy when registered, 404 when not", async () => {
1028
1489
  // Two cases share one fixture so the contrast is explicit:
1029
1490
  // - `/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.
1491
+ // - `/vault/nonexistent` has no match → 404 directly (no SPA-shell
1492
+ // fallback under /vault since hub#231 moved the admin SPA to
1493
+ // /admin/*; the /vault/<name>/* slot is now exclusively the
1494
+ // per-vault content proxy).
1033
1495
  // 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.
1496
+ // before the 404; the SPA fallback that used to live here is gone.
1036
1497
  const h = makeHarness();
1037
1498
  const upstream = startUpstream("default-vault");
1038
1499
  try {
@@ -1065,10 +1526,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1065
1526
  expect(body.tag).toBe("default-vault");
1066
1527
  expect(body.pathname).toBe("/vault/default");
1067
1528
 
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>");
1529
+ const notFound = await fetcher(req("/vault/nonexistent"));
1530
+ expect(notFound.status).toBe(404);
1072
1531
  } finally {
1073
1532
  upstream.stop();
1074
1533
  h.cleanup();