@openparachute/hub 0.6.4 → 0.6.5-rc.1

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.
package/src/hub-server.ts CHANGED
@@ -184,6 +184,7 @@ import { applyCorsHeaders, corsPreflightResponse, isCorsAllowedRoute } from "./c
184
184
  import { ensureCsrfToken } from "./csrf.ts";
185
185
  import { readExposeState } from "./expose-state.ts";
186
186
  import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
187
+ import { classifyDbError, createDbHolder, probeDbLiveness } from "./hub-db-liveness.ts";
187
188
  import { hubDbPath, openHubDb } from "./hub-db.ts";
188
189
  import { getHubOrigin } from "./hub-settings.ts";
189
190
  import { type RenderHubOpts, renderHub } from "./hub.ts";
@@ -831,6 +832,16 @@ export interface HubFetchDeps {
831
832
  * DB-dependent routes when this is absent.
832
833
  */
833
834
  getDb?: () => Database;
835
+ /**
836
+ * React to a thrown SQLite error per the self-heal-or-die policy (#594).
837
+ * Production wires the {@link DbHolder}'s `healOrExit` so a request that
838
+ * hits the persistent-corruption class (disk I/O error / malformed image)
839
+ * triggers ONE reopen attempt, then `process.exit(1)` if reopen fails — the
840
+ * platform manager restarts the hub with a fresh handle. Returns the holder
841
+ * verdict (`"healed"` / `"ignored"` / `"exited"`) so the handler can shape
842
+ * the response. Absent in tests that don't exercise the DB-error path.
843
+ */
844
+ onDbError?: (err: unknown) => "ignored" | "healed" | "exited";
834
845
  /**
835
846
  * Hub origin used as the OAuth `iss` claim and to build the authorization-
836
847
  * server metadata document. When omitted, OAuth endpoints fall back to the
@@ -1429,1219 +1440,1272 @@ export function hubFetch(
1429
1440
  // is then null and `layerOf` fails closed to `public`.
1430
1441
  const peerAddr = server?.requestIP(req)?.address ?? null;
1431
1442
 
1432
- // 301 back-compat for the pre-hub#231 admin-SPA mounts:
1433
- //
1434
- // `/vault` → `/admin/vaults`
1435
- // `/vault/new` `/admin/vaults/new`
1436
- // `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
1437
- // it now retargets at the new admin mount instead
1438
- // of the interim `/vault` mount)
1439
- // `/hub/permissions` → `/admin/permissions`
1440
- // `/hub/tokens` → `/admin/tokens`
1441
- // `/hub` (bare) → `/admin/vaults`
1442
- //
1443
- // Permanent redirect so cached operator URLs keep working without
1444
- // leaving dangling SPA routes. Query string preserved; fragment is
1445
- // client-side and survives the redirect at the browser. Method-agnostic
1446
- // even a misrouted POST gets the redirect; none of these paths host a
1447
- // POST endpoint to protect.
1448
- //
1449
- // `/vault/<name>/*` is INTENTIONALLY excluded that's the per-vault
1450
- // content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
1451
- if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
1452
- const sub = pathname === "/vault/new" ? "/new" : "";
1453
- return new Response("", {
1454
- status: 301,
1455
- headers: { location: `/admin/vaults${sub}${url.search}` },
1456
- });
1457
- }
1458
- if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
1459
- const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
1460
- return new Response("", {
1461
- status: 301,
1462
- headers: { location: `${newPath}${url.search}` },
1463
- });
1464
- }
1465
- if (pathname === "/hub/permissions") {
1466
- return new Response("", {
1467
- status: 301,
1468
- headers: { location: `/admin/permissions${url.search}` },
1469
- });
1470
- }
1471
- if (pathname === "/hub/tokens") {
1472
- return new Response("", {
1473
- status: 301,
1474
- headers: { location: `/admin/tokens${url.search}` },
1475
- });
1476
- }
1477
- if (pathname === "/hub" || pathname === "/hub/") {
1478
- return new Response("", {
1479
- status: 301,
1480
- headers: { location: `/admin/vaults${url.search}` },
1481
- });
1482
- }
1483
-
1484
- // Login surface rename: `/admin/login` and `/admin/logout` 301 to the
1485
- // canonical `/login` and `/logout`. The names were "admin" only by
1486
- // historical accident — the handlers serve every parachute auth flow
1487
- // (operator, OAuth user-redirect, future SPA sign-in). Renaming makes
1488
- // the surface name match its actual scope.
1489
- if (pathname === "/admin/login") {
1490
- return new Response("", {
1491
- status: 301,
1492
- headers: { location: `/login${url.search}` },
1493
- });
1494
- }
1495
- if (pathname === "/admin/logout") {
1496
- return new Response("", {
1497
- status: 301,
1498
- headers: { location: `/logout${url.search}` },
1499
- });
1500
- }
1501
-
1502
- // Notes-as-app migration Phase 2 (parachute-app design doc §16).
1503
- // `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land on
1504
- // the apps-hosted Notes. Default-on; operators on notes-as-module-only
1505
- // installs can opt out via `hub_settings.notes_redirect_disabled = true`
1506
- // (see hub-settings.ts). The opt-out exists so a legacy operator
1507
- // doesn't hit redirect → 404 in the deprecation window. Phase 3
1508
- // (parachute-notes v0.5) retires this redirect entirely.
1509
- //
1510
- // Method-agnostic — same shape as the other back-compat 301s above.
1511
- // The browser re-issues GET on the new URL per RFC 7231 (a POST won't
1512
- // round-trip its body, but no /notes/* path hosts a POST endpoint
1513
- // worth preserving — the Notes PWA is read-write against vault, not
1514
- // against the hub mount itself).
1515
- //
1516
- // Lazy DB read: only consult `getDb` when the path actually matches a
1517
- // legacy notes prefix — every non-notes request must NOT touch the DB
1518
- // here (some tests + the /health route assert getDb is never called).
1519
- if (isLegacyNotesPath(pathname)) {
1520
- const notesRedirect = maybeRedirectNotes(pathname, url.search, getDb?.());
1521
- if (notesRedirect !== undefined) {
1522
- logNotesRedirect(pathname, notesRedirect);
1523
- return new Response("", {
1524
- status: 301,
1525
- headers: { location: notesRedirect },
1526
- });
1527
- }
1528
- }
1529
-
1530
- // CORS preflight for the public OAuth + discovery surface. Browsers
1531
- // issue OPTIONS before any non-simple cross-origin request — third-party
1532
- // SPAs hitting `/oauth/register` (RFC 7591 DCR), `/oauth/token`,
1533
- // `/.well-known/oauth-authorization-server`, etc. Handling this above
1534
- // the route table means an OPTIONS to e.g. `/oauth/register` doesn't
1535
- // hit the method-not-allowed branch in the handler — the preflight is a
1536
- // CORS-protocol artifact, not a "real" request to the endpoint. The
1537
- // single `isCorsAllowedRoute` predicate is the source of truth for
1538
- // which paths carry wildcard-CORS; see `src/cors.ts` for the rationale.
1539
- // Out-of-scope paths (`/api/*`, `/admin/*`, `/login`, `/account/*`,
1540
- // `/vault/*`, generic service proxy) fall through and OPTIONS reaches
1541
- // whatever default the downstream handler enforces (typically 405).
1542
- if (req.method === "OPTIONS" && isCorsAllowedRoute(pathname)) {
1543
- return corsPreflightResponse(req);
1544
- }
1545
-
1546
- // Platform health check (Render, Fly, Kubernetes, etc.). Plain JSON,
1547
- // no DB required — the route reports liveness, not readiness. Anything
1548
- // more invasive (DB ping, schema check) would let a transient lock turn
1549
- // into a restart loop on the platform side. 200 always while the
1550
- // process is up.
1551
- if (pathname === "/health") {
1443
+ // Self-heal-or-die wrapper (#594). Any DB throw that escapes a route
1444
+ // handler lands here. A persistent-corruption error (disk I/O error /
1445
+ // malformed image — the state-dir-deleted-under-a-running-hub class)
1446
+ // triggers ONE reopen attempt via `onDbError`; if reopen fails the holder
1447
+ // exits the process so the platform manager restarts with a fresh handle.
1448
+ // The CALLER (this catch) shapes the HTTP response so the operator/CLI see
1449
+ // a structured cause instead of a bare bodyless 500 ("HTTP 500 with no
1450
+ // error detail"). A transient SQLITE_BUSY is classified non-fatal and just
1451
+ // surfaces a 503 the next request clears — it never kills the hub.
1452
+ try {
1453
+ return await dispatch();
1454
+ } catch (err) {
1455
+ const klass = classifyDbError(err);
1456
+ if (klass === "other") throw err; // not a DB-handle failure — let it propagate
1457
+ const verdict = deps?.onDbError?.(err) ?? "ignored";
1458
+ const detail = err instanceof Error ? err.message : String(err);
1459
+ // 503 (not bare 500): the fault is transient-from-the-client's-view —
1460
+ // either the handle was just reopened (`healed`) or it's a momentary
1461
+ // lock (`ignored`/transient); a retry is the right next move. `exited` is
1462
+ // only reachable in tests (production has exited the process). All carry
1463
+ // a structured body so the CLI's `asErrorBody` prints the real cause
1464
+ // instead of "HTTP 500 with no error detail" (#594 part 3).
1552
1465
  return new Response(
1553
- JSON.stringify({ status: "ok", service: "parachute-hub", version: pkg.version }),
1466
+ JSON.stringify({
1467
+ error: "db_unavailable",
1468
+ error_description:
1469
+ verdict === "healed"
1470
+ ? `hub database handle was reopened after a fault (${detail}); retry the request.`
1471
+ : `hub database error (${klass}): ${detail}`,
1472
+ }),
1554
1473
  {
1555
- headers: {
1556
- "content-type": "application/json",
1557
- "cache-control": "no-store",
1558
- },
1474
+ status: 503,
1475
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
1559
1476
  },
1560
1477
  );
1561
1478
  }
1562
1479
 
1563
- // Boot-readiness probe (hub#443). Used by the transient-state proxy
1564
- // error page's inline poll script to detect when a still-booting
1565
- // module has come up. Public + DB-free so it works during the pre-
1566
- // admin lockout (the page that polls it is itself served pre-auth).
1567
- if (pathname === "/api/ready") {
1568
- const readyDeps: Parameters<typeof handleApiReady>[1] = {};
1569
- if (deps?.supervisor !== undefined) readyDeps.supervisor = deps.supervisor;
1570
- return handleApiReady(req, readyDeps);
1571
- }
1572
-
1573
- // First-boot setup wizard (hub#259). Three steps server-rendered:
1574
- // GET /admin/setup — derive state, render the right step
1575
- // POST /admin/setup/account — create the admin row, set session
1576
- // POST /admin/setup/vault — provision the first vault
1577
- //
1578
- // The wizard owns the "should I 301 to /login now?" decision: setup is
1579
- // complete only when admin AND a vault entry both exist. A re-visit
1580
- // after partial setup picks up at the next step. See
1581
- // src/setup-wizard.ts for the renderer + handler internals.
1582
- if (pathname === "/admin/setup" || pathname.startsWith("/admin/setup/")) {
1583
- if (!getDb) return dbNotConfigured();
1584
- const wizardDeps: SetupWizardDeps = {
1585
- db: getDb(),
1586
- manifestPath,
1587
- configDir: CONFIG_DIR,
1588
- issuer: oauthDeps(req).issuer,
1589
- registry: getDefaultOperationsRegistry(),
1590
- // hub#576: a loopback peer (the on-box operator's own shell) is allowed
1591
- // to read the actual bootstrap token from the GET /admin/setup JSON
1592
- // probe. `layerOf` fails closed to non-loopback when peerAddr is
1593
- // unknown, so a header-less caller never gets the token.
1594
- requestIsLoopback: layerOf(req, peerAddr) === "loopback",
1595
- };
1596
- if (deps?.supervisor !== undefined) wizardDeps.supervisor = deps.supervisor;
1597
- if (pathname === "/admin/setup") {
1598
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1599
- return handleSetupGet(req, wizardDeps);
1480
+ async function dispatch(): Promise<Response> {
1481
+ // 301 back-compat for the pre-hub#231 admin-SPA mounts:
1482
+ //
1483
+ // `/vault` → `/admin/vaults`
1484
+ // `/vault/new` → `/admin/vaults/new`
1485
+ // `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
1486
+ // it now retargets at the new admin mount instead
1487
+ // of the interim `/vault` mount)
1488
+ // `/hub/permissions` → `/admin/permissions`
1489
+ // `/hub/tokens` → `/admin/tokens`
1490
+ // `/hub` (bare) `/admin/vaults`
1491
+ //
1492
+ // Permanent redirect so cached operator URLs keep working without
1493
+ // leaving dangling SPA routes. Query string preserved; fragment is
1494
+ // client-side and survives the redirect at the browser. Method-agnostic
1495
+ // even a misrouted POST gets the redirect; none of these paths host a
1496
+ // POST endpoint to protect.
1497
+ //
1498
+ // `/vault/<name>/*` is INTENTIONALLY excluded that's the per-vault
1499
+ // content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
1500
+ if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
1501
+ const sub = pathname === "/vault/new" ? "/new" : "";
1502
+ return new Response("", {
1503
+ status: 301,
1504
+ headers: { location: `/admin/vaults${sub}${url.search}` },
1505
+ });
1600
1506
  }
1601
- if (pathname === "/admin/setup/account") {
1602
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1603
- return handleSetupAccountPost(req, wizardDeps);
1507
+ if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
1508
+ const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
1509
+ return new Response("", {
1510
+ status: 301,
1511
+ headers: { location: `${newPath}${url.search}` },
1512
+ });
1604
1513
  }
1605
- if (pathname === "/admin/setup/vault") {
1606
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1607
- return handleSetupVaultPost(req, wizardDeps);
1514
+ if (pathname === "/hub/permissions") {
1515
+ return new Response("", {
1516
+ status: 301,
1517
+ headers: { location: `/admin/permissions${url.search}` },
1518
+ });
1608
1519
  }
1609
- if (pathname === "/admin/setup/expose") {
1610
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1611
- return handleSetupExposePost(req, wizardDeps);
1612
- }
1613
- // hub#272 Item B: post-wizard direct module-install POSTs from
1614
- // the done-screen "What's next?" tiles. Path shape is
1615
- // `/admin/setup/install/<short>`; the handler rejects on
1616
- // unknown shorts, on `vault` (the wizard's own step owns that),
1617
- // and on missing session/CSRF — same gates as the vault POST.
1618
- if (pathname.startsWith("/admin/setup/install/")) {
1619
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1620
- const short = pathname.slice("/admin/setup/install/".length);
1621
- return handleSetupInstallPost(req, short, wizardDeps);
1520
+ if (pathname === "/hub/tokens") {
1521
+ return new Response("", {
1522
+ status: 301,
1523
+ headers: { location: `/admin/tokens${url.search}` },
1524
+ });
1525
+ }
1526
+ if (pathname === "/hub" || pathname === "/hub/") {
1527
+ return new Response("", {
1528
+ status: 301,
1529
+ headers: { location: `/admin/vaults${url.search}` },
1530
+ });
1622
1531
  }
1623
- return new Response("not found", { status: 404 });
1624
- }
1625
1532
 
1626
- // Fresh-hub redirect: when the wizard still has work to do, the
1627
- // discovery page (`/`, `/hub.html`) funnels straight to it. Two
1628
- // wizard-mode conditions trigger the redirect:
1629
- //
1630
- // 1. No admin row exists (the original fresh-deploy case). The
1631
- // static portal carries no usable signal — no installed
1632
- // services to discover, no admin to sign in as.
1633
- // 2. Admin exists but no vault is installed (env-seed deploys
1634
- // where the operator baked admin into env vars but hasn't
1635
- // walked the wizard's vault step). Pre-fix, env-seeded
1636
- // operators bounced past the wizard entirely and had to
1637
- // hand-find /admin/modules + /admin/vaults; surface
1638
- // "let me finish the wizard" instead.
1639
- //
1640
- // The wizard's GET handler already picks the right step
1641
- // (`deriveWizardState` resumes at vault step when admin exists +
1642
- // no vault), so we just need the redirect to fire.
1643
- //
1644
- // 302 (not 301) so the redirect disappears the moment the wizard
1645
- // finishes. Sits before the JSON-shaped 503 gate below because `/`
1646
- // is an HTML surface — a JSON 503 there would render as raw text
1647
- // in the operator's browser tab. The 503 gate handles API + admin
1648
- // SPA + OAuth callers that branch on the structured body.
1649
- if (getDb && (pathname === "/" || pathname === "/hub.html")) {
1650
- const db = getDb();
1651
- // Either condition triggers the wizard funnel:
1652
- // - no admin row (the fresh-deploy case)
1653
- // - admin row exists but no vault installed (env-seed case)
1654
- // Short-circuit the manifest read when `noAdmin` is true; the
1655
- // wizard's first step is admin creation regardless of vault state.
1656
- const needsWizard = userCount(db) === 0 || !hasVaultInstalled(manifestPath);
1657
- if (needsWizard) {
1658
- return new Response(null, {
1659
- status: 302,
1660
- headers: { location: "/admin/setup" },
1533
+ // Login surface rename: `/admin/login` and `/admin/logout` 301 to the
1534
+ // canonical `/login` and `/logout`. The names were "admin" only by
1535
+ // historical accident the handlers serve every parachute auth flow
1536
+ // (operator, OAuth user-redirect, future SPA sign-in). Renaming makes
1537
+ // the surface name match its actual scope.
1538
+ if (pathname === "/admin/login") {
1539
+ return new Response("", {
1540
+ status: 301,
1541
+ headers: { location: `/login${url.search}` },
1542
+ });
1543
+ }
1544
+ if (pathname === "/admin/logout") {
1545
+ return new Response("", {
1546
+ status: 301,
1547
+ headers: { location: `/logout${url.search}` },
1661
1548
  });
1662
1549
  }
1663
- }
1664
1550
 
1665
- // Pre-admin lockout. When the hub has booted with no admin row (the
1666
- // fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
1667
- // /admin/setup is walked), every operator-facing surface that requires
1668
- // identity is meaningless auth flows can't validate, the SPA can't
1669
- // mint a host-admin token, OAuth can't issue codes. Route those to a
1670
- // 503 that points at /admin/setup. Health, well-known, /admin/setup
1671
- // itself, OAuth third-party endpoints, and content proxies pass
1672
- // through; the fresh-hub `/` and `/hub.html` redirect above handled
1673
- // the discovery-page case.
1674
- //
1675
- // `shouldGateForSetup` runs first so non-gated paths (well-known, /,
1676
- // /health, /admin/setup) never touch getDb keeping the
1677
- // existing OPTIONS-preflight contract that those routes are db-free.
1678
- if (getDb && shouldGateForSetup(pathname) && userCount(getDb()) === 0) {
1679
- return new Response(
1680
- JSON.stringify({
1681
- error: "setup_required",
1682
- error_description:
1683
- "no admin configured. Visit /admin/setup, or set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD and restart.",
1684
- setup_url: "/admin/setup",
1685
- }),
1686
- {
1687
- status: 503,
1688
- headers: {
1689
- "content-type": "application/json",
1690
- "cache-control": "no-store",
1551
+ // Notes-as-app migration Phase 2 (parachute-app design doc §16).
1552
+ // `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land on
1553
+ // the apps-hosted Notes. Default-on; operators on notes-as-module-only
1554
+ // installs can opt out via `hub_settings.notes_redirect_disabled = true`
1555
+ // (see hub-settings.ts). The opt-out exists so a legacy operator
1556
+ // doesn't hit redirect 404 in the deprecation window. Phase 3
1557
+ // (parachute-notes v0.5) retires this redirect entirely.
1558
+ //
1559
+ // Method-agnostic — same shape as the other back-compat 301s above.
1560
+ // The browser re-issues GET on the new URL per RFC 7231 (a POST won't
1561
+ // round-trip its body, but no /notes/* path hosts a POST endpoint
1562
+ // worth preserving the Notes PWA is read-write against vault, not
1563
+ // against the hub mount itself).
1564
+ //
1565
+ // Lazy DB read: only consult `getDb` when the path actually matches a
1566
+ // legacy notes prefix — every non-notes request must NOT touch the DB
1567
+ // here (some tests + the /health route assert getDb is never called).
1568
+ if (isLegacyNotesPath(pathname)) {
1569
+ const notesRedirect = maybeRedirectNotes(pathname, url.search, getDb?.());
1570
+ if (notesRedirect !== undefined) {
1571
+ logNotesRedirect(pathname, notesRedirect);
1572
+ return new Response("", {
1573
+ status: 301,
1574
+ headers: { location: notesRedirect },
1575
+ });
1576
+ }
1577
+ }
1578
+
1579
+ // CORS preflight for the public OAuth + discovery surface. Browsers
1580
+ // issue OPTIONS before any non-simple cross-origin request — third-party
1581
+ // SPAs hitting `/oauth/register` (RFC 7591 DCR), `/oauth/token`,
1582
+ // `/.well-known/oauth-authorization-server`, etc. Handling this above
1583
+ // the route table means an OPTIONS to e.g. `/oauth/register` doesn't
1584
+ // hit the method-not-allowed branch in the handler — the preflight is a
1585
+ // CORS-protocol artifact, not a "real" request to the endpoint. The
1586
+ // single `isCorsAllowedRoute` predicate is the source of truth for
1587
+ // which paths carry wildcard-CORS; see `src/cors.ts` for the rationale.
1588
+ // Out-of-scope paths (`/api/*`, `/admin/*`, `/login`, `/account/*`,
1589
+ // `/vault/*`, generic service proxy) fall through and OPTIONS reaches
1590
+ // whatever default the downstream handler enforces (typically 405).
1591
+ if (req.method === "OPTIONS" && isCorsAllowedRoute(pathname)) {
1592
+ return corsPreflightResponse(req);
1593
+ }
1594
+
1595
+ // Platform health check (Render, Fly, Kubernetes, etc.). Plain JSON.
1596
+ // Always 200 while the process is up — the HTTP status reports process
1597
+ // liveness, not DB readiness, so a transient DB blip never turns into a
1598
+ // platform-side restart loop. The `db` field (#594) carries the cheap
1599
+ // `SELECT 1` verdict ("ok" / "error: <class>") so monitoring, `parachute
1600
+ // status`, and the #590/#591 adoption probe can distinguish "hub up" from
1601
+ // "hub up but its database is gone" (the dead-handle field repro: green
1602
+ // /health while every DB route 500s). The probe NEVER throws — a thrown
1603
+ // probe would make /health itself 500, defeating the point.
1604
+ if (pathname === "/health") {
1605
+ let db: "ok" | string = "unconfigured";
1606
+ if (getDb) {
1607
+ try {
1608
+ db = probeDbLiveness(getDb());
1609
+ } catch {
1610
+ // getDb() itself threw (e.g. openHubDb failed) — report it as an
1611
+ // error class without letting /health 500.
1612
+ db = "error: other";
1613
+ }
1614
+ }
1615
+ return new Response(
1616
+ JSON.stringify({ status: "ok", service: "parachute-hub", version: pkg.version, db }),
1617
+ {
1618
+ headers: {
1619
+ "content-type": "application/json",
1620
+ "cache-control": "no-store",
1621
+ },
1691
1622
  },
1692
- },
1693
- );
1694
- }
1623
+ );
1624
+ }
1695
1625
 
1696
- if (pathname === "/" || pathname === "/hub.html") {
1697
- // When a DB is configured, render the discovery page dynamically so
1698
- // the header carries a "Signed in as <name>" affordance for the
1699
- // active session. Without a DB, fall back to the static disk file
1700
- // (signed-out shape) — the disk file is what `parachute expose`
1701
- // wrote out, used when the hub-server is running without state.
1702
- if (getDb) {
1703
- const db = getDb();
1704
- const session = findActiveSession(db, req);
1705
- let renderOpts: RenderHubOpts = {};
1706
- const headers: Record<string, string> = {
1707
- "content-type": "text/html; charset=utf-8",
1626
+ // Boot-readiness probe (hub#443). Used by the transient-state proxy
1627
+ // error page's inline poll script to detect when a still-booting
1628
+ // module has come up. Public + DB-free so it works during the pre-
1629
+ // admin lockout (the page that polls it is itself served pre-auth).
1630
+ if (pathname === "/api/ready") {
1631
+ const readyDeps: Parameters<typeof handleApiReady>[1] = {};
1632
+ if (deps?.supervisor !== undefined) readyDeps.supervisor = deps.supervisor;
1633
+ return handleApiReady(req, readyDeps);
1634
+ }
1635
+
1636
+ // First-boot setup wizard (hub#259). Three steps server-rendered:
1637
+ // GET /admin/setup — derive state, render the right step
1638
+ // POST /admin/setup/account — create the admin row, set session
1639
+ // POST /admin/setup/vault — provision the first vault
1640
+ //
1641
+ // The wizard owns the "should I 301 to /login now?" decision: setup is
1642
+ // complete only when admin AND a vault entry both exist. A re-visit
1643
+ // after partial setup picks up at the next step. See
1644
+ // src/setup-wizard.ts for the renderer + handler internals.
1645
+ if (pathname === "/admin/setup" || pathname.startsWith("/admin/setup/")) {
1646
+ if (!getDb) return dbNotConfigured();
1647
+ const wizardDeps: SetupWizardDeps = {
1648
+ db: getDb(),
1649
+ manifestPath,
1650
+ configDir: CONFIG_DIR,
1651
+ issuer: oauthDeps(req).issuer,
1652
+ registry: getDefaultOperationsRegistry(),
1653
+ // hub#576: a loopback peer (the on-box operator's own shell) is allowed
1654
+ // to read the actual bootstrap token from the GET /admin/setup JSON
1655
+ // probe. `layerOf` fails closed to non-loopback when peerAddr is
1656
+ // unknown, so a header-less caller never gets the token.
1657
+ requestIsLoopback: layerOf(req, peerAddr) === "loopback",
1708
1658
  };
1709
- if (session) {
1710
- const user = getUserById(db, session.userId);
1711
- if (user) {
1712
- const csrf = ensureCsrfToken(req);
1713
- renderOpts = {
1714
- session: { displayName: user.username, csrfToken: csrf.token },
1715
- };
1716
- if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
1717
- }
1659
+ if (deps?.supervisor !== undefined) wizardDeps.supervisor = deps.supervisor;
1660
+ if (pathname === "/admin/setup") {
1661
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
1662
+ return handleSetupGet(req, wizardDeps);
1663
+ }
1664
+ if (pathname === "/admin/setup/account") {
1665
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1666
+ return handleSetupAccountPost(req, wizardDeps);
1667
+ }
1668
+ if (pathname === "/admin/setup/vault") {
1669
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1670
+ return handleSetupVaultPost(req, wizardDeps);
1718
1671
  }
1719
- return new Response(renderHub(renderOpts), { headers });
1672
+ if (pathname === "/admin/setup/expose") {
1673
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1674
+ return handleSetupExposePost(req, wizardDeps);
1675
+ }
1676
+ // hub#272 Item B: post-wizard direct module-install POSTs from
1677
+ // the done-screen "What's next?" tiles. Path shape is
1678
+ // `/admin/setup/install/<short>`; the handler rejects on
1679
+ // unknown shorts, on `vault` (the wizard's own step owns that),
1680
+ // and on missing session/CSRF — same gates as the vault POST.
1681
+ if (pathname.startsWith("/admin/setup/install/")) {
1682
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
1683
+ const short = pathname.slice("/admin/setup/install/".length);
1684
+ return handleSetupInstallPost(req, short, wizardDeps);
1685
+ }
1686
+ return new Response("not found", { status: 404 });
1720
1687
  }
1721
- // No DB configured → fall back to static file (signed-out only).
1722
- if (!existsSync(hubHtmlPath)) {
1723
- return new Response("hub.html not found", { status: 404 });
1688
+
1689
+ // Fresh-hub redirect: when the wizard still has work to do, the
1690
+ // discovery page (`/`, `/hub.html`) funnels straight to it. Two
1691
+ // wizard-mode conditions trigger the redirect:
1692
+ //
1693
+ // 1. No admin row exists (the original fresh-deploy case). The
1694
+ // static portal carries no usable signal — no installed
1695
+ // services to discover, no admin to sign in as.
1696
+ // 2. Admin exists but no vault is installed (env-seed deploys
1697
+ // where the operator baked admin into env vars but hasn't
1698
+ // walked the wizard's vault step). Pre-fix, env-seeded
1699
+ // operators bounced past the wizard entirely and had to
1700
+ // hand-find /admin/modules + /admin/vaults; surface
1701
+ // "let me finish the wizard" instead.
1702
+ //
1703
+ // The wizard's GET handler already picks the right step
1704
+ // (`deriveWizardState` resumes at vault step when admin exists +
1705
+ // no vault), so we just need the redirect to fire.
1706
+ //
1707
+ // 302 (not 301) so the redirect disappears the moment the wizard
1708
+ // finishes. Sits before the JSON-shaped 503 gate below because `/`
1709
+ // is an HTML surface — a JSON 503 there would render as raw text
1710
+ // in the operator's browser tab. The 503 gate handles API + admin
1711
+ // SPA + OAuth callers that branch on the structured body.
1712
+ if (getDb && (pathname === "/" || pathname === "/hub.html")) {
1713
+ const db = getDb();
1714
+ // Either condition triggers the wizard funnel:
1715
+ // - no admin row (the fresh-deploy case)
1716
+ // - admin row exists but no vault installed (env-seed case)
1717
+ // Short-circuit the manifest read when `noAdmin` is true; the
1718
+ // wizard's first step is admin creation regardless of vault state.
1719
+ const needsWizard = userCount(db) === 0 || !hasVaultInstalled(manifestPath);
1720
+ if (needsWizard) {
1721
+ return new Response(null, {
1722
+ status: 302,
1723
+ headers: { location: "/admin/setup" },
1724
+ });
1725
+ }
1724
1726
  }
1725
- return new Response(Bun.file(hubHtmlPath), {
1726
- headers: { "content-type": "text/html; charset=utf-8" },
1727
- });
1728
- }
1729
1727
 
1730
- if (pathname === "/.well-known/parachute.json") {
1731
- // The well-known doc is a public service-discovery manifest (no
1732
- // secrets, no PII), and Notes / future browser clients fetch it
1733
- // cross-origin from their own loopback port. Wildcard CORS is the
1734
- // shape it needs. Browsers send an OPTIONS preflight when the request
1735
- // adds non-simple headers; answer it with 204 + the same allow-list.
1728
+ // Pre-admin lockout. When the hub has booted with no admin row (the
1729
+ // fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
1730
+ // /admin/setup is walked), every operator-facing surface that requires
1731
+ // identity is meaningless auth flows can't validate, the SPA can't
1732
+ // mint a host-admin token, OAuth can't issue codes. Route those to a
1733
+ // 503 that points at /admin/setup. Health, well-known, /admin/setup
1734
+ // itself, OAuth third-party endpoints, and content proxies pass
1735
+ // through; the fresh-hub `/` and `/hub.html` redirect above handled
1736
+ // the discovery-page case.
1736
1737
  //
1737
- // `cache-control: no-store` matters here: the discovery page (`/`)
1738
- // fetches this doc and renders Service tiles from it; without
1739
- // no-store, the browser's HTTP cache returns the stale services list
1740
- // the next time the operator navigates back to `/` after installing
1741
- // a module via the admin SPA. The doc is small and built per-request
1742
- // anyway, so giving up cacheability has no real cost (hub#268 Item 1).
1743
- const corsHeaders = {
1744
- "access-control-allow-origin": "*",
1745
- "access-control-allow-methods": "GET, OPTIONS",
1746
- "cache-control": "no-store",
1747
- };
1748
- if (req.method === "OPTIONS") {
1749
- return new Response(null, { status: 204, headers: corsHeaders });
1750
- }
1751
- // Built dynamically from services.json on every request — that's what
1752
- // makes `parachute vault create` show up here without re-running
1753
- // expose. canonicalOrigin reuses the OAuth issuer fallback: prefer the
1754
- // configured public origin (set by `--issuer https://<fqdn>`), else
1755
- // the request's own origin (fine for direct loopback hits).
1756
- try {
1757
- // Lenient — see hub#406.
1758
- const manifest = readManifestLenient(manifestPath);
1759
- // Same precedence as the OAuth issuer (hub#298): hub_settings →
1760
- // env → request origin. The well-known doc embeds this origin
1761
- // in service URLs + the issuer metadata link, so it must follow
1762
- // the same chain — otherwise a public-domain operator who set
1763
- // `hub_origin` would still see the Render-assigned URL on
1764
- // `/.well-known/parachute.json` while their JWTs carry the
1765
- // canonical URL, and discovery clients would split-brain on
1766
- // which one to trust.
1767
- const canonicalOrigin = resolveIssuer(
1768
- req,
1769
- getDb?.(),
1770
- configuredIssuer,
1771
- loadExposeHubOrigin,
1738
+ // `shouldGateForSetup` runs first so non-gated paths (well-known, /,
1739
+ // /health, /admin/setup) never touch getDb keeping the
1740
+ // existing OPTIONS-preflight contract that those routes are db-free.
1741
+ if (getDb && shouldGateForSetup(pathname) && userCount(getDb()) === 0) {
1742
+ return new Response(
1743
+ JSON.stringify({
1744
+ error: "setup_required",
1745
+ error_description:
1746
+ "no admin configured. Visit /admin/setup, or set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD and restart.",
1747
+ setup_url: "/admin/setup",
1748
+ }),
1749
+ {
1750
+ status: 503,
1751
+ headers: {
1752
+ "content-type": "application/json",
1753
+ "cache-control": "no-store",
1754
+ },
1755
+ },
1772
1756
  );
1773
- const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
1774
- const [managementUrlByName, serviceUiMeta] = await Promise.all([
1775
- loadManagementUrls(manifest.services, readManifestFn),
1776
- loadServiceUiMetadata(manifest.services, readManifestFn),
1777
- ]);
1778
- const doc = buildWellKnown({
1779
- services: manifest.services,
1780
- canonicalOrigin,
1781
- managementUrlFor: (entry) => managementUrlByName.get(entry.name),
1782
- uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
1783
- displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
1784
- });
1785
- return new Response(JSON.stringify(doc), {
1786
- headers: { "content-type": "application/json", ...corsHeaders },
1787
- });
1788
- } catch (err) {
1789
- // ServicesManifestError lands here too — corrupt JSON or schema
1790
- // violation in services.json shouldn't crash the hub for everyone.
1791
- const msg = err instanceof Error ? err.message : String(err);
1792
- return new Response(JSON.stringify({ error: `well-known build failed: ${msg}` }), {
1793
- status: 500,
1794
- headers: { "content-type": "application/json", ...corsHeaders },
1795
- });
1796
1757
  }
1797
- }
1798
1758
 
1799
- if (pathname === REVOCATION_LIST_MOUNT) {
1800
- // Revocation list (hub#212 Phase 1). Public same CORS posture as
1801
- // jwks.json since resource servers (vault/scribe/agent) fetch it
1802
- // cross-origin on the 60s polling cadence wired in Phase 4.
1803
- const corsHeaders = {
1804
- "access-control-allow-origin": "*",
1805
- "access-control-allow-methods": "GET, OPTIONS",
1806
- };
1807
- if (req.method === "OPTIONS") {
1808
- return new Response(null, { status: 204, headers: corsHeaders });
1809
- }
1810
- if (!getDb) {
1811
- return new Response('{"error":"revocation list unavailable: db not configured"}', {
1812
- status: 503,
1813
- headers: { "content-type": "application/json", ...corsHeaders },
1759
+ if (pathname === "/" || pathname === "/hub.html") {
1760
+ // When a DB is configured, render the discovery page dynamically so
1761
+ // the header carries a "Signed in as <name>" affordance for the
1762
+ // active session. Without a DB, fall back to the static disk file
1763
+ // (signed-out shape) — the disk file is what `parachute expose`
1764
+ // wrote out, used when the hub-server is running without state.
1765
+ if (getDb) {
1766
+ const db = getDb();
1767
+ const session = findActiveSession(db, req);
1768
+ let renderOpts: RenderHubOpts = {};
1769
+ const headers: Record<string, string> = {
1770
+ "content-type": "text/html; charset=utf-8",
1771
+ };
1772
+ if (session) {
1773
+ const user = getUserById(db, session.userId);
1774
+ if (user) {
1775
+ const csrf = ensureCsrfToken(req);
1776
+ renderOpts = {
1777
+ session: { displayName: user.username, csrfToken: csrf.token },
1778
+ };
1779
+ if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
1780
+ }
1781
+ }
1782
+ return new Response(renderHub(renderOpts), { headers });
1783
+ }
1784
+ // No DB configured → fall back to static file (signed-out only).
1785
+ if (!existsSync(hubHtmlPath)) {
1786
+ return new Response("hub.html not found", { status: 404 });
1787
+ }
1788
+ return new Response(Bun.file(hubHtmlPath), {
1789
+ headers: { "content-type": "text/html; charset=utf-8" },
1814
1790
  });
1815
1791
  }
1816
- const resp = handleRevocationList(req, { db: getDb() });
1817
- // Layer the wildcard CORS over whatever cache-control the handler set.
1818
- const merged = new Headers(resp.headers);
1819
- for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
1820
- return new Response(resp.body, { status: resp.status, headers: merged });
1821
- }
1822
1792
 
1823
- if (pathname === "/.well-known/jwks.json") {
1824
- // JWKS is also a cross-origin fetch target (browser-side OAuth
1825
- // libraries pull this to verify access tokens). Same wildcard CORS
1826
- // shape as parachute.json JWKS is public-by-design (only public
1827
- // keys leave the server).
1828
- const corsHeaders = {
1829
- "access-control-allow-origin": "*",
1830
- "access-control-allow-methods": "GET, OPTIONS",
1831
- };
1832
- if (req.method === "OPTIONS") {
1833
- return new Response(null, { status: 204, headers: corsHeaders });
1834
- }
1835
- if (!getDb) {
1836
- return new Response('{"error":"jwks unavailable: db not configured"}', {
1837
- status: 503,
1838
- headers: { "content-type": "application/json", ...corsHeaders },
1839
- });
1840
- }
1841
- try {
1842
- const db = getDb();
1843
- const keys = getAllPublicKeys(db).map((k) => pemToJwk(k.publicKeyPem, k.kid));
1844
- return new Response(JSON.stringify({ keys }), {
1845
- headers: { "content-type": "application/json", ...corsHeaders },
1846
- });
1847
- } catch (err) {
1848
- const msg = err instanceof Error ? err.message : String(err);
1849
- return new Response(JSON.stringify({ error: `jwks failed: ${msg}` }), {
1850
- status: 500,
1851
- headers: { "content-type": "application/json", ...corsHeaders },
1852
- });
1793
+ if (pathname === "/.well-known/parachute.json") {
1794
+ // The well-known doc is a public service-discovery manifest (no
1795
+ // secrets, no PII), and Notes / future browser clients fetch it
1796
+ // cross-origin from their own loopback port. Wildcard CORS is the
1797
+ // shape it needs. Browsers send an OPTIONS preflight when the request
1798
+ // adds non-simple headers; answer it with 204 + the same allow-list.
1799
+ //
1800
+ // `cache-control: no-store` matters here: the discovery page (`/`)
1801
+ // fetches this doc and renders Service tiles from it; without
1802
+ // no-store, the browser's HTTP cache returns the stale services list
1803
+ // the next time the operator navigates back to `/` after installing
1804
+ // a module via the admin SPA. The doc is small and built per-request
1805
+ // anyway, so giving up cacheability has no real cost (hub#268 Item 1).
1806
+ const corsHeaders = {
1807
+ "access-control-allow-origin": "*",
1808
+ "access-control-allow-methods": "GET, OPTIONS",
1809
+ "cache-control": "no-store",
1810
+ };
1811
+ if (req.method === "OPTIONS") {
1812
+ return new Response(null, { status: 204, headers: corsHeaders });
1813
+ }
1814
+ // Built dynamically from services.json on every request — that's what
1815
+ // makes `parachute vault create` show up here without re-running
1816
+ // expose. canonicalOrigin reuses the OAuth issuer fallback: prefer the
1817
+ // configured public origin (set by `--issuer https://<fqdn>`), else
1818
+ // the request's own origin (fine for direct loopback hits).
1819
+ try {
1820
+ // Lenient — see hub#406.
1821
+ const manifest = readManifestLenient(manifestPath);
1822
+ // Same precedence as the OAuth issuer (hub#298): hub_settings →
1823
+ // env → request origin. The well-known doc embeds this origin
1824
+ // in service URLs + the issuer metadata link, so it must follow
1825
+ // the same chain — otherwise a public-domain operator who set
1826
+ // `hub_origin` would still see the Render-assigned URL on
1827
+ // `/.well-known/parachute.json` while their JWTs carry the
1828
+ // canonical URL, and discovery clients would split-brain on
1829
+ // which one to trust.
1830
+ const canonicalOrigin = resolveIssuer(
1831
+ req,
1832
+ getDb?.(),
1833
+ configuredIssuer,
1834
+ loadExposeHubOrigin,
1835
+ );
1836
+ const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
1837
+ const [managementUrlByName, serviceUiMeta] = await Promise.all([
1838
+ loadManagementUrls(manifest.services, readManifestFn),
1839
+ loadServiceUiMetadata(manifest.services, readManifestFn),
1840
+ ]);
1841
+ const doc = buildWellKnown({
1842
+ services: manifest.services,
1843
+ canonicalOrigin,
1844
+ managementUrlFor: (entry) => managementUrlByName.get(entry.name),
1845
+ uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
1846
+ displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
1847
+ });
1848
+ return new Response(JSON.stringify(doc), {
1849
+ headers: { "content-type": "application/json", ...corsHeaders },
1850
+ });
1851
+ } catch (err) {
1852
+ // ServicesManifestError lands here too — corrupt JSON or schema
1853
+ // violation in services.json shouldn't crash the hub for everyone.
1854
+ const msg = err instanceof Error ? err.message : String(err);
1855
+ return new Response(JSON.stringify({ error: `well-known build failed: ${msg}` }), {
1856
+ status: 500,
1857
+ headers: { "content-type": "application/json", ...corsHeaders },
1858
+ });
1859
+ }
1853
1860
  }
1854
- }
1855
1861
 
1856
- if (pathname === "/.well-known/oauth-authorization-server") {
1857
- // Public discovery doc clients pull this cross-origin to find the
1858
- // authorize/token endpoints. Same wildcard CORS shape as the JWKS
1859
- // and the parachute manifest.
1860
- const corsHeaders = {
1861
- "access-control-allow-origin": "*",
1862
- "access-control-allow-methods": "GET, OPTIONS",
1863
- };
1864
- if (req.method === "OPTIONS") {
1865
- return new Response(null, { status: 204, headers: corsHeaders });
1866
- }
1867
- const res = authorizationServerMetadata(oauthDeps(req));
1868
- // Fold CORS into the existing JSON response.
1869
- const merged = new Headers(res.headers);
1870
- for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
1871
- return new Response(res.body, { status: res.status, headers: merged });
1872
- }
1862
+ if (pathname === REVOCATION_LIST_MOUNT) {
1863
+ // Revocation list (hub#212 Phase 1). Public same CORS posture as
1864
+ // jwks.json since resource servers (vault/scribe/agent) fetch it
1865
+ // cross-origin on the 60s polling cadence wired in Phase 4.
1866
+ const corsHeaders = {
1867
+ "access-control-allow-origin": "*",
1868
+ "access-control-allow-methods": "GET, OPTIONS",
1869
+ };
1870
+ if (req.method === "OPTIONS") {
1871
+ return new Response(null, { status: 204, headers: corsHeaders });
1872
+ }
1873
+ if (!getDb) {
1874
+ return new Response('{"error":"revocation list unavailable: db not configured"}', {
1875
+ status: 503,
1876
+ headers: { "content-type": "application/json", ...corsHeaders },
1877
+ });
1878
+ }
1879
+ const resp = handleRevocationList(req, { db: getDb() });
1880
+ // Layer the wildcard CORS over whatever cache-control the handler set.
1881
+ const merged = new Headers(resp.headers);
1882
+ for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
1883
+ return new Response(resp.body, { status: resp.status, headers: merged });
1884
+ }
1873
1885
 
1874
- if (pathname === "/.well-known/oauth-protected-resource") {
1875
- // RFC 9728 companion to oauth-authorization-server. MCP clients
1876
- // (since 2025-06-18 spec) probe this to discover scopes + the
1877
- // authorization server. Same wildcard CORS shape. Closes hub#393.
1878
- const corsHeaders = {
1879
- "access-control-allow-origin": "*",
1880
- "access-control-allow-methods": "GET, OPTIONS",
1881
- };
1882
- if (req.method === "OPTIONS") {
1883
- return new Response(null, { status: 204, headers: corsHeaders });
1884
- }
1885
- const res = protectedResourceMetadata(oauthDeps(req));
1886
- const merged = new Headers(res.headers);
1887
- for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
1888
- return new Response(res.body, { status: res.status, headers: merged });
1889
- }
1886
+ if (pathname === "/.well-known/jwks.json") {
1887
+ // JWKS is also a cross-origin fetch target (browser-side OAuth
1888
+ // libraries pull this to verify access tokens). Same wildcard CORS
1889
+ // shape as parachute.json JWKS is public-by-design (only public
1890
+ // keys leave the server).
1891
+ const corsHeaders = {
1892
+ "access-control-allow-origin": "*",
1893
+ "access-control-allow-methods": "GET, OPTIONS",
1894
+ };
1895
+ if (req.method === "OPTIONS") {
1896
+ return new Response(null, { status: 204, headers: corsHeaders });
1897
+ }
1898
+ if (!getDb) {
1899
+ return new Response('{"error":"jwks unavailable: db not configured"}', {
1900
+ status: 503,
1901
+ headers: { "content-type": "application/json", ...corsHeaders },
1902
+ });
1903
+ }
1904
+ try {
1905
+ const db = getDb();
1906
+ const keys = getAllPublicKeys(db).map((k) => pemToJwk(k.publicKeyPem, k.kid));
1907
+ return new Response(JSON.stringify({ keys }), {
1908
+ headers: { "content-type": "application/json", ...corsHeaders },
1909
+ });
1910
+ } catch (err) {
1911
+ const msg = err instanceof Error ? err.message : String(err);
1912
+ return new Response(JSON.stringify({ error: `jwks failed: ${msg}` }), {
1913
+ status: 500,
1914
+ headers: { "content-type": "application/json", ...corsHeaders },
1915
+ });
1916
+ }
1917
+ }
1890
1918
 
1891
- // OAuth surface every handler return is wrapped in `applyCorsHeaders`
1892
- // so third-party SPAs can fetch these endpoints cross-origin (the entire
1893
- // point of OAuth DCR: arbitrary SPAs register authorize → exchange
1894
- // tokens). Preflight OPTIONS already returned at the top of dispatch.
1895
- // See `src/cors.ts` for the wildcard-origin rationale.
1896
- if (pathname === "/oauth/authorize") {
1897
- if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1898
- // Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 3:
1899
- // a signed-in pre-rotation user must NOT be able to ride the consent flow
1900
- // to an auth code `/oauth/token` exchange → vault-scoped access token
1901
- // without rotating the temp password. Gating `/oauth/authorize` (the
1902
- // session-backed consent path) is sufficient — no code is issued without
1903
- // it, so `/oauth/token` (back-channel code exchange, no session cookie)
1904
- // is intentionally NOT gated (gating it would break the legitimate
1905
- // exchange). An UNAUTHENTICATED authorize request returns null from the
1906
- // gate and falls through to render the login form, unchanged.
1907
- const oauthGate = forceChangePasswordGate(getDb(), req);
1908
- if (oauthGate) return applyCorsHeaders(req, oauthGate);
1909
- if (req.method === "GET") {
1910
- // handleAuthorizeGet is sync (returns Response, not Promise<Response>).
1911
- // handleAuthorizePost is async — keep the await on POST only.
1912
- return applyCorsHeaders(req, handleAuthorizeGet(getDb(), req, oauthDeps(req)));
1913
- }
1914
- if (req.method === "POST") {
1915
- return applyCorsHeaders(req, await handleAuthorizePost(getDb(), req, oauthDeps(req)));
1916
- }
1917
- return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1918
- }
1919
+ if (pathname === "/.well-known/oauth-authorization-server") {
1920
+ // Public discovery doc clients pull this cross-origin to find the
1921
+ // authorize/token endpoints. Same wildcard CORS shape as the JWKS
1922
+ // and the parachute manifest.
1923
+ const corsHeaders = {
1924
+ "access-control-allow-origin": "*",
1925
+ "access-control-allow-methods": "GET, OPTIONS",
1926
+ };
1927
+ if (req.method === "OPTIONS") {
1928
+ return new Response(null, { status: 204, headers: corsHeaders });
1929
+ }
1930
+ const res = authorizationServerMetadata(oauthDeps(req));
1931
+ // Fold CORS into the existing JSON response.
1932
+ const merged = new Headers(res.headers);
1933
+ for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
1934
+ return new Response(res.body, { status: res.status, headers: merged });
1935
+ }
1919
1936
 
1920
- // Inline approve form for the operator-driven pending-client flow (#208).
1921
- // Receives `client_id` + `csrf_token` + `return_to` from the form rendered
1922
- // by handleAuthorizeGet when the operator hits a pending client. Three
1923
- // gates inside the handler: CSRF, active session, same-origin Origin.
1924
- if (pathname === "/oauth/authorize/approve") {
1925
- if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1926
- if (req.method !== "POST") {
1927
- return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1937
+ if (pathname === "/.well-known/oauth-protected-resource") {
1938
+ // RFC 9728 companion to oauth-authorization-server. MCP clients
1939
+ // (since 2025-06-18 spec) probe this to discover scopes + the
1940
+ // authorization server. Same wildcard CORS shape. Closes hub#393.
1941
+ const corsHeaders = {
1942
+ "access-control-allow-origin": "*",
1943
+ "access-control-allow-methods": "GET, OPTIONS",
1944
+ };
1945
+ if (req.method === "OPTIONS") {
1946
+ return new Response(null, { status: 204, headers: corsHeaders });
1947
+ }
1948
+ const res = protectedResourceMetadata(oauthDeps(req));
1949
+ const merged = new Headers(res.headers);
1950
+ for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
1951
+ return new Response(res.body, { status: res.status, headers: merged });
1928
1952
  }
1929
- return applyCorsHeaders(req, await handleApproveClientPost(getDb(), req, oauthDeps(req)));
1930
- }
1931
1953
 
1932
- if (pathname === "/oauth/token") {
1933
- if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1934
- if (req.method !== "POST") {
1954
+ // OAuth surface every handler return is wrapped in `applyCorsHeaders`
1955
+ // so third-party SPAs can fetch these endpoints cross-origin (the entire
1956
+ // point of OAuth DCR: arbitrary SPAs register → authorize → exchange
1957
+ // tokens). Preflight OPTIONS already returned at the top of dispatch.
1958
+ // See `src/cors.ts` for the wildcard-origin rationale.
1959
+ if (pathname === "/oauth/authorize") {
1960
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1961
+ // Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 3:
1962
+ // a signed-in pre-rotation user must NOT be able to ride the consent flow
1963
+ // to an auth code → `/oauth/token` exchange → vault-scoped access token
1964
+ // without rotating the temp password. Gating `/oauth/authorize` (the
1965
+ // session-backed consent path) is sufficient — no code is issued without
1966
+ // it, so `/oauth/token` (back-channel code exchange, no session cookie)
1967
+ // is intentionally NOT gated (gating it would break the legitimate
1968
+ // exchange). An UNAUTHENTICATED authorize request returns null from the
1969
+ // gate and falls through to render the login form, unchanged.
1970
+ const oauthGate = forceChangePasswordGate(getDb(), req);
1971
+ if (oauthGate) return applyCorsHeaders(req, oauthGate);
1972
+ if (req.method === "GET") {
1973
+ // handleAuthorizeGet is sync (returns Response, not Promise<Response>).
1974
+ // handleAuthorizePost is async — keep the await on POST only.
1975
+ return applyCorsHeaders(req, handleAuthorizeGet(getDb(), req, oauthDeps(req)));
1976
+ }
1977
+ if (req.method === "POST") {
1978
+ return applyCorsHeaders(req, await handleAuthorizePost(getDb(), req, oauthDeps(req)));
1979
+ }
1935
1980
  return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1936
1981
  }
1937
- return applyCorsHeaders(req, await handleToken(getDb(), req, oauthDeps(req)));
1938
- }
1939
1982
 
1940
- if (pathname === "/oauth/register") {
1941
- if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1942
- if (req.method !== "POST") {
1943
- return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1983
+ // Inline approve form for the operator-driven pending-client flow (#208).
1984
+ // Receives `client_id` + `csrf_token` + `return_to` from the form rendered
1985
+ // by handleAuthorizeGet when the operator hits a pending client. Three
1986
+ // gates inside the handler: CSRF, active session, same-origin Origin.
1987
+ if (pathname === "/oauth/authorize/approve") {
1988
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1989
+ if (req.method !== "POST") {
1990
+ return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1991
+ }
1992
+ return applyCorsHeaders(req, await handleApproveClientPost(getDb(), req, oauthDeps(req)));
1944
1993
  }
1945
- return applyCorsHeaders(req, await handleRegister(getDb(), req, oauthDeps(req)));
1946
- }
1947
1994
 
1948
- if (pathname === "/oauth/revoke") {
1949
- if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1950
- if (req.method !== "POST") {
1951
- return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1995
+ if (pathname === "/oauth/token") {
1996
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
1997
+ if (req.method !== "POST") {
1998
+ return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
1999
+ }
2000
+ return applyCorsHeaders(req, await handleToken(getDb(), req, oauthDeps(req)));
1952
2001
  }
1953
- return applyCorsHeaders(req, await handleRevoke(getDb(), req, oauthDeps(req)));
1954
- }
1955
2002
 
1956
- if (pathname === "/vaults") {
1957
- if (!getDb) return dbNotConfigured();
1958
- return handleCreateVault(req, {
1959
- db: getDb(),
1960
- issuer: oauthDeps(req).issuer,
1961
- });
1962
- }
2003
+ if (pathname === "/oauth/register") {
2004
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
2005
+ if (req.method !== "POST") {
2006
+ return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
2007
+ }
2008
+ return applyCorsHeaders(req, await handleRegister(getDb(), req, oauthDeps(req)));
2009
+ }
1963
2010
 
1964
- // Note: the old `/hub/*` SPA mount has been retired. Known prefixes
1965
- // (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
1966
- // 301-redirected at the top of dispatch. Any other `/hub/*` path falls
1967
- // through to the catch-all 404 there's no admin surface left there.
1968
-
1969
- if (pathname === "/admin/host-admin-token") {
1970
- if (!getDb) return dbNotConfigured();
1971
- return handleHostAdminToken(req, {
1972
- db: getDb(),
1973
- issuer: oauthDeps(req).issuer,
1974
- });
1975
- }
2011
+ if (pathname === "/oauth/revoke") {
2012
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
2013
+ if (req.method !== "POST") {
2014
+ return applyCorsHeaders(req, new Response("method not allowed", { status: 405 }));
2015
+ }
2016
+ return applyCorsHeaders(req, await handleRevoke(getDb(), req, oauthDeps(req)));
2017
+ }
1976
2018
 
1977
- if (pathname.startsWith("/admin/vault-admin-token/")) {
1978
- if (!getDb) return dbNotConfigured();
1979
- const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
1980
- // The vault name must correspond to an actual vault instance — same
1981
- // shape the well-known doc derives. Source from services.json so a
1982
- // freshly-created vault is mintable on the next request without a
1983
- // restart.
1984
- // Lenient — see hub#406.
1985
- const manifest = readManifestLenient(manifestPath);
1986
- const knownVaultNames = new Set<string>();
1987
- for (const s of manifest.services) {
1988
- if (!isVaultEntry(s)) continue;
1989
- for (const path of s.paths) knownVaultNames.add(vaultInstanceNameFor(s.name, path));
1990
- }
1991
- return handleVaultAdminToken(req, vaultName, {
1992
- db: getDb(),
1993
- issuer: oauthDeps(req).issuer,
1994
- knownVaultNames,
1995
- });
1996
- }
2019
+ if (pathname === "/vaults") {
2020
+ if (!getDb) return dbNotConfigured();
2021
+ return handleCreateVault(req, {
2022
+ db: getDb(),
2023
+ issuer: oauthDeps(req).issuer,
2024
+ });
2025
+ }
1997
2026
 
1998
- if (pathname === "/api/me") {
1999
- if (!getDb) return dbNotConfigured();
2000
- return handleApiMe(req, { db: getDb() });
2001
- }
2027
+ // Note: the old `/hub/*` SPA mount has been retired. Known prefixes
2028
+ // (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
2029
+ // 301-redirected at the top of dispatch. Any other `/hub/*` path falls
2030
+ // through to the catch-all 404 — there's no admin surface left there.
2002
2031
 
2003
- // SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4). Dedicated
2004
- // endpoint the hub is NOT a supervised module (no /api/modules/hub/*),
2005
- // so it gets its own route. Checked BEFORE the `/api/hub` exact match
2006
- // below (and the `/api/modules/*` switch) so the more-specific path wins.
2007
- // Does NOT require a supervisor: the hub upgrades itself via a detached
2008
- // helper, not the supervisor. Host-admin gated inside the handler (reuses
2009
- // the same validateAccessToken + scope check the module-ops API uses); the
2010
- // channel param is a closed enum (rc|latest) — no injection surface.
2011
- if (pathname === "/api/hub/upgrade") {
2012
- if (!getDb) return dbNotConfigured();
2013
- return handleHubUpgrade(req, {
2014
- db: getDb(),
2015
- issuer: oauthDeps(req).issuer,
2016
- configDir: CONFIG_DIR,
2017
- });
2018
- }
2019
- if (pathname === "/api/hub/upgrade/status") {
2020
- if (!getDb) return dbNotConfigured();
2021
- return handleHubUpgradeStatus(req, {
2022
- db: getDb(),
2023
- issuer: oauthDeps(req).issuer,
2024
- configDir: CONFIG_DIR,
2025
- });
2026
- }
2032
+ if (pathname === "/admin/host-admin-token") {
2033
+ if (!getDb) return dbNotConfigured();
2034
+ return handleHostAdminToken(req, {
2035
+ db: getDb(),
2036
+ issuer: oauthDeps(req).issuer,
2037
+ });
2038
+ }
2027
2039
 
2028
- // Hub version + uptime + install-source — drives the admin SPA's
2029
- // version badge (hub#348). Bearer-gated on `parachute:host:admin`
2030
- // (same as the rest of the operator-only admin surface).
2031
- if (pathname === "/api/hub") {
2032
- if (!getDb) return dbNotConfigured();
2033
- return handleApiHub(req, {
2034
- db: getDb(),
2035
- issuer: oauthDeps(req).issuer,
2036
- });
2037
- }
2040
+ if (pathname.startsWith("/admin/vault-admin-token/")) {
2041
+ if (!getDb) return dbNotConfigured();
2042
+ const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
2043
+ // The vault name must correspond to an actual vault instance — same
2044
+ // shape the well-known doc derives. Source from services.json so a
2045
+ // freshly-created vault is mintable on the next request without a
2046
+ // restart.
2047
+ // Lenient — see hub#406.
2048
+ const manifest = readManifestLenient(manifestPath);
2049
+ const knownVaultNames = new Set<string>();
2050
+ for (const s of manifest.services) {
2051
+ if (!isVaultEntry(s)) continue;
2052
+ for (const path of s.paths) knownVaultNames.add(vaultInstanceNameFor(s.name, path));
2053
+ }
2054
+ return handleVaultAdminToken(req, vaultName, {
2055
+ db: getDb(),
2056
+ issuer: oauthDeps(req).issuer,
2057
+ knownVaultNames,
2058
+ });
2059
+ }
2038
2060
 
2039
- if (pathname === "/api/modules") {
2040
- if (!getDb) return dbNotConfigured();
2041
- const od = oauthDeps(req);
2042
- const modulesDeps: Parameters<typeof handleApiModules>[1] = {
2043
- db: getDb(),
2044
- issuer: od.issuer,
2045
- // hub#516: validate the host-admin bearer's `iss` against the SET of
2046
- // origins the hub answers on (loopback ∪ expose-state ∪ env/platform ∪
2047
- // per-request issuer), so `parachute status` works on an exposed box
2048
- // where the operator token carries the public origin but the loopback
2049
- // request resolves the loopback issuer.
2050
- knownIssuers: od.hubBoundOrigins(),
2051
- manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2052
- };
2053
- if (deps?.supervisor !== undefined) modulesDeps.supervisor = deps.supervisor;
2054
- // hub#342: thread the test-injectable module-manifest reader
2055
- // through so `management_url` resolution can be exercised in
2056
- // unit tests without writing real install dirs.
2057
- if (deps?.readModuleManifest !== undefined)
2058
- modulesDeps.readModuleManifest = deps.readModuleManifest;
2059
- return handleApiModules(req, modulesDeps);
2060
- }
2061
+ if (pathname === "/api/me") {
2062
+ if (!getDb) return dbNotConfigured();
2063
+ return handleApiMe(req, { db: getDb() });
2064
+ }
2061
2065
 
2062
- // Channel toggle (hub#275) — pre-empts the /api/modules/:short/*
2063
- // routes below so `/api/modules/channel` doesn't accidentally match
2064
- // `parseModulesPath` (which would reject it as a non-curated short
2065
- // anyway, but precedence makes the intent explicit).
2066
- if (pathname === "/api/modules/channel") {
2067
- if (!getDb) return dbNotConfigured();
2068
- return handleApiModulesChannel(req, {
2069
- db: getDb(),
2070
- issuer: oauthDeps(req).issuer,
2071
- });
2072
- }
2066
+ // SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4). Dedicated
2067
+ // endpoint the hub is NOT a supervised module (no /api/modules/hub/*),
2068
+ // so it gets its own route. Checked BEFORE the `/api/hub` exact match
2069
+ // below (and the `/api/modules/*` switch) so the more-specific path wins.
2070
+ // Does NOT require a supervisor: the hub upgrades itself via a detached
2071
+ // helper, not the supervisor. Host-admin gated inside the handler (reuses
2072
+ // the same validateAccessToken + scope check the module-ops API uses); the
2073
+ // channel param is a closed enum (rc|latest) — no injection surface.
2074
+ if (pathname === "/api/hub/upgrade") {
2075
+ if (!getDb) return dbNotConfigured();
2076
+ return handleHubUpgrade(req, {
2077
+ db: getDb(),
2078
+ issuer: oauthDeps(req).issuer,
2079
+ configDir: CONFIG_DIR,
2080
+ });
2081
+ }
2082
+ if (pathname === "/api/hub/upgrade/status") {
2083
+ if (!getDb) return dbNotConfigured();
2084
+ return handleHubUpgradeStatus(req, {
2085
+ db: getDb(),
2086
+ issuer: oauthDeps(req).issuer,
2087
+ configDir: CONFIG_DIR,
2088
+ });
2089
+ }
2073
2090
 
2074
- // Canonical hub URL (hub#298). Admin SPA reads + writes the
2075
- // operator-set issuer override. The handler computes the resolved
2076
- // issuer + source here so it can surface them in the GET payload
2077
- // without re-walking the precedence chain inside the handler.
2078
- if (pathname === "/api/settings/hub-origin") {
2079
- if (!getDb) return dbNotConfigured();
2080
- const db = getDb();
2081
- return handleApiSettingsHubOrigin(req, {
2082
- db,
2083
- issuer: oauthDeps(req).issuer,
2084
- resolvedIssuer: resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin),
2085
- resolvedSource: resolveIssuerSource(db, configuredIssuer, loadExposeHubOrigin),
2086
- });
2087
- }
2091
+ // Hub version + uptime + install-source drives the admin SPA's
2092
+ // version badge (hub#348). Bearer-gated on `parachute:host:admin`
2093
+ // (same as the rest of the operator-only admin surface).
2094
+ if (pathname === "/api/hub") {
2095
+ if (!getDb) return dbNotConfigured();
2096
+ return handleApiHub(req, {
2097
+ db: getDb(),
2098
+ issuer: oauthDeps(req).issuer,
2099
+ });
2100
+ }
2088
2101
 
2089
- // Module operation poll surface — pre-empts the /api/modules/:short/*
2090
- // routes below so `/api/modules/operations/<uuid>` doesn't accidentally
2091
- // match a parseModulesPath("/operations") and fall through.
2092
- if (pathname.startsWith("/api/modules/operations/")) {
2093
- if (!getDb) return dbNotConfigured();
2094
- if (!deps?.supervisor) {
2095
- return new Response(
2096
- JSON.stringify({
2097
- error: "supervisor_unavailable",
2098
- error_description:
2099
- "module operations require `parachute serve` (supervisor mode); on-box CLI uses `parachute install/upgrade/restart`",
2100
- }),
2101
- { status: 503, headers: { "content-type": "application/json" } },
2102
- );
2102
+ if (pathname === "/api/modules") {
2103
+ if (!getDb) return dbNotConfigured();
2104
+ const od = oauthDeps(req);
2105
+ const modulesDeps: Parameters<typeof handleApiModules>[1] = {
2106
+ db: getDb(),
2107
+ issuer: od.issuer,
2108
+ // hub#516: validate the host-admin bearer's `iss` against the SET of
2109
+ // origins the hub answers on (loopback ∪ expose-state ∪ env/platform ∪
2110
+ // per-request issuer), so `parachute status` works on an exposed box
2111
+ // where the operator token carries the public origin but the loopback
2112
+ // request resolves the loopback issuer.
2113
+ knownIssuers: od.hubBoundOrigins(),
2114
+ manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2115
+ };
2116
+ if (deps?.supervisor !== undefined) modulesDeps.supervisor = deps.supervisor;
2117
+ // hub#342: thread the test-injectable module-manifest reader
2118
+ // through so `management_url` resolution can be exercised in
2119
+ // unit tests without writing real install dirs.
2120
+ if (deps?.readModuleManifest !== undefined)
2121
+ modulesDeps.readModuleManifest = deps.readModuleManifest;
2122
+ return handleApiModules(req, modulesDeps);
2103
2123
  }
2104
- const opId = decodeURIComponent(pathname.slice("/api/modules/operations/".length));
2105
- if (!opId || opId.includes("/")) return new Response("not found", { status: 404 });
2106
- const od = oauthDeps(req);
2107
- return handleOperationGet(req, opId, {
2108
- db: getDb(),
2109
- issuer: od.issuer,
2110
- // hub#516: see the `/api/modules` deps note — the CLI polls async ops
2111
- // on loopback with the operator token (public `iss`).
2112
- knownIssuers: od.hubBoundOrigins(),
2113
- manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2114
- configDir: CONFIG_DIR,
2115
- supervisor: deps.supervisor,
2116
- });
2117
- }
2118
2124
 
2119
- // Per-module config surface (hub#260) — schema + values GET, values PUT.
2120
- // Sits ahead of the install/restart/upgrade/uninstall switch below so
2121
- // `/api/modules/<short>/config[/schema]` doesn't fall into the default-
2122
- // branch 404 (`parseModulesPath` only matches the action-suffix shape,
2123
- // not the `config` / `config/schema` shape).
2124
- //
2125
- // Diverges from the action endpoints in two ways: doesn't require a
2126
- // supervisor (we just proxy to the running module's HTTP surface, not
2127
- // spawn a child), and mints a `<short>:admin` token at proxy time so
2128
- // the upstream auth gate is satisfied without forcing the SPA bearer
2129
- // to carry per-module scopes.
2130
- {
2131
- const configMatch = parseModulesConfigPath(pathname);
2132
- if (configMatch) {
2125
+ // Channel toggle (hub#275) — pre-empts the /api/modules/:short/*
2126
+ // routes below so `/api/modules/channel` doesn't accidentally match
2127
+ // `parseModulesPath` (which would reject it as a non-curated short
2128
+ // anyway, but precedence makes the intent explicit).
2129
+ if (pathname === "/api/modules/channel") {
2133
2130
  if (!getDb) return dbNotConfigured();
2134
- return handleApiModulesConfig(req, configMatch, {
2131
+ return handleApiModulesChannel(req, {
2135
2132
  db: getDb(),
2136
2133
  issuer: oauthDeps(req).issuer,
2137
- manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2138
2134
  });
2139
2135
  }
2140
- }
2141
2136
 
2142
- // Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
2143
- if (pathname.startsWith("/api/modules/")) {
2144
- if (!getDb) return dbNotConfigured();
2145
- if (!deps?.supervisor) {
2146
- return new Response(
2147
- JSON.stringify({
2148
- error: "supervisor_unavailable",
2149
- error_description:
2150
- "module operations require `parachute serve` (supervisor mode); on-box CLI uses `parachute install/upgrade/restart`",
2151
- }),
2152
- { status: 503, headers: { "content-type": "application/json" } },
2153
- );
2137
+ // Canonical hub URL (hub#298). Admin SPA reads + writes the
2138
+ // operator-set issuer override. The handler computes the resolved
2139
+ // issuer + source here so it can surface them in the GET payload
2140
+ // without re-walking the precedence chain inside the handler.
2141
+ if (pathname === "/api/settings/hub-origin") {
2142
+ if (!getDb) return dbNotConfigured();
2143
+ const db = getDb();
2144
+ return handleApiSettingsHubOrigin(req, {
2145
+ db,
2146
+ issuer: oauthDeps(req).issuer,
2147
+ resolvedIssuer: resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin),
2148
+ resolvedSource: resolveIssuerSource(db, configuredIssuer, loadExposeHubOrigin),
2149
+ });
2154
2150
  }
2155
- const match = parseModulesPath(pathname);
2156
- if (!match) return new Response("not found", { status: 404 });
2157
- const od = oauthDeps(req);
2158
- const opsDeps = {
2159
- db: getDb(),
2160
- issuer: od.issuer,
2161
- // hub#516: the CLI drives start/stop/restart/install/upgrade/uninstall
2162
- // on loopback with the operator token, whose `iss` is the hub's public
2163
- // origin after `expose`. Validate against the hub's known-origin set.
2164
- knownIssuers: od.hubBoundOrigins(),
2165
- manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2166
- configDir: CONFIG_DIR,
2167
- supervisor: deps.supervisor,
2168
- };
2169
- switch (match.rest) {
2170
- case "install":
2171
- return handleInstall(req, match.short, opsDeps);
2172
- case "start":
2173
- return handleStart(req, match.short, opsDeps);
2174
- case "stop":
2175
- return handleStop(req, match.short, opsDeps);
2176
- case "restart":
2177
- return handleRestart(req, match.short, opsDeps);
2178
- case "logs":
2179
- return handleLogs(req, match.short, opsDeps);
2180
- case "upgrade":
2181
- return handleUpgrade(req, match.short, opsDeps);
2182
- case "uninstall":
2183
- return handleUninstall(req, match.short, opsDeps);
2184
- default:
2185
- return new Response("not found", { status: 404 });
2151
+
2152
+ // Module operation poll surface pre-empts the /api/modules/:short/*
2153
+ // routes below so `/api/modules/operations/<uuid>` doesn't accidentally
2154
+ // match a parseModulesPath("/operations") and fall through.
2155
+ if (pathname.startsWith("/api/modules/operations/")) {
2156
+ if (!getDb) return dbNotConfigured();
2157
+ if (!deps?.supervisor) {
2158
+ return new Response(
2159
+ JSON.stringify({
2160
+ error: "supervisor_unavailable",
2161
+ error_description:
2162
+ "module operations require `parachute serve` (supervisor mode); on-box CLI uses `parachute install/upgrade/restart`",
2163
+ }),
2164
+ { status: 503, headers: { "content-type": "application/json" } },
2165
+ );
2166
+ }
2167
+ const opId = decodeURIComponent(pathname.slice("/api/modules/operations/".length));
2168
+ if (!opId || opId.includes("/")) return new Response("not found", { status: 404 });
2169
+ const od = oauthDeps(req);
2170
+ return handleOperationGet(req, opId, {
2171
+ db: getDb(),
2172
+ issuer: od.issuer,
2173
+ // hub#516: see the `/api/modules` deps note — the CLI polls async ops
2174
+ // on loopback with the operator token (public `iss`).
2175
+ knownIssuers: od.hubBoundOrigins(),
2176
+ manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2177
+ configDir: CONFIG_DIR,
2178
+ supervisor: deps.supervisor,
2179
+ });
2186
2180
  }
2187
- }
2188
2181
 
2189
- if (pathname === "/api/auth/mint-token") {
2190
- if (!getDb) return dbNotConfigured();
2191
- // Derive the set of registered vault names so the handler can reject a
2192
- // `vault:<typo>:admin` mint (item D / hub#450) same source + shape the
2193
- // session-cookie `/admin/vault-admin-token/<name>` path uses. Lenient
2194
- // read so a malformed manifest doesn't 500 the mint endpoint.
2195
- const mintManifest = readManifestLenient(manifestPath);
2196
- const mintKnownVaultNames = new Set<string>();
2197
- for (const s of mintManifest.services) {
2198
- if (!isVaultEntry(s)) continue;
2199
- for (const path of s.paths) mintKnownVaultNames.add(vaultInstanceNameFor(s.name, path));
2200
- }
2201
- return handleApiMintToken(req, {
2202
- db: getDb(),
2203
- issuer: oauthDeps(req).issuer,
2204
- knownVaultNames: mintKnownVaultNames,
2205
- });
2206
- }
2182
+ // Per-module config surface (hub#260) — schema + values GET, values PUT.
2183
+ // Sits ahead of the install/restart/upgrade/uninstall switch below so
2184
+ // `/api/modules/<short>/config[/schema]` doesn't fall into the default-
2185
+ // branch 404 (`parseModulesPath` only matches the action-suffix shape,
2186
+ // not the `config` / `config/schema` shape).
2187
+ //
2188
+ // Diverges from the action endpoints in two ways: doesn't require a
2189
+ // supervisor (we just proxy to the running module's HTTP surface, not
2190
+ // spawn a child), and mints a `<short>:admin` token at proxy time so
2191
+ // the upstream auth gate is satisfied without forcing the SPA bearer
2192
+ // to carry per-module scopes.
2193
+ {
2194
+ const configMatch = parseModulesConfigPath(pathname);
2195
+ if (configMatch) {
2196
+ if (!getDb) return dbNotConfigured();
2197
+ return handleApiModulesConfig(req, configMatch, {
2198
+ db: getDb(),
2199
+ issuer: oauthDeps(req).issuer,
2200
+ manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2201
+ });
2202
+ }
2203
+ }
2207
2204
 
2208
- if (pathname === "/api/auth/revoke-token") {
2209
- if (!getDb) return dbNotConfigured();
2210
- return handleApiRevokeToken(req, {
2211
- db: getDb(),
2212
- issuer: oauthDeps(req).issuer,
2213
- });
2214
- }
2205
+ // Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
2206
+ if (pathname.startsWith("/api/modules/")) {
2207
+ if (!getDb) return dbNotConfigured();
2208
+ if (!deps?.supervisor) {
2209
+ return new Response(
2210
+ JSON.stringify({
2211
+ error: "supervisor_unavailable",
2212
+ error_description:
2213
+ "module operations require `parachute serve` (supervisor mode); on-box CLI uses `parachute install/upgrade/restart`",
2214
+ }),
2215
+ { status: 503, headers: { "content-type": "application/json" } },
2216
+ );
2217
+ }
2218
+ const match = parseModulesPath(pathname);
2219
+ if (!match) return new Response("not found", { status: 404 });
2220
+ const od = oauthDeps(req);
2221
+ const opsDeps = {
2222
+ db: getDb(),
2223
+ issuer: od.issuer,
2224
+ // hub#516: the CLI drives start/stop/restart/install/upgrade/uninstall
2225
+ // on loopback with the operator token, whose `iss` is the hub's public
2226
+ // origin after `expose`. Validate against the hub's known-origin set.
2227
+ knownIssuers: od.hubBoundOrigins(),
2228
+ manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2229
+ configDir: CONFIG_DIR,
2230
+ supervisor: deps.supervisor,
2231
+ };
2232
+ switch (match.rest) {
2233
+ case "install":
2234
+ return handleInstall(req, match.short, opsDeps);
2235
+ case "start":
2236
+ return handleStart(req, match.short, opsDeps);
2237
+ case "stop":
2238
+ return handleStop(req, match.short, opsDeps);
2239
+ case "restart":
2240
+ return handleRestart(req, match.short, opsDeps);
2241
+ case "logs":
2242
+ return handleLogs(req, match.short, opsDeps);
2243
+ case "upgrade":
2244
+ return handleUpgrade(req, match.short, opsDeps);
2245
+ case "uninstall":
2246
+ return handleUninstall(req, match.short, opsDeps);
2247
+ default:
2248
+ return new Response("not found", { status: 404 });
2249
+ }
2250
+ }
2215
2251
 
2216
- if (pathname === "/api/auth/tokens") {
2217
- if (!getDb) return dbNotConfigured();
2218
- return handleApiTokens(req, {
2219
- db: getDb(),
2220
- issuer: oauthDeps(req).issuer,
2221
- });
2222
- }
2252
+ if (pathname === "/api/auth/mint-token") {
2253
+ if (!getDb) return dbNotConfigured();
2254
+ // Derive the set of registered vault names so the handler can reject a
2255
+ // `vault:<typo>:admin` mint (item D / hub#450) — same source + shape the
2256
+ // session-cookie `/admin/vault-admin-token/<name>` path uses. Lenient
2257
+ // read so a malformed manifest doesn't 500 the mint endpoint.
2258
+ const mintManifest = readManifestLenient(manifestPath);
2259
+ const mintKnownVaultNames = new Set<string>();
2260
+ for (const s of mintManifest.services) {
2261
+ if (!isVaultEntry(s)) continue;
2262
+ for (const path of s.paths) mintKnownVaultNames.add(vaultInstanceNameFor(s.name, path));
2263
+ }
2264
+ return handleApiMintToken(req, {
2265
+ db: getDb(),
2266
+ issuer: oauthDeps(req).issuer,
2267
+ knownVaultNames: mintKnownVaultNames,
2268
+ });
2269
+ }
2223
2270
 
2224
- if (pathname === "/api/grants") {
2225
- if (!getDb) return dbNotConfigured();
2226
- return handleListGrants(req, {
2227
- db: getDb(),
2228
- issuer: oauthDeps(req).issuer,
2229
- });
2230
- }
2271
+ if (pathname === "/api/auth/revoke-token") {
2272
+ if (!getDb) return dbNotConfigured();
2273
+ return handleApiRevokeToken(req, {
2274
+ db: getDb(),
2275
+ issuer: oauthDeps(req).issuer,
2276
+ });
2277
+ }
2231
2278
 
2232
- if (pathname.startsWith("/api/grants/")) {
2233
- if (!getDb) return dbNotConfigured();
2234
- const clientId = decodeURIComponent(pathname.slice("/api/grants/".length));
2235
- if (!clientId || clientId.includes("/")) {
2236
- return new Response("not found", { status: 404 });
2279
+ if (pathname === "/api/auth/tokens") {
2280
+ if (!getDb) return dbNotConfigured();
2281
+ return handleApiTokens(req, {
2282
+ db: getDb(),
2283
+ issuer: oauthDeps(req).issuer,
2284
+ });
2237
2285
  }
2238
- return handleRevokeGrant(req, clientId, {
2239
- db: getDb(),
2240
- issuer: oauthDeps(req).issuer,
2241
- });
2242
- }
2243
2286
 
2244
- // OAuth client lookup + approval. Both bearer-gated under host:admin.
2245
- // Two paths: `/api/oauth/clients/<id>` (GET, details) and
2246
- // `/api/oauth/clients/<id>/approve` (POST, flip to approved). The
2247
- // SPA approve-client deep link reads details from the first and
2248
- // submits approval to the second — keeps the surface easy to test
2249
- // and audit without overloading a single verb.
2250
- if (pathname.startsWith("/api/oauth/clients/")) {
2251
- if (!getDb) return dbNotConfigured();
2252
- const tail = pathname.slice("/api/oauth/clients/".length);
2253
- if (!tail) return new Response("not found", { status: 404 });
2254
- const approveSuffix = "/approve";
2255
- if (tail.endsWith(approveSuffix)) {
2256
- const clientId = decodeURIComponent(tail.slice(0, -approveSuffix.length));
2287
+ if (pathname === "/api/grants") {
2288
+ if (!getDb) return dbNotConfigured();
2289
+ return handleListGrants(req, {
2290
+ db: getDb(),
2291
+ issuer: oauthDeps(req).issuer,
2292
+ });
2293
+ }
2294
+
2295
+ if (pathname.startsWith("/api/grants/")) {
2296
+ if (!getDb) return dbNotConfigured();
2297
+ const clientId = decodeURIComponent(pathname.slice("/api/grants/".length));
2257
2298
  if (!clientId || clientId.includes("/")) {
2258
2299
  return new Response("not found", { status: 404 });
2259
2300
  }
2260
- return handleApproveClient(req, clientId, {
2301
+ return handleRevokeGrant(req, clientId, {
2261
2302
  db: getDb(),
2262
2303
  issuer: oauthDeps(req).issuer,
2263
2304
  });
2264
2305
  }
2265
- const clientId = decodeURIComponent(tail);
2266
- if (!clientId || clientId.includes("/")) {
2267
- return new Response("not found", { status: 404 });
2306
+
2307
+ // OAuth client lookup + approval. Both bearer-gated under host:admin.
2308
+ // Two paths: `/api/oauth/clients/<id>` (GET, details) and
2309
+ // `/api/oauth/clients/<id>/approve` (POST, flip to approved). The
2310
+ // SPA approve-client deep link reads details from the first and
2311
+ // submits approval to the second — keeps the surface easy to test
2312
+ // and audit without overloading a single verb.
2313
+ if (pathname.startsWith("/api/oauth/clients/")) {
2314
+ if (!getDb) return dbNotConfigured();
2315
+ const tail = pathname.slice("/api/oauth/clients/".length);
2316
+ if (!tail) return new Response("not found", { status: 404 });
2317
+ const approveSuffix = "/approve";
2318
+ if (tail.endsWith(approveSuffix)) {
2319
+ const clientId = decodeURIComponent(tail.slice(0, -approveSuffix.length));
2320
+ if (!clientId || clientId.includes("/")) {
2321
+ return new Response("not found", { status: 404 });
2322
+ }
2323
+ return handleApproveClient(req, clientId, {
2324
+ db: getDb(),
2325
+ issuer: oauthDeps(req).issuer,
2326
+ });
2327
+ }
2328
+ const clientId = decodeURIComponent(tail);
2329
+ if (!clientId || clientId.includes("/")) {
2330
+ return new Response("not found", { status: 404 });
2331
+ }
2332
+ return handleGetClient(req, clientId, {
2333
+ db: getDb(),
2334
+ issuer: oauthDeps(req).issuer,
2335
+ });
2268
2336
  }
2269
- return handleGetClient(req, clientId, {
2270
- db: getDb(),
2271
- issuer: oauthDeps(req).issuer,
2272
- });
2273
- }
2274
2337
 
2275
- // Multi-user Phase 1 admin endpoints (hub#252, design 2026-05-20).
2276
- // `/api/users` collection (GET list / POST create) and
2277
- // `/api/users/vaults` for the assigned-vault picker. Per-id route
2278
- // `/api/users/:id` (DELETE only — Phase 1 doesn't ship edit) is
2279
- // handled by the `startsWith("/api/users/")` branch below, with the
2280
- // `/api/users/vaults` sub-path pre-empted *before* the catch-all so
2281
- // a literal `vaults` segment can't be mistaken for a user id.
2282
- if (pathname === "/api/users") {
2283
- if (!getDb) return dbNotConfigured();
2284
- const usersDeps = {
2285
- db: getDb(),
2286
- issuer: oauthDeps(req).issuer,
2287
- manifestPath,
2288
- };
2289
- if (req.method === "GET") return handleListUsers(req, usersDeps);
2290
- if (req.method === "POST") return handleCreateUser(req, usersDeps);
2291
- return new Response("method not allowed", { status: 405 });
2292
- }
2293
- if (pathname === "/api/users/vaults") {
2294
- if (!getDb) return dbNotConfigured();
2295
- return handleListVaults(req, {
2296
- db: getDb(),
2297
- issuer: oauthDeps(req).issuer,
2298
- manifestPath,
2299
- });
2300
- }
2301
- // Phase 2 PR 1 — `/api/users/:id/reset-password` (admin-initiated
2302
- // password reset for non-admin users). Routed BEFORE the per-id DELETE
2303
- // catch-all so the trailing `/reset-password` segment isn't mistaken
2304
- // for part of a user id. Same `host:admin` Bearer gate as the other
2305
- // /api/users surfaces.
2306
- {
2307
- const resetMatch = pathname.match(/^\/api\/users\/([^/]+)\/reset-password$/);
2308
- if (resetMatch) {
2338
+ // Multi-user Phase 1 admin endpoints (hub#252, design 2026-05-20).
2339
+ // `/api/users` collection (GET list / POST create) and
2340
+ // `/api/users/vaults` for the assigned-vault picker. Per-id route
2341
+ // `/api/users/:id` (DELETE only — Phase 1 doesn't ship edit) is
2342
+ // handled by the `startsWith("/api/users/")` branch below, with the
2343
+ // `/api/users/vaults` sub-path pre-empted *before* the catch-all so
2344
+ // a literal `vaults` segment can't be mistaken for a user id.
2345
+ if (pathname === "/api/users") {
2346
+ if (!getDb) return dbNotConfigured();
2347
+ const usersDeps = {
2348
+ db: getDb(),
2349
+ issuer: oauthDeps(req).issuer,
2350
+ manifestPath,
2351
+ };
2352
+ if (req.method === "GET") return handleListUsers(req, usersDeps);
2353
+ if (req.method === "POST") return handleCreateUser(req, usersDeps);
2354
+ return new Response("method not allowed", { status: 405 });
2355
+ }
2356
+ if (pathname === "/api/users/vaults") {
2309
2357
  if (!getDb) return dbNotConfigured();
2310
- const id = decodeURIComponent(resetMatch[1] ?? "");
2311
- if (!id) {
2358
+ return handleListVaults(req, {
2359
+ db: getDb(),
2360
+ issuer: oauthDeps(req).issuer,
2361
+ manifestPath,
2362
+ });
2363
+ }
2364
+ // Phase 2 PR 1 — `/api/users/:id/reset-password` (admin-initiated
2365
+ // password reset for non-admin users). Routed BEFORE the per-id DELETE
2366
+ // catch-all so the trailing `/reset-password` segment isn't mistaken
2367
+ // for part of a user id. Same `host:admin` Bearer gate as the other
2368
+ // /api/users surfaces.
2369
+ {
2370
+ const resetMatch = pathname.match(/^\/api\/users\/([^/]+)\/reset-password$/);
2371
+ if (resetMatch) {
2372
+ if (!getDb) return dbNotConfigured();
2373
+ const id = decodeURIComponent(resetMatch[1] ?? "");
2374
+ if (!id) {
2375
+ return new Response("not found", { status: 404 });
2376
+ }
2377
+ return handleResetUserPassword(req, id, {
2378
+ db: getDb(),
2379
+ issuer: oauthDeps(req).issuer,
2380
+ manifestPath,
2381
+ });
2382
+ }
2383
+ }
2384
+ // Phase 2 PR 2 — `/api/users/:id/vaults` (replace a user's vault
2385
+ // assignments). Routed before the per-id DELETE catch-all so the
2386
+ // trailing `/vaults` segment isn't mistaken for part of a user id.
2387
+ {
2388
+ const vaultsMatch = pathname.match(/^\/api\/users\/([^/]+)\/vaults$/);
2389
+ if (vaultsMatch) {
2390
+ if (!getDb) return dbNotConfigured();
2391
+ const id = decodeURIComponent(vaultsMatch[1] ?? "");
2392
+ if (!id) {
2393
+ return new Response("not found", { status: 404 });
2394
+ }
2395
+ return handleUpdateUserVaults(req, id, {
2396
+ db: getDb(),
2397
+ issuer: oauthDeps(req).issuer,
2398
+ manifestPath,
2399
+ });
2400
+ }
2401
+ }
2402
+ if (pathname.startsWith("/api/users/")) {
2403
+ if (!getDb) return dbNotConfigured();
2404
+ const id = decodeURIComponent(pathname.slice("/api/users/".length));
2405
+ if (!id || id.includes("/")) {
2312
2406
  return new Response("not found", { status: 404 });
2313
2407
  }
2314
- return handleResetUserPassword(req, id, {
2408
+ return handleDeleteUser(req, id, {
2315
2409
  db: getDb(),
2316
2410
  issuer: oauthDeps(req).issuer,
2317
2411
  manifestPath,
2318
2412
  });
2319
2413
  }
2320
- }
2321
- // Phase 2 PR 2 `/api/users/:id/vaults` (replace a user's vault
2322
- // assignments). Routed before the per-id DELETE catch-all so the
2323
- // trailing `/vaults` segment isn't mistaken for part of a user id.
2324
- {
2325
- const vaultsMatch = pathname.match(/^\/api\/users\/([^/]+)\/vaults$/);
2326
- if (vaultsMatch) {
2414
+
2415
+ // One-time invite links (design §7). host:admin-gated, same gate flavor
2416
+ // as /api/users. POST creates (returns the single-emit token + URL), GET
2417
+ // lists (status-annotated), DELETE /:id revokes by sha256 hash.
2418
+ if (pathname === "/api/invites") {
2419
+ if (!getDb) return dbNotConfigured();
2420
+ const invitesDeps = { db: getDb(), issuer: oauthDeps(req).issuer, manifestPath };
2421
+ if (req.method === "GET") return handleListInvites(req, invitesDeps);
2422
+ if (req.method === "POST") return handleCreateInvite(req, invitesDeps);
2423
+ return new Response("method not allowed", { status: 405 });
2424
+ }
2425
+ if (pathname.startsWith("/api/invites/")) {
2327
2426
  if (!getDb) return dbNotConfigured();
2328
- const id = decodeURIComponent(vaultsMatch[1] ?? "");
2329
- if (!id) {
2427
+ const id = decodeURIComponent(pathname.slice("/api/invites/".length));
2428
+ if (!id || id.includes("/")) {
2330
2429
  return new Response("not found", { status: 404 });
2331
2430
  }
2332
- return handleUpdateUserVaults(req, id, {
2431
+ return handleRevokeInvite(req, id, {
2333
2432
  db: getDb(),
2334
2433
  issuer: oauthDeps(req).issuer,
2335
2434
  manifestPath,
2336
2435
  });
2337
2436
  }
2338
- }
2339
- if (pathname.startsWith("/api/users/")) {
2340
- if (!getDb) return dbNotConfigured();
2341
- const id = decodeURIComponent(pathname.slice("/api/users/".length));
2342
- if (!id || id.includes("/")) {
2343
- return new Response("not found", { status: 404 });
2437
+
2438
+ // Canonical login/logout. The handlers themselves are unchanged from
2439
+ // when they lived at /admin/login + /admin/logout; the rename surfaced
2440
+ // via #231-followup so the URL reflects the surface's actual scope
2441
+ // (entry point for ALL parachute auth — not admin-only). The
2442
+ // /admin/login and /admin/logout paths 301 to here, dispatched at the
2443
+ // top of this fn alongside the other back-compat redirects.
2444
+ if (pathname === "/login") {
2445
+ if (!getDb) return dbNotConfigured();
2446
+ if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
2447
+ if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
2448
+ return new Response("method not allowed", { status: 405 });
2344
2449
  }
2345
- return handleDeleteUser(req, id, {
2346
- db: getDb(),
2347
- issuer: oauthDeps(req).issuer,
2348
- manifestPath,
2349
- });
2350
- }
2351
2450
 
2352
- // One-time invite links (design §7). host:admin-gated, same gate flavor
2353
- // as /api/users. POST creates (returns the single-emit token + URL), GET
2354
- // lists (status-annotated), DELETE /:id revokes by sha256 hash.
2355
- if (pathname === "/api/invites") {
2356
- if (!getDb) return dbNotConfigured();
2357
- const invitesDeps = { db: getDb(), issuer: oauthDeps(req).issuer, manifestPath };
2358
- if (req.method === "GET") return handleListInvites(req, invitesDeps);
2359
- if (req.method === "POST") return handleCreateInvite(req, invitesDeps);
2360
- return new Response("method not allowed", { status: 405 });
2361
- }
2362
- if (pathname.startsWith("/api/invites/")) {
2363
- if (!getDb) return dbNotConfigured();
2364
- const id = decodeURIComponent(pathname.slice("/api/invites/".length));
2365
- if (!id || id.includes("/")) {
2366
- return new Response("not found", { status: 404 });
2451
+ // /login/2fa — second-factor step (hub#473). POST-only: reached only
2452
+ // after a correct password POST for a 2FA-enrolled user handed back a
2453
+ // pending-login cookie + rendered the challenge page. A bare GET (e.g.
2454
+ // browser back button) has no form to render usefully, so 405 → the
2455
+ // operator restarts at /login.
2456
+ if (pathname === "/login/2fa") {
2457
+ if (!getDb) return dbNotConfigured();
2458
+ if (req.method === "POST") return handleAdminLoginTotpPost(getDb(), req);
2459
+ return new Response("method not allowed", { status: 405 });
2367
2460
  }
2368
- return handleRevokeInvite(req, id, {
2369
- db: getDb(),
2370
- issuer: oauthDeps(req).issuer,
2371
- manifestPath,
2372
- });
2373
- }
2374
2461
 
2375
- // Canonical login/logout. The handlers themselves are unchanged from
2376
- // when they lived at /admin/login + /admin/logout; the rename surfaced
2377
- // via #231-followup so the URL reflects the surface's actual scope
2378
- // (entry point for ALL parachute auth — not admin-only). The
2379
- // /admin/login and /admin/logout paths 301 to here, dispatched at the
2380
- // top of this fn alongside the other back-compat redirects.
2381
- if (pathname === "/login") {
2382
- if (!getDb) return dbNotConfigured();
2383
- if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
2384
- if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
2385
- return new Response("method not allowed", { status: 405 });
2386
- }
2462
+ if (pathname === "/logout") {
2463
+ if (!getDb) return dbNotConfigured();
2464
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
2465
+ return handleAdminLogoutPost(getDb(), req);
2466
+ }
2387
2467
 
2388
- // /login/2fasecond-factor step (hub#473). POST-only: reached only
2389
- // after a correct password POST for a 2FA-enrolled user handed back a
2390
- // pending-login cookie + rendered the challenge page. A bare GET (e.g.
2391
- // browser back button) has no form to render usefully, so 405 the
2392
- // operator restarts at /login.
2393
- if (pathname === "/login/2fa") {
2394
- if (!getDb) return dbNotConfigured();
2395
- if (req.method === "POST") return handleAdminLoginTotpPost(getDb(), req);
2396
- return new Response("method not allowed", { status: 405 });
2397
- }
2468
+ // Invite redemption `/account/setup/<token>` (design §7). Un-authed
2469
+ // onboarding surface (the invitee has no session yet); routed BEFORE the
2470
+ // per-request force-change choke point and the `/account/` matches below.
2471
+ // GET renders the claim form; POST redeems creates account + own vault,
2472
+ // mints a session, 302 → /account/. The handler validates the invite
2473
+ // (sha256 lookup, expiry/used/revoked), CSRF, and rate-limits on the
2474
+ // /login IP bucket. The invite alone is the authorization — no host:admin.
2475
+ if (pathname.startsWith("/account/setup/")) {
2476
+ if (!getDb) return dbNotConfigured();
2477
+ const rawToken = decodeURIComponent(pathname.slice("/account/setup/".length));
2478
+ if (!rawToken || rawToken.includes("/")) {
2479
+ return new Response("not found", { status: 404 });
2480
+ }
2481
+ const db = getDb();
2482
+ const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
2483
+ const setupDeps = { db, hubOrigin, manifestPath };
2484
+ if (req.method === "GET") return handleAccountSetupGet(req, rawToken, setupDeps);
2485
+ if (req.method === "POST") return handleAccountSetupPost(req, rawToken, setupDeps);
2486
+ return new Response("method not allowed", { status: 405 });
2487
+ }
2398
2488
 
2399
- if (pathname === "/logout") {
2400
- if (!getDb) return dbNotConfigured();
2401
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
2402
- return handleAdminLogoutPost(getDb(), req);
2403
- }
2489
+ // Multi-user Phase 1 PR 3 — user self-service change-password surface
2490
+ // (hub#252, design §sign-in flow change). Both GET (render form) and
2491
+ // POST (apply change) require a session cookie. The handler itself
2492
+ // does the session check + 302 to /login when missing — same posture
2493
+ // as the rest of /account/* will use as Phase 2 broadens this prefix.
2494
+ //
2495
+ // This route is intentionally NOT gated by `password_changed === false`
2496
+ // — that's only the *redirect* path from /login. A signed-in user with
2497
+ // `password_changed: true` can still navigate here to rotate their
2498
+ // password (design §"Direct navigation").
2499
+ if (pathname === "/account/change-password") {
2500
+ if (!getDb) return dbNotConfigured();
2501
+ // `now` deliberately omitted — handlers fall through to `new Date()` in
2502
+ // production; the seam exists only so tests can advance the rate-limiter
2503
+ // clock deterministically.
2504
+ const accountDeps = { db: getDb() };
2505
+ if (req.method === "GET") return handleAccountChangePasswordGet(req, accountDeps);
2506
+ if (req.method === "POST") return handleAccountChangePasswordPost(req, accountDeps);
2507
+ return new Response("method not allowed", { status: 405 });
2508
+ }
2404
2509
 
2405
- // Invite redemption `/account/setup/<token>` (design §7). Un-authed
2406
- // onboarding surface (the invitee has no session yet); routed BEFORE the
2407
- // per-request force-change choke point and the `/account/` matches below.
2408
- // GET renders the claim form; POST redeems creates account + own vault,
2409
- // mints a session, 302 /account/. The handler validates the invite
2410
- // (sha256 lookup, expiry/used/revoked), CSRF, and rate-limits on the
2411
- // /login IP bucket. The invite alone is the authorization — no host:admin.
2412
- if (pathname.startsWith("/account/setup/")) {
2413
- if (!getDb) return dbNotConfigured();
2414
- const rawToken = decodeURIComponent(pathname.slice("/account/setup/".length));
2415
- if (!rawToken || rawToken.includes("/")) {
2416
- return new Response("not found", { status: 404 });
2510
+ // Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
2511
+ // every `/account/*` route BELOW this line is gated. `/logout` and
2512
+ // `/account/change-password` (the rotation/exit path) ran above and already
2513
+ // returned, so they're never reached here they stay reachable
2514
+ // pre-rotation by construction. A signed-in user with
2515
+ // `password_changed === false` is bounced (302 → change-password for
2516
+ // browsers, 403 JSON for API clients) before any account surface
2517
+ // (2fa, vault-token, vault-admin-token, account home) resolves. DRY: one
2518
+ // gate for the whole `/account/*` family rather than per-route. The
2519
+ // per-route mints in `account-vault-{token,admin-token}.ts` keep their own
2520
+ // gate as defence-in-depth (they're also reachable in tests directly).
2521
+ //
2522
+ // The bare `/account` (no trailing slash) is matched explicitly too —
2523
+ // otherwise it would slip past `startsWith("/account/")` to its 301 →
2524
+ // `/account/` below, and a pre-rotation user wouldn't be gated until the
2525
+ // second hop. Exact-match `/account` (not `startsWith("/account")`) so
2526
+ // unrelated paths like `/accounts-something` aren't caught.
2527
+ if (getDb && (pathname === "/account" || pathname.startsWith("/account/"))) {
2528
+ const gate = forceChangePasswordGate(getDb(), req);
2529
+ if (gate) return gate;
2417
2530
  }
2418
- const db = getDb();
2419
- const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
2420
- const setupDeps = { db, hubOrigin, manifestPath };
2421
- if (req.method === "GET") return handleAccountSetupGet(req, rawToken, setupDeps);
2422
- if (req.method === "POST") return handleAccountSetupPost(req, rawToken, setupDeps);
2423
- return new Response("method not allowed", { status: 405 });
2424
- }
2425
2531
 
2426
- // Multi-user Phase 1 PR 3 — user self-service change-password surface
2427
- // (hub#252, design §sign-in flow change). Both GET (render form) and
2428
- // POST (apply change) require a session cookie. The handler itself
2429
- // does the session check + 302 to /login when missing — same posture
2430
- // as the rest of /account/* will use as Phase 2 broadens this prefix.
2431
- //
2432
- // This route is intentionally NOT gated by `password_changed === false`
2433
- // that's only the *redirect* path from /login. A signed-in user with
2434
- // `password_changed: true` can still navigate here to rotate their
2435
- // password (design §"Direct navigation").
2436
- if (pathname === "/account/change-password") {
2437
- if (!getDb) return dbNotConfigured();
2438
- // `now` deliberately omitted — handlers fall through to `new Date()` in
2439
- // production; the seam exists only so tests can advance the rate-limiter
2440
- // clock deterministically.
2441
- const accountDeps = { db: getDb() };
2442
- if (req.method === "GET") return handleAccountChangePasswordGet(req, accountDeps);
2443
- if (req.method === "POST") return handleAccountChangePasswordPost(req, accountDeps);
2444
- return new Response("method not allowed", { status: 405 });
2445
- }
2532
+ // /account/2fa — user self-service TOTP 2FA enroll / disenroll (hub#473).
2533
+ // Both GET (render state) and POST (start/confirm/disable) require an
2534
+ // active session; the handler does the session check + 302 to /login when
2535
+ // missing, same posture as /account/change-password.
2536
+ if (pathname === "/account/2fa") {
2537
+ if (!getDb) return dbNotConfigured();
2538
+ const twoFactorDeps = { db: getDb() };
2539
+ if (req.method === "GET") return handleTwoFactorGet(req, twoFactorDeps);
2540
+ if (req.method === "POST") return handleTwoFactorPost(req, twoFactorDeps);
2541
+ return new Response("method not allowed", { status: 405 });
2542
+ }
2446
2543
 
2447
- // Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
2448
- // every `/account/*` route BELOW this line is gated. `/logout` and
2449
- // `/account/change-password` (the rotation/exit path) ran above and already
2450
- // returned, so they're never reached here they stay reachable
2451
- // pre-rotation by construction. A signed-in user with
2452
- // `password_changed === false` is bounced (302 change-password for
2453
- // browsers, 403 JSON for API clients) before any account surface
2454
- // (2fa, vault-token, vault-admin-token, account home) resolves. DRY: one
2455
- // gate for the whole `/account/*` family rather than per-route. The
2456
- // per-route mints in `account-vault-{token,admin-token}.ts` keep their own
2457
- // gate as defence-in-depth (they're also reachable in tests directly).
2458
- //
2459
- // The bare `/account` (no trailing slash) is matched explicitly too —
2460
- // otherwise it would slip past `startsWith("/account/")` to its 301
2461
- // `/account/` below, and a pre-rotation user wouldn't be gated until the
2462
- // second hop. Exact-match `/account` (not `startsWith("/account")`) so
2463
- // unrelated paths like `/accounts-something` aren't caught.
2464
- if (getDb && (pathname === "/account" || pathname.startsWith("/account/"))) {
2465
- const gate = forceChangePasswordGate(getDb(), req);
2466
- if (gate) return gate;
2467
- }
2544
+ // /account/vault-admin-token/<name> friend-facing vault ADMIN deep-link.
2545
+ // POST-only, session-gated, assignment-capped to the `admin` verb: an
2546
+ // assigned non-admin user mints a `vault:<name>:admin` bootstrap token and
2547
+ // 303-redirects into the vault's own admin SPA (`#token=<jwt>`), where they
2548
+ // can rotate vault tokens AND configure Git backup / mirror. The non-admin
2549
+ // sibling of `/admin/vault-admin-token/<name>` (which is first-admin-gated
2550
+ // and returns JSON for the hub SPA). The handler enforces session →
2551
+ // assignment-grants-admin → CSRF → force-change-password (item F / #469)
2552
+ // before minting. Must precede `/account/vault-token/` (it isn't a prefix
2553
+ // of it, but keep the more-specific admin path first for clarity) and the
2554
+ // `/account/` match below. See `account-vault-admin-token.ts`.
2555
+ if (pathname.startsWith("/account/vault-admin-token/")) {
2556
+ if (!getDb) return dbNotConfigured();
2557
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
2558
+ const vaultName = decodeURIComponent(pathname.slice("/account/vault-admin-token/".length));
2559
+ const db = getDb();
2560
+ const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
2561
+ // Resolve the vault's declared `managementUrl` at request time (same
2562
+ // source the well-known doc reads — `installDir/.parachute/module.json`)
2563
+ // so the deep-link lands on the vault admin SPA's real entry point. Quiet
2564
+ // on a malformed/absent manifest: the handler defaults to `/admin/`
2565
+ // (vault's canonical value), the same target the admin sibling uses.
2566
+ const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
2567
+ const manifest = readManifestLenient(manifestPath);
2568
+ let managementUrl: string | undefined;
2569
+ for (const s of manifest.services) {
2570
+ if (!isVaultEntry(s) || !s.installDir) continue;
2571
+ const instanceNames = new Set(s.paths.map((p) => vaultInstanceNameFor(s.name, p)));
2572
+ if (!instanceNames.has(vaultName)) continue;
2573
+ try {
2574
+ const m = await readManifestFn(s.installDir);
2575
+ if (m?.managementUrl) managementUrl = m.managementUrl;
2576
+ } catch {
2577
+ // Leave undefined → handler defaults to /admin/.
2578
+ }
2579
+ break;
2580
+ }
2581
+ return handleAccountVaultAdminTokenPost(req, vaultName, {
2582
+ db,
2583
+ hubOrigin,
2584
+ ...(managementUrl !== undefined ? { managementUrl } : {}),
2585
+ });
2586
+ }
2468
2587
 
2469
- // /account/2fauser self-service TOTP 2FA enroll / disenroll (hub#473).
2470
- // Both GET (render state) and POST (start/confirm/disable) require an
2471
- // active session; the handler does the session check + 302 to /login when
2472
- // missing, same posture as /account/change-password.
2473
- if (pathname === "/account/2fa") {
2474
- if (!getDb) return dbNotConfigured();
2475
- const twoFactorDeps = { db: getDb() };
2476
- if (req.method === "GET") return handleTwoFactorGet(req, twoFactorDeps);
2477
- if (req.method === "POST") return handleTwoFactorPost(req, twoFactorDeps);
2478
- return new Response("method not allowed", { status: 405 });
2479
- }
2588
+ // /account/vault-token/<name>friend-facing scoped vault token mint.
2589
+ // POST-only, session-gated, assignment-capped: a non-admin friend mints a
2590
+ // `vault:<name>:read|write` bearer for a vault they're ASSIGNED to, for
2591
+ // scripts / headless clients that can't do browser OAuth. The handler
2592
+ // enforces session → assignment → scope-cap (never `:admin`, never a
2593
+ // vault outside the assignment, never a broader verb than the role
2594
+ // grants) + CSRF + per-user rate limit. Must precede the `/account/`
2595
+ // match below (more specific prefix). See `account-vault-token.ts`.
2596
+ if (pathname.startsWith("/account/vault-token/")) {
2597
+ if (!getDb) return dbNotConfigured();
2598
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
2599
+ const vaultName = decodeURIComponent(pathname.slice("/account/vault-token/".length));
2600
+ const db = getDb();
2601
+ const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
2602
+ return handleAccountVaultTokenPost(req, vaultName, { db, hubOrigin });
2603
+ }
2480
2604
 
2481
- // /account/vault-admin-token/<name> — friend-facing vault ADMIN deep-link.
2482
- // POST-only, session-gated, assignment-capped to the `admin` verb: an
2483
- // assigned non-admin user mints a `vault:<name>:admin` bootstrap token and
2484
- // 303-redirects into the vault's own admin SPA (`#token=<jwt>`), where they
2485
- // can rotate vault tokens AND configure Git backup / mirror. The non-admin
2486
- // sibling of `/admin/vault-admin-token/<name>` (which is first-admin-gated
2487
- // and returns JSON for the hub SPA). The handler enforces session →
2488
- // assignment-grants-admin CSRF force-change-password (item F / #469)
2489
- // before minting. Must precede `/account/vault-token/` (it isn't a prefix
2490
- // of it, but keep the more-specific admin path first for clarity) and the
2491
- // `/account/` match below. See `account-vault-admin-token.ts`.
2492
- if (pathname.startsWith("/account/vault-admin-token/")) {
2493
- if (!getDb) return dbNotConfigured();
2494
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
2495
- const vaultName = decodeURIComponent(pathname.slice("/account/vault-admin-token/".length));
2496
- const db = getDb();
2497
- const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
2498
- // Resolve the vault's declared `managementUrl` at request time (same
2499
- // source the well-known doc reads — `installDir/.parachute/module.json`)
2500
- // so the deep-link lands on the vault admin SPA's real entry point. Quiet
2501
- // on a malformed/absent manifest: the handler defaults to `/admin/`
2502
- // (vault's canonical value), the same target the admin sibling uses.
2503
- const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
2504
- const manifest = readManifestLenient(manifestPath);
2505
- let managementUrl: string | undefined;
2506
- for (const s of manifest.services) {
2507
- if (!isVaultEntry(s) || !s.installDir) continue;
2508
- const instanceNames = new Set(s.paths.map((p) => vaultInstanceNameFor(s.name, p)));
2509
- if (!instanceNames.has(vaultName)) continue;
2510
- try {
2511
- const m = await readManifestFn(s.installDir);
2512
- if (m?.managementUrl) managementUrl = m.managementUrl;
2513
- } catch {
2514
- // Leave undefined → handler defaults to /admin/.
2605
+ // /account/ — friend-facing user home (multi-user Phase 1 follow-up).
2606
+ // Companion to the first-admin gate on `/admin/host-admin-token`: a
2607
+ // signed-in non-admin (friend) lands here instead of bouncing against
2608
+ // a 403 wall on the admin SPA. Admin users also land here when they
2609
+ // hit `/account/` directly, with a "you're the administrator /admin/"
2610
+ // exit ramp. Bare `/account` 301-redirects to `/account/` so links
2611
+ // without the trailing slash work.
2612
+ if (pathname === "/account" || pathname === "/account/") {
2613
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2614
+ if (pathname === "/account") {
2615
+ return new Response(null, { status: 301, headers: { location: "/account/" } });
2515
2616
  }
2516
- break;
2617
+ if (!getDb) return dbNotConfigured();
2618
+ const db = getDb();
2619
+ const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
2620
+ // Resolve each assigned vault's loopback port from services.json so the
2621
+ // home can fetch per-vault usage. Read at request time (same dynamism as
2622
+ // proxyToVault) — a vault created seconds ago surfaces a stat without a
2623
+ // restart. Returns null for an unknown name → that tile skips the stat.
2624
+ const resolveVaultPort = (vaultName: string): number | null => {
2625
+ const services = readManifestLenient(manifestPath).services;
2626
+ const match = findVaultUpstream(services, `/vault/${vaultName}`);
2627
+ return match ? match.port : null;
2628
+ };
2629
+ return handleAccountHomeGet(req, { db, hubOrigin, resolveVaultPort });
2517
2630
  }
2518
- return handleAccountVaultAdminTokenPost(req, vaultName, {
2519
- db,
2520
- hubOrigin,
2521
- ...(managementUrl !== undefined ? { managementUrl } : {}),
2522
- });
2523
- }
2524
2631
 
2525
- // /account/vault-token/<name> — friend-facing scoped vault token mint.
2526
- // POST-only, session-gated, assignment-capped: a non-admin friend mints a
2527
- // `vault:<name>:read|write` bearer for a vault they're ASSIGNED to, for
2528
- // scripts / headless clients that can't do browser OAuth. The handler
2529
- // enforces session assignment scope-cap (never `:admin`, never a
2530
- // vault outside the assignment, never a broader verb than the role
2531
- // grants) + CSRF + per-user rate limit. Must precede the `/account/`
2532
- // match below (more specific prefix). See `account-vault-token.ts`.
2533
- if (pathname.startsWith("/account/vault-token/")) {
2534
- if (!getDb) return dbNotConfigured();
2535
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
2536
- const vaultName = decodeURIComponent(pathname.slice("/account/vault-token/".length));
2537
- const db = getDb();
2538
- const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
2539
- return handleAccountVaultTokenPost(req, vaultName, { db, hubOrigin });
2540
- }
2541
-
2542
- // /account/ — friend-facing user home (multi-user Phase 1 follow-up).
2543
- // Companion to the first-admin gate on `/admin/host-admin-token`: a
2544
- // signed-in non-admin (friend) lands here instead of bouncing against
2545
- // a 403 wall on the admin SPA. Admin users also land here when they
2546
- // hit `/account/` directly, with a "you're the administrator → /admin/"
2547
- // exit ramp. Bare `/account` 301-redirects to `/account/` so links
2548
- // without the trailing slash work.
2549
- if (pathname === "/account" || pathname === "/account/") {
2550
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2551
- if (pathname === "/account") {
2552
- return new Response(null, { status: 301, headers: { location: "/account/" } });
2553
- }
2554
- if (!getDb) return dbNotConfigured();
2555
- const db = getDb();
2556
- const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
2557
- // Resolve each assigned vault's loopback port from services.json so the
2558
- // home can fetch per-vault usage. Read at request time (same dynamism as
2559
- // proxyToVault) — a vault created seconds ago surfaces a stat without a
2560
- // restart. Returns null for an unknown name → that tile skips the stat.
2561
- const resolveVaultPort = (vaultName: string): number | null => {
2562
- const services = readManifestLenient(manifestPath).services;
2563
- const match = findVaultUpstream(services, `/vault/${vaultName}`);
2564
- return match ? match.port : null;
2565
- };
2566
- return handleAccountHomeGet(req, { db, hubOrigin, resolveVaultPort });
2567
- }
2632
+ // Legacy `/admin/config` (server-rendered module-config portal, #46)
2633
+ // retired post-SPA-rework. 301 the SPA home so any bookmark or stale
2634
+ // post-login redirect lands somewhere useful. The route stays here in
2635
+ // dispatch order (above the /admin/* SPA catch-all) so the redirect
2636
+ // wins over a SPA shell render.
2637
+ if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
2638
+ return new Response(null, {
2639
+ status: 301,
2640
+ headers: { location: "/admin/vaults" },
2641
+ });
2642
+ }
2568
2643
 
2569
- // Legacy `/admin/config` (server-rendered module-config portal, #46)
2570
- // retired post-SPA-rework. 301 the SPA home so any bookmark or stale
2571
- // post-login redirect lands somewhere useful. The route stays here in
2572
- // dispatch order (above the /admin/* SPA catch-all) so the redirect
2573
- // wins over a SPA shell render.
2574
- if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
2575
- return new Response(null, {
2576
- status: 301,
2577
- headers: { location: "/admin/vaults" },
2578
- });
2579
- }
2644
+ // /vault/<name>/* per-vault content proxy. Stays as user-facing
2645
+ // surface (the Notes PWA loads through here, etc.). The bare `/vault`
2646
+ // and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
2647
+ // the top of dispatch now. Multi-segment requests like
2648
+ // `/vault/<unknown>/health` are vault-API shapes targeting a
2649
+ // non-existent vault and 404 directly — there's no SPA-shell fallback
2650
+ // here anymore (the SPA moved to /admin), so we can't accidentally
2651
+ // mask a backend 404 with HTML.
2652
+ if (pathname.startsWith("/vault/")) {
2653
+ // Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 2:
2654
+ // a pre-rotation signed-in user can't reach a per-vault user surface
2655
+ // (Notes PWA, MCP, vault API) on the un-rotated temp password — they're
2656
+ // bounced to change-password (browser) / 403 (API). Same posture as the
2657
+ // `/account/*` gate above. An UNAUTHENTICATED proxy request (no hub
2658
+ // session — the common Notes/MCP case carrying its own bearer) passes the
2659
+ // gate untouched (`forceChangePasswordGate` returns null with no session)
2660
+ // and is handled by the vault's own auth downstream.
2661
+ if (getDb) {
2662
+ const gate = forceChangePasswordGate(getDb(), req);
2663
+ if (gate) return gate;
2664
+ }
2665
+ const proxied = await proxyToVault(req, manifestPath, deps?.supervisor, peerAddr);
2666
+ if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
2667
+ return new Response("not found", { status: 404 });
2668
+ }
2580
2669
 
2581
- // /vault/<name>/* per-vault content proxy. Stays as user-facing
2582
- // surface (the Notes PWA loads through here, etc.). The bare `/vault`
2583
- // and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
2584
- // the top of dispatch now. Multi-segment requests like
2585
- // `/vault/<unknown>/health` are vault-API shapes targeting a
2586
- // non-existent vault and 404 directly — there's no SPA-shell fallback
2587
- // here anymore (the SPA moved to /admin), so we can't accidentally
2588
- // mask a backend 404 with HTML.
2589
- if (pathname.startsWith("/vault/")) {
2590
- // Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 2:
2591
- // a pre-rotation signed-in user can't reach a per-vault user surface
2592
- // (Notes PWA, MCP, vault API) on the un-rotated temp password — they're
2593
- // bounced to change-password (browser) / 403 (API). Same posture as the
2594
- // `/account/*` gate above. An UNAUTHENTICATED proxy request (no hub
2595
- // session the common Notes/MCP case carrying its own bearer) passes the
2596
- // gate untouched (`forceChangePasswordGate` returns null with no session)
2597
- // and is handled by the vault's own auth downstream.
2598
- if (getDb) {
2599
- const gate = forceChangePasswordGate(getDb(), req);
2600
- if (gate) return gate;
2670
+ // /admin/* SPA mount. All non-SPA admin handlers (host-admin-token,
2671
+ // vault-admin-token, login, logout, config, api/auth/*, api/grants,
2672
+ // grants/*) ran above and either matched or returned. Anything that
2673
+ // makes it here under /admin/* is a SPA route or asset request; the
2674
+ // SPA's own router renders the page and handles 404 client-side for
2675
+ // unknown sub-paths.
2676
+ if (pathname === "/admin" || pathname === "/admin/") {
2677
+ // Unprefixed /admin SPA shell pointed at the vault list (its home).
2678
+ // The SPA's basename is /admin, so the router will land on / and
2679
+ // render VaultsList.
2680
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2681
+ return serveSpa(spaDistDir, pathname, "/admin");
2682
+ }
2683
+ if (pathname.startsWith("/admin/")) {
2684
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2685
+ return serveSpa(spaDistDir, pathname, "/admin");
2601
2686
  }
2602
- const proxied = await proxyToVault(req, manifestPath, deps?.supervisor, peerAddr);
2603
- if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
2604
- return new Response("not found", { status: 404 });
2605
- }
2606
2687
 
2607
- // /admin/* SPA mount. All non-SPA admin handlers (host-admin-token,
2608
- // vault-admin-token, login, logout, config, api/auth/*, api/grants,
2609
- // grants/*) ran above and either matched or returned. Anything that
2610
- // makes it here under /admin/* is a SPA route or asset request; the
2611
- // SPA's own router renders the page and handles 404 client-side for
2612
- // unknown sub-paths.
2613
- if (pathname === "/admin" || pathname === "/admin/") {
2614
- // Unprefixed /admin → SPA shell pointed at the vault list (its home).
2615
- // The SPA's basename is /admin, so the router will land on / and
2616
- // render VaultsList.
2617
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2618
- return serveSpa(spaDistDir, pathname, "/admin");
2619
- }
2620
- if (pathname.startsWith("/admin/")) {
2621
- if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2622
- return serveSpa(spaDistDir, pathname, "/admin");
2623
- }
2688
+ // Generic services.json-driven dispatch for non-vault modules. Reaches
2689
+ // here only after every hub-owned prefix above has had its turn — so
2690
+ // `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
2691
+ // `/api/*` are excluded by ordering, not by an explicit denylist (#182).
2692
+ const proxied = await proxyToService(req, manifestPath, deps?.supervisor, peerAddr);
2693
+ if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
2624
2694
 
2625
- // Generic services.json-driven dispatch for non-vault modules. Reaches
2626
- // here only after every hub-owned prefix above has had its turn so
2627
- // `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
2628
- // `/api/*` are excluded by ordering, not by an explicit denylist (#182).
2629
- const proxied = await proxyToService(req, manifestPath, deps?.supervisor, peerAddr);
2630
- if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
2631
-
2632
- // Branded fall-through 404 (closes hub#392) — the operator who mistyped
2633
- // a URL sees a clear "not found" page with a path back home, not the
2634
- // browser's default empty-body chrome. Only HTML clients get the
2635
- // rendered page; non-HTML callers (curl, API probes) still see the
2636
- // shorter "not found" text so log noise stays low.
2637
- const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html");
2638
- if (wantsHtml) {
2639
- return new Response(renderNotFoundPage(pathname), {
2640
- status: 404,
2641
- headers: { "content-type": "text/html; charset=utf-8" },
2642
- });
2643
- }
2644
- return new Response("not found", { status: 404 });
2695
+ // Branded fall-through 404 (closes hub#392) the operator who mistyped
2696
+ // a URL sees a clear "not found" page with a path back home, not the
2697
+ // browser's default empty-body chrome. Only HTML clients get the
2698
+ // rendered page; non-HTML callers (curl, API probes) still see the
2699
+ // shorter "not found" text so log noise stays low.
2700
+ const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html");
2701
+ if (wantsHtml) {
2702
+ return new Response(renderNotFoundPage(pathname), {
2703
+ status: 404,
2704
+ headers: { "content-type": "text/html; charset=utf-8" },
2705
+ });
2706
+ }
2707
+ return new Response("not found", { status: 404 });
2708
+ } // end dispatch()
2645
2709
  };
2646
2710
  }
2647
2711
 
@@ -2701,11 +2765,18 @@ async function decorateWithChrome(
2701
2765
 
2702
2766
  if (import.meta.main) {
2703
2767
  const { port, hostname, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
2704
- let cachedDb: Database | undefined;
2768
+ // Self-heal-or-die DB holder (#594), opened lazily so a route that doesn't
2769
+ // touch the DB still works before first open. Once opened, the holder owns
2770
+ // reopen-once-or-exit on a persistent SQLite fault.
2771
+ let holder: ReturnType<typeof createDbHolder> | undefined;
2705
2772
  const getDb = () => {
2706
- if (!cachedDb) cachedDb = openHubDb(dbPath);
2707
- return cachedDb;
2773
+ if (!holder) {
2774
+ holder = createDbHolder(openHubDb(dbPath), { reopen: () => openHubDb(dbPath) });
2775
+ }
2776
+ return holder.get();
2708
2777
  };
2778
+ const onDbError = (err: unknown): "ignored" | "healed" | "exited" =>
2779
+ holder ? holder.healOrExit(err) : "ignored";
2709
2780
  Bun.serve({
2710
2781
  port,
2711
2782
  hostname,
@@ -2721,7 +2792,7 @@ if (import.meta.main) {
2721
2792
  // Bun's equivalent is this. 255s comfortably exceeds Render's edge
2722
2793
  // pool TTL (community-observed ~120s). Closes hub#399.
2723
2794
  idleTimeout: 255,
2724
- fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
2795
+ fetch: hubFetch(wellKnownDir, { getDb, onDbError, issuer, loopbackPort: port }),
2725
2796
  });
2726
2797
  // Register PID + port from the running hub itself so any startup path
2727
2798
  // (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from