@openparachute/hub 0.6.4 → 0.6.5-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/cloudflare-tunnel.test.ts +78 -0
- package/src/__tests__/expose-cloudflare.test.ts +253 -0
- package/src/__tests__/hub-db-liveness.test.ts +139 -0
- package/src/__tests__/hub-server.test.ts +145 -6
- package/src/__tests__/hub-unit.test.ts +110 -1
- package/src/__tests__/oauth-handlers.test.ts +457 -0
- package/src/__tests__/oauth-ui.test.ts +27 -0
- package/src/cloudflare/tunnel.ts +70 -0
- package/src/commands/expose-cloudflare.ts +157 -2
- package/src/commands/serve.ts +14 -4
- package/src/hub-db-liveness.ts +211 -0
- package/src/hub-server.ts +1175 -1104
- package/src/hub-unit.ts +74 -27
- package/src/oauth-handlers.ts +69 -25
- package/src/oauth-ui.ts +28 -2
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
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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({
|
|
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
|
-
|
|
1556
|
-
|
|
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
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
if (
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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 === "/
|
|
1602
|
-
|
|
1603
|
-
return
|
|
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 === "/
|
|
1606
|
-
|
|
1607
|
-
|
|
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 === "/
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
1697
|
-
//
|
|
1698
|
-
//
|
|
1699
|
-
//
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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 (
|
|
1710
|
-
|
|
1711
|
-
if (
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
-
|
|
1731
|
-
//
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
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
|
-
// `
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
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
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
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
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
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
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
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
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
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
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
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
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
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
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
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
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
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
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
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
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
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
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
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
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
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
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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
|
|
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
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
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
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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
|
-
|
|
2190
|
-
|
|
2191
|
-
//
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2194
|
-
//
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
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
|
-
|
|
2209
|
-
if (
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
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
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
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
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
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
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
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
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
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
|
|
2301
|
+
return handleRevokeGrant(req, clientId, {
|
|
2261
2302
|
db: getDb(),
|
|
2262
2303
|
issuer: oauthDeps(req).issuer,
|
|
2263
2304
|
});
|
|
2264
2305
|
}
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
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
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
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
|
-
|
|
2311
|
-
|
|
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
|
|
2408
|
+
return handleDeleteUser(req, id, {
|
|
2315
2409
|
db: getDb(),
|
|
2316
2410
|
issuer: oauthDeps(req).issuer,
|
|
2317
2411
|
manifestPath,
|
|
2318
2412
|
});
|
|
2319
2413
|
}
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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(
|
|
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
|
|
2431
|
+
return handleRevokeInvite(req, id, {
|
|
2333
2432
|
db: getDb(),
|
|
2334
2433
|
issuer: oauthDeps(req).issuer,
|
|
2335
2434
|
manifestPath,
|
|
2336
2435
|
});
|
|
2337
2436
|
}
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
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
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
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
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
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
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
if (
|
|
2396
|
-
|
|
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
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
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
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
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
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
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
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
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
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
if (
|
|
2478
|
-
|
|
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
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
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
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
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
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
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
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
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
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
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
|
-
|
|
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 (!
|
|
2707
|
-
|
|
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
|