@openparachute/hub 0.5.7 → 0.5.10-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +526 -67
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
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,10 @@ 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 pkg from "../package.json" with { type: "json" };
89
+ import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
43
90
  import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
44
91
  import {
45
- handleAdminConfigGet,
46
- handleAdminConfigPost,
47
92
  handleAdminLoginGet,
48
93
  handleAdminLoginPost,
49
94
  handleAdminLogoutPost,
@@ -51,9 +96,17 @@ import {
51
96
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
52
97
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
53
98
  import { handleCreateVault } from "./admin-vaults.ts";
99
+ import { handleApiMe } from "./api-me.ts";
100
+ import { handleApiMintToken } from "./api-mint-token.ts";
101
+ import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
102
+ import { handleApiRevokeToken } from "./api-revoke-token.ts";
103
+ import { handleApiTokens } from "./api-tokens.ts";
54
104
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
55
- import { HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
105
+ import { ensureCsrfToken } from "./csrf.ts";
106
+ import { readExposeState } from "./expose-state.ts";
107
+ import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
56
108
  import { hubDbPath, openHubDb } from "./hub-db.ts";
109
+ import { type RenderHubOpts, renderHub } from "./hub.ts";
57
110
  import { pemToJwk } from "./jwks.ts";
58
111
  import {
59
112
  type ModuleManifest,
@@ -68,6 +121,7 @@ import {
68
121
  handleRevoke,
69
122
  handleToken,
70
123
  } from "./oauth-handlers.ts";
124
+ import { buildHubBoundOrigins } from "./origin-check.ts";
71
125
  import { clearPid, writePid } from "./process-state.ts";
72
126
  import {
73
127
  FIRST_PARTY_FALLBACKS,
@@ -75,18 +129,39 @@ import {
75
129
  shortNameForManifest,
76
130
  } from "./service-spec.ts";
77
131
  import { type ServiceEntry, readManifest } from "./services-manifest.ts";
132
+ import { findActiveSession } from "./sessions.ts";
78
133
  import { getAllPublicKeys } from "./signing-keys.ts";
79
- import { buildWellKnown, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
134
+ import { getUserById, userCount } from "./users.ts";
135
+ import {
136
+ WELL_KNOWN_DIR,
137
+ buildWellKnown,
138
+ isVaultEntry,
139
+ vaultInstanceNameFor,
140
+ } from "./well-known.ts";
80
141
 
81
142
  interface Args {
82
143
  port: number;
144
+ hostname: string;
83
145
  wellKnownDir: string;
84
146
  dbPath: string;
85
147
  issuer: string | undefined;
86
148
  }
87
149
 
88
- function parseArgs(argv: string[]): Args {
150
+ /**
151
+ * Parse hub-server flags. Container hosts (Render, Docker) configure us
152
+ * entirely via env vars — no flags. The `parachute expose` spawn path passes
153
+ * everything as flags. Flags beat env, env beats defaults.
154
+ *
155
+ * PORT — bind port (Render injects this)
156
+ * PARACHUTE_BIND_HOST — bind hostname; default 127.0.0.1 to keep
157
+ * the historical loopback posture safe.
158
+ * Containers should set 0.0.0.0.
159
+ * PARACHUTE_HUB_ORIGIN — canonical https://… origin used as the
160
+ * OAuth issuer claim.
161
+ */
162
+ function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): Args {
89
163
  let port: number | undefined;
164
+ let hostname: string | undefined;
90
165
  let wellKnownDir: string | undefined;
91
166
  let dbPath: string | undefined;
92
167
  let issuer: string | undefined;
@@ -95,11 +170,11 @@ function parseArgs(argv: string[]): Args {
95
170
  if (a === "--port") {
96
171
  const v = argv[++i];
97
172
  if (!v) throw new Error("--port requires a value");
98
- const n = Number.parseInt(v, 10);
99
- if (!Number.isInteger(n) || n <= 0 || n > 65535) {
100
- throw new Error(`--port must be 1..65535, got "${v}"`);
101
- }
102
- port = n;
173
+ port = parsePort(v);
174
+ } else if (a === "--hostname") {
175
+ const v = argv[++i];
176
+ if (!v) throw new Error("--hostname requires a value");
177
+ hostname = v;
103
178
  } else if (a === "--well-known-dir") {
104
179
  const v = argv[++i];
105
180
  if (!v) throw new Error("--well-known-dir requires a value");
@@ -116,9 +191,22 @@ function parseArgs(argv: string[]): Args {
116
191
  throw new Error(`unknown argument: ${a}`);
117
192
  }
118
193
  }
119
- if (port === undefined) throw new Error("--port is required");
120
- if (wellKnownDir === undefined) throw new Error("--well-known-dir is required");
121
- return { port, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
194
+ if (port === undefined && env.PORT) port = parsePort(env.PORT);
195
+ if (port === undefined) port = HUB_DEFAULT_PORT;
196
+ if (hostname === undefined) hostname = env.PARACHUTE_BIND_HOST || "127.0.0.1";
197
+ if (wellKnownDir === undefined) wellKnownDir = WELL_KNOWN_DIR;
198
+ if (issuer === undefined && env.PARACHUTE_HUB_ORIGIN) {
199
+ issuer = env.PARACHUTE_HUB_ORIGIN.replace(/\/+$/, "");
200
+ }
201
+ return { port, hostname, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
202
+ }
203
+
204
+ function parsePort(v: string): number {
205
+ const n = Number.parseInt(v, 10);
206
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
207
+ throw new Error(`port must be 1..65535, got "${v}"`);
208
+ }
209
+ return n;
122
210
  }
123
211
 
124
212
  /**
@@ -458,6 +546,23 @@ export interface HubFetchDeps {
458
546
  * fixture installDirs.
459
547
  */
460
548
  readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
549
+ /**
550
+ * Hub's listening port. Threaded into the OAuth `hubBoundOrigins` set so
551
+ * the same-origin defense accepts loopback access (`http://localhost:<port>`,
552
+ * `http://127.0.0.1:<port>`) alongside the configured issuer. Closes #245
553
+ * Case A (operator on `localhost:1939` getting "Cross-origin request
554
+ * rejected" because Origin ≠ tailnet issuer).
555
+ */
556
+ loopbackPort?: number;
557
+ /**
558
+ * Test seam for reading `expose-state.json`. Production reads the operator's
559
+ * `~/.parachute/expose-state.json` via `readExposeState`; tests inject a
560
+ * fake to drive tailnet/funnel origins into the bound set without standing
561
+ * up real exposes. Returns `undefined` when no state file is present
562
+ * (pre-`parachute expose` state — fine, the issuer + loopback still cover
563
+ * legitimate access).
564
+ */
565
+ loadExposeHubOrigin?: () => string | undefined;
461
566
  }
462
567
 
463
568
  /**
@@ -490,6 +595,50 @@ async function loadManagementUrls(
490
595
  return out;
491
596
  }
492
597
 
598
+ /**
599
+ * For each NON-vault `ServiceEntry` with a known `installDir`, read its
600
+ * `.parachute/module.json` and surface the optional `uiUrl` and
601
+ * `displayName`. Returns two `name → value` maps keyed by services.json
602
+ * entry name. Mirrors `loadManagementUrls` (vault is the analog there;
603
+ * non-vault services are the analog here — vaults are user-facing via
604
+ * Notes, not their own UI).
605
+ *
606
+ * Why read at request time and not from services.json: services own the
607
+ * write side of services.json (`upsertService` replaces the whole entry
608
+ * on every boot), so any install-time copy of `uiUrl` / `displayName`
609
+ * would be clobbered the first time the service writes its own entry.
610
+ * Reading from `installDir/module.json` at request time avoids the gap
611
+ * and matches the established `managementUrl` precedent.
612
+ *
613
+ * Quiet on per-entry errors: a malformed module.json on one service
614
+ * shouldn't 500 the entire well-known doc — its row just renders without
615
+ * a Services tile. The validator already throws structured errors from
616
+ * `readModuleManifest`; logging them once here is the right floor.
617
+ */
618
+ async function loadServiceUiMetadata(
619
+ services: readonly ServiceEntry[],
620
+ read: (installDir: string) => Promise<ModuleManifest | null>,
621
+ ): Promise<{ uiUrls: Map<string, string>; displayNames: Map<string, string> }> {
622
+ const uiUrls = new Map<string, string>();
623
+ const displayNames = new Map<string, string>();
624
+ await Promise.all(
625
+ services.map(async (s) => {
626
+ // Skip vaults — they have their own loadManagementUrls path and no
627
+ // operator-facing user UI of their own (content browses via Notes).
628
+ if (isVaultEntry(s) || !s.installDir) return;
629
+ try {
630
+ const m = await read(s.installDir);
631
+ if (m?.uiUrl) uiUrls.set(s.name, m.uiUrl);
632
+ if (m?.displayName) displayNames.set(s.name, m.displayName);
633
+ } catch (err) {
634
+ const msg = err instanceof Error ? err.message : String(err);
635
+ console.warn(`well-known: skipping uiUrl/displayName for ${s.name}: ${msg}`);
636
+ }
637
+ }),
638
+ );
639
+ return { uiUrls, displayNames };
640
+ }
641
+
493
642
  /**
494
643
  * Resolve the SPA bundle dir. We anchor to this file's location so a
495
644
  * `bun src/hub-server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
@@ -503,22 +652,23 @@ function defaultSpaDistDir(): string {
503
652
  }
504
653
 
505
654
  /**
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.
655
+ * The admin SPA serves at a single mount: `/admin/*` (since hub#231).
656
+ *
657
+ * Routes:
658
+ * - `/admin/vaults` → vault list (the SPA's home)
659
+ * - `/admin/vaults/new` vault create form
660
+ * - `/admin/permissions` → OAuth consent grant management
661
+ * - `/admin/tokens` token registry: mint / list / revoke
662
+ *
663
+ * Asset URLs are origin-absolute (`/admin/assets/...`) per the Vite build
664
+ * base. main.tsx pins react-router's basename to `/admin`.
665
+ *
666
+ * Pre-rename mounts (the old `/vault` for the vault SPA, `/hub/*` for
667
+ * permissions+tokens) are 301-redirected further up the dispatch so cached
668
+ * operator URLs keep working. `/vault/<name>/*` (per-vault content proxy)
669
+ * stays — that's user-facing vault data, not part of this admin SPA.
520
670
  */
521
- type SpaMount = "/vault" | "/hub";
671
+ type SpaMount = "/admin";
522
672
 
523
673
  /**
524
674
  * Pick a content type for static assets the SPA build produces. Vite's
@@ -565,8 +715,8 @@ function spaContentType(pathname: string): string {
565
715
  * filter rejects sub-paths containing "..", and the resolved absolute
566
716
  * path is checked to start with `dist/` before any read.
567
717
  *
568
- * `mount` is the prefix being served (`/vault` or `/hub`); we strip it
569
- * from `pathname` to land on the file path inside `dist/`.
718
+ * `mount` is the prefix being served (`/admin`); we strip it from
719
+ * `pathname` to land on the file path inside `dist/`.
570
720
  */
571
721
  async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount): Promise<Response> {
572
722
  if (!existsSync(spaDistDir)) {
@@ -575,7 +725,7 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
575
725
  { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
576
726
  );
577
727
  }
578
- // Strip the mount prefix; "/vault" → "", "/vault/" → "/", "/vault/x" → "/x".
728
+ // Strip the mount prefix; "/admin" → "", "/admin/" → "/", "/admin/x" → "/x".
579
729
  const sub = pathname === mount ? "" : pathname.slice(mount.length);
580
730
  const indexPath = join(spaDistDir, "index.html");
581
731
 
@@ -608,6 +758,104 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
608
758
  });
609
759
  }
610
760
 
761
+ /**
762
+ * Routes that fall through the pre-admin lockout (503 → /admin/setup).
763
+ *
764
+ * Gated (operator-facing) when no admin row exists:
765
+ * - `/admin/*` (except `/admin/setup`) — vault admin, permissions, tokens
766
+ * - `/api/*` — host-admin API surface
767
+ * - `/login`, `/logout` — pointless without an account
768
+ *
769
+ * Open through the gate (so the container is reachable and discoverable
770
+ * the moment it boots, even before an admin is seeded):
771
+ * - `/health` — platform liveness check
772
+ * - `/.well-known/*` — public discovery + JWKS
773
+ * - `/admin/setup` — the setup placeholder itself
774
+ * - `/` and `/hub.html` — static discovery page
775
+ * - `/oauth/*` — third-party OAuth surface; clients
776
+ * can register/refresh independent
777
+ * of admin onboarding
778
+ * - `/vault/*`, `/<service>/*` — content proxies; service-level auth
779
+ * handles its own failure modes
780
+ *
781
+ * The function is called only when an admin row is missing; in the
782
+ * normal-running case (any admin row exists) this gate is a no-op and the
783
+ * regular dispatch continues.
784
+ */
785
+ function shouldGateForSetup(pathname: string): boolean {
786
+ if (pathname === "/login" || pathname === "/logout") return true;
787
+ if (pathname === "/admin/setup") return false;
788
+ if (pathname === "/admin" || pathname.startsWith("/admin/")) return true;
789
+ if (pathname.startsWith("/api/")) return true;
790
+ return false;
791
+ }
792
+
793
+ /**
794
+ * Minimal first-boot setup page. The real wizard (form + submit + admin
795
+ * row create) ships in hub#259. For now this is a static, single-screen
796
+ * HTML doc that tells the operator how to seed an admin via env vars and
797
+ * restart. No external assets — the body sits inline so a fresh container
798
+ * with no SPA bundle still serves something coherent.
799
+ */
800
+ function renderSetupPlaceholder(): string {
801
+ return `<!doctype html>
802
+ <html lang="en">
803
+ <head>
804
+ <meta charset="utf-8" />
805
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
806
+ <title>Parachute Hub — first-boot setup</title>
807
+ <style>
808
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
809
+ max-width: 36rem; margin: 4rem auto; padding: 0 1.5rem; line-height: 1.55;
810
+ color: #1a1a1a; background: #fafafa; }
811
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
812
+ p.lede { color: #555; margin-top: 0; }
813
+ code, pre { background: #f0eee7; border-radius: 4px; padding: 0.1rem 0.35rem;
814
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.95em; }
815
+ pre { padding: 0.75rem 1rem; overflow-x: auto; }
816
+ .note { background: #fff8e1; border-left: 3px solid #d4a017; padding: 0.75rem 1rem;
817
+ margin: 1.5rem 0; font-size: 0.92em; }
818
+ </style>
819
+ </head>
820
+ <body>
821
+ <h1>Parachute Hub — first-boot setup</h1>
822
+ <p class="lede">No admin account exists yet on this hub. Set the seed env
823
+ vars below and restart the container to bootstrap the first operator.</p>
824
+
825
+ <h2>Option 1 — env-var seed (recommended for containers)</h2>
826
+ <p>Set these on the container and restart:</p>
827
+ <pre>PARACHUTE_INITIAL_ADMIN_USERNAME=ops
828
+ PARACHUTE_INITIAL_ADMIN_PASSWORD=&lt;a strong password&gt;</pre>
829
+ <p>On boot the hub will create the admin row, then ignore the env vars
830
+ on every subsequent restart — they're a first-boot seed, not a reset
831
+ switch. Rotate the password later via <code>parachute auth set-password</code>
832
+ inside the container, or via the admin UI.</p>
833
+
834
+ <h2>Option 2 — CLI from inside the container</h2>
835
+ <pre>docker exec -it &lt;container&gt; parachute auth set-password \\
836
+ --username ops --password '&lt;…&gt;'</pre>
837
+
838
+ <div class="note">
839
+ The full web-based setup wizard (browser form, no env vars or shell
840
+ needed) is tracked in <code>hub#259</code> and will replace this
841
+ placeholder when it ships.
842
+ </div>
843
+ </body>
844
+ </html>
845
+ `;
846
+ }
847
+
848
+ // Canonical 503 body for "getDb is unset, hub is unconfigured." Shape matches
849
+ // the OAuth error vocabulary used by /api/auth/* (`service_unavailable`) so a
850
+ // consumer never has to branch on content-type to extract a message. See
851
+ // hub#227 for the migration from plain-text bodies.
852
+ function dbNotConfigured(): Response {
853
+ return Response.json(
854
+ { error: "service_unavailable", error_description: "hub db not configured" },
855
+ { status: 503 },
856
+ );
857
+ }
858
+
611
859
  export function hubFetch(
612
860
  wellKnownDir: string,
613
861
  deps?: HubFetchDeps,
@@ -617,31 +865,204 @@ export function hubFetch(
617
865
  const configuredIssuer = deps?.issuer;
618
866
  const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
619
867
  const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
868
+ const loopbackPort = deps?.loopbackPort;
869
+ const loadExposeHubOrigin =
870
+ deps?.loadExposeHubOrigin ??
871
+ (() => {
872
+ try {
873
+ return readExposeState()?.hubOrigin;
874
+ } catch {
875
+ // Malformed expose-state.json shouldn't 500 hub on every same-origin
876
+ // check — the issuer + loopback already cover legitimate access.
877
+ return undefined;
878
+ }
879
+ });
620
880
 
621
- const oauthDeps = (req: Request) => ({
622
- issuer: configuredIssuer ?? new URL(req.url).origin,
623
- });
881
+ const oauthDeps = (req: Request) => {
882
+ const issuer = configuredIssuer ?? new URL(req.url).origin;
883
+ return {
884
+ issuer,
885
+ // Per-request resolution (closes #245): expose-state.json can change
886
+ // mid-session (operator runs `parachute expose tailnet` while hub is
887
+ // up), so we re-read the bound origins on each call rather than
888
+ // capturing at hub start. Cheap — a single small JSON parse per OAuth
889
+ // request, only on the cookie-POST paths that consult it.
890
+ hubBoundOrigins: () =>
891
+ buildHubBoundOrigins({
892
+ issuer,
893
+ loopbackPort,
894
+ exposeHubOrigin: loadExposeHubOrigin(),
895
+ }),
896
+ };
897
+ };
624
898
 
625
899
  return async (req) => {
626
900
  const url = new URL(req.url);
627
901
  const pathname = url.pathname;
628
902
 
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.
903
+ // 301 back-compat for the pre-hub#231 admin-SPA mounts:
904
+ //
905
+ // `/vault` → `/admin/vaults`
906
+ // `/vault/new` → `/admin/vaults/new`
907
+ // `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
908
+ // it now retargets at the new admin mount instead
909
+ // of the interim `/vault` mount)
910
+ // `/hub/permissions` → `/admin/permissions`
911
+ // `/hub/tokens` → `/admin/tokens`
912
+ // `/hub` (bare) → `/admin/vaults`
913
+ //
914
+ // Permanent redirect so cached operator URLs keep working without
915
+ // leaving dangling SPA routes. Query string preserved; fragment is
916
+ // client-side and survives the redirect at the browser. Method-agnostic
917
+ // — even a misrouted POST gets the redirect; none of these paths host a
918
+ // POST endpoint to protect.
919
+ //
920
+ // `/vault/<name>/*` is INTENTIONALLY excluded — that's the per-vault
921
+ // content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
922
+ if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
923
+ const sub = pathname === "/vault/new" ? "/new" : "";
924
+ return new Response("", {
925
+ status: 301,
926
+ headers: { location: `/admin/vaults${sub}${url.search}` },
927
+ });
928
+ }
636
929
  if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
637
- const newPath = `/vault${pathname.slice("/hub/vaults".length)}`;
930
+ const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
638
931
  return new Response("", {
639
932
  status: 301,
640
933
  headers: { location: `${newPath}${url.search}` },
641
934
  });
642
935
  }
936
+ if (pathname === "/hub/permissions") {
937
+ return new Response("", {
938
+ status: 301,
939
+ headers: { location: `/admin/permissions${url.search}` },
940
+ });
941
+ }
942
+ if (pathname === "/hub/tokens") {
943
+ return new Response("", {
944
+ status: 301,
945
+ headers: { location: `/admin/tokens${url.search}` },
946
+ });
947
+ }
948
+ if (pathname === "/hub" || pathname === "/hub/") {
949
+ return new Response("", {
950
+ status: 301,
951
+ headers: { location: `/admin/vaults${url.search}` },
952
+ });
953
+ }
954
+
955
+ // Login surface rename: `/admin/login` and `/admin/logout` 301 to the
956
+ // canonical `/login` and `/logout`. The names were "admin" only by
957
+ // historical accident — the handlers serve every parachute auth flow
958
+ // (operator, OAuth user-redirect, future SPA sign-in). Renaming makes
959
+ // the surface name match its actual scope.
960
+ if (pathname === "/admin/login") {
961
+ return new Response("", {
962
+ status: 301,
963
+ headers: { location: `/login${url.search}` },
964
+ });
965
+ }
966
+ if (pathname === "/admin/logout") {
967
+ return new Response("", {
968
+ status: 301,
969
+ headers: { location: `/logout${url.search}` },
970
+ });
971
+ }
972
+
973
+ // Platform health check (Render, Fly, Kubernetes, etc.). Plain JSON,
974
+ // no DB required — the route reports liveness, not readiness. Anything
975
+ // more invasive (DB ping, schema check) would let a transient lock turn
976
+ // into a restart loop on the platform side. 200 always while the
977
+ // process is up.
978
+ if (pathname === "/health") {
979
+ return new Response(
980
+ JSON.stringify({ status: "ok", service: "parachute-hub", version: pkg.version }),
981
+ {
982
+ headers: {
983
+ "content-type": "application/json",
984
+ "cache-control": "no-store",
985
+ },
986
+ },
987
+ );
988
+ }
989
+
990
+ // First-boot setup placeholder. Real wizard ships in hub#259. When no
991
+ // admin exists, render a minimal HTML page pointing operators at the
992
+ // env-var seed path. When an admin already exists, 301 to /login —
993
+ // the route doesn't disappear after setup so a stale bookmark still
994
+ // lands somewhere useful.
995
+ if (pathname === "/admin/setup") {
996
+ if (getDb) {
997
+ const db = getDb();
998
+ if (userCount(db) > 0) {
999
+ return new Response("", {
1000
+ status: 301,
1001
+ headers: { location: "/login" },
1002
+ });
1003
+ }
1004
+ }
1005
+ return new Response(renderSetupPlaceholder(), {
1006
+ headers: { "content-type": "text/html; charset=utf-8" },
1007
+ });
1008
+ }
1009
+
1010
+ // Pre-admin lockout. When the hub has booted with no admin row (the
1011
+ // fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
1012
+ // /admin/setup is walked), every operator-facing surface that requires
1013
+ // identity is meaningless — auth flows can't validate, the SPA can't
1014
+ // mint a host-admin token, OAuth can't issue codes. Route those to a
1015
+ // 503 that points at /admin/setup. Health, well-known, /admin/setup
1016
+ // itself, and the static discovery page (/) ran above and are
1017
+ // unaffected; OAuth + admin + api endpoints fall here.
1018
+ //
1019
+ // `shouldGateForSetup` runs first so non-gated paths (well-known, /,
1020
+ // /health, /admin/setup) never touch getDb — keeping the
1021
+ // existing OPTIONS-preflight contract that those routes are db-free.
1022
+ if (getDb && shouldGateForSetup(pathname) && userCount(getDb()) === 0) {
1023
+ return new Response(
1024
+ JSON.stringify({
1025
+ error: "setup_required",
1026
+ error_description:
1027
+ "no admin configured. Visit /admin/setup, or set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD and restart.",
1028
+ setup_url: "/admin/setup",
1029
+ }),
1030
+ {
1031
+ status: 503,
1032
+ headers: {
1033
+ "content-type": "application/json",
1034
+ "cache-control": "no-store",
1035
+ },
1036
+ },
1037
+ );
1038
+ }
643
1039
 
644
1040
  if (pathname === "/" || pathname === "/hub.html") {
1041
+ // When a DB is configured, render the discovery page dynamically so
1042
+ // the header carries a "Signed in as <name>" affordance for the
1043
+ // active session. Without a DB, fall back to the static disk file
1044
+ // (signed-out shape) — the disk file is what `parachute expose`
1045
+ // wrote out, used when the hub-server is running without state.
1046
+ if (getDb) {
1047
+ const db = getDb();
1048
+ const session = findActiveSession(db, req);
1049
+ let renderOpts: RenderHubOpts = {};
1050
+ const headers: Record<string, string> = {
1051
+ "content-type": "text/html; charset=utf-8",
1052
+ };
1053
+ if (session) {
1054
+ const user = getUserById(db, session.userId);
1055
+ if (user) {
1056
+ const csrf = ensureCsrfToken(req);
1057
+ renderOpts = {
1058
+ session: { displayName: user.username, csrfToken: csrf.token },
1059
+ };
1060
+ if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
1061
+ }
1062
+ }
1063
+ return new Response(renderHub(renderOpts), { headers });
1064
+ }
1065
+ // No DB configured → fall back to static file (signed-out only).
645
1066
  if (!existsSync(hubHtmlPath)) {
646
1067
  return new Response("hub.html not found", { status: 404 });
647
1068
  }
@@ -671,14 +1092,17 @@ export function hubFetch(
671
1092
  try {
672
1093
  const manifest = readManifest(manifestPath);
673
1094
  const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
674
- const managementUrlByName = await loadManagementUrls(
675
- manifest.services,
676
- deps?.readModuleManifest ?? defaultReadModuleManifest,
677
- );
1095
+ const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
1096
+ const [managementUrlByName, serviceUiMeta] = await Promise.all([
1097
+ loadManagementUrls(manifest.services, readManifestFn),
1098
+ loadServiceUiMetadata(manifest.services, readManifestFn),
1099
+ ]);
678
1100
  const doc = buildWellKnown({
679
1101
  services: manifest.services,
680
1102
  canonicalOrigin,
681
1103
  managementUrlFor: (entry) => managementUrlByName.get(entry.name),
1104
+ uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
1105
+ displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
682
1106
  });
683
1107
  return new Response(JSON.stringify(doc), {
684
1108
  headers: { "content-type": "application/json", ...corsHeaders },
@@ -694,6 +1118,30 @@ export function hubFetch(
694
1118
  }
695
1119
  }
696
1120
 
1121
+ if (pathname === REVOCATION_LIST_MOUNT) {
1122
+ // Revocation list (hub#212 Phase 1). Public — same CORS posture as
1123
+ // jwks.json since resource servers (vault/scribe/agent) fetch it
1124
+ // cross-origin on the 60s polling cadence wired in Phase 4.
1125
+ const corsHeaders = {
1126
+ "access-control-allow-origin": "*",
1127
+ "access-control-allow-methods": "GET, OPTIONS",
1128
+ };
1129
+ if (req.method === "OPTIONS") {
1130
+ return new Response(null, { status: 204, headers: corsHeaders });
1131
+ }
1132
+ if (!getDb) {
1133
+ return new Response('{"error":"revocation list unavailable: db not configured"}', {
1134
+ status: 503,
1135
+ headers: { "content-type": "application/json", ...corsHeaders },
1136
+ });
1137
+ }
1138
+ const resp = handleRevocationList(req, { db: getDb() });
1139
+ // Layer the wildcard CORS over whatever cache-control the handler set.
1140
+ const merged = new Headers(resp.headers);
1141
+ for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
1142
+ return new Response(resp.body, { status: resp.status, headers: merged });
1143
+ }
1144
+
697
1145
  if (pathname === "/.well-known/jwks.json") {
698
1146
  // JWKS is also a cross-origin fetch target (browser-side OAuth
699
1147
  // libraries pull this to verify access tokens). Same wildcard CORS
@@ -746,9 +1194,7 @@ export function hubFetch(
746
1194
  }
747
1195
 
748
1196
  if (pathname === "/oauth/authorize") {
749
- if (!getDb) {
750
- return new Response("hub db not configured", { status: 503 });
751
- }
1197
+ if (!getDb) return dbNotConfigured();
752
1198
  if (req.method === "GET") return handleAuthorizeGet(getDb(), req, oauthDeps(req));
753
1199
  if (req.method === "POST") return handleAuthorizePost(getDb(), req, oauthDeps(req));
754
1200
  return new Response("method not allowed", { status: 405 });
@@ -759,59 +1205,44 @@ export function hubFetch(
759
1205
  // by handleAuthorizeGet when the operator hits a pending client. Three
760
1206
  // gates inside the handler: CSRF, active session, same-origin Origin.
761
1207
  if (pathname === "/oauth/authorize/approve") {
762
- if (!getDb) {
763
- return new Response("hub db not configured", { status: 503 });
764
- }
1208
+ if (!getDb) return dbNotConfigured();
765
1209
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
766
1210
  return handleApproveClientPost(getDb(), req, oauthDeps(req));
767
1211
  }
768
1212
 
769
1213
  if (pathname === "/oauth/token") {
770
- if (!getDb) {
771
- return new Response("hub db not configured", { status: 503 });
772
- }
1214
+ if (!getDb) return dbNotConfigured();
773
1215
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
774
1216
  return handleToken(getDb(), req, oauthDeps(req));
775
1217
  }
776
1218
 
777
1219
  if (pathname === "/oauth/register") {
778
- if (!getDb) {
779
- return new Response("hub db not configured", { status: 503 });
780
- }
1220
+ if (!getDb) return dbNotConfigured();
781
1221
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
782
1222
  return handleRegister(getDb(), req, oauthDeps(req));
783
1223
  }
784
1224
 
785
1225
  if (pathname === "/oauth/revoke") {
786
- if (!getDb) {
787
- return new Response("hub db not configured", { status: 503 });
788
- }
1226
+ if (!getDb) return dbNotConfigured();
789
1227
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
790
1228
  return handleRevoke(getDb(), req, oauthDeps(req));
791
1229
  }
792
1230
 
793
1231
  if (pathname === "/vaults") {
794
- if (!getDb) {
795
- return new Response("hub db not configured", { status: 503 });
796
- }
1232
+ if (!getDb) return dbNotConfigured();
797
1233
  return handleCreateVault(req, {
798
1234
  db: getDb(),
799
1235
  issuer: oauthDeps(req).issuer,
800
1236
  });
801
1237
  }
802
1238
 
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
- }
1239
+ // Note: the old `/hub/*` SPA mount has been retired. Known prefixes
1240
+ // (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
1241
+ // 301-redirected at the top of dispatch. Any other `/hub/*` path falls
1242
+ // through to the catch-all 404 there's no admin surface left there.
812
1243
 
813
1244
  if (pathname === "/admin/host-admin-token") {
814
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1245
+ if (!getDb) return dbNotConfigured();
815
1246
  return handleHostAdminToken(req, {
816
1247
  db: getDb(),
817
1248
  issuer: oauthDeps(req).issuer,
@@ -819,7 +1250,7 @@ export function hubFetch(
819
1250
  }
820
1251
 
821
1252
  if (pathname.startsWith("/admin/vault-admin-token/")) {
822
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1253
+ if (!getDb) return dbNotConfigured();
823
1254
  const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
824
1255
  // The vault name must correspond to an actual vault instance — same
825
1256
  // shape the well-known doc derives. Source from services.json so a
@@ -838,8 +1269,37 @@ export function hubFetch(
838
1269
  });
839
1270
  }
840
1271
 
1272
+ if (pathname === "/api/me") {
1273
+ if (!getDb) return dbNotConfigured();
1274
+ return handleApiMe(req, { db: getDb() });
1275
+ }
1276
+
1277
+ if (pathname === "/api/auth/mint-token") {
1278
+ if (!getDb) return dbNotConfigured();
1279
+ return handleApiMintToken(req, {
1280
+ db: getDb(),
1281
+ issuer: oauthDeps(req).issuer,
1282
+ });
1283
+ }
1284
+
1285
+ if (pathname === "/api/auth/revoke-token") {
1286
+ if (!getDb) return dbNotConfigured();
1287
+ return handleApiRevokeToken(req, {
1288
+ db: getDb(),
1289
+ issuer: oauthDeps(req).issuer,
1290
+ });
1291
+ }
1292
+
1293
+ if (pathname === "/api/auth/tokens") {
1294
+ if (!getDb) return dbNotConfigured();
1295
+ return handleApiTokens(req, {
1296
+ db: getDb(),
1297
+ issuer: oauthDeps(req).issuer,
1298
+ });
1299
+ }
1300
+
841
1301
  if (pathname === "/api/grants") {
842
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1302
+ if (!getDb) return dbNotConfigured();
843
1303
  return handleListGrants(req, {
844
1304
  db: getDb(),
845
1305
  issuer: oauthDeps(req).issuer,
@@ -847,7 +1307,7 @@ export function hubFetch(
847
1307
  }
848
1308
 
849
1309
  if (pathname.startsWith("/api/grants/")) {
850
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1310
+ if (!getDb) return dbNotConfigured();
851
1311
  const clientId = decodeURIComponent(pathname.slice("/api/grants/".length));
852
1312
  if (!clientId || clientId.includes("/")) {
853
1313
  return new Response("not found", { status: 404 });
@@ -858,63 +1318,98 @@ export function hubFetch(
858
1318
  });
859
1319
  }
860
1320
 
861
- if (pathname === "/admin/login") {
862
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1321
+ // OAuth client lookup + approval. Both bearer-gated under host:admin.
1322
+ // Two paths: `/api/oauth/clients/<id>` (GET, details) and
1323
+ // `/api/oauth/clients/<id>/approve` (POST, flip to approved). The
1324
+ // SPA approve-client deep link reads details from the first and
1325
+ // submits approval to the second — keeps the surface easy to test
1326
+ // and audit without overloading a single verb.
1327
+ if (pathname.startsWith("/api/oauth/clients/")) {
1328
+ if (!getDb) return dbNotConfigured();
1329
+ const tail = pathname.slice("/api/oauth/clients/".length);
1330
+ if (!tail) return new Response("not found", { status: 404 });
1331
+ const approveSuffix = "/approve";
1332
+ if (tail.endsWith(approveSuffix)) {
1333
+ const clientId = decodeURIComponent(tail.slice(0, -approveSuffix.length));
1334
+ if (!clientId || clientId.includes("/")) {
1335
+ return new Response("not found", { status: 404 });
1336
+ }
1337
+ return handleApproveClient(req, clientId, {
1338
+ db: getDb(),
1339
+ issuer: oauthDeps(req).issuer,
1340
+ });
1341
+ }
1342
+ const clientId = decodeURIComponent(tail);
1343
+ if (!clientId || clientId.includes("/")) {
1344
+ return new Response("not found", { status: 404 });
1345
+ }
1346
+ return handleGetClient(req, clientId, {
1347
+ db: getDb(),
1348
+ issuer: oauthDeps(req).issuer,
1349
+ });
1350
+ }
1351
+
1352
+ // Canonical login/logout. The handlers themselves are unchanged from
1353
+ // when they lived at /admin/login + /admin/logout; the rename surfaced
1354
+ // via #231-followup so the URL reflects the surface's actual scope
1355
+ // (entry point for ALL parachute auth — not admin-only). The
1356
+ // /admin/login and /admin/logout paths 301 to here, dispatched at the
1357
+ // top of this fn alongside the other back-compat redirects.
1358
+ if (pathname === "/login") {
1359
+ if (!getDb) return dbNotConfigured();
863
1360
  if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
864
1361
  if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
865
1362
  return new Response("method not allowed", { status: 405 });
866
1363
  }
867
1364
 
868
- if (pathname === "/admin/logout") {
869
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1365
+ if (pathname === "/logout") {
1366
+ if (!getDb) return dbNotConfigured();
870
1367
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
871
1368
  return handleAdminLogoutPost(getDb(), req);
872
1369
  }
873
1370
 
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);
1371
+ // Legacy `/admin/config` (server-rendered module-config portal, #46)
1372
+ // retired post-SPA-rework. 301 the SPA home so any bookmark or stale
1373
+ // post-login redirect lands somewhere useful. The route stays here in
1374
+ // dispatch order (above the /admin/* SPA catch-all) so the redirect
1375
+ // wins over a SPA shell render.
1376
+ if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
1377
+ return new Response(null, {
1378
+ status: 301,
1379
+ headers: { location: "/admin/vaults" },
1380
+ });
878
1381
  }
879
1382
 
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
- }
1383
+ // /vault/<name>/* — per-vault content proxy. Stays as user-facing
1384
+ // surface (the Notes PWA loads through here, etc.). The bare `/vault`
1385
+ // and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
1386
+ // the top of dispatch now. Multi-segment requests like
1387
+ // `/vault/<unknown>/health` are vault-API shapes targeting a
1388
+ // non-existent vault and 404 directly there's no SPA-shell fallback
1389
+ // here anymore (the SPA moved to /admin), so we can't accidentally
1390
+ // mask a backend 404 with HTML.
910
1391
  if (pathname.startsWith("/vault/")) {
911
1392
  const proxied = await proxyToVault(req, manifestPath);
912
1393
  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");
1394
+ return new Response("not found", { status: 404 });
1395
+ }
1396
+
1397
+ // /admin/* SPA mount. All non-SPA admin handlers (host-admin-token,
1398
+ // vault-admin-token, login, logout, config, api/auth/*, api/grants,
1399
+ // grants/*) ran above and either matched or returned. Anything that
1400
+ // makes it here under /admin/* is a SPA route or asset request; the
1401
+ // SPA's own router renders the page and handles 404 client-side for
1402
+ // unknown sub-paths.
1403
+ if (pathname === "/admin" || pathname === "/admin/") {
1404
+ // Unprefixed /admin → SPA shell pointed at the vault list (its home).
1405
+ // The SPA's basename is /admin, so the router will land on / and
1406
+ // render VaultsList.
1407
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1408
+ return serveSpa(spaDistDir, pathname, "/admin");
1409
+ }
1410
+ if (pathname.startsWith("/admin/")) {
1411
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1412
+ return serveSpa(spaDistDir, pathname, "/admin");
918
1413
  }
919
1414
 
920
1415
  // Generic services.json-driven dispatch for non-vault modules. Reaches
@@ -929,7 +1424,7 @@ export function hubFetch(
929
1424
  }
930
1425
 
931
1426
  if (import.meta.main) {
932
- const { port, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
1427
+ const { port, hostname, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
933
1428
  let cachedDb: Database | undefined;
934
1429
  const getDb = () => {
935
1430
  if (!cachedDb) cachedDb = openHubDb(dbPath);
@@ -937,8 +1432,8 @@ if (import.meta.main) {
937
1432
  };
938
1433
  Bun.serve({
939
1434
  port,
940
- hostname: "127.0.0.1",
941
- fetch: hubFetch(wellKnownDir, { getDb, issuer }),
1435
+ hostname,
1436
+ fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
942
1437
  });
943
1438
  // Register PID + port from the running hub itself so any startup path
944
1439
  // (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from
@@ -961,7 +1456,7 @@ if (import.meta.main) {
961
1456
  });
962
1457
  process.on("exit", cleanup);
963
1458
  console.log(
964
- `parachute-hub listening on http://127.0.0.1:${port} (dir=${wellKnownDir}, db=${dbPath}${
1459
+ `parachute-hub listening on http://${hostname}:${port} (dir=${wellKnownDir}, db=${dbPath}${
965
1460
  issuer ? `, issuer=${issuer}` : ""
966
1461
  })`,
967
1462
  );