@openparachute/hub 0.5.7 → 0.5.9-rc.6

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 (60) 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 +338 -65
  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 +266 -5
  20. package/src/__tests__/operator-token.test.ts +379 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/status.test.ts +199 -0
  23. package/src/__tests__/well-known.test.ts +69 -0
  24. package/src/admin-clients.ts +139 -0
  25. package/src/admin-handlers.ts +32 -254
  26. package/src/admin-host-admin-token.ts +25 -10
  27. package/src/admin-login-ui.ts +256 -0
  28. package/src/admin-vault-admin-token.ts +1 -1
  29. package/src/api-me.ts +124 -0
  30. package/src/api-mint-token.ts +239 -0
  31. package/src/api-revocation-list.ts +59 -0
  32. package/src/api-revoke-token.ts +153 -0
  33. package/src/api-tokens.ts +224 -0
  34. package/src/commands/auth.ts +408 -51
  35. package/src/commands/expose-2fa-warning.ts +6 -6
  36. package/src/commands/status.ts +74 -10
  37. package/src/csrf.ts +6 -3
  38. package/src/help.ts +10 -4
  39. package/src/hub-db.ts +63 -0
  40. package/src/hub-server.ts +426 -97
  41. package/src/hub.ts +272 -149
  42. package/src/install-source.ts +291 -0
  43. package/src/jwt-sign.ts +265 -5
  44. package/src/module-manifest.ts +48 -10
  45. package/src/oauth-handlers.ts +183 -54
  46. package/src/oauth-ui.ts +23 -2
  47. package/src/operator-token.ts +272 -18
  48. package/src/origin-check.ts +127 -0
  49. package/src/rate-limit.ts +5 -2
  50. package/src/scope-explanations.ts +33 -2
  51. package/src/sessions.ts +1 -1
  52. package/src/well-known.ts +54 -1
  53. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  54. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  55. package/web/ui/dist/index.html +2 -2
  56. package/src/__tests__/admin-config.test.ts +0 -281
  57. package/src/admin-config-ui.ts +0 -534
  58. package/src/admin-config.ts +0 -226
  59. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  60. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -81,7 +81,7 @@ describe("printPublic2FAWarning", () => {
81
81
  expect(fired).toBe(true);
82
82
  const joined = logs.join("\n");
83
83
  expect(joined).toContain("2FA is not enrolled");
84
- expect(joined).toContain("https://vault.example.com/admin/login");
84
+ expect(joined).toContain("https://vault.example.com/login");
85
85
  expect(joined).toContain("parachute auth 2fa enroll");
86
86
  });
87
87
 
@@ -111,15 +111,13 @@ describe("printPublic2FAWarning", () => {
111
111
  expect(logs.some((l) => l.includes("2FA is not enrolled"))).toBe(true);
112
112
  });
113
113
 
114
- test("embeds the supplied publicUrl into the /admin/login pointer", () => {
114
+ test("embeds the supplied publicUrl into the /login pointer", () => {
115
115
  const logs: string[] = [];
116
116
  printPublic2FAWarning({
117
117
  status: status({ hasTotp: false }),
118
118
  log: (l) => logs.push(l),
119
119
  publicUrl: "https://parachute.taildf9ce2.ts.net",
120
120
  });
121
- expect(logs.some((l) => l.includes("https://parachute.taildf9ce2.ts.net/admin/login"))).toBe(
122
- true,
123
- );
121
+ expect(logs.some((l) => l.includes("https://parachute.taildf9ce2.ts.net/login"))).toBe(true);
124
122
  });
125
123
  });
@@ -557,7 +557,7 @@ describe("exposeCloudflareUp", () => {
557
557
  expect(code).toBe(0);
558
558
  const joined = logs.join("\n");
559
559
  expect(joined).toContain("2FA is not enrolled");
560
- expect(joined).toContain("https://vault.example.com/admin/login");
560
+ expect(joined).toContain("https://vault.example.com/login");
561
561
  expect(joined).toContain("parachute auth 2fa enroll");
562
562
  } finally {
563
563
  env.cleanup();
@@ -963,8 +963,8 @@ describe("expose public up", () => {
963
963
  const joined = logs.join("\n");
964
964
  expect(joined).toContain("2FA is not enrolled");
965
965
  expect(joined).toContain("parachute auth 2fa enroll");
966
- // /admin/login pointer uses the canonical https://<fqdn> origin.
967
- expect(joined).toContain("https://parachute.taildf9ce2.ts.net/admin/login");
966
+ // /login pointer uses the canonical https://<fqdn> origin.
967
+ expect(joined).toContain("https://parachute.taildf9ce2.ts.net/login");
968
968
  } finally {
969
969
  h.cleanup();
970
970
  }
@@ -57,7 +57,7 @@ describe("hubFetch routing", () => {
57
57
  }
58
58
  });
59
59
 
60
- test("/hub.html serves the same file as /", async () => {
60
+ test("/hub.html serves the same file as / (no DB → static fallback)", async () => {
61
61
  const h = makeHarness();
62
62
  try {
63
63
  writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
@@ -69,6 +69,58 @@ describe("hubFetch routing", () => {
69
69
  }
70
70
  });
71
71
 
72
+ test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
73
+ // The dynamic path takes over from the static disk file the moment a
74
+ // DB is configured. With no session cookie, we still render — just
75
+ // with the "Sign in" affordance.
76
+ const h = makeHarness();
77
+ try {
78
+ const db = openHubDb(hubDbPath(h.dir));
79
+ try {
80
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
81
+ expect(res.status).toBe(200);
82
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
83
+ const body = await res.text();
84
+ expect(body).toContain('class="auth-indicator"');
85
+ expect(body).toContain("Sign in");
86
+ expect(body).not.toContain("Signed in as");
87
+ } finally {
88
+ db.close();
89
+ }
90
+ } finally {
91
+ h.cleanup();
92
+ }
93
+ });
94
+
95
+ test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
96
+ const h = makeHarness();
97
+ try {
98
+ const db = openHubDb(hubDbPath(h.dir));
99
+ try {
100
+ const { createUser } = await import("../users.ts");
101
+ const { createSession, buildSessionCookie, SESSION_TTL_MS } = await import(
102
+ "../sessions.ts"
103
+ );
104
+ const user = await createUser(db, "aaron", "pw");
105
+ const session = createSession(db, { userId: user.id });
106
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
107
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/", { headers: { cookie } }));
108
+ expect(res.status).toBe(200);
109
+ const body = await res.text();
110
+ expect(body).toContain("Signed in as");
111
+ expect(body).toContain("aaron");
112
+ expect(body).toContain('action="/logout"');
113
+ expect(body).toContain('name="__csrf"');
114
+ // CSRF cookie was minted on the response (no prior cookie present).
115
+ expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_csrf=");
116
+ } finally {
117
+ db.close();
118
+ }
119
+ } finally {
120
+ h.cleanup();
121
+ }
122
+ });
123
+
72
124
  test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
73
125
  const h = makeHarness();
74
126
  try {
@@ -194,6 +246,105 @@ describe("hubFetch routing", () => {
194
246
  }
195
247
  });
196
248
 
249
+ // Phase D consumer-side: each non-vault service entry's
250
+ // module.json:uiUrl + displayName ride through to doc.services. The
251
+ // discovery page (`/`) reads them to render data-driven Service tiles.
252
+ test("/.well-known/parachute.json surfaces uiUrl + displayName from non-vault module manifests", async () => {
253
+ const h = makeHarness();
254
+ try {
255
+ const notesEntry: ServiceEntry = {
256
+ name: "parachute-notes",
257
+ port: 5173,
258
+ paths: ["/notes"],
259
+ health: "/health",
260
+ version: "0.0.1",
261
+ installDir: "/fake/notes",
262
+ };
263
+ writeManifest({ services: [notesEntry] }, h.manifestPath);
264
+ const res = await hubFetch(h.dir, {
265
+ manifestPath: h.manifestPath,
266
+ readModuleManifest: async () => ({
267
+ name: "notes",
268
+ manifestName: "parachute-notes",
269
+ kind: "frontend",
270
+ port: 5173,
271
+ paths: ["/notes"],
272
+ health: "/health",
273
+ uiUrl: "/notes",
274
+ displayName: "Notes",
275
+ }),
276
+ })(req("/.well-known/parachute.json"));
277
+ expect(res.status).toBe(200);
278
+ const body = (await res.json()) as {
279
+ services: Array<{ name: string; uiUrl?: string; displayName?: string }>;
280
+ };
281
+ const svc = body.services.find((s) => s.name === "parachute-notes");
282
+ expect(svc?.uiUrl).toMatch(/\/notes$/);
283
+ expect(svc?.displayName).toBe("Notes");
284
+ } finally {
285
+ h.cleanup();
286
+ }
287
+ });
288
+
289
+ test("/.well-known/parachute.json omits uiUrl when the non-vault manifest has none", async () => {
290
+ const h = makeHarness();
291
+ try {
292
+ const notesEntry: ServiceEntry = {
293
+ name: "parachute-notes",
294
+ port: 5173,
295
+ paths: ["/notes"],
296
+ health: "/health",
297
+ version: "0.0.1",
298
+ installDir: "/fake/notes",
299
+ };
300
+ writeManifest({ services: [notesEntry] }, h.manifestPath);
301
+ const res = await hubFetch(h.dir, {
302
+ manifestPath: h.manifestPath,
303
+ readModuleManifest: async () => ({
304
+ name: "notes",
305
+ manifestName: "parachute-notes",
306
+ kind: "frontend",
307
+ port: 5173,
308
+ paths: ["/notes"],
309
+ health: "/health",
310
+ // no uiUrl declared — discovery page will skip the tile.
311
+ }),
312
+ })(req("/.well-known/parachute.json"));
313
+ const body = (await res.json()) as { services: Array<{ uiUrl?: string }> };
314
+ expect(body.services[0]).not.toHaveProperty("uiUrl");
315
+ } finally {
316
+ h.cleanup();
317
+ }
318
+ });
319
+
320
+ test("/.well-known/parachute.json: uiUrl resolver is skipped for vault entries (loadManagementUrls handles vault)", async () => {
321
+ const h = makeHarness();
322
+ try {
323
+ const vaultWithDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake/vault" };
324
+ writeManifest({ services: [vaultWithDir] }, h.manifestPath);
325
+ // The fake module.json declares uiUrl, but vault is supposed to be
326
+ // skipped by loadServiceUiMetadata (it has its own managementUrl
327
+ // path). So doc.services[vault] should NOT carry uiUrl.
328
+ const res = await hubFetch(h.dir, {
329
+ manifestPath: h.manifestPath,
330
+ readModuleManifest: async () => ({
331
+ name: "vault",
332
+ manifestName: "parachute-vault",
333
+ kind: "api",
334
+ port: 1940,
335
+ paths: ["/vault/default"],
336
+ health: "/health",
337
+ uiUrl: "/should-be-ignored",
338
+ }),
339
+ })(req("/.well-known/parachute.json"));
340
+ const body = (await res.json()) as { services: Array<{ name: string; uiUrl?: string }> };
341
+ const vaultSvc = body.services.find((s) => s.name === "parachute-vault");
342
+ expect(vaultSvc).not.toHaveProperty("uiUrl");
343
+ } finally {
344
+ h.cleanup();
345
+ }
346
+ });
347
+
197
348
  // The bug this PR fixes: `parachute vault create techne` updates
198
349
  // services.json but the old code only re-derived parachute.json on
199
350
  // `parachute expose`. With the dynamic build, the second GET reflects
@@ -398,15 +549,12 @@ describe("hubFetch routing", () => {
398
549
  }
399
550
  });
400
551
 
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.
552
+ // SPA mount after hub#231: single `/admin/*` mount serves vault
553
+ // provisioning + permissions + tokens. Pre-rename `/vault` and `/hub/*`
554
+ // SPA URLs are 301-redirected; the per-vault content proxy at
555
+ // `/vault/<name>/*` stays where it is.
408
556
 
409
- test("/vault serves index.html when the SPA bundle exists", async () => {
557
+ test("/admin/vaults serves the SPA shell when the bundle exists", async () => {
410
558
  const h = makeHarness();
411
559
  try {
412
560
  const dist = join(h.dir, "dist");
@@ -414,7 +562,7 @@ describe("hubFetch routing", () => {
414
562
  writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
415
563
  writeManifest({ services: [] }, h.manifestPath);
416
564
  const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
417
- req("/vault"),
565
+ req("/admin/vaults"),
418
566
  );
419
567
  expect(res.status).toBe(200);
420
568
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
@@ -424,10 +572,7 @@ describe("hubFetch routing", () => {
424
572
  }
425
573
  });
426
574
 
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.
575
+ test("/admin/vaults/new serves the SPA shell (client-side route)", async () => {
431
576
  const h = makeHarness();
432
577
  try {
433
578
  const dist = join(h.dir, "dist");
@@ -435,7 +580,7 @@ describe("hubFetch routing", () => {
435
580
  writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
436
581
  writeManifest({ services: [] }, h.manifestPath);
437
582
  const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
438
- req("/vault/new"),
583
+ req("/admin/vaults/new"),
439
584
  );
440
585
  expect(res.status).toBe(200);
441
586
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
@@ -445,7 +590,34 @@ describe("hubFetch routing", () => {
445
590
  }
446
591
  });
447
592
 
448
- test("/vault/assets/*.js is served with the matching content-type", async () => {
593
+ test("/admin/permissions serves the SPA shell", async () => {
594
+ const h = makeHarness();
595
+ try {
596
+ const dist = join(h.dir, "dist");
597
+ mkdirIfMissing(dist);
598
+ writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
599
+ const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/permissions"));
600
+ expect(res.status).toBe(200);
601
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
602
+ } finally {
603
+ h.cleanup();
604
+ }
605
+ });
606
+
607
+ test("/admin/tokens serves the SPA shell", async () => {
608
+ const h = makeHarness();
609
+ try {
610
+ const dist = join(h.dir, "dist");
611
+ mkdirIfMissing(dist);
612
+ writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
613
+ const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/tokens"));
614
+ expect(res.status).toBe(200);
615
+ } finally {
616
+ h.cleanup();
617
+ }
618
+ });
619
+
620
+ test("/admin/assets/*.js is served with the matching content-type", async () => {
449
621
  const h = makeHarness();
450
622
  try {
451
623
  const dist = join(h.dir, "dist");
@@ -456,7 +628,7 @@ describe("hubFetch routing", () => {
456
628
  writeFileSync(join(assets, "main.js"), "console.log('hi');");
457
629
  writeManifest({ services: [] }, h.manifestPath);
458
630
  const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
459
- req("/vault/assets/main.js"),
631
+ req("/admin/assets/main.js"),
460
632
  );
461
633
  expect(res.status).toBe(200);
462
634
  expect(res.headers.get("content-type")).toBe("application/javascript; charset=utf-8");
@@ -466,14 +638,14 @@ describe("hubFetch routing", () => {
466
638
  }
467
639
  });
468
640
 
469
- test("/vault/* returns 503 with build hint when dist is missing", async () => {
641
+ test("/admin/* returns 503 with build hint when dist is missing", async () => {
470
642
  const h = makeHarness();
471
643
  try {
472
644
  writeManifest({ services: [] }, h.manifestPath);
473
645
  const res = await hubFetch(h.dir, {
474
646
  spaDistDir: join(h.dir, "missing"),
475
647
  manifestPath: h.manifestPath,
476
- })(req("/vault"));
648
+ })(req("/admin/vaults"));
477
649
  expect(res.status).toBe(503);
478
650
  expect(await res.text()).toContain("bun run build");
479
651
  } finally {
@@ -481,92 +653,194 @@ describe("hubFetch routing", () => {
481
653
  }
482
654
  });
483
655
 
484
- test("/vault rejects non-GET methods with 405", async () => {
656
+ test("/admin/vaults rejects non-GET methods with 405", async () => {
485
657
  const h = makeHarness();
486
658
  try {
487
659
  const dist = join(h.dir, "dist");
488
660
  mkdirIfMissing(dist);
489
661
  writeFileSync(join(dist, "index.html"), "<!doctype html>");
490
- const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/vault", { method: "POST" }));
662
+ const res = await hubFetch(h.dir, { spaDistDir: dist })(
663
+ req("/admin/vaults", { method: "POST" }),
664
+ );
491
665
  expect(res.status).toBe(405);
492
666
  } finally {
493
667
  h.cleanup();
494
668
  }
495
669
  });
496
670
 
497
- test("/hub/permissions serves the SPA shell (back-compat mount)", async () => {
671
+ // 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
672
+ // 301-redirect to the new /admin/* mount. Tests cover every entry in the
673
+ // dispatch — operator bookmarks landing on any of these still work.
674
+
675
+ test("301: /vault → /admin/vaults", async () => {
498
676
  const h = makeHarness();
499
677
  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>");
678
+ const res = await hubFetch(h.dir)(req("/vault"));
679
+ expect(res.status).toBe(301);
680
+ expect(res.headers.get("location")).toBe("/admin/vaults");
507
681
  } finally {
508
682
  h.cleanup();
509
683
  }
510
684
  });
511
685
 
512
- test("/hub/* returns 503 with build hint when dist is missing", async () => {
686
+ test("301: /vault/new /admin/vaults/new", async () => {
513
687
  const h = makeHarness();
514
688
  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");
689
+ const res = await hubFetch(h.dir)(req("/vault/new"));
690
+ expect(res.status).toBe(301);
691
+ expect(res.headers.get("location")).toBe("/admin/vaults/new");
520
692
  } finally {
521
693
  h.cleanup();
522
694
  }
523
695
  });
524
696
 
525
- test("/hub rejects non-GET methods with 405", async () => {
697
+ test("301: /vault preserves query string", async () => {
526
698
  const h = makeHarness();
527
699
  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);
700
+ const res = await hubFetch(h.dir)(req("/vault?next=foo"));
701
+ expect(res.status).toBe(301);
702
+ expect(res.headers.get("location")).toBe("/admin/vaults?next=foo");
535
703
  } finally {
536
704
  h.cleanup();
537
705
  }
538
706
  });
539
707
 
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.
708
+ test("301: /hub/vaults /admin/vaults (chain through the rename)", async () => {
709
+ // The /hub/vaults redirect predates #231 it used to land at /vault.
710
+ // Now it lands at the final /admin/vaults so old bookmarks don't bounce
711
+ // through two redirects.
543
712
  const h = makeHarness();
544
713
  try {
545
714
  const res = await hubFetch(h.dir)(req("/hub/vaults"));
546
715
  expect(res.status).toBe(301);
547
- expect(res.headers.get("location")).toBe("/vault");
716
+ expect(res.headers.get("location")).toBe("/admin/vaults");
548
717
  } finally {
549
718
  h.cleanup();
550
719
  }
551
720
  });
552
721
 
553
- test("/hub/vaults/new redirects to /vault/new", async () => {
722
+ test("301: /hub/vaults/new /admin/vaults/new", async () => {
554
723
  const h = makeHarness();
555
724
  try {
556
725
  const res = await hubFetch(h.dir)(req("/hub/vaults/new"));
557
726
  expect(res.status).toBe(301);
558
- expect(res.headers.get("location")).toBe("/vault/new");
727
+ expect(res.headers.get("location")).toBe("/admin/vaults/new");
559
728
  } finally {
560
729
  h.cleanup();
561
730
  }
562
731
  });
563
732
 
564
- test("/hub/vaults/* preserves the query string in the redirect", async () => {
733
+ test("301: /hub/vaults/* preserves the query string", async () => {
565
734
  const h = makeHarness();
566
735
  try {
567
736
  const res = await hubFetch(h.dir)(req("/hub/vaults/foo?bar=1&baz=2"));
568
737
  expect(res.status).toBe(301);
569
- expect(res.headers.get("location")).toBe("/vault/foo?bar=1&baz=2");
738
+ expect(res.headers.get("location")).toBe("/admin/vaults/foo?bar=1&baz=2");
739
+ } finally {
740
+ h.cleanup();
741
+ }
742
+ });
743
+
744
+ test("301: /hub/permissions → /admin/permissions", async () => {
745
+ const h = makeHarness();
746
+ try {
747
+ const res = await hubFetch(h.dir)(req("/hub/permissions"));
748
+ expect(res.status).toBe(301);
749
+ expect(res.headers.get("location")).toBe("/admin/permissions");
750
+ } finally {
751
+ h.cleanup();
752
+ }
753
+ });
754
+
755
+ test("301: /hub/tokens → /admin/tokens", async () => {
756
+ const h = makeHarness();
757
+ try {
758
+ const res = await hubFetch(h.dir)(req("/hub/tokens"));
759
+ expect(res.status).toBe(301);
760
+ expect(res.headers.get("location")).toBe("/admin/tokens");
761
+ } finally {
762
+ h.cleanup();
763
+ }
764
+ });
765
+
766
+ test("301: /hub bare → /admin/vaults", async () => {
767
+ const h = makeHarness();
768
+ try {
769
+ const res = await hubFetch(h.dir)(req("/hub"));
770
+ expect(res.status).toBe(301);
771
+ expect(res.headers.get("location")).toBe("/admin/vaults");
772
+ } finally {
773
+ h.cleanup();
774
+ }
775
+ });
776
+
777
+ // Login surface rename redirects (auth-UX cleanup): /admin/login and
778
+ // /admin/logout 301 to /login and /logout. Path-only test — the
779
+ // handlers themselves are exercised through the existing
780
+ // handleAdminLoginGet/Post + handleAdminLogoutPost test files.
781
+ test("301: /admin/login → /login", async () => {
782
+ const h = makeHarness();
783
+ try {
784
+ const res = await hubFetch(h.dir)(req("/admin/login"));
785
+ expect(res.status).toBe(301);
786
+ expect(res.headers.get("location")).toBe("/login");
787
+ } finally {
788
+ h.cleanup();
789
+ }
790
+ });
791
+
792
+ test("301: /admin/login preserves the next= query param", async () => {
793
+ const h = makeHarness();
794
+ try {
795
+ const res = await hubFetch(h.dir)(req("/admin/login?next=/admin/permissions"));
796
+ expect(res.status).toBe(301);
797
+ expect(res.headers.get("location")).toBe("/login?next=/admin/permissions");
798
+ } finally {
799
+ h.cleanup();
800
+ }
801
+ });
802
+
803
+ test("301: /admin/config → /admin/vaults (legacy server-rendered portal retired)", async () => {
804
+ const h = makeHarness();
805
+ try {
806
+ const res = await hubFetch(h.dir)(req("/admin/config"));
807
+ expect(res.status).toBe(301);
808
+ expect(res.headers.get("location")).toBe("/admin/vaults");
809
+ } finally {
810
+ h.cleanup();
811
+ }
812
+ });
813
+
814
+ test("301: /admin/config/<name> → /admin/vaults", async () => {
815
+ const h = makeHarness();
816
+ try {
817
+ const res = await hubFetch(h.dir)(req("/admin/config/vault"));
818
+ expect(res.status).toBe(301);
819
+ expect(res.headers.get("location")).toBe("/admin/vaults");
820
+ } finally {
821
+ h.cleanup();
822
+ }
823
+ });
824
+
825
+ test("301: /admin/logout → /logout", async () => {
826
+ const h = makeHarness();
827
+ try {
828
+ const res = await hubFetch(h.dir)(req("/admin/logout"));
829
+ expect(res.status).toBe(301);
830
+ expect(res.headers.get("location")).toBe("/logout");
831
+ } finally {
832
+ h.cleanup();
833
+ }
834
+ });
835
+
836
+ test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
837
+ const h = makeHarness();
838
+ try {
839
+ writeManifest({ services: [] }, h.manifestPath);
840
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
841
+ req("/hub/unknown-thing"),
842
+ );
843
+ expect(res.status).toBe(404);
570
844
  } finally {
571
845
  h.cleanup();
572
846
  }
@@ -591,10 +865,11 @@ describe("hubFetch routing", () => {
591
865
  ["/oauth/register", { method: "POST" }],
592
866
  ["/oauth/revoke", { method: "POST" }],
593
867
  ["/vaults", { method: "POST" }],
594
- ["/admin/login", { method: "POST" }],
595
- ["/admin/logout", { method: "POST" }],
596
- ["/admin/config", { method: "GET" }],
597
- ["/admin/config/example", { method: "POST" }],
868
+ // /login + /logout canonical names since the auth-UX rename;
869
+ // /admin/login + /admin/logout 301-redirect to here (separate
870
+ // tests pin the redirects themselves).
871
+ ["/login", { method: "POST" }],
872
+ ["/logout", { method: "POST" }],
598
873
  ["/admin/host-admin-token", { method: "GET" }],
599
874
  ];
600
875
  for (const [path, init] of cases) {
@@ -1024,15 +1299,15 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1024
1299
  }
1025
1300
  });
1026
1301
 
1027
- test("single-segment /vault/<name> picks proxy when registered, SPA shell when not", async () => {
1302
+ test("single-segment /vault/<name> picks proxy when registered, 404 when not", async () => {
1028
1303
  // Two cases share one fixture so the contrast is explicit:
1029
1304
  // - `/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.
1305
+ // - `/vault/nonexistent` has no match → 404 directly (no SPA-shell
1306
+ // fallback under /vault since hub#231 moved the admin SPA to
1307
+ // /admin/*; the /vault/<name>/* slot is now exclusively the
1308
+ // per-vault content proxy).
1033
1309
  // 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.
1310
+ // before the 404; the SPA fallback that used to live here is gone.
1036
1311
  const h = makeHarness();
1037
1312
  const upstream = startUpstream("default-vault");
1038
1313
  try {
@@ -1065,10 +1340,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1065
1340
  expect(body.tag).toBe("default-vault");
1066
1341
  expect(body.pathname).toBe("/vault/default");
1067
1342
 
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>");
1343
+ const notFound = await fetcher(req("/vault/nonexistent"));
1344
+ expect(notFound.status).toBe(404);
1072
1345
  } finally {
1073
1346
  upstream.stop();
1074
1347
  h.cleanup();