@openparachute/hub 0.5.7 → 0.5.10-rc.10

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 (85) 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-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/hub-server.ts CHANGED
@@ -9,17 +9,69 @@
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/modules (GET) → curated + installed module catalog (host:auth)
45
+ * /api/modules/channel (PUT) → operator channel toggle (host:admin)
46
+ * /api/modules/:short/install (POST) → bun add + spawn (async op)
47
+ * /api/modules/:short/restart (POST) → supervisor restart (sync)
48
+ * /api/modules/:short/upgrade (POST) → bun add @<channel> + restart (async op)
49
+ * /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
50
+ * /api/modules/operations/:id (GET) → poll async op status
51
+ * /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
52
+ * /api/auth/revoke-token (POST) → revoke registry-row token by jti
53
+ * /api/auth/tokens (GET) → paginated registry list
54
+ * /api/grants (GET) → OAuth consent grants list
55
+ * /api/grants/<client_id> (DELETE) → revoke a single OAuth grant
56
+ * /api/oauth/clients/<id> (GET) → OAuth client details
57
+ * /api/oauth/clients/<id>/approve (POST) → flip a pending client to approved
58
+ * /login (GET + POST) → operator password login
59
+ * /logout (POST) → end admin session
60
+ * /admin/config* → 301 → /admin/vaults (legacy
61
+ * portal retired post-SPA-rework)
62
+ *
63
+ * # Per-vault content proxy (user-facing vault data: Notes PWA, MCP, etc.).
64
+ * /vault/<name>/* → proxy to the vault backend
65
+ *
66
+ * # Admin SPA mount (catch-all under /admin; runs after all admin API
67
+ * # handlers above, so /admin/<known> reaches the right handler and
68
+ * # /admin/<spa-route> serves the SPA shell).
69
+ * /admin, /admin/, /admin/* → SPA shell (vaults / new / permissions / tokens)
70
+ *
71
+ * # Generic services.json-driven proxy (non-vault modules: notes, scribe, agent).
72
+ * /<service-mount>/* → proxy via services.json longest-prefix
73
+ *
74
+ * anything else → 404
23
75
  *
24
76
  * Invoked as:
25
77
  * bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
@@ -40,10 +92,10 @@ import type { Database } from "bun:sqlite";
40
92
  import { existsSync } from "node:fs";
41
93
  import { dirname, join, resolve } from "node:path";
42
94
  import { fileURLToPath } from "node:url";
95
+ import pkg from "../package.json" with { type: "json" };
96
+ import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
43
97
  import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
44
98
  import {
45
- handleAdminConfigGet,
46
- handleAdminConfigPost,
47
99
  handleAdminLoginGet,
48
100
  handleAdminLoginPost,
49
101
  handleAdminLogoutPost,
@@ -51,9 +103,27 @@ import {
51
103
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
52
104
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
53
105
  import { handleCreateVault } from "./admin-vaults.ts";
54
- import { SERVICES_MANIFEST_PATH } from "./config.ts";
55
- import { HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
106
+ import { handleApiMe } from "./api-me.ts";
107
+ import { handleApiMintToken } from "./api-mint-token.ts";
108
+ import {
109
+ getDefaultOperationsRegistry,
110
+ handleInstall,
111
+ handleOperationGet,
112
+ handleRestart,
113
+ handleUninstall,
114
+ handleUpgrade,
115
+ parseModulesPath,
116
+ } from "./api-modules-ops.ts";
117
+ import { handleApiModules, handleApiModulesChannel } from "./api-modules.ts";
118
+ import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
119
+ import { handleApiRevokeToken } from "./api-revoke-token.ts";
120
+ import { handleApiTokens } from "./api-tokens.ts";
121
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
122
+ import { ensureCsrfToken } from "./csrf.ts";
123
+ import { readExposeState } from "./expose-state.ts";
124
+ import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
56
125
  import { hubDbPath, openHubDb } from "./hub-db.ts";
126
+ import { type RenderHubOpts, renderHub } from "./hub.ts";
57
127
  import { pemToJwk } from "./jwks.ts";
58
128
  import {
59
129
  type ModuleManifest,
@@ -68,6 +138,7 @@ import {
68
138
  handleRevoke,
69
139
  handleToken,
70
140
  } from "./oauth-handlers.ts";
141
+ import { buildHubBoundOrigins } from "./origin-check.ts";
71
142
  import { clearPid, writePid } from "./process-state.ts";
72
143
  import {
73
144
  FIRST_PARTY_FALLBACKS,
@@ -75,18 +146,48 @@ import {
75
146
  shortNameForManifest,
76
147
  } from "./service-spec.ts";
77
148
  import { type ServiceEntry, readManifest } from "./services-manifest.ts";
149
+ import { findActiveSession } from "./sessions.ts";
150
+ import {
151
+ type SetupWizardDeps,
152
+ handleSetupAccountPost,
153
+ handleSetupExposePost,
154
+ handleSetupGet,
155
+ handleSetupInstallPost,
156
+ handleSetupVaultPost,
157
+ } from "./setup-wizard.ts";
78
158
  import { getAllPublicKeys } from "./signing-keys.ts";
79
- import { buildWellKnown, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
159
+ import type { Supervisor } from "./supervisor.ts";
160
+ import { getUserById, userCount } from "./users.ts";
161
+ import {
162
+ WELL_KNOWN_DIR,
163
+ buildWellKnown,
164
+ isVaultEntry,
165
+ vaultInstanceNameFor,
166
+ } from "./well-known.ts";
80
167
 
81
168
  interface Args {
82
169
  port: number;
170
+ hostname: string;
83
171
  wellKnownDir: string;
84
172
  dbPath: string;
85
173
  issuer: string | undefined;
86
174
  }
87
175
 
88
- function parseArgs(argv: string[]): Args {
176
+ /**
177
+ * Parse hub-server flags. Container hosts (Render, Docker) configure us
178
+ * entirely via env vars — no flags. The `parachute expose` spawn path passes
179
+ * everything as flags. Flags beat env, env beats defaults.
180
+ *
181
+ * PORT — bind port (Render injects this)
182
+ * PARACHUTE_BIND_HOST — bind hostname; default 127.0.0.1 to keep
183
+ * the historical loopback posture safe.
184
+ * Containers should set 0.0.0.0.
185
+ * PARACHUTE_HUB_ORIGIN — canonical https://… origin used as the
186
+ * OAuth issuer claim.
187
+ */
188
+ function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): Args {
89
189
  let port: number | undefined;
190
+ let hostname: string | undefined;
90
191
  let wellKnownDir: string | undefined;
91
192
  let dbPath: string | undefined;
92
193
  let issuer: string | undefined;
@@ -95,11 +196,11 @@ function parseArgs(argv: string[]): Args {
95
196
  if (a === "--port") {
96
197
  const v = argv[++i];
97
198
  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;
199
+ port = parsePort(v);
200
+ } else if (a === "--hostname") {
201
+ const v = argv[++i];
202
+ if (!v) throw new Error("--hostname requires a value");
203
+ hostname = v;
103
204
  } else if (a === "--well-known-dir") {
104
205
  const v = argv[++i];
105
206
  if (!v) throw new Error("--well-known-dir requires a value");
@@ -116,9 +217,22 @@ function parseArgs(argv: string[]): Args {
116
217
  throw new Error(`unknown argument: ${a}`);
117
218
  }
118
219
  }
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 };
220
+ if (port === undefined && env.PORT) port = parsePort(env.PORT);
221
+ if (port === undefined) port = HUB_DEFAULT_PORT;
222
+ if (hostname === undefined) hostname = env.PARACHUTE_BIND_HOST || "127.0.0.1";
223
+ if (wellKnownDir === undefined) wellKnownDir = WELL_KNOWN_DIR;
224
+ if (issuer === undefined && env.PARACHUTE_HUB_ORIGIN) {
225
+ issuer = env.PARACHUTE_HUB_ORIGIN.replace(/\/+$/, "");
226
+ }
227
+ return { port, hostname, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
228
+ }
229
+
230
+ function parsePort(v: string): number {
231
+ const n = Number.parseInt(v, 10);
232
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
233
+ throw new Error(`port must be 1..65535, got "${v}"`);
234
+ }
235
+ return n;
122
236
  }
123
237
 
124
238
  /**
@@ -458,6 +572,32 @@ export interface HubFetchDeps {
458
572
  * fixture installDirs.
459
573
  */
460
574
  readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
575
+ /**
576
+ * Hub's listening port. Threaded into the OAuth `hubBoundOrigins` set so
577
+ * the same-origin defense accepts loopback access (`http://localhost:<port>`,
578
+ * `http://127.0.0.1:<port>`) alongside the configured issuer. Closes #245
579
+ * Case A (operator on `localhost:1939` getting "Cross-origin request
580
+ * rejected" because Origin ≠ tailnet issuer).
581
+ */
582
+ loopbackPort?: number;
583
+ /**
584
+ * Test seam for reading `expose-state.json`. Production reads the operator's
585
+ * `~/.parachute/expose-state.json` via `readExposeState`; tests inject a
586
+ * fake to drive tailnet/funnel origins into the bound set without standing
587
+ * up real exposes. Returns `undefined` when no state file is present
588
+ * (pre-`parachute expose` state — fine, the issuer + loopback still cover
589
+ * legitimate access).
590
+ */
591
+ loadExposeHubOrigin?: () => string | undefined;
592
+ /**
593
+ * Container-mode child supervisor. When present (under `parachute serve`),
594
+ * `/api/modules/*` handlers drive install/restart/upgrade/uninstall through
595
+ * it. Absent under the on-box CLI path (`parachute expose`) where
596
+ * `commands/lifecycle.ts` owns the detached-pidfile lifecycle instead —
597
+ * the module-mgmt API in that mode returns 503 with a hint to use the
598
+ * CLI commands directly.
599
+ */
600
+ supervisor?: Supervisor;
461
601
  }
462
602
 
463
603
  /**
@@ -490,6 +630,50 @@ async function loadManagementUrls(
490
630
  return out;
491
631
  }
492
632
 
633
+ /**
634
+ * For each NON-vault `ServiceEntry` with a known `installDir`, read its
635
+ * `.parachute/module.json` and surface the optional `uiUrl` and
636
+ * `displayName`. Returns two `name → value` maps keyed by services.json
637
+ * entry name. Mirrors `loadManagementUrls` (vault is the analog there;
638
+ * non-vault services are the analog here — vaults are user-facing via
639
+ * Notes, not their own UI).
640
+ *
641
+ * Why read at request time and not from services.json: services own the
642
+ * write side of services.json (`upsertService` replaces the whole entry
643
+ * on every boot), so any install-time copy of `uiUrl` / `displayName`
644
+ * would be clobbered the first time the service writes its own entry.
645
+ * Reading from `installDir/module.json` at request time avoids the gap
646
+ * and matches the established `managementUrl` precedent.
647
+ *
648
+ * Quiet on per-entry errors: a malformed module.json on one service
649
+ * shouldn't 500 the entire well-known doc — its row just renders without
650
+ * a Services tile. The validator already throws structured errors from
651
+ * `readModuleManifest`; logging them once here is the right floor.
652
+ */
653
+ async function loadServiceUiMetadata(
654
+ services: readonly ServiceEntry[],
655
+ read: (installDir: string) => Promise<ModuleManifest | null>,
656
+ ): Promise<{ uiUrls: Map<string, string>; displayNames: Map<string, string> }> {
657
+ const uiUrls = new Map<string, string>();
658
+ const displayNames = new Map<string, string>();
659
+ await Promise.all(
660
+ services.map(async (s) => {
661
+ // Skip vaults — they have their own loadManagementUrls path and no
662
+ // operator-facing user UI of their own (content browses via Notes).
663
+ if (isVaultEntry(s) || !s.installDir) return;
664
+ try {
665
+ const m = await read(s.installDir);
666
+ if (m?.uiUrl) uiUrls.set(s.name, m.uiUrl);
667
+ if (m?.displayName) displayNames.set(s.name, m.displayName);
668
+ } catch (err) {
669
+ const msg = err instanceof Error ? err.message : String(err);
670
+ console.warn(`well-known: skipping uiUrl/displayName for ${s.name}: ${msg}`);
671
+ }
672
+ }),
673
+ );
674
+ return { uiUrls, displayNames };
675
+ }
676
+
493
677
  /**
494
678
  * Resolve the SPA bundle dir. We anchor to this file's location so a
495
679
  * `bun src/hub-server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
@@ -503,22 +687,23 @@ function defaultSpaDistDir(): string {
503
687
  }
504
688
 
505
689
  /**
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.
690
+ * The admin SPA serves at a single mount: `/admin/*` (since hub#231).
691
+ *
692
+ * Routes:
693
+ * - `/admin/vaults` → vault list (the SPA's home)
694
+ * - `/admin/vaults/new` vault create form
695
+ * - `/admin/permissions` → OAuth consent grant management
696
+ * - `/admin/tokens` token registry: mint / list / revoke
697
+ *
698
+ * Asset URLs are origin-absolute (`/admin/assets/...`) per the Vite build
699
+ * base. main.tsx pins react-router's basename to `/admin`.
700
+ *
701
+ * Pre-rename mounts (the old `/vault` for the vault SPA, `/hub/*` for
702
+ * permissions+tokens) are 301-redirected further up the dispatch so cached
703
+ * operator URLs keep working. `/vault/<name>/*` (per-vault content proxy)
704
+ * stays — that's user-facing vault data, not part of this admin SPA.
520
705
  */
521
- type SpaMount = "/vault" | "/hub";
706
+ type SpaMount = "/admin";
522
707
 
523
708
  /**
524
709
  * Pick a content type for static assets the SPA build produces. Vite's
@@ -565,8 +750,8 @@ function spaContentType(pathname: string): string {
565
750
  * filter rejects sub-paths containing "..", and the resolved absolute
566
751
  * path is checked to start with `dist/` before any read.
567
752
  *
568
- * `mount` is the prefix being served (`/vault` or `/hub`); we strip it
569
- * from `pathname` to land on the file path inside `dist/`.
753
+ * `mount` is the prefix being served (`/admin`); we strip it from
754
+ * `pathname` to land on the file path inside `dist/`.
570
755
  */
571
756
  async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount): Promise<Response> {
572
757
  if (!existsSync(spaDistDir)) {
@@ -575,7 +760,7 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
575
760
  { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
576
761
  );
577
762
  }
578
- // Strip the mount prefix; "/vault" → "", "/vault/" → "/", "/vault/x" → "/x".
763
+ // Strip the mount prefix; "/admin" → "", "/admin/" → "/", "/admin/x" → "/x".
579
764
  const sub = pathname === mount ? "" : pathname.slice(mount.length);
580
765
  const indexPath = join(spaDistDir, "index.html");
581
766
 
@@ -608,6 +793,64 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
608
793
  });
609
794
  }
610
795
 
796
+ /**
797
+ * Routes that fall through the pre-admin lockout (503 → /admin/setup).
798
+ *
799
+ * Gated (operator-facing) when no admin row exists:
800
+ * - `/admin/*` (except `/admin/setup*`) — vault admin, permissions, tokens
801
+ * - `/api/*` — host-admin API surface
802
+ * - `/login`, `/logout` — pointless without an account
803
+ *
804
+ * Open through the gate (so the container is reachable and discoverable
805
+ * the moment it boots, even before an admin is seeded):
806
+ * - `/health` — platform liveness check
807
+ * - `/.well-known/*` — public discovery + JWKS
808
+ * - `/admin/setup`, `/admin/setup/*` — the first-boot wizard (hub#259)
809
+ * and its POST endpoints; the
810
+ * wizard is the *only* browser
811
+ * path to exit the lockout
812
+ * - `/` and `/hub.html` — static discovery page
813
+ * - `/oauth/*` — third-party OAuth surface; clients
814
+ * can register/refresh independent
815
+ * of admin onboarding
816
+ * - `/vault/*`, `/<service>/*` — content proxies; service-level auth
817
+ * handles its own failure modes
818
+ *
819
+ * The function is called only when an admin row is missing; in the
820
+ * normal-running case (any admin row exists) this gate is a no-op and the
821
+ * regular dispatch continues.
822
+ */
823
+ function shouldGateForSetup(pathname: string): boolean {
824
+ if (pathname === "/login" || pathname === "/logout") return true;
825
+ // The wizard itself + its POST endpoints are the *only* way to exit
826
+ // the pre-admin lockout from a browser — they must pass through. Any
827
+ // path under `/admin/setup` (including `/admin/setup/account` and
828
+ // `/admin/setup/vault`) is fair game for an un-authed operator on a
829
+ // fresh hub.
830
+ if (pathname === "/admin/setup" || pathname.startsWith("/admin/setup/")) return false;
831
+ if (pathname === "/admin" || pathname.startsWith("/admin/")) return true;
832
+ if (pathname.startsWith("/api/")) return true;
833
+ return false;
834
+ }
835
+
836
+ // hub#259 replaced the static placeholder with a real three-step wizard.
837
+ // The handlers (account creation + vault provisioning) live in
838
+ // `src/setup-wizard.ts` so the dispatch in this file stays a one-liner per
839
+ // route. The env-var seed path (PARACHUTE_INITIAL_ADMIN_*) still works on
840
+ // first boot — see `src/commands/serve.ts` — and is surfaced as the
841
+ // "alt-path" disclosure on the wizard's welcome screen.
842
+
843
+ // Canonical 503 body for "getDb is unset, hub is unconfigured." Shape matches
844
+ // the OAuth error vocabulary used by /api/auth/* (`service_unavailable`) so a
845
+ // consumer never has to branch on content-type to extract a message. See
846
+ // hub#227 for the migration from plain-text bodies.
847
+ function dbNotConfigured(): Response {
848
+ return Response.json(
849
+ { error: "service_unavailable", error_description: "hub db not configured" },
850
+ { status: 503 },
851
+ );
852
+ }
853
+
611
854
  export function hubFetch(
612
855
  wellKnownDir: string,
613
856
  deps?: HubFetchDeps,
@@ -617,31 +860,252 @@ export function hubFetch(
617
860
  const configuredIssuer = deps?.issuer;
618
861
  const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
619
862
  const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
863
+ const loopbackPort = deps?.loopbackPort;
864
+ const loadExposeHubOrigin =
865
+ deps?.loadExposeHubOrigin ??
866
+ (() => {
867
+ try {
868
+ return readExposeState()?.hubOrigin;
869
+ } catch {
870
+ // Malformed expose-state.json shouldn't 500 hub on every same-origin
871
+ // check — the issuer + loopback already cover legitimate access.
872
+ return undefined;
873
+ }
874
+ });
620
875
 
621
- const oauthDeps = (req: Request) => ({
622
- issuer: configuredIssuer ?? new URL(req.url).origin,
623
- });
876
+ const oauthDeps = (req: Request) => {
877
+ const issuer = configuredIssuer ?? new URL(req.url).origin;
878
+ return {
879
+ issuer,
880
+ // Per-request resolution (closes #245): expose-state.json can change
881
+ // mid-session (operator runs `parachute expose tailnet` while hub is
882
+ // up), so we re-read the bound origins on each call rather than
883
+ // capturing at hub start. Cheap — a single small JSON parse per OAuth
884
+ // request, only on the cookie-POST paths that consult it.
885
+ hubBoundOrigins: () =>
886
+ buildHubBoundOrigins({
887
+ issuer,
888
+ loopbackPort,
889
+ exposeHubOrigin: loadExposeHubOrigin(),
890
+ }),
891
+ };
892
+ };
624
893
 
625
894
  return async (req) => {
626
895
  const url = new URL(req.url);
627
896
  const pathname = url.pathname;
628
897
 
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.
898
+ // 301 back-compat for the pre-hub#231 admin-SPA mounts:
899
+ //
900
+ // `/vault` → `/admin/vaults`
901
+ // `/vault/new` → `/admin/vaults/new`
902
+ // `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
903
+ // it now retargets at the new admin mount instead
904
+ // of the interim `/vault` mount)
905
+ // `/hub/permissions` → `/admin/permissions`
906
+ // `/hub/tokens` → `/admin/tokens`
907
+ // `/hub` (bare) → `/admin/vaults`
908
+ //
909
+ // Permanent redirect so cached operator URLs keep working without
910
+ // leaving dangling SPA routes. Query string preserved; fragment is
911
+ // client-side and survives the redirect at the browser. Method-agnostic
912
+ // — even a misrouted POST gets the redirect; none of these paths host a
913
+ // POST endpoint to protect.
914
+ //
915
+ // `/vault/<name>/*` is INTENTIONALLY excluded — that's the per-vault
916
+ // content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
917
+ if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
918
+ const sub = pathname === "/vault/new" ? "/new" : "";
919
+ return new Response("", {
920
+ status: 301,
921
+ headers: { location: `/admin/vaults${sub}${url.search}` },
922
+ });
923
+ }
636
924
  if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
637
- const newPath = `/vault${pathname.slice("/hub/vaults".length)}`;
925
+ const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
638
926
  return new Response("", {
639
927
  status: 301,
640
928
  headers: { location: `${newPath}${url.search}` },
641
929
  });
642
930
  }
931
+ if (pathname === "/hub/permissions") {
932
+ return new Response("", {
933
+ status: 301,
934
+ headers: { location: `/admin/permissions${url.search}` },
935
+ });
936
+ }
937
+ if (pathname === "/hub/tokens") {
938
+ return new Response("", {
939
+ status: 301,
940
+ headers: { location: `/admin/tokens${url.search}` },
941
+ });
942
+ }
943
+ if (pathname === "/hub" || pathname === "/hub/") {
944
+ return new Response("", {
945
+ status: 301,
946
+ headers: { location: `/admin/vaults${url.search}` },
947
+ });
948
+ }
949
+
950
+ // Login surface rename: `/admin/login` and `/admin/logout` 301 to the
951
+ // canonical `/login` and `/logout`. The names were "admin" only by
952
+ // historical accident — the handlers serve every parachute auth flow
953
+ // (operator, OAuth user-redirect, future SPA sign-in). Renaming makes
954
+ // the surface name match its actual scope.
955
+ if (pathname === "/admin/login") {
956
+ return new Response("", {
957
+ status: 301,
958
+ headers: { location: `/login${url.search}` },
959
+ });
960
+ }
961
+ if (pathname === "/admin/logout") {
962
+ return new Response("", {
963
+ status: 301,
964
+ headers: { location: `/logout${url.search}` },
965
+ });
966
+ }
967
+
968
+ // Platform health check (Render, Fly, Kubernetes, etc.). Plain JSON,
969
+ // no DB required — the route reports liveness, not readiness. Anything
970
+ // more invasive (DB ping, schema check) would let a transient lock turn
971
+ // into a restart loop on the platform side. 200 always while the
972
+ // process is up.
973
+ if (pathname === "/health") {
974
+ return new Response(
975
+ JSON.stringify({ status: "ok", service: "parachute-hub", version: pkg.version }),
976
+ {
977
+ headers: {
978
+ "content-type": "application/json",
979
+ "cache-control": "no-store",
980
+ },
981
+ },
982
+ );
983
+ }
984
+
985
+ // First-boot setup wizard (hub#259). Three steps server-rendered:
986
+ // GET /admin/setup — derive state, render the right step
987
+ // POST /admin/setup/account — create the admin row, set session
988
+ // POST /admin/setup/vault — provision the first vault
989
+ //
990
+ // The wizard owns the "should I 301 to /login now?" decision: setup is
991
+ // complete only when admin AND a vault entry both exist. A re-visit
992
+ // after partial setup picks up at the next step. See
993
+ // src/setup-wizard.ts for the renderer + handler internals.
994
+ if (pathname === "/admin/setup" || pathname.startsWith("/admin/setup/")) {
995
+ if (!getDb) return dbNotConfigured();
996
+ const wizardDeps: SetupWizardDeps = {
997
+ db: getDb(),
998
+ manifestPath,
999
+ configDir: CONFIG_DIR,
1000
+ issuer: oauthDeps(req).issuer,
1001
+ registry: getDefaultOperationsRegistry(),
1002
+ };
1003
+ if (deps?.supervisor !== undefined) wizardDeps.supervisor = deps.supervisor;
1004
+ if (pathname === "/admin/setup") {
1005
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1006
+ return handleSetupGet(req, wizardDeps);
1007
+ }
1008
+ if (pathname === "/admin/setup/account") {
1009
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1010
+ return handleSetupAccountPost(req, wizardDeps);
1011
+ }
1012
+ if (pathname === "/admin/setup/vault") {
1013
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1014
+ return handleSetupVaultPost(req, wizardDeps);
1015
+ }
1016
+ if (pathname === "/admin/setup/expose") {
1017
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1018
+ return handleSetupExposePost(req, wizardDeps);
1019
+ }
1020
+ // hub#272 Item B: post-wizard direct module-install POSTs from
1021
+ // the done-screen "What's next?" tiles. Path shape is
1022
+ // `/admin/setup/install/<short>`; the handler rejects on
1023
+ // unknown shorts, on `vault` (the wizard's own step owns that),
1024
+ // and on missing session/CSRF — same gates as the vault POST.
1025
+ if (pathname.startsWith("/admin/setup/install/")) {
1026
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1027
+ const short = pathname.slice("/admin/setup/install/".length);
1028
+ return handleSetupInstallPost(req, short, wizardDeps);
1029
+ }
1030
+ return new Response("not found", { status: 404 });
1031
+ }
1032
+
1033
+ // Fresh-hub redirect: on a hub with no admin row yet, the discovery
1034
+ // page (`/`, `/hub.html`) funnels straight to the wizard. The static
1035
+ // portal isn't useful pre-setup — nothing's installed, the
1036
+ // "Signed in" affordance has no session to surface — and the
1037
+ // operator landing on `/` in a browser otherwise has to manually
1038
+ // type `/admin/setup` to escape. 302 (not 301) so the redirect
1039
+ // disappears the moment the wizard finishes.
1040
+ //
1041
+ // Sits before the JSON-shaped 503 gate below because `/` is an
1042
+ // HTML surface — a JSON 503 there would render as raw text in the
1043
+ // operator's browser tab. The 503 gate handles API + admin SPA +
1044
+ // OAuth callers that branch on the structured body.
1045
+ if (getDb && (pathname === "/" || pathname === "/hub.html") && userCount(getDb()) === 0) {
1046
+ return new Response(null, {
1047
+ status: 302,
1048
+ headers: { location: "/admin/setup" },
1049
+ });
1050
+ }
1051
+
1052
+ // Pre-admin lockout. When the hub has booted with no admin row (the
1053
+ // fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
1054
+ // /admin/setup is walked), every operator-facing surface that requires
1055
+ // identity is meaningless — auth flows can't validate, the SPA can't
1056
+ // mint a host-admin token, OAuth can't issue codes. Route those to a
1057
+ // 503 that points at /admin/setup. Health, well-known, /admin/setup
1058
+ // itself, OAuth third-party endpoints, and content proxies pass
1059
+ // through; the fresh-hub `/` and `/hub.html` redirect above handled
1060
+ // the discovery-page case.
1061
+ //
1062
+ // `shouldGateForSetup` runs first so non-gated paths (well-known, /,
1063
+ // /health, /admin/setup) never touch getDb — keeping the
1064
+ // existing OPTIONS-preflight contract that those routes are db-free.
1065
+ if (getDb && shouldGateForSetup(pathname) && userCount(getDb()) === 0) {
1066
+ return new Response(
1067
+ JSON.stringify({
1068
+ error: "setup_required",
1069
+ error_description:
1070
+ "no admin configured. Visit /admin/setup, or set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD and restart.",
1071
+ setup_url: "/admin/setup",
1072
+ }),
1073
+ {
1074
+ status: 503,
1075
+ headers: {
1076
+ "content-type": "application/json",
1077
+ "cache-control": "no-store",
1078
+ },
1079
+ },
1080
+ );
1081
+ }
643
1082
 
644
1083
  if (pathname === "/" || pathname === "/hub.html") {
1084
+ // When a DB is configured, render the discovery page dynamically so
1085
+ // the header carries a "Signed in as <name>" affordance for the
1086
+ // active session. Without a DB, fall back to the static disk file
1087
+ // (signed-out shape) — the disk file is what `parachute expose`
1088
+ // wrote out, used when the hub-server is running without state.
1089
+ if (getDb) {
1090
+ const db = getDb();
1091
+ const session = findActiveSession(db, req);
1092
+ let renderOpts: RenderHubOpts = {};
1093
+ const headers: Record<string, string> = {
1094
+ "content-type": "text/html; charset=utf-8",
1095
+ };
1096
+ if (session) {
1097
+ const user = getUserById(db, session.userId);
1098
+ if (user) {
1099
+ const csrf = ensureCsrfToken(req);
1100
+ renderOpts = {
1101
+ session: { displayName: user.username, csrfToken: csrf.token },
1102
+ };
1103
+ if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
1104
+ }
1105
+ }
1106
+ return new Response(renderHub(renderOpts), { headers });
1107
+ }
1108
+ // No DB configured → fall back to static file (signed-out only).
645
1109
  if (!existsSync(hubHtmlPath)) {
646
1110
  return new Response("hub.html not found", { status: 404 });
647
1111
  }
@@ -656,9 +1120,17 @@ export function hubFetch(
656
1120
  // cross-origin from their own loopback port. Wildcard CORS is the
657
1121
  // shape it needs. Browsers send an OPTIONS preflight when the request
658
1122
  // adds non-simple headers; answer it with 204 + the same allow-list.
1123
+ //
1124
+ // `cache-control: no-store` matters here: the discovery page (`/`)
1125
+ // fetches this doc and renders Service tiles from it; without
1126
+ // no-store, the browser's HTTP cache returns the stale services list
1127
+ // the next time the operator navigates back to `/` after installing
1128
+ // a module via the admin SPA. The doc is small and built per-request
1129
+ // anyway, so giving up cacheability has no real cost (hub#268 Item 1).
659
1130
  const corsHeaders = {
660
1131
  "access-control-allow-origin": "*",
661
1132
  "access-control-allow-methods": "GET, OPTIONS",
1133
+ "cache-control": "no-store",
662
1134
  };
663
1135
  if (req.method === "OPTIONS") {
664
1136
  return new Response(null, { status: 204, headers: corsHeaders });
@@ -671,14 +1143,17 @@ export function hubFetch(
671
1143
  try {
672
1144
  const manifest = readManifest(manifestPath);
673
1145
  const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
674
- const managementUrlByName = await loadManagementUrls(
675
- manifest.services,
676
- deps?.readModuleManifest ?? defaultReadModuleManifest,
677
- );
1146
+ const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
1147
+ const [managementUrlByName, serviceUiMeta] = await Promise.all([
1148
+ loadManagementUrls(manifest.services, readManifestFn),
1149
+ loadServiceUiMetadata(manifest.services, readManifestFn),
1150
+ ]);
678
1151
  const doc = buildWellKnown({
679
1152
  services: manifest.services,
680
1153
  canonicalOrigin,
681
1154
  managementUrlFor: (entry) => managementUrlByName.get(entry.name),
1155
+ uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
1156
+ displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
682
1157
  });
683
1158
  return new Response(JSON.stringify(doc), {
684
1159
  headers: { "content-type": "application/json", ...corsHeaders },
@@ -694,6 +1169,30 @@ export function hubFetch(
694
1169
  }
695
1170
  }
696
1171
 
1172
+ if (pathname === REVOCATION_LIST_MOUNT) {
1173
+ // Revocation list (hub#212 Phase 1). Public — same CORS posture as
1174
+ // jwks.json since resource servers (vault/scribe/agent) fetch it
1175
+ // cross-origin on the 60s polling cadence wired in Phase 4.
1176
+ const corsHeaders = {
1177
+ "access-control-allow-origin": "*",
1178
+ "access-control-allow-methods": "GET, OPTIONS",
1179
+ };
1180
+ if (req.method === "OPTIONS") {
1181
+ return new Response(null, { status: 204, headers: corsHeaders });
1182
+ }
1183
+ if (!getDb) {
1184
+ return new Response('{"error":"revocation list unavailable: db not configured"}', {
1185
+ status: 503,
1186
+ headers: { "content-type": "application/json", ...corsHeaders },
1187
+ });
1188
+ }
1189
+ const resp = handleRevocationList(req, { db: getDb() });
1190
+ // Layer the wildcard CORS over whatever cache-control the handler set.
1191
+ const merged = new Headers(resp.headers);
1192
+ for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
1193
+ return new Response(resp.body, { status: resp.status, headers: merged });
1194
+ }
1195
+
697
1196
  if (pathname === "/.well-known/jwks.json") {
698
1197
  // JWKS is also a cross-origin fetch target (browser-side OAuth
699
1198
  // libraries pull this to verify access tokens). Same wildcard CORS
@@ -746,9 +1245,7 @@ export function hubFetch(
746
1245
  }
747
1246
 
748
1247
  if (pathname === "/oauth/authorize") {
749
- if (!getDb) {
750
- return new Response("hub db not configured", { status: 503 });
751
- }
1248
+ if (!getDb) return dbNotConfigured();
752
1249
  if (req.method === "GET") return handleAuthorizeGet(getDb(), req, oauthDeps(req));
753
1250
  if (req.method === "POST") return handleAuthorizePost(getDb(), req, oauthDeps(req));
754
1251
  return new Response("method not allowed", { status: 405 });
@@ -759,59 +1256,44 @@ export function hubFetch(
759
1256
  // by handleAuthorizeGet when the operator hits a pending client. Three
760
1257
  // gates inside the handler: CSRF, active session, same-origin Origin.
761
1258
  if (pathname === "/oauth/authorize/approve") {
762
- if (!getDb) {
763
- return new Response("hub db not configured", { status: 503 });
764
- }
1259
+ if (!getDb) return dbNotConfigured();
765
1260
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
766
1261
  return handleApproveClientPost(getDb(), req, oauthDeps(req));
767
1262
  }
768
1263
 
769
1264
  if (pathname === "/oauth/token") {
770
- if (!getDb) {
771
- return new Response("hub db not configured", { status: 503 });
772
- }
1265
+ if (!getDb) return dbNotConfigured();
773
1266
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
774
1267
  return handleToken(getDb(), req, oauthDeps(req));
775
1268
  }
776
1269
 
777
1270
  if (pathname === "/oauth/register") {
778
- if (!getDb) {
779
- return new Response("hub db not configured", { status: 503 });
780
- }
1271
+ if (!getDb) return dbNotConfigured();
781
1272
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
782
1273
  return handleRegister(getDb(), req, oauthDeps(req));
783
1274
  }
784
1275
 
785
1276
  if (pathname === "/oauth/revoke") {
786
- if (!getDb) {
787
- return new Response("hub db not configured", { status: 503 });
788
- }
1277
+ if (!getDb) return dbNotConfigured();
789
1278
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
790
1279
  return handleRevoke(getDb(), req, oauthDeps(req));
791
1280
  }
792
1281
 
793
1282
  if (pathname === "/vaults") {
794
- if (!getDb) {
795
- return new Response("hub db not configured", { status: 503 });
796
- }
1283
+ if (!getDb) return dbNotConfigured();
797
1284
  return handleCreateVault(req, {
798
1285
  db: getDb(),
799
1286
  issuer: oauthDeps(req).issuer,
800
1287
  });
801
1288
  }
802
1289
 
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
- }
1290
+ // Note: the old `/hub/*` SPA mount has been retired. Known prefixes
1291
+ // (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
1292
+ // 301-redirected at the top of dispatch. Any other `/hub/*` path falls
1293
+ // through to the catch-all 404 there's no admin surface left there.
812
1294
 
813
1295
  if (pathname === "/admin/host-admin-token") {
814
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1296
+ if (!getDb) return dbNotConfigured();
815
1297
  return handleHostAdminToken(req, {
816
1298
  db: getDb(),
817
1299
  issuer: oauthDeps(req).issuer,
@@ -819,7 +1301,7 @@ export function hubFetch(
819
1301
  }
820
1302
 
821
1303
  if (pathname.startsWith("/admin/vault-admin-token/")) {
822
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1304
+ if (!getDb) return dbNotConfigured();
823
1305
  const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
824
1306
  // The vault name must correspond to an actual vault instance — same
825
1307
  // shape the well-known doc derives. Source from services.json so a
@@ -838,8 +1320,122 @@ export function hubFetch(
838
1320
  });
839
1321
  }
840
1322
 
1323
+ if (pathname === "/api/me") {
1324
+ if (!getDb) return dbNotConfigured();
1325
+ return handleApiMe(req, { db: getDb() });
1326
+ }
1327
+
1328
+ if (pathname === "/api/modules") {
1329
+ if (!getDb) return dbNotConfigured();
1330
+ const modulesDeps: Parameters<typeof handleApiModules>[1] = {
1331
+ db: getDb(),
1332
+ issuer: oauthDeps(req).issuer,
1333
+ manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1334
+ };
1335
+ if (deps?.supervisor !== undefined) modulesDeps.supervisor = deps.supervisor;
1336
+ return handleApiModules(req, modulesDeps);
1337
+ }
1338
+
1339
+ // Channel toggle (hub#275) — pre-empts the /api/modules/:short/*
1340
+ // routes below so `/api/modules/channel` doesn't accidentally match
1341
+ // `parseModulesPath` (which would reject it as a non-curated short
1342
+ // anyway, but precedence makes the intent explicit).
1343
+ if (pathname === "/api/modules/channel") {
1344
+ if (!getDb) return dbNotConfigured();
1345
+ return handleApiModulesChannel(req, {
1346
+ db: getDb(),
1347
+ issuer: oauthDeps(req).issuer,
1348
+ });
1349
+ }
1350
+
1351
+ // Module operation poll surface — pre-empts the /api/modules/:short/*
1352
+ // routes below so `/api/modules/operations/<uuid>` doesn't accidentally
1353
+ // match a parseModulesPath("/operations") and fall through.
1354
+ if (pathname.startsWith("/api/modules/operations/")) {
1355
+ if (!getDb) return dbNotConfigured();
1356
+ if (!deps?.supervisor) {
1357
+ return new Response(
1358
+ JSON.stringify({
1359
+ error: "supervisor_unavailable",
1360
+ error_description:
1361
+ "module operations require `parachute serve` (supervisor mode); on-box CLI uses `parachute install/upgrade/restart`",
1362
+ }),
1363
+ { status: 503, headers: { "content-type": "application/json" } },
1364
+ );
1365
+ }
1366
+ const opId = decodeURIComponent(pathname.slice("/api/modules/operations/".length));
1367
+ if (!opId || opId.includes("/")) return new Response("not found", { status: 404 });
1368
+ return handleOperationGet(req, opId, {
1369
+ db: getDb(),
1370
+ issuer: oauthDeps(req).issuer,
1371
+ manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1372
+ configDir: CONFIG_DIR,
1373
+ supervisor: deps.supervisor,
1374
+ });
1375
+ }
1376
+
1377
+ // Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
1378
+ if (pathname.startsWith("/api/modules/")) {
1379
+ if (!getDb) return dbNotConfigured();
1380
+ if (!deps?.supervisor) {
1381
+ return new Response(
1382
+ JSON.stringify({
1383
+ error: "supervisor_unavailable",
1384
+ error_description:
1385
+ "module operations require `parachute serve` (supervisor mode); on-box CLI uses `parachute install/upgrade/restart`",
1386
+ }),
1387
+ { status: 503, headers: { "content-type": "application/json" } },
1388
+ );
1389
+ }
1390
+ const match = parseModulesPath(pathname);
1391
+ if (!match) return new Response("not found", { status: 404 });
1392
+ const opsDeps = {
1393
+ db: getDb(),
1394
+ issuer: oauthDeps(req).issuer,
1395
+ manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1396
+ configDir: CONFIG_DIR,
1397
+ supervisor: deps.supervisor,
1398
+ };
1399
+ switch (match.rest) {
1400
+ case "install":
1401
+ return handleInstall(req, match.short, opsDeps);
1402
+ case "restart":
1403
+ return handleRestart(req, match.short, opsDeps);
1404
+ case "upgrade":
1405
+ return handleUpgrade(req, match.short, opsDeps);
1406
+ case "uninstall":
1407
+ return handleUninstall(req, match.short, opsDeps);
1408
+ default:
1409
+ return new Response("not found", { status: 404 });
1410
+ }
1411
+ }
1412
+
1413
+ if (pathname === "/api/auth/mint-token") {
1414
+ if (!getDb) return dbNotConfigured();
1415
+ return handleApiMintToken(req, {
1416
+ db: getDb(),
1417
+ issuer: oauthDeps(req).issuer,
1418
+ });
1419
+ }
1420
+
1421
+ if (pathname === "/api/auth/revoke-token") {
1422
+ if (!getDb) return dbNotConfigured();
1423
+ return handleApiRevokeToken(req, {
1424
+ db: getDb(),
1425
+ issuer: oauthDeps(req).issuer,
1426
+ });
1427
+ }
1428
+
1429
+ if (pathname === "/api/auth/tokens") {
1430
+ if (!getDb) return dbNotConfigured();
1431
+ return handleApiTokens(req, {
1432
+ db: getDb(),
1433
+ issuer: oauthDeps(req).issuer,
1434
+ });
1435
+ }
1436
+
841
1437
  if (pathname === "/api/grants") {
842
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1438
+ if (!getDb) return dbNotConfigured();
843
1439
  return handleListGrants(req, {
844
1440
  db: getDb(),
845
1441
  issuer: oauthDeps(req).issuer,
@@ -847,7 +1443,7 @@ export function hubFetch(
847
1443
  }
848
1444
 
849
1445
  if (pathname.startsWith("/api/grants/")) {
850
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1446
+ if (!getDb) return dbNotConfigured();
851
1447
  const clientId = decodeURIComponent(pathname.slice("/api/grants/".length));
852
1448
  if (!clientId || clientId.includes("/")) {
853
1449
  return new Response("not found", { status: 404 });
@@ -858,63 +1454,98 @@ export function hubFetch(
858
1454
  });
859
1455
  }
860
1456
 
861
- if (pathname === "/admin/login") {
862
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1457
+ // OAuth client lookup + approval. Both bearer-gated under host:admin.
1458
+ // Two paths: `/api/oauth/clients/<id>` (GET, details) and
1459
+ // `/api/oauth/clients/<id>/approve` (POST, flip to approved). The
1460
+ // SPA approve-client deep link reads details from the first and
1461
+ // submits approval to the second — keeps the surface easy to test
1462
+ // and audit without overloading a single verb.
1463
+ if (pathname.startsWith("/api/oauth/clients/")) {
1464
+ if (!getDb) return dbNotConfigured();
1465
+ const tail = pathname.slice("/api/oauth/clients/".length);
1466
+ if (!tail) return new Response("not found", { status: 404 });
1467
+ const approveSuffix = "/approve";
1468
+ if (tail.endsWith(approveSuffix)) {
1469
+ const clientId = decodeURIComponent(tail.slice(0, -approveSuffix.length));
1470
+ if (!clientId || clientId.includes("/")) {
1471
+ return new Response("not found", { status: 404 });
1472
+ }
1473
+ return handleApproveClient(req, clientId, {
1474
+ db: getDb(),
1475
+ issuer: oauthDeps(req).issuer,
1476
+ });
1477
+ }
1478
+ const clientId = decodeURIComponent(tail);
1479
+ if (!clientId || clientId.includes("/")) {
1480
+ return new Response("not found", { status: 404 });
1481
+ }
1482
+ return handleGetClient(req, clientId, {
1483
+ db: getDb(),
1484
+ issuer: oauthDeps(req).issuer,
1485
+ });
1486
+ }
1487
+
1488
+ // Canonical login/logout. The handlers themselves are unchanged from
1489
+ // when they lived at /admin/login + /admin/logout; the rename surfaced
1490
+ // via #231-followup so the URL reflects the surface's actual scope
1491
+ // (entry point for ALL parachute auth — not admin-only). The
1492
+ // /admin/login and /admin/logout paths 301 to here, dispatched at the
1493
+ // top of this fn alongside the other back-compat redirects.
1494
+ if (pathname === "/login") {
1495
+ if (!getDb) return dbNotConfigured();
863
1496
  if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
864
1497
  if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
865
1498
  return new Response("method not allowed", { status: 405 });
866
1499
  }
867
1500
 
868
- if (pathname === "/admin/logout") {
869
- if (!getDb) return new Response("hub db not configured", { status: 503 });
1501
+ if (pathname === "/logout") {
1502
+ if (!getDb) return dbNotConfigured();
870
1503
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
871
1504
  return handleAdminLogoutPost(getDb(), req);
872
1505
  }
873
1506
 
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);
1507
+ // Legacy `/admin/config` (server-rendered module-config portal, #46)
1508
+ // retired post-SPA-rework. 301 the SPA home so any bookmark or stale
1509
+ // post-login redirect lands somewhere useful. The route stays here in
1510
+ // dispatch order (above the /admin/* SPA catch-all) so the redirect
1511
+ // wins over a SPA shell render.
1512
+ if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
1513
+ return new Response(null, {
1514
+ status: 301,
1515
+ headers: { location: "/admin/vaults" },
1516
+ });
878
1517
  }
879
1518
 
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
- }
1519
+ // /vault/<name>/* — per-vault content proxy. Stays as user-facing
1520
+ // surface (the Notes PWA loads through here, etc.). The bare `/vault`
1521
+ // and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
1522
+ // the top of dispatch now. Multi-segment requests like
1523
+ // `/vault/<unknown>/health` are vault-API shapes targeting a
1524
+ // non-existent vault and 404 directly there's no SPA-shell fallback
1525
+ // here anymore (the SPA moved to /admin), so we can't accidentally
1526
+ // mask a backend 404 with HTML.
910
1527
  if (pathname.startsWith("/vault/")) {
911
1528
  const proxied = await proxyToVault(req, manifestPath);
912
1529
  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");
1530
+ return new Response("not found", { status: 404 });
1531
+ }
1532
+
1533
+ // /admin/* SPA mount. All non-SPA admin handlers (host-admin-token,
1534
+ // vault-admin-token, login, logout, config, api/auth/*, api/grants,
1535
+ // grants/*) ran above and either matched or returned. Anything that
1536
+ // makes it here under /admin/* is a SPA route or asset request; the
1537
+ // SPA's own router renders the page and handles 404 client-side for
1538
+ // unknown sub-paths.
1539
+ if (pathname === "/admin" || pathname === "/admin/") {
1540
+ // Unprefixed /admin → SPA shell pointed at the vault list (its home).
1541
+ // The SPA's basename is /admin, so the router will land on / and
1542
+ // render VaultsList.
1543
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1544
+ return serveSpa(spaDistDir, pathname, "/admin");
1545
+ }
1546
+ if (pathname.startsWith("/admin/")) {
1547
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1548
+ return serveSpa(spaDistDir, pathname, "/admin");
918
1549
  }
919
1550
 
920
1551
  // Generic services.json-driven dispatch for non-vault modules. Reaches
@@ -929,7 +1560,7 @@ export function hubFetch(
929
1560
  }
930
1561
 
931
1562
  if (import.meta.main) {
932
- const { port, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
1563
+ const { port, hostname, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
933
1564
  let cachedDb: Database | undefined;
934
1565
  const getDb = () => {
935
1566
  if (!cachedDb) cachedDb = openHubDb(dbPath);
@@ -937,8 +1568,8 @@ if (import.meta.main) {
937
1568
  };
938
1569
  Bun.serve({
939
1570
  port,
940
- hostname: "127.0.0.1",
941
- fetch: hubFetch(wellKnownDir, { getDb, issuer }),
1571
+ hostname,
1572
+ fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
942
1573
  });
943
1574
  // Register PID + port from the running hub itself so any startup path
944
1575
  // (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from
@@ -961,7 +1592,7 @@ if (import.meta.main) {
961
1592
  });
962
1593
  process.on("exit", cleanup);
963
1594
  console.log(
964
- `parachute-hub listening on http://127.0.0.1:${port} (dir=${wellKnownDir}, db=${dbPath}${
1595
+ `parachute-hub listening on http://${hostname}:${port} (dir=${wellKnownDir}, db=${dbPath}${
965
1596
  issuer ? `, issuer=${issuer}` : ""
966
1597
  })`,
967
1598
  );