@openparachute/hub 0.5.2 → 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 (76) 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 +159 -320
  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 +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/hub-server.ts CHANGED
@@ -9,16 +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/token (POST) authorization_code + refresh_token grants
20
- * /oauth/register (POST) RFC 7591 dynamic client registration
21
- * 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
22
68
  *
23
69
  * Invoked as:
24
70
  * bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
@@ -39,10 +85,9 @@ import type { Database } from "bun:sqlite";
39
85
  import { existsSync } from "node:fs";
40
86
  import { dirname, join, resolve } from "node:path";
41
87
  import { fileURLToPath } from "node:url";
88
+ import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
42
89
  import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
43
90
  import {
44
- handleAdminConfigGet,
45
- handleAdminConfigPost,
46
91
  handleAdminLoginGet,
47
92
  handleAdminLoginPost,
48
93
  handleAdminLogoutPost,
@@ -50,9 +95,17 @@ import {
50
95
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
51
96
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
52
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";
53
103
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
104
+ import { ensureCsrfToken } from "./csrf.ts";
105
+ import { readExposeState } from "./expose-state.ts";
54
106
  import { HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
55
107
  import { hubDbPath, openHubDb } from "./hub-db.ts";
108
+ import { type RenderHubOpts, renderHub } from "./hub.ts";
56
109
  import { pemToJwk } from "./jwks.ts";
57
110
  import {
58
111
  type ModuleManifest,
@@ -60,15 +113,24 @@ import {
60
113
  } from "./module-manifest.ts";
61
114
  import {
62
115
  authorizationServerMetadata,
116
+ handleApproveClientPost,
63
117
  handleAuthorizeGet,
64
118
  handleAuthorizePost,
65
119
  handleRegister,
66
120
  handleRevoke,
67
121
  handleToken,
68
122
  } from "./oauth-handlers.ts";
123
+ import { buildHubBoundOrigins } from "./origin-check.ts";
69
124
  import { clearPid, writePid } from "./process-state.ts";
125
+ import {
126
+ FIRST_PARTY_FALLBACKS,
127
+ effectivePublicExposure,
128
+ shortNameForManifest,
129
+ } from "./service-spec.ts";
70
130
  import { type ServiceEntry, readManifest } from "./services-manifest.ts";
131
+ import { findActiveSession } from "./sessions.ts";
71
132
  import { getAllPublicKeys } from "./signing-keys.ts";
133
+ import { getUserById } from "./users.ts";
72
134
  import { buildWellKnown, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
73
135
 
74
136
  interface Args {
@@ -136,9 +198,16 @@ export function findVaultUpstream(
136
198
  for (const s of services) {
137
199
  if (!isVaultEntry(s)) continue;
138
200
  for (const path of s.paths) {
139
- if (pathname === path || pathname.startsWith(`${path}/`)) {
140
- if (!best || path.length > best.mount.length) {
141
- best = { port: s.port, mount: path, entry: s };
201
+ // Normalize trailing slashes before comparison (#197). A services.json
202
+ // entry written with `paths: ["/vault/default/"]` would otherwise only
203
+ // match the exact pathname `/vault/default/` and never any sub-path,
204
+ // because `pathname.startsWith("/vault/default//")` is always false.
205
+ // The "|| '/'" branch keeps a bare-root mount "/" stable rather than
206
+ // collapsing it to an empty string.
207
+ const norm = path.replace(/\/+$/, "") || "/";
208
+ if (pathname === norm || pathname.startsWith(`${norm}/`)) {
209
+ if (!best || norm.length > best.mount.length) {
210
+ best = { port: s.port, mount: norm, entry: s };
142
211
  }
143
212
  }
144
213
  }
@@ -146,6 +215,65 @@ export function findVaultUpstream(
146
215
  return best;
147
216
  }
148
217
 
218
+ /**
219
+ * The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
220
+ * every request reaches it via one of three trusted forwarders (or directly
221
+ * over loopback). The forwarder injects characteristic headers that we use to
222
+ * classify; nothing else can reach the listener, so spoofing isn't a concern.
223
+ *
224
+ * "loopback" — direct localhost call (CLI, on-box service, dev shell).
225
+ * "tailnet" — `tailscale serve` forwarding an authed tailnet user.
226
+ * "public" — `tailscale funnel` (public-over-tailnet, unauthed) OR a
227
+ * cloudflared tunnel forwarding from the public internet.
228
+ *
229
+ * Used to gate `publicExposure: "loopback"` services on the generic
230
+ * `/<svc>/*` dispatch (the hub's only layer-gate). Hub-owned paths (`/`,
231
+ * `/admin/*`, `/api/*`, `/hub/*`, `/oauth/*`, `/.well-known/*`, `/vault/*`,
232
+ * `/vaults`) reach all layers and rely on app-level auth (admin session
233
+ * cookie + 2FA, OAuth, per-service tokens) — they are NOT layer-blocked.
234
+ */
235
+ export type RequestLayer = "loopback" | "tailnet" | "public";
236
+
237
+ /**
238
+ * Classify the trust layer for an incoming request by inspecting proxy
239
+ * headers. Order matters: cloudflared headers come first because cloudflared
240
+ * could in principle be deployed alongside tailscale on the same node.
241
+ *
242
+ * Header reference (verified against tailscale serve.go on 2026-05-08):
243
+ * - `Tailscale-User-Login` is set ONLY by `tailscale serve` for an authed
244
+ * tailnet user. Tagged-source nodes don't get it. Funnel never sets it.
245
+ * - `Tailscale-Funnel-Request: ?1` is set ONLY by Tailscale Funnel.
246
+ * Mutually exclusive with `Tailscale-User-Login` (the serve.go path
247
+ * returns early when funneled).
248
+ * - `CF-Ray` and `CF-Connecting-IP` are set by Cloudflare's edge for
249
+ * anything proxied through a cloudflared tunnel.
250
+ *
251
+ * Spoofing isn't a concern: hub binds `127.0.0.1:1939`, so external requests
252
+ * can't reach the listener except via these trusted forwarders. Tailscale
253
+ * specifically strips the same headers from incoming requests before
254
+ * re-injecting them, so even a malicious tailnet peer can't impersonate a
255
+ * different user. We could mirror that strip-on-arrival defense, but it's
256
+ * belt-and-braces given the bind shape.
257
+ *
258
+ * Default to "loopback" when no proxy headers are present — that's the
259
+ * direct-localhost case. Funnel without `Tailscale-Funnel-Request` would
260
+ * also fall here, but Tailscale always sets the header on funneled
261
+ * requests, so this branch only fires for true loopback callers.
262
+ */
263
+ export function layerOf(req: Request): RequestLayer {
264
+ const h = req.headers;
265
+ if (h.get("cf-ray") !== null || h.get("cf-connecting-ip") !== null) return "public";
266
+ // Match the structured-header value (`?1`) rather than mere presence:
267
+ // serve.go only ever emits `?1`, so insisting on the canonical value keeps
268
+ // the classifier's intent obvious to a future reader (don't loosen this to
269
+ // `!== null` — Tailscale's contract is the value, not the header name).
270
+ // CF-Ray / CF-Connecting-IP are open-string identifiers with no canonical
271
+ // value to compare against, hence the presence-check above.
272
+ if (h.get("tailscale-funnel-request") === "?1") return "public";
273
+ if (h.get("tailscale-user-login") !== null) return "tailnet";
274
+ return "loopback";
275
+ }
276
+
149
277
  /**
150
278
  * Forward a request to a loopback service on `127.0.0.1:<port>`. By default
151
279
  * the incoming pathname + query are preserved verbatim; pass `targetPath` to
@@ -229,7 +357,20 @@ async function proxyToVault(req: Request, manifestPath: string): Promise<Respons
229
357
  const url = new URL(req.url);
230
358
  const match = findVaultUpstream(services, url.pathname);
231
359
  if (!match) return undefined;
232
- return proxyRequest(req, match.port, "vault");
360
+ // Layer-gate on `publicExposure: "loopback"` — hide the entry from non-
361
+ // loopback callers as if it doesn't exist. "allowed" / "auth-required"
362
+ // pass through; the service does its own auth.
363
+ if (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
364
+ return new Response("not found", { status: 404 });
365
+ }
366
+ // Symmetry with proxyToService (#196): honor `stripPrefix` with FIRST_-
367
+ // PARTY_FALLBACKS as a fallback source. No first-party vault fallback
368
+ // declares stripPrefix today (vault expects the full `/vault/<name>/*`
369
+ // path), so this is a no-op in practice — but reading the same shape in
370
+ // both proxies keeps the dispatch surface consistent for future readers.
371
+ const stripPrefix = stripPrefixFor(match.entry);
372
+ const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
373
+ return proxyRequest(req, match.port, "vault", targetPath);
233
374
  }
234
375
 
235
376
  /**
@@ -248,9 +389,18 @@ export function findServiceUpstream(
248
389
  for (const s of services) {
249
390
  if (isVaultEntry(s)) continue;
250
391
  for (const path of s.paths) {
251
- if (pathname === path || pathname.startsWith(`${path}/`)) {
252
- if (!best || path.length > best.mount.length) {
253
- best = { port: s.port, mount: path, entry: s };
392
+ // Normalize trailing slashes before comparison (#197). A services.json
393
+ // entry written with `paths: ["/notes/"]` would otherwise only match
394
+ // the exact pathname `/notes/` and never `/notes/assets/index.js`
395
+ // `pathname.startsWith("/notes//")` is always false because URLs
396
+ // don't have double slashes. Result: SPA shell loads but every asset
397
+ // 404s (notes blank-screen on Aaron's box, 2026-05-08).
398
+ // The "|| '/'" branch keeps a bare-root mount "/" stable rather than
399
+ // collapsing it to an empty string.
400
+ const norm = path.replace(/\/+$/, "") || "/";
401
+ if (pathname === norm || pathname.startsWith(`${norm}/`)) {
402
+ if (!best || norm.length > best.mount.length) {
403
+ best = { port: s.port, mount: norm, entry: s };
254
404
  }
255
405
  }
256
406
  }
@@ -290,12 +440,42 @@ async function proxyToService(req: Request, manifestPath: string): Promise<Respo
290
440
  const url = new URL(req.url);
291
441
  const match = findServiceUpstream(services, url.pathname);
292
442
  if (!match) return undefined;
293
- const targetPath = match.entry.stripPrefix
294
- ? url.pathname.slice(match.mount.length) || "/"
295
- : undefined;
443
+ // Layer-gate on `publicExposure: "loopback"`. From the perspective of a
444
+ // tailnet/public caller, a loopback-only service must be indistinguishable
445
+ // from "not installed" — 404, not 403, so we don't leak the existence of
446
+ // the route. "allowed" / "auth-required" pass through; the service does
447
+ // its own auth.
448
+ if (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
449
+ return new Response("not found", { status: 404 });
450
+ }
451
+ // Consult FIRST_PARTY_FALLBACKS as a fallback for `stripPrefix` (#196).
452
+ // Scribe v0.4.0 doesn't write `stripPrefix: true` to its services.json
453
+ // entry — the declaration only lives in hub's SCRIBE_FALLBACK manifest.
454
+ // Pre-#187 this didn't matter because the per-service tailscale serve
455
+ // plan baked the path into the target URL; post-#187 routing went through
456
+ // hub which wasn't consulting the fallback registry. Same shape as how
457
+ // `effectivePublicExposure` already handles fallback derivation in
458
+ // service-spec.ts. Explicit-on-entry still wins; absent → fallback →
459
+ // false (preserving existing keep-prefix default for unknown services).
460
+ const stripPrefix = stripPrefixFor(match.entry);
461
+ const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
296
462
  return proxyRequest(req, match.port, match.entry.name, targetPath);
297
463
  }
298
464
 
465
+ /**
466
+ * Resolve effective `stripPrefix` for a service entry. Explicit on-entry
467
+ * wins; otherwise consult `FIRST_PARTY_FALLBACKS` keyed by short name (so
468
+ * scribe's vendored fallback supplies `stripPrefix: true` even when scribe's
469
+ * own boot doesn't write it). Defaults to `false` — keep the prefix —
470
+ * matching the pre-#196 dispatch behavior for unknown / third-party services.
471
+ */
472
+ function stripPrefixFor(entry: ServiceEntry): boolean {
473
+ if (entry.stripPrefix !== undefined) return entry.stripPrefix;
474
+ const short = shortNameForManifest(entry.name);
475
+ const fb = short !== undefined ? FIRST_PARTY_FALLBACKS[short] : undefined;
476
+ return fb?.manifest.stripPrefix ?? false;
477
+ }
478
+
299
479
  export interface HubFetchDeps {
300
480
  /**
301
481
  * Lazily opens (or returns a cached handle to) the hub DB. Optional so
@@ -333,6 +513,23 @@ export interface HubFetchDeps {
333
513
  * fixture installDirs.
334
514
  */
335
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;
336
533
  }
337
534
 
338
535
  /**
@@ -365,6 +562,50 @@ async function loadManagementUrls(
365
562
  return out;
366
563
  }
367
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
+
368
609
  /**
369
610
  * Resolve the SPA bundle dir. We anchor to this file's location so a
370
611
  * `bun src/hub-server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
@@ -378,22 +619,23 @@ function defaultSpaDistDir(): string {
378
619
  }
379
620
 
380
621
  /**
381
- * The SPA serves at two mounts:
382
- *
383
- * - `/vault` — primary, since hub#168-realignment. Matches the operator
384
- * pattern of `/<module>` as the entry point (alongside `/notes`, `/agent`,
385
- * `/scribe`). VaultsList, NewVault, and per-vault detail routes hang off
386
- * here.
387
- * - `/hub` back-compat. `/hub/permissions` (cross-vault grants) is a hub
388
- * concern and stays where bookmarks expect it. `/hub/vaults*` is a 301 to
389
- * `/vault*` further up the dispatch keeping it out of this mount.
390
- *
391
- * Both mounts serve the same SPA bundle. Asset URLs are origin-absolute
392
- * (`/vault/assets/...`) per the build base, so the HTML loads correctly
393
- * regardless of which mount served it. main.tsx detects the active mount
394
- * 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.
395
637
  */
396
- type SpaMount = "/vault" | "/hub";
638
+ type SpaMount = "/admin";
397
639
 
398
640
  /**
399
641
  * Pick a content type for static assets the SPA build produces. Vite's
@@ -440,8 +682,8 @@ function spaContentType(pathname: string): string {
440
682
  * filter rejects sub-paths containing "..", and the resolved absolute
441
683
  * path is checked to start with `dist/` before any read.
442
684
  *
443
- * `mount` is the prefix being served (`/vault` or `/hub`); we strip it
444
- * 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/`.
445
687
  */
446
688
  async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount): Promise<Response> {
447
689
  if (!existsSync(spaDistDir)) {
@@ -450,7 +692,7 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
450
692
  { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
451
693
  );
452
694
  }
453
- // Strip the mount prefix; "/vault" → "", "/vault/" → "/", "/vault/x" → "/x".
695
+ // Strip the mount prefix; "/admin" → "", "/admin/" → "/", "/admin/x" → "/x".
454
696
  const sub = pathname === mount ? "" : pathname.slice(mount.length);
455
697
  const indexPath = join(spaDistDir, "index.html");
456
698
 
@@ -492,31 +734,137 @@ export function hubFetch(
492
734
  const configuredIssuer = deps?.issuer;
493
735
  const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
494
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
+ });
495
749
 
496
- const oauthDeps = (req: Request) => ({
497
- issuer: configuredIssuer ?? new URL(req.url).origin,
498
- });
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
+ };
499
767
 
500
768
  return async (req) => {
501
769
  const url = new URL(req.url);
502
770
  const pathname = url.pathname;
503
771
 
504
- // 301 back-compat: `/hub/vaults*` was the SPA's vault-management entry
505
- // before hub#168-realignment. Bookmarks and any cached operator-typed
506
- // URLs land here; permanent redirect keeps them working without leaving
507
- // a dangling SPA route. Query string preserved; fragment is client-side
508
- // and survives the redirect at the browser. Method-agnostic — even a
509
- // misrouted POST gets the redirect, since there's no /hub/vaults POST
510
- // 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
+ }
511
798
  if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
512
- const newPath = `/vault${pathname.slice("/hub/vaults".length)}`;
799
+ const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
513
800
  return new Response("", {
514
801
  status: 301,
515
802
  headers: { location: `${newPath}${url.search}` },
516
803
  });
517
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
+ }
518
841
 
519
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).
520
868
  if (!existsSync(hubHtmlPath)) {
521
869
  return new Response("hub.html not found", { status: 404 });
522
870
  }
@@ -546,14 +894,17 @@ export function hubFetch(
546
894
  try {
547
895
  const manifest = readManifest(manifestPath);
548
896
  const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
549
- const managementUrlByName = await loadManagementUrls(
550
- manifest.services,
551
- deps?.readModuleManifest ?? defaultReadModuleManifest,
552
- );
897
+ const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
898
+ const [managementUrlByName, serviceUiMeta] = await Promise.all([
899
+ loadManagementUrls(manifest.services, readManifestFn),
900
+ loadServiceUiMetadata(manifest.services, readManifestFn),
901
+ ]);
553
902
  const doc = buildWellKnown({
554
903
  services: manifest.services,
555
904
  canonicalOrigin,
556
905
  managementUrlFor: (entry) => managementUrlByName.get(entry.name),
906
+ uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
907
+ displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
557
908
  });
558
909
  return new Response(JSON.stringify(doc), {
559
910
  headers: { "content-type": "application/json", ...corsHeaders },
@@ -569,6 +920,30 @@ export function hubFetch(
569
920
  }
570
921
  }
571
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
+
572
947
  if (pathname === "/.well-known/jwks.json") {
573
948
  // JWKS is also a cross-origin fetch target (browser-side OAuth
574
949
  // libraries pull this to verify access tokens). Same wildcard CORS
@@ -629,6 +1004,18 @@ export function hubFetch(
629
1004
  return new Response("method not allowed", { status: 405 });
630
1005
  }
631
1006
 
1007
+ // Inline approve form for the operator-driven pending-client flow (#208).
1008
+ // Receives `client_id` + `csrf_token` + `return_to` from the form rendered
1009
+ // by handleAuthorizeGet when the operator hits a pending client. Three
1010
+ // gates inside the handler: CSRF, active session, same-origin Origin.
1011
+ if (pathname === "/oauth/authorize/approve") {
1012
+ if (!getDb) {
1013
+ return new Response("hub db not configured", { status: 503 });
1014
+ }
1015
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1016
+ return handleApproveClientPost(getDb(), req, oauthDeps(req));
1017
+ }
1018
+
632
1019
  if (pathname === "/oauth/token") {
633
1020
  if (!getDb) {
634
1021
  return new Response("hub db not configured", { status: 503 });
@@ -663,15 +1050,10 @@ export function hubFetch(
663
1050
  });
664
1051
  }
665
1052
 
666
- // /hub SPA mount (back-compat). Kept for `/hub/permissions` and any other
667
- // hub-level admin surface that lived under /hub/ before the realignment.
668
- // /hub/vaults* is a separate concern handled by the 301 redirect lower
669
- // down the redirect runs first so it never reaches here. Only GET —
670
- // POSTs for vault create go to /vaults, not the SPA mount.
671
- if (pathname === "/hub" || pathname.startsWith("/hub/")) {
672
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
673
- return serveSpa(spaDistDir, pathname, "/hub");
674
- }
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.
675
1057
 
676
1058
  if (pathname === "/admin/host-admin-token") {
677
1059
  if (!getDb) return new Response("hub db not configured", { status: 503 });
@@ -701,6 +1083,55 @@ export function hubFetch(
701
1083
  });
702
1084
  }
703
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
+
704
1135
  if (pathname === "/api/grants") {
705
1136
  if (!getDb) return new Response("hub db not configured", { status: 503 });
706
1137
  return handleListGrants(req, {
@@ -721,63 +1152,98 @@ export function hubFetch(
721
1152
  });
722
1153
  }
723
1154
 
724
- 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") {
725
1193
  if (!getDb) return new Response("hub db not configured", { status: 503 });
726
1194
  if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
727
1195
  if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
728
1196
  return new Response("method not allowed", { status: 405 });
729
1197
  }
730
1198
 
731
- if (pathname === "/admin/logout") {
1199
+ if (pathname === "/logout") {
732
1200
  if (!getDb) return new Response("hub db not configured", { status: 503 });
733
1201
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
734
1202
  return handleAdminLogoutPost(getDb(), req);
735
1203
  }
736
1204
 
737
- if (pathname === "/admin/config") {
738
- if (!getDb) return new Response("hub db not configured", { status: 503 });
739
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
740
- 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
+ });
741
1215
  }
742
1216
 
743
- if (pathname.startsWith("/admin/config/")) {
744
- if (!getDb) return new Response("hub db not configured", { status: 503 });
745
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
746
- const name = decodeURIComponent(pathname.slice("/admin/config/".length));
747
- if (!name || name.includes("/")) {
748
- return new Response("not found", { status: 404 });
749
- }
750
- return handleAdminConfigPost(getDb(), req, name);
751
- }
752
-
753
- // /vault — primary SPA mount + dynamic per-vault proxy share this
754
- // namespace. Order matters:
755
- // 1. `/vault` exact → SPA shell (vault list).
756
- // 2. `/vault/<known-vault>/...` → proxy to the vault backend, picked
757
- // from services.json by longest-mount-prefix. Read per request so a
758
- // `parachute vault create` performed after `parachute expose` is
759
- // immediately reachable (#144).
760
- // 3. `/vault/<spa-route>` → SPA shell. Only single-segment paths
761
- // (`/vault/new`, `/vault/<name>`) and `/vault/assets/*` count as
762
- // SPA routes. Multi-segment requests like `/vault/<unknown>/health`
763
- // are vault-API shapes targeting a non-existent vault and 404 —
764
- // otherwise the SPA shell would mask backend 404s with HTML.
765
- // `new` and `assets` are reserved vault names (see
766
- // `RESERVED_VAULT_NAMES` in admin-vaults.ts) so an operator
767
- // can't register a vault that shadows the SPA's create route or
768
- // its static asset bundle.
769
- if (pathname === "/vault") {
770
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
771
- return serveSpa(spaDistDir, pathname, "/vault");
772
- }
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.
773
1225
  if (pathname.startsWith("/vault/")) {
774
1226
  const proxied = await proxyToVault(req, manifestPath);
775
1227
  if (proxied) return proxied;
776
- const sub = pathname.slice("/vault/".length);
777
- const isSpaRoute = !sub.includes("/") || sub.startsWith("assets/");
778
- if (!isSpaRoute) return new Response("not found", { status: 404 });
779
- if (req.method !== "GET") return new Response("not found", { status: 404 });
780
- 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");
781
1247
  }
782
1248
 
783
1249
  // Generic services.json-driven dispatch for non-vault modules. Reaches
@@ -801,7 +1267,7 @@ if (import.meta.main) {
801
1267
  Bun.serve({
802
1268
  port,
803
1269
  hostname: "127.0.0.1",
804
- fetch: hubFetch(wellKnownDir, { getDb, issuer }),
1270
+ fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
805
1271
  });
806
1272
  // Register PID + port from the running hub itself so any startup path
807
1273
  // (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from