@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
package/src/hub-server.ts CHANGED
@@ -9,17 +9,62 @@
9
9
  * `tailscale serve … --set-path=/ http://127.0.0.1:<port>`. This shim is
10
10
  * that localhost backing.
11
11
  *
12
- * Routes (all bound to 127.0.0.1):
13
- * / → hub.html
14
- * /hub.html → hub.html
15
- * /.well-known/parachute.json → built dynamically from services.json
16
- * /.well-known/jwks.json → JWKS from hub.db
17
- * /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints)
18
- * /oauth/authorize (GET + POST) loginconsent → auth code
19
- * /oauth/authorize/approve (POST) inline DCR approve form (#208)
20
- * /oauth/token (POST) authorization_code + refresh_token grants
21
- * /oauth/register (POST) RFC 7591 dynamic client registration
22
- * anything else 404
12
+ * Routes (all bound to 127.0.0.1) — listed in dispatch order. Order is
13
+ * load-bearing: 301 redirects fire before the proxies and SPA mount they
14
+ * preempt; admin API endpoints fire before the /admin/* SPA catch-all.
15
+ *
16
+ * # Pre-rename 301 back-compat (hub#231 — first so they preempt any
17
+ * # remaining handlers under /vault or /hub).
18
+ * /vault, /vault/, /vault/new 301/admin/vaults[/new]
19
+ * /hub/vaults* 301 /admin/vaults*
20
+ * /hub/permissions 301 /admin/permissions
21
+ * /hub/tokens 301 /admin/tokens
22
+ * /hub, /hub/ 301 → /admin/vaults
23
+ * /admin/login, /admin/logout → 301 → /login, /logout
24
+ *
25
+ * # Discovery + well-known.
26
+ * /, /hub.html → hub.html (the discovery page)
27
+ * /.well-known/parachute.json → built dynamically from services.json
28
+ * /.well-known/parachute-revocation.json → revoked-jti list (hub#212 Phase 1)
29
+ * /.well-known/jwks.json → JWKS from hub.db
30
+ * /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints)
31
+ *
32
+ * # OAuth issuer.
33
+ * /oauth/authorize (GET + POST) → login → consent → auth code
34
+ * /oauth/authorize/approve (POST) → inline DCR approve form (#208)
35
+ * /oauth/token (POST) → authorization_code + refresh_token grants
36
+ * /oauth/register (POST) → RFC 7591 dynamic client registration
37
+ * /oauth/revoke (POST) → RFC 7009 refresh-token revocation
38
+ *
39
+ * # Admin API + bearer-mint surfaces (must precede /admin/* SPA mount).
40
+ * /vaults (POST) → create vault
41
+ * /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
42
+ * /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
43
+ * /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
44
+ * /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
45
+ * /api/auth/revoke-token (POST) → revoke registry-row token by jti
46
+ * /api/auth/tokens (GET) → paginated registry list
47
+ * /api/grants (GET) → OAuth consent grants list
48
+ * /api/grants/<client_id> (DELETE) → revoke a single OAuth grant
49
+ * /api/oauth/clients/<id> (GET) → OAuth client details
50
+ * /api/oauth/clients/<id>/approve (POST) → flip a pending client to approved
51
+ * /login (GET + POST) → operator password login
52
+ * /logout (POST) → end admin session
53
+ * /admin/config* → 301 → /admin/vaults (legacy
54
+ * portal retired post-SPA-rework)
55
+ *
56
+ * # Per-vault content proxy (user-facing vault data: Notes PWA, MCP, etc.).
57
+ * /vault/<name>/* → proxy to the vault backend
58
+ *
59
+ * # Admin SPA mount (catch-all under /admin; runs after all admin API
60
+ * # handlers above, so /admin/<known> reaches the right handler and
61
+ * # /admin/<spa-route> serves the SPA shell).
62
+ * /admin, /admin/, /admin/* → SPA shell (vaults / new / permissions / tokens)
63
+ *
64
+ * # Generic services.json-driven proxy (non-vault modules: notes, scribe, agent).
65
+ * /<service-mount>/* → proxy via services.json longest-prefix
66
+ *
67
+ * anything else → 404
23
68
  *
24
69
  * Invoked as:
25
70
  * bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
@@ -40,10 +85,9 @@ import type { Database } from "bun:sqlite";
40
85
  import { existsSync } from "node:fs";
41
86
  import { dirname, join, resolve } from "node:path";
42
87
  import { fileURLToPath } from "node:url";
88
+ import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
43
89
  import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
44
90
  import {
45
- handleAdminConfigGet,
46
- handleAdminConfigPost,
47
91
  handleAdminLoginGet,
48
92
  handleAdminLoginPost,
49
93
  handleAdminLogoutPost,
@@ -51,9 +95,17 @@ import {
51
95
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
52
96
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
53
97
  import { handleCreateVault } from "./admin-vaults.ts";
98
+ import { handleApiMe } from "./api-me.ts";
99
+ import { handleApiMintToken } from "./api-mint-token.ts";
100
+ import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
101
+ import { handleApiRevokeToken } from "./api-revoke-token.ts";
102
+ import { handleApiTokens } from "./api-tokens.ts";
54
103
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
104
+ import { ensureCsrfToken } from "./csrf.ts";
105
+ import { readExposeState } from "./expose-state.ts";
55
106
  import { HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
56
107
  import { hubDbPath, openHubDb } from "./hub-db.ts";
108
+ import { type RenderHubOpts, renderHub } from "./hub.ts";
57
109
  import { pemToJwk } from "./jwks.ts";
58
110
  import {
59
111
  type ModuleManifest,
@@ -68,6 +120,7 @@ import {
68
120
  handleRevoke,
69
121
  handleToken,
70
122
  } from "./oauth-handlers.ts";
123
+ import { buildHubBoundOrigins } from "./origin-check.ts";
71
124
  import { clearPid, writePid } from "./process-state.ts";
72
125
  import {
73
126
  FIRST_PARTY_FALLBACKS,
@@ -75,7 +128,9 @@ import {
75
128
  shortNameForManifest,
76
129
  } from "./service-spec.ts";
77
130
  import { type ServiceEntry, readManifest } from "./services-manifest.ts";
131
+ import { findActiveSession } from "./sessions.ts";
78
132
  import { getAllPublicKeys } from "./signing-keys.ts";
133
+ import { getUserById } from "./users.ts";
79
134
  import { buildWellKnown, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
80
135
 
81
136
  interface Args {
@@ -458,6 +513,23 @@ export interface HubFetchDeps {
458
513
  * fixture installDirs.
459
514
  */
460
515
  readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
516
+ /**
517
+ * Hub's listening port. Threaded into the OAuth `hubBoundOrigins` set so
518
+ * the same-origin defense accepts loopback access (`http://localhost:<port>`,
519
+ * `http://127.0.0.1:<port>`) alongside the configured issuer. Closes #245
520
+ * Case A (operator on `localhost:1939` getting "Cross-origin request
521
+ * rejected" because Origin ≠ tailnet issuer).
522
+ */
523
+ loopbackPort?: number;
524
+ /**
525
+ * Test seam for reading `expose-state.json`. Production reads the operator's
526
+ * `~/.parachute/expose-state.json` via `readExposeState`; tests inject a
527
+ * fake to drive tailnet/funnel origins into the bound set without standing
528
+ * up real exposes. Returns `undefined` when no state file is present
529
+ * (pre-`parachute expose` state — fine, the issuer + loopback still cover
530
+ * legitimate access).
531
+ */
532
+ loadExposeHubOrigin?: () => string | undefined;
461
533
  }
462
534
 
463
535
  /**
@@ -490,6 +562,50 @@ async function loadManagementUrls(
490
562
  return out;
491
563
  }
492
564
 
565
+ /**
566
+ * For each NON-vault `ServiceEntry` with a known `installDir`, read its
567
+ * `.parachute/module.json` and surface the optional `uiUrl` and
568
+ * `displayName`. Returns two `name → value` maps keyed by services.json
569
+ * entry name. Mirrors `loadManagementUrls` (vault is the analog there;
570
+ * non-vault services are the analog here — vaults are user-facing via
571
+ * Notes, not their own UI).
572
+ *
573
+ * Why read at request time and not from services.json: services own the
574
+ * write side of services.json (`upsertService` replaces the whole entry
575
+ * on every boot), so any install-time copy of `uiUrl` / `displayName`
576
+ * would be clobbered the first time the service writes its own entry.
577
+ * Reading from `installDir/module.json` at request time avoids the gap
578
+ * and matches the established `managementUrl` precedent.
579
+ *
580
+ * Quiet on per-entry errors: a malformed module.json on one service
581
+ * shouldn't 500 the entire well-known doc — its row just renders without
582
+ * a Services tile. The validator already throws structured errors from
583
+ * `readModuleManifest`; logging them once here is the right floor.
584
+ */
585
+ async function loadServiceUiMetadata(
586
+ services: readonly ServiceEntry[],
587
+ read: (installDir: string) => Promise<ModuleManifest | null>,
588
+ ): Promise<{ uiUrls: Map<string, string>; displayNames: Map<string, string> }> {
589
+ const uiUrls = new Map<string, string>();
590
+ const displayNames = new Map<string, string>();
591
+ await Promise.all(
592
+ services.map(async (s) => {
593
+ // Skip vaults — they have their own loadManagementUrls path and no
594
+ // operator-facing user UI of their own (content browses via Notes).
595
+ if (isVaultEntry(s) || !s.installDir) return;
596
+ try {
597
+ const m = await read(s.installDir);
598
+ if (m?.uiUrl) uiUrls.set(s.name, m.uiUrl);
599
+ if (m?.displayName) displayNames.set(s.name, m.displayName);
600
+ } catch (err) {
601
+ const msg = err instanceof Error ? err.message : String(err);
602
+ console.warn(`well-known: skipping uiUrl/displayName for ${s.name}: ${msg}`);
603
+ }
604
+ }),
605
+ );
606
+ return { uiUrls, displayNames };
607
+ }
608
+
493
609
  /**
494
610
  * Resolve the SPA bundle dir. We anchor to this file's location so a
495
611
  * `bun src/hub-server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
@@ -503,22 +619,23 @@ function defaultSpaDistDir(): string {
503
619
  }
504
620
 
505
621
  /**
506
- * The SPA serves at two mounts:
507
- *
508
- * - `/vault` — primary, since hub#168-realignment. Matches the operator
509
- * pattern of `/<module>` as the entry point (alongside `/notes`, `/agent`,
510
- * `/scribe`). VaultsList, NewVault, and per-vault detail routes hang off
511
- * here.
512
- * - `/hub` back-compat. `/hub/permissions` (cross-vault grants) is a hub
513
- * concern and stays where bookmarks expect it. `/hub/vaults*` is a 301 to
514
- * `/vault*` further up the dispatch keeping it out of this mount.
515
- *
516
- * Both mounts serve the same SPA bundle. Asset URLs are origin-absolute
517
- * (`/vault/assets/...`) per the build base, so the HTML loads correctly
518
- * regardless of which mount served it. main.tsx detects the active mount
519
- * at runtime and configures react-router's `basename` accordingly.
622
+ * The admin SPA serves at a single mount: `/admin/*` (since hub#231).
623
+ *
624
+ * Routes:
625
+ * - `/admin/vaults` → vault list (the SPA's home)
626
+ * - `/admin/vaults/new` vault create form
627
+ * - `/admin/permissions` → OAuth consent grant management
628
+ * - `/admin/tokens` token registry: mint / list / revoke
629
+ *
630
+ * Asset URLs are origin-absolute (`/admin/assets/...`) per the Vite build
631
+ * base. main.tsx pins react-router's basename to `/admin`.
632
+ *
633
+ * Pre-rename mounts (the old `/vault` for the vault SPA, `/hub/*` for
634
+ * permissions+tokens) are 301-redirected further up the dispatch so cached
635
+ * operator URLs keep working. `/vault/<name>/*` (per-vault content proxy)
636
+ * stays — that's user-facing vault data, not part of this admin SPA.
520
637
  */
521
- type SpaMount = "/vault" | "/hub";
638
+ type SpaMount = "/admin";
522
639
 
523
640
  /**
524
641
  * Pick a content type for static assets the SPA build produces. Vite's
@@ -565,8 +682,8 @@ function spaContentType(pathname: string): string {
565
682
  * filter rejects sub-paths containing "..", and the resolved absolute
566
683
  * path is checked to start with `dist/` before any read.
567
684
  *
568
- * `mount` is the prefix being served (`/vault` or `/hub`); we strip it
569
- * from `pathname` to land on the file path inside `dist/`.
685
+ * `mount` is the prefix being served (`/admin`); we strip it from
686
+ * `pathname` to land on the file path inside `dist/`.
570
687
  */
571
688
  async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount): Promise<Response> {
572
689
  if (!existsSync(spaDistDir)) {
@@ -575,7 +692,7 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
575
692
  { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
576
693
  );
577
694
  }
578
- // Strip the mount prefix; "/vault" → "", "/vault/" → "/", "/vault/x" → "/x".
695
+ // Strip the mount prefix; "/admin" → "", "/admin/" → "/", "/admin/x" → "/x".
579
696
  const sub = pathname === mount ? "" : pathname.slice(mount.length);
580
697
  const indexPath = join(spaDistDir, "index.html");
581
698
 
@@ -617,31 +734,137 @@ export function hubFetch(
617
734
  const configuredIssuer = deps?.issuer;
618
735
  const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
619
736
  const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
737
+ const loopbackPort = deps?.loopbackPort;
738
+ const loadExposeHubOrigin =
739
+ deps?.loadExposeHubOrigin ??
740
+ (() => {
741
+ try {
742
+ return readExposeState()?.hubOrigin;
743
+ } catch {
744
+ // Malformed expose-state.json shouldn't 500 hub on every same-origin
745
+ // check — the issuer + loopback already cover legitimate access.
746
+ return undefined;
747
+ }
748
+ });
620
749
 
621
- const oauthDeps = (req: Request) => ({
622
- issuer: configuredIssuer ?? new URL(req.url).origin,
623
- });
750
+ const oauthDeps = (req: Request) => {
751
+ const issuer = configuredIssuer ?? new URL(req.url).origin;
752
+ return {
753
+ issuer,
754
+ // Per-request resolution (closes #245): expose-state.json can change
755
+ // mid-session (operator runs `parachute expose tailnet` while hub is
756
+ // up), so we re-read the bound origins on each call rather than
757
+ // capturing at hub start. Cheap — a single small JSON parse per OAuth
758
+ // request, only on the cookie-POST paths that consult it.
759
+ hubBoundOrigins: () =>
760
+ buildHubBoundOrigins({
761
+ issuer,
762
+ loopbackPort,
763
+ exposeHubOrigin: loadExposeHubOrigin(),
764
+ }),
765
+ };
766
+ };
624
767
 
625
768
  return async (req) => {
626
769
  const url = new URL(req.url);
627
770
  const pathname = url.pathname;
628
771
 
629
- // 301 back-compat: `/hub/vaults*` was the SPA's vault-management entry
630
- // before hub#168-realignment. Bookmarks and any cached operator-typed
631
- // URLs land here; permanent redirect keeps them working without leaving
632
- // a dangling SPA route. Query string preserved; fragment is client-side
633
- // and survives the redirect at the browser. Method-agnostic — even a
634
- // misrouted POST gets the redirect, since there's no /hub/vaults POST
635
- // endpoint to protect.
772
+ // 301 back-compat for the pre-hub#231 admin-SPA mounts:
773
+ //
774
+ // `/vault` → `/admin/vaults`
775
+ // `/vault/new` → `/admin/vaults/new`
776
+ // `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
777
+ // it now retargets at the new admin mount instead
778
+ // of the interim `/vault` mount)
779
+ // `/hub/permissions` → `/admin/permissions`
780
+ // `/hub/tokens` → `/admin/tokens`
781
+ // `/hub` (bare) → `/admin/vaults`
782
+ //
783
+ // Permanent redirect so cached operator URLs keep working without
784
+ // leaving dangling SPA routes. Query string preserved; fragment is
785
+ // client-side and survives the redirect at the browser. Method-agnostic
786
+ // — even a misrouted POST gets the redirect; none of these paths host a
787
+ // POST endpoint to protect.
788
+ //
789
+ // `/vault/<name>/*` is INTENTIONALLY excluded — that's the per-vault
790
+ // content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
791
+ if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
792
+ const sub = pathname === "/vault/new" ? "/new" : "";
793
+ return new Response("", {
794
+ status: 301,
795
+ headers: { location: `/admin/vaults${sub}${url.search}` },
796
+ });
797
+ }
636
798
  if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
637
- const newPath = `/vault${pathname.slice("/hub/vaults".length)}`;
799
+ const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
638
800
  return new Response("", {
639
801
  status: 301,
640
802
  headers: { location: `${newPath}${url.search}` },
641
803
  });
642
804
  }
805
+ if (pathname === "/hub/permissions") {
806
+ return new Response("", {
807
+ status: 301,
808
+ headers: { location: `/admin/permissions${url.search}` },
809
+ });
810
+ }
811
+ if (pathname === "/hub/tokens") {
812
+ return new Response("", {
813
+ status: 301,
814
+ headers: { location: `/admin/tokens${url.search}` },
815
+ });
816
+ }
817
+ if (pathname === "/hub" || pathname === "/hub/") {
818
+ return new Response("", {
819
+ status: 301,
820
+ headers: { location: `/admin/vaults${url.search}` },
821
+ });
822
+ }
823
+
824
+ // Login surface rename: `/admin/login` and `/admin/logout` 301 to the
825
+ // canonical `/login` and `/logout`. The names were "admin" only by
826
+ // historical accident — the handlers serve every parachute auth flow
827
+ // (operator, OAuth user-redirect, future SPA sign-in). Renaming makes
828
+ // the surface name match its actual scope.
829
+ if (pathname === "/admin/login") {
830
+ return new Response("", {
831
+ status: 301,
832
+ headers: { location: `/login${url.search}` },
833
+ });
834
+ }
835
+ if (pathname === "/admin/logout") {
836
+ return new Response("", {
837
+ status: 301,
838
+ headers: { location: `/logout${url.search}` },
839
+ });
840
+ }
643
841
 
644
842
  if (pathname === "/" || pathname === "/hub.html") {
843
+ // When a DB is configured, render the discovery page dynamically so
844
+ // the header carries a "Signed in as <name>" affordance for the
845
+ // active session. Without a DB, fall back to the static disk file
846
+ // (signed-out shape) — the disk file is what `parachute expose`
847
+ // wrote out, used when the hub-server is running without state.
848
+ if (getDb) {
849
+ const db = getDb();
850
+ const session = findActiveSession(db, req);
851
+ let renderOpts: RenderHubOpts = {};
852
+ const headers: Record<string, string> = {
853
+ "content-type": "text/html; charset=utf-8",
854
+ };
855
+ if (session) {
856
+ const user = getUserById(db, session.userId);
857
+ if (user) {
858
+ const csrf = ensureCsrfToken(req);
859
+ renderOpts = {
860
+ session: { displayName: user.username, csrfToken: csrf.token },
861
+ };
862
+ if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
863
+ }
864
+ }
865
+ return new Response(renderHub(renderOpts), { headers });
866
+ }
867
+ // No DB configured → fall back to static file (signed-out only).
645
868
  if (!existsSync(hubHtmlPath)) {
646
869
  return new Response("hub.html not found", { status: 404 });
647
870
  }
@@ -671,14 +894,17 @@ export function hubFetch(
671
894
  try {
672
895
  const manifest = readManifest(manifestPath);
673
896
  const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
674
- const managementUrlByName = await loadManagementUrls(
675
- manifest.services,
676
- deps?.readModuleManifest ?? defaultReadModuleManifest,
677
- );
897
+ const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
898
+ const [managementUrlByName, serviceUiMeta] = await Promise.all([
899
+ loadManagementUrls(manifest.services, readManifestFn),
900
+ loadServiceUiMetadata(manifest.services, readManifestFn),
901
+ ]);
678
902
  const doc = buildWellKnown({
679
903
  services: manifest.services,
680
904
  canonicalOrigin,
681
905
  managementUrlFor: (entry) => managementUrlByName.get(entry.name),
906
+ uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
907
+ displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
682
908
  });
683
909
  return new Response(JSON.stringify(doc), {
684
910
  headers: { "content-type": "application/json", ...corsHeaders },
@@ -694,6 +920,30 @@ export function hubFetch(
694
920
  }
695
921
  }
696
922
 
923
+ if (pathname === REVOCATION_LIST_MOUNT) {
924
+ // Revocation list (hub#212 Phase 1). Public — same CORS posture as
925
+ // jwks.json since resource servers (vault/scribe/agent) fetch it
926
+ // cross-origin on the 60s polling cadence wired in Phase 4.
927
+ const corsHeaders = {
928
+ "access-control-allow-origin": "*",
929
+ "access-control-allow-methods": "GET, OPTIONS",
930
+ };
931
+ if (req.method === "OPTIONS") {
932
+ return new Response(null, { status: 204, headers: corsHeaders });
933
+ }
934
+ if (!getDb) {
935
+ return new Response('{"error":"revocation list unavailable: db not configured"}', {
936
+ status: 503,
937
+ headers: { "content-type": "application/json", ...corsHeaders },
938
+ });
939
+ }
940
+ const resp = handleRevocationList(req, { db: getDb() });
941
+ // Layer the wildcard CORS over whatever cache-control the handler set.
942
+ const merged = new Headers(resp.headers);
943
+ for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
944
+ return new Response(resp.body, { status: resp.status, headers: merged });
945
+ }
946
+
697
947
  if (pathname === "/.well-known/jwks.json") {
698
948
  // JWKS is also a cross-origin fetch target (browser-side OAuth
699
949
  // libraries pull this to verify access tokens). Same wildcard CORS
@@ -800,15 +1050,10 @@ export function hubFetch(
800
1050
  });
801
1051
  }
802
1052
 
803
- // /hub SPA mount (back-compat). Kept for `/hub/permissions` and any other
804
- // hub-level admin surface that lived under /hub/ before the realignment.
805
- // /hub/vaults* is a separate concern handled by the 301 redirect lower
806
- // down the redirect runs first so it never reaches here. Only GET —
807
- // POSTs for vault create go to /vaults, not the SPA mount.
808
- if (pathname === "/hub" || pathname.startsWith("/hub/")) {
809
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
810
- return serveSpa(spaDistDir, pathname, "/hub");
811
- }
1053
+ // Note: the old `/hub/*` SPA mount has been retired. Known prefixes
1054
+ // (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
1055
+ // 301-redirected at the top of dispatch. Any other `/hub/*` path falls
1056
+ // through to the catch-all 404 there's no admin surface left there.
812
1057
 
813
1058
  if (pathname === "/admin/host-admin-token") {
814
1059
  if (!getDb) return new Response("hub db not configured", { status: 503 });
@@ -838,6 +1083,55 @@ export function hubFetch(
838
1083
  });
839
1084
  }
840
1085
 
1086
+ if (pathname === "/api/me") {
1087
+ if (!getDb) {
1088
+ return Response.json(
1089
+ { error: "service_unavailable", error_description: "hub db not configured" },
1090
+ { status: 503 },
1091
+ );
1092
+ }
1093
+ return handleApiMe(req, { db: getDb() });
1094
+ }
1095
+
1096
+ if (pathname === "/api/auth/mint-token") {
1097
+ if (!getDb) {
1098
+ return Response.json(
1099
+ { error: "service_unavailable", error_description: "hub db not configured" },
1100
+ { status: 503 },
1101
+ );
1102
+ }
1103
+ return handleApiMintToken(req, {
1104
+ db: getDb(),
1105
+ issuer: oauthDeps(req).issuer,
1106
+ });
1107
+ }
1108
+
1109
+ if (pathname === "/api/auth/revoke-token") {
1110
+ if (!getDb) {
1111
+ return Response.json(
1112
+ { error: "service_unavailable", error_description: "hub db not configured" },
1113
+ { status: 503 },
1114
+ );
1115
+ }
1116
+ return handleApiRevokeToken(req, {
1117
+ db: getDb(),
1118
+ issuer: oauthDeps(req).issuer,
1119
+ });
1120
+ }
1121
+
1122
+ if (pathname === "/api/auth/tokens") {
1123
+ if (!getDb) {
1124
+ return Response.json(
1125
+ { error: "service_unavailable", error_description: "hub db not configured" },
1126
+ { status: 503 },
1127
+ );
1128
+ }
1129
+ return handleApiTokens(req, {
1130
+ db: getDb(),
1131
+ issuer: oauthDeps(req).issuer,
1132
+ });
1133
+ }
1134
+
841
1135
  if (pathname === "/api/grants") {
842
1136
  if (!getDb) return new Response("hub db not configured", { status: 503 });
843
1137
  return handleListGrants(req, {
@@ -858,63 +1152,98 @@ export function hubFetch(
858
1152
  });
859
1153
  }
860
1154
 
861
- if (pathname === "/admin/login") {
1155
+ // OAuth client lookup + approval. Both bearer-gated under host:admin.
1156
+ // Two paths: `/api/oauth/clients/<id>` (GET, details) and
1157
+ // `/api/oauth/clients/<id>/approve` (POST, flip to approved). The
1158
+ // SPA approve-client deep link reads details from the first and
1159
+ // submits approval to the second — keeps the surface easy to test
1160
+ // and audit without overloading a single verb.
1161
+ if (pathname.startsWith("/api/oauth/clients/")) {
1162
+ if (!getDb) return new Response("hub db not configured", { status: 503 });
1163
+ const tail = pathname.slice("/api/oauth/clients/".length);
1164
+ if (!tail) return new Response("not found", { status: 404 });
1165
+ const approveSuffix = "/approve";
1166
+ if (tail.endsWith(approveSuffix)) {
1167
+ const clientId = decodeURIComponent(tail.slice(0, -approveSuffix.length));
1168
+ if (!clientId || clientId.includes("/")) {
1169
+ return new Response("not found", { status: 404 });
1170
+ }
1171
+ return handleApproveClient(req, clientId, {
1172
+ db: getDb(),
1173
+ issuer: oauthDeps(req).issuer,
1174
+ });
1175
+ }
1176
+ const clientId = decodeURIComponent(tail);
1177
+ if (!clientId || clientId.includes("/")) {
1178
+ return new Response("not found", { status: 404 });
1179
+ }
1180
+ return handleGetClient(req, clientId, {
1181
+ db: getDb(),
1182
+ issuer: oauthDeps(req).issuer,
1183
+ });
1184
+ }
1185
+
1186
+ // Canonical login/logout. The handlers themselves are unchanged from
1187
+ // when they lived at /admin/login + /admin/logout; the rename surfaced
1188
+ // via #231-followup so the URL reflects the surface's actual scope
1189
+ // (entry point for ALL parachute auth — not admin-only). The
1190
+ // /admin/login and /admin/logout paths 301 to here, dispatched at the
1191
+ // top of this fn alongside the other back-compat redirects.
1192
+ if (pathname === "/login") {
862
1193
  if (!getDb) return new Response("hub db not configured", { status: 503 });
863
1194
  if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
864
1195
  if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
865
1196
  return new Response("method not allowed", { status: 405 });
866
1197
  }
867
1198
 
868
- if (pathname === "/admin/logout") {
1199
+ if (pathname === "/logout") {
869
1200
  if (!getDb) return new Response("hub db not configured", { status: 503 });
870
1201
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
871
1202
  return handleAdminLogoutPost(getDb(), req);
872
1203
  }
873
1204
 
874
- if (pathname === "/admin/config") {
875
- if (!getDb) return new Response("hub db not configured", { status: 503 });
876
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
877
- return handleAdminConfigGet(getDb(), req);
1205
+ // Legacy `/admin/config` (server-rendered module-config portal, #46)
1206
+ // retired post-SPA-rework. 301 the SPA home so any bookmark or stale
1207
+ // post-login redirect lands somewhere useful. The route stays here in
1208
+ // dispatch order (above the /admin/* SPA catch-all) so the redirect
1209
+ // wins over a SPA shell render.
1210
+ if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
1211
+ return new Response(null, {
1212
+ status: 301,
1213
+ headers: { location: "/admin/vaults" },
1214
+ });
878
1215
  }
879
1216
 
880
- if (pathname.startsWith("/admin/config/")) {
881
- if (!getDb) return new Response("hub db not configured", { status: 503 });
882
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
883
- const name = decodeURIComponent(pathname.slice("/admin/config/".length));
884
- if (!name || name.includes("/")) {
885
- return new Response("not found", { status: 404 });
886
- }
887
- return handleAdminConfigPost(getDb(), req, name);
888
- }
889
-
890
- // /vault — primary SPA mount + dynamic per-vault proxy share this
891
- // namespace. Order matters:
892
- // 1. `/vault` exact → SPA shell (vault list).
893
- // 2. `/vault/<known-vault>/...` → proxy to the vault backend, picked
894
- // from services.json by longest-mount-prefix. Read per request so a
895
- // `parachute vault create` performed after `parachute expose` is
896
- // immediately reachable (#144).
897
- // 3. `/vault/<spa-route>` → SPA shell. Only single-segment paths
898
- // (`/vault/new`, `/vault/<name>`) and `/vault/assets/*` count as
899
- // SPA routes. Multi-segment requests like `/vault/<unknown>/health`
900
- // are vault-API shapes targeting a non-existent vault and 404 —
901
- // otherwise the SPA shell would mask backend 404s with HTML.
902
- // `new` and `assets` are reserved vault names (see
903
- // `RESERVED_VAULT_NAMES` in admin-vaults.ts) so an operator
904
- // can't register a vault that shadows the SPA's create route or
905
- // its static asset bundle.
906
- if (pathname === "/vault") {
907
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
908
- return serveSpa(spaDistDir, pathname, "/vault");
909
- }
1217
+ // /vault/<name>/* — per-vault content proxy. Stays as user-facing
1218
+ // surface (the Notes PWA loads through here, etc.). The bare `/vault`
1219
+ // and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
1220
+ // the top of dispatch now. Multi-segment requests like
1221
+ // `/vault/<unknown>/health` are vault-API shapes targeting a
1222
+ // non-existent vault and 404 directly there's no SPA-shell fallback
1223
+ // here anymore (the SPA moved to /admin), so we can't accidentally
1224
+ // mask a backend 404 with HTML.
910
1225
  if (pathname.startsWith("/vault/")) {
911
1226
  const proxied = await proxyToVault(req, manifestPath);
912
1227
  if (proxied) return proxied;
913
- const sub = pathname.slice("/vault/".length);
914
- const isSpaRoute = !sub.includes("/") || sub.startsWith("assets/");
915
- if (!isSpaRoute) return new Response("not found", { status: 404 });
916
- if (req.method !== "GET") return new Response("not found", { status: 404 });
917
- return serveSpa(spaDistDir, pathname, "/vault");
1228
+ return new Response("not found", { status: 404 });
1229
+ }
1230
+
1231
+ // /admin/* SPA mount. All non-SPA admin handlers (host-admin-token,
1232
+ // vault-admin-token, login, logout, config, api/auth/*, api/grants,
1233
+ // grants/*) ran above and either matched or returned. Anything that
1234
+ // makes it here under /admin/* is a SPA route or asset request; the
1235
+ // SPA's own router renders the page and handles 404 client-side for
1236
+ // unknown sub-paths.
1237
+ if (pathname === "/admin" || pathname === "/admin/") {
1238
+ // Unprefixed /admin → SPA shell pointed at the vault list (its home).
1239
+ // The SPA's basename is /admin, so the router will land on / and
1240
+ // render VaultsList.
1241
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1242
+ return serveSpa(spaDistDir, pathname, "/admin");
1243
+ }
1244
+ if (pathname.startsWith("/admin/")) {
1245
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1246
+ return serveSpa(spaDistDir, pathname, "/admin");
918
1247
  }
919
1248
 
920
1249
  // Generic services.json-driven dispatch for non-vault modules. Reaches
@@ -938,7 +1267,7 @@ if (import.meta.main) {
938
1267
  Bun.serve({
939
1268
  port,
940
1269
  hostname: "127.0.0.1",
941
- fetch: hubFetch(wellKnownDir, { getDb, issuer }),
1270
+ fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
942
1271
  });
943
1272
  // Register PID + port from the running hub itself so any startup path
944
1273
  // (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from