@openparachute/hub 0.6.3 → 0.6.4-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/hub-server.ts
CHANGED
|
@@ -78,7 +78,13 @@
|
|
|
78
78
|
* /account/change-password (GET + POST) → user self-service change-password
|
|
79
79
|
* (force-redirect target for users
|
|
80
80
|
* with password_changed=false; also
|
|
81
|
-
* reachable directly to rotate)
|
|
81
|
+
* reachable directly to rotate). With
|
|
82
|
+
* hub#469, EVERY other /account/* route
|
|
83
|
+
* + the per-vault proxy is hard-gated
|
|
84
|
+
* per-request on password_changed===true
|
|
85
|
+
* (forceChangePasswordGate); only this
|
|
86
|
+
* route and /logout stay reachable
|
|
87
|
+
* pre-rotation.
|
|
82
88
|
* /account/2fa (GET + POST) → user self-service 2FA enroll/disenroll
|
|
83
89
|
* (hub#473; QR + backup codes)
|
|
84
90
|
* /account/vault-token/<name> (POST) → friend mints a scoped
|
|
@@ -121,6 +127,8 @@ import { existsSync } from "node:fs";
|
|
|
121
127
|
import { dirname, join, resolve } from "node:path";
|
|
122
128
|
import { fileURLToPath } from "node:url";
|
|
123
129
|
import pkg from "../package.json" with { type: "json" };
|
|
130
|
+
import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
|
|
131
|
+
import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
|
|
124
132
|
import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
|
|
125
133
|
import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
|
|
126
134
|
import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
|
|
@@ -140,6 +148,7 @@ import {
|
|
|
140
148
|
} from "./api-account.ts";
|
|
141
149
|
import { handleHubUpgrade, handleHubUpgradeStatus } from "./api-hub-upgrade.ts";
|
|
142
150
|
import { handleApiHub } from "./api-hub.ts";
|
|
151
|
+
import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "./api-invites.ts";
|
|
143
152
|
import { handleApiMe } from "./api-me.ts";
|
|
144
153
|
import { handleApiMintToken } from "./api-mint-token.ts";
|
|
145
154
|
import { handleApiModulesConfig, parseModulesConfigPath } from "./api-modules-config.ts";
|
|
@@ -220,6 +229,7 @@ import { getAllPublicKeys } from "./signing-keys.ts";
|
|
|
220
229
|
import type { Supervisor } from "./supervisor.ts";
|
|
221
230
|
import { handleTwoFactorGet, handleTwoFactorPost } from "./two-factor-handlers.ts";
|
|
222
231
|
import { getUserById, userCount } from "./users.ts";
|
|
232
|
+
import { sanitizePublicOrigin } from "./vault-hub-origin-env.ts";
|
|
223
233
|
import {
|
|
224
234
|
WELL_KNOWN_DIR,
|
|
225
235
|
buildWellKnown,
|
|
@@ -428,19 +438,31 @@ export type RequestLayer = "loopback" | "tailnet" | "public";
|
|
|
428
438
|
* - `CF-Ray` and `CF-Connecting-IP` are set by Cloudflare's edge for
|
|
429
439
|
* anything proxied through a cloudflared tunnel.
|
|
430
440
|
*
|
|
431
|
-
* Spoofing isn't a concern
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* re-injecting them, so even a
|
|
435
|
-
*
|
|
436
|
-
* belt-and-braces given the bind shape.
|
|
441
|
+
* Spoofing isn't a concern for the proxy-injected layers: the trusted
|
|
442
|
+
* forwarders (tailscale serve/funnel, cloudflared) set these headers and a
|
|
443
|
+
* peer can't forge them past the forwarder. Tailscale specifically strips the
|
|
444
|
+
* same headers from incoming requests before re-injecting them, so even a
|
|
445
|
+
* malicious tailnet peer can't impersonate a different user.
|
|
437
446
|
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
* requests
|
|
447
|
+
* Header-absence is NOT a loopback signal (item E / hub#526). The old default
|
|
448
|
+
* returned "loopback" — the most-trusted layer — for any request with no proxy
|
|
449
|
+
* headers, on the premise (true only on a loopback bind) that "external
|
|
450
|
+
* requests can't reach the listener." Containers / Render legitimately bind
|
|
451
|
+
* `0.0.0.0`, where a network peer can reach the listener directly with no proxy
|
|
452
|
+
* headers and would be misclassified `loopback`, bypassing the
|
|
453
|
+
* `publicExposure:"loopback"` 404-cloak on `proxyToService` / `proxyToVault`.
|
|
454
|
+
*
|
|
455
|
+
* Fix: derive loopback from the actual PEER ADDRESS (`peerAddr`, resolved by
|
|
456
|
+
* the caller from `server.requestIP(req)` — `requestIP` lives on the Bun
|
|
457
|
+
* Server, not the Request; see rate-limit.ts:282-285). A header-absent request
|
|
458
|
+
* is `loopback` ONLY when its peer is `127.0.0.1` / `::1` (the on-box CLI
|
|
459
|
+
* caller, which must stay loopback). A header-absent NON-loopback peer is the
|
|
460
|
+
* untrusted direct-network case and is classified `public` (least-trusted) so
|
|
461
|
+
* the cloak fires. When `peerAddr` is unknown (null/undefined — no Server
|
|
462
|
+
* threaded, e.g. a unit test calling `layerOf(req)` directly), we fail CLOSED
|
|
463
|
+
* to `public` rather than open to `loopback`.
|
|
442
464
|
*/
|
|
443
|
-
export function layerOf(req: Request): RequestLayer {
|
|
465
|
+
export function layerOf(req: Request, peerAddr?: string | null): RequestLayer {
|
|
444
466
|
const h = req.headers;
|
|
445
467
|
if (h.get("cf-ray") !== null || h.get("cf-connecting-ip") !== null) return "public";
|
|
446
468
|
// Match the structured-header value (`?1`) rather than mere presence:
|
|
@@ -451,7 +473,22 @@ export function layerOf(req: Request): RequestLayer {
|
|
|
451
473
|
// value to compare against, hence the presence-check above.
|
|
452
474
|
if (h.get("tailscale-funnel-request") === "?1") return "public";
|
|
453
475
|
if (h.get("tailscale-user-login") !== null) return "tailnet";
|
|
454
|
-
|
|
476
|
+
// No proxy headers — classify by peer address, failing closed when unknown.
|
|
477
|
+
return isLoopbackPeer(peerAddr) ? "loopback" : "public";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* True when `peerAddr` (a `server.requestIP(req)?.address`) is a loopback
|
|
482
|
+
* address. Handles the IPv4-mapped IPv6 form (`::ffff:127.0.0.1`) Bun can emit
|
|
483
|
+
* on a dual-stack listener. A null/undefined/unparseable address is NOT
|
|
484
|
+
* loopback — `layerOf` fails closed to `public` in that case.
|
|
485
|
+
*/
|
|
486
|
+
function isLoopbackPeer(peerAddr: string | null | undefined): boolean {
|
|
487
|
+
if (!peerAddr) return false;
|
|
488
|
+
const addr = peerAddr.trim().toLowerCase();
|
|
489
|
+
return (
|
|
490
|
+
addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1" || addr.startsWith("127.")
|
|
491
|
+
);
|
|
455
492
|
}
|
|
456
493
|
|
|
457
494
|
/**
|
|
@@ -586,6 +623,7 @@ async function proxyToVault(
|
|
|
586
623
|
req: Request,
|
|
587
624
|
manifestPath: string,
|
|
588
625
|
supervisor: Supervisor | undefined,
|
|
626
|
+
peerAddr: string | null,
|
|
589
627
|
): Promise<Response | undefined> {
|
|
590
628
|
// Lenient — see hub#406. One bad services.json row no longer takes
|
|
591
629
|
// down vault routing the way it used to take down /admin/setup and
|
|
@@ -596,10 +634,49 @@ async function proxyToVault(
|
|
|
596
634
|
if (!match) return undefined;
|
|
597
635
|
// Layer-gate on `publicExposure: "loopback"` — hide the entry from non-
|
|
598
636
|
// loopback callers as if it doesn't exist. "allowed" / "auth-required"
|
|
599
|
-
// pass through; the service does its own auth.
|
|
600
|
-
|
|
637
|
+
// pass through; the service does its own auth. `peerAddr` (item E / #526)
|
|
638
|
+
// is the loopback discriminator: a header-absent NON-loopback peer is NOT
|
|
639
|
+
// loopback, so the cloak fires on a 0.0.0.0 bind.
|
|
640
|
+
if (
|
|
641
|
+
effectivePublicExposure(match.entry) === "loopback" &&
|
|
642
|
+
layerOf(req, peerAddr) !== "loopback"
|
|
643
|
+
) {
|
|
601
644
|
return new Response("not found", { status: 404 });
|
|
602
645
|
}
|
|
646
|
+
// Bare `/vault/<name>` POST → point at `/vault/<name>/mcp` (#525). Operators
|
|
647
|
+
// paste the bare vault URL (no `/mcp` suffix) into MCP clients; OAuth completes
|
|
648
|
+
// against the bare path (so the client looks "connected") but the JSON-RPC POST
|
|
649
|
+
// then hits a path vault has no MCP handler for and 405s — a confusing
|
|
650
|
+
// "connected but erroring" half-state. We catch the bare-path POST here, BEFORE
|
|
651
|
+
// proxying, and 308-redirect to the canonical `<mount>/mcp`. 308 (vs 307)
|
|
652
|
+
// signals the redirect is permanent/cacheable, and like 307 it preserves the
|
|
653
|
+
// method + body, so a spec-compliant MCP client re-POSTs the JSON-RPC payload
|
|
654
|
+
// to the right endpoint and connects cleanly. Clients that DON'T follow
|
|
655
|
+
// redirects still get an actionable signal: the Location header + JSON body name
|
|
656
|
+
// the correct URL (vs the old opaque 405). Only the EXACT bare mount is caught —
|
|
657
|
+
// any sub-path (`<mount>/mcp`, `<mount>/api/...`, the Notes PWA) proxies through
|
|
658
|
+
// untouched, and only POST (the MCP transport verb) is redirected so a stray
|
|
659
|
+
// browser GET to the bare path keeps its existing proxy behavior.
|
|
660
|
+
if (req.method === "POST" && url.pathname === match.mount) {
|
|
661
|
+
const mcpUrl = `${match.mount}/mcp`;
|
|
662
|
+
const body = {
|
|
663
|
+
error: "missing_mcp_suffix",
|
|
664
|
+
message: `This is a Parachute vault path, not an MCP endpoint. Use ${mcpUrl} as your MCP server URL.`,
|
|
665
|
+
mcp_url: mcpUrl,
|
|
666
|
+
};
|
|
667
|
+
return new Response(JSON.stringify(body), {
|
|
668
|
+
status: 308,
|
|
669
|
+
headers: {
|
|
670
|
+
location: mcpUrl,
|
|
671
|
+
"content-type": "application/json",
|
|
672
|
+
// 308 is permanently cacheable by default; without no-store a client
|
|
673
|
+
// (or an intermediary) could cache the redirect and keep bouncing the
|
|
674
|
+
// bare path to `/mcp` even after a remount changes the routing. Same
|
|
675
|
+
// guard as the force-change-password redirect below.
|
|
676
|
+
"cache-control": "no-store",
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
}
|
|
603
680
|
// Symmetry with proxyToService (#196): honor `stripPrefix` with FIRST_-
|
|
604
681
|
// PARTY_FALLBACKS as a fallback source. No first-party vault fallback
|
|
605
682
|
// declares stripPrefix today (vault expects the full `/vault/<name>/*`
|
|
@@ -671,6 +748,7 @@ async function proxyToService(
|
|
|
671
748
|
req: Request,
|
|
672
749
|
manifestPath: string,
|
|
673
750
|
supervisor: Supervisor | undefined,
|
|
751
|
+
peerAddr: string | null,
|
|
674
752
|
): Promise<Response | undefined> {
|
|
675
753
|
// Lenient read on the hot-path — a single malformed services.json
|
|
676
754
|
// entry (e.g. a module installed at a buggy version that wrote
|
|
@@ -692,8 +770,11 @@ async function proxyToService(
|
|
|
692
770
|
// tailnet/public caller, a loopback-only service must be indistinguishable
|
|
693
771
|
// from "not installed" — 404, not 403, so we don't leak the existence of
|
|
694
772
|
// the route. "allowed" / "auth-required" pass through; the service does
|
|
695
|
-
// its own auth.
|
|
696
|
-
if (
|
|
773
|
+
// its own auth. `peerAddr` (item E / #526) is the loopback discriminator.
|
|
774
|
+
if (
|
|
775
|
+
effectivePublicExposure(match.entry) === "loopback" &&
|
|
776
|
+
layerOf(req, peerAddr) !== "loopback"
|
|
777
|
+
) {
|
|
697
778
|
return new Response("not found", { status: 404 });
|
|
698
779
|
}
|
|
699
780
|
// Consult FIRST_PARTY_FALLBACKS as a fallback for `stripPrefix` (#196).
|
|
@@ -788,12 +869,20 @@ export interface HubFetchDeps {
|
|
|
788
869
|
*/
|
|
789
870
|
loopbackPort?: number;
|
|
790
871
|
/**
|
|
791
|
-
* Test seam for reading `expose-state.json`. Production reads
|
|
792
|
-
* `~/.parachute/expose-state.json` via `readExposeState`;
|
|
793
|
-
* fake to drive tailnet/funnel origins into the bound set
|
|
794
|
-
* up real exposes. Returns `undefined` when no state file
|
|
795
|
-
* (pre-`parachute expose` state — fine, the issuer + loopback
|
|
796
|
-
* legitimate access).
|
|
872
|
+
* Test seam for reading `expose-state.json`'s `hubOrigin`. Production reads
|
|
873
|
+
* the operator's `~/.parachute/expose-state.json` via `readExposeState`;
|
|
874
|
+
* tests inject a fake to drive tailnet/funnel origins into the bound set
|
|
875
|
+
* without standing up real exposes. Returns `undefined` when no state file
|
|
876
|
+
* is present (pre-`parachute expose` state — fine, the issuer + loopback
|
|
877
|
+
* still cover legitimate access).
|
|
878
|
+
*
|
|
879
|
+
* This single seam now feeds BOTH (a) the same-origin bound set via
|
|
880
|
+
* `buildHubBoundOrigins`, and (b) the issuer resolution via `resolveIssuer`
|
|
881
|
+
* (#531): on the reboot-persistent owner-operated path the launchd /
|
|
882
|
+
* systemd unit carries no `PARACHUTE_HUB_ORIGIN`, so the hub boots with no
|
|
883
|
+
* `configuredIssuer` and falls back to this exposed origin rather than
|
|
884
|
+
* stamping `iss` from the per-request (loopback) origin — which exposed
|
|
885
|
+
* resource servers (vault) reject until they restart.
|
|
797
886
|
*/
|
|
798
887
|
loadExposeHubOrigin?: () => string | undefined;
|
|
799
888
|
/**
|
|
@@ -1038,6 +1127,13 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
|
|
|
1038
1127
|
* regular dispatch continues.
|
|
1039
1128
|
*/
|
|
1040
1129
|
function shouldGateForSetup(pathname: string): boolean {
|
|
1130
|
+
// Invite redemption (`/account/setup/<token>`) is an un-authed onboarding
|
|
1131
|
+
// surface like the wizard — it must pass through the pre-admin lockout so a
|
|
1132
|
+
// recipient can claim an invite. (In practice invites can only be issued
|
|
1133
|
+
// after an admin exists, so the no-admin lockout rarely coincides; the
|
|
1134
|
+
// explicit pass-through keeps the surface's posture clear + matches the
|
|
1135
|
+
// wizard's `/admin/setup/*` exemption.)
|
|
1136
|
+
if (pathname === "/account/setup" || pathname.startsWith("/account/setup/")) return false;
|
|
1041
1137
|
if (pathname === "/login" || pathname === "/logout") return true;
|
|
1042
1138
|
// The wizard itself + its POST endpoints are the *only* way to exit
|
|
1043
1139
|
// the pre-admin lockout from a browser — they must pass through. Any
|
|
@@ -1068,38 +1164,95 @@ function dbNotConfigured(): Response {
|
|
|
1068
1164
|
);
|
|
1069
1165
|
}
|
|
1070
1166
|
|
|
1167
|
+
/**
|
|
1168
|
+
* Read the exposed public origin off `expose-state.json` for the issuer
|
|
1169
|
+
* fallback, guarded to a non-loopback http(s) origin and malformed-safe.
|
|
1170
|
+
* Returns undefined when no exposure is recorded, the file is corrupt, or
|
|
1171
|
+
* the recorded origin is loopback / not an http(s) URL (a loopback value
|
|
1172
|
+
* here would re-pin the degraded request-origin mode — expose-state should
|
|
1173
|
+
* never carry one, but we defend anyway). This is the seam the launchd /
|
|
1174
|
+
* systemd reboot path leans on: those units carry no `PARACHUTE_HUB_ORIGIN`,
|
|
1175
|
+
* so without it the hub boots issuer-less and stamps `iss` from the
|
|
1176
|
+
* per-request origin, which exposed resource servers (vault) reject.
|
|
1177
|
+
*
|
|
1178
|
+
* The `readExpose()` call is itself wrapped in try/catch so ANY reader —
|
|
1179
|
+
* the default OR an injected one — that throws yields undefined rather than
|
|
1180
|
+
* propagating into the request path. The default reader self-wraps too, but
|
|
1181
|
+
* the seam must not depend on that: a future caller passing a non-swallowing
|
|
1182
|
+
* reader still can't 500 the hub. Origin sanitization is delegated to the
|
|
1183
|
+
* shared `sanitizePublicOrigin` helper (strips trailing slashes, validates
|
|
1184
|
+
* http(s), rejects loopback), kept identical with resolveStartupIssuer (#531).
|
|
1185
|
+
*/
|
|
1186
|
+
export function exposeIssuerOrigin(
|
|
1187
|
+
readExpose: () => string | undefined = defaultExposeHubOriginRead,
|
|
1188
|
+
): string | undefined {
|
|
1189
|
+
let raw: string | undefined;
|
|
1190
|
+
try {
|
|
1191
|
+
raw = readExpose();
|
|
1192
|
+
} catch {
|
|
1193
|
+
return undefined;
|
|
1194
|
+
}
|
|
1195
|
+
return sanitizePublicOrigin(raw);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function defaultExposeHubOriginRead(): string | undefined {
|
|
1199
|
+
try {
|
|
1200
|
+
return readExposeState()?.hubOrigin;
|
|
1201
|
+
} catch {
|
|
1202
|
+
return undefined;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1071
1206
|
/**
|
|
1072
1207
|
* Resolve the OAuth issuer URL for this request. Precedence, highest
|
|
1073
|
-
* first (hub#298):
|
|
1208
|
+
* first (hub#298, expose tier added #531):
|
|
1074
1209
|
*
|
|
1075
1210
|
* 1. `hub_settings.hub_origin` — operator-set canonical URL from the
|
|
1076
1211
|
* admin SPA. Wins when present.
|
|
1077
1212
|
* 2. `configuredIssuer` — `--issuer` flag or `PARACHUTE_HUB_ORIGIN`
|
|
1078
1213
|
* env var captured at hub start. The deploy-time setting.
|
|
1079
|
-
* 3. `
|
|
1214
|
+
* 3. `expose-state.json`'s `hubOrigin` — the canonical public origin a
|
|
1215
|
+
* live tailscale/cloudflare exposure recorded. Load-bearing for the
|
|
1216
|
+
* reboot-persistent owner-operated path: the launchd plist / systemd
|
|
1217
|
+
* unit carries no `PARACHUTE_HUB_ORIGIN`, so a hub booted off it has
|
|
1218
|
+
* no `configuredIssuer` and would otherwise stamp `iss` from the
|
|
1219
|
+
* per-request origin — which exposed resource servers (vault) reject
|
|
1220
|
+
* with `unexpected "iss" claim value` until they restart. Reading the
|
|
1221
|
+
* exposed origin off disk closes that. Guarded non-loopback http(s)
|
|
1222
|
+
* (see `exposeIssuerOrigin`).
|
|
1223
|
+
* 4. `new URL(req.url).origin` — the request's own origin. Local dev
|
|
1080
1224
|
* + Render-assigned subdomains land here when nothing's been
|
|
1081
1225
|
* configured.
|
|
1082
1226
|
*
|
|
1083
1227
|
* Per-request (not cached at hub start) so a PUT to
|
|
1084
1228
|
* `/api/settings/hub-origin` takes effect on the next request without a
|
|
1085
|
-
* restart.
|
|
1086
|
-
* relative to JWT signing on
|
|
1229
|
+
* restart. Both the hub_settings read and the expose-state read are cheap
|
|
1230
|
+
* (a small SQLite query / a small JSON parse) relative to JWT signing on
|
|
1231
|
+
* the same request path — and the expose read is per-request so an operator
|
|
1232
|
+
* who runs `parachute expose` while the hub is up gets the new origin on the
|
|
1233
|
+
* next request without a restart.
|
|
1087
1234
|
*
|
|
1088
1235
|
* `db` is optional because the wellknown / discovery surfaces are
|
|
1089
1236
|
* reachable on a hub with no DB configured (the dbNotConfigured 503
|
|
1090
1237
|
* gate sits behind these in the dispatcher). In that case we skip the
|
|
1091
|
-
* settings layer and fall through to env/request precedence.
|
|
1238
|
+
* settings layer and fall through to env/expose/request precedence.
|
|
1239
|
+
*
|
|
1240
|
+
* `readExpose` is injectable so tests exercise the expose tier without
|
|
1241
|
+
* touching the real `~/.parachute`.
|
|
1092
1242
|
*/
|
|
1093
1243
|
export function resolveIssuer(
|
|
1094
1244
|
req: Request,
|
|
1095
1245
|
db: Database | undefined,
|
|
1096
1246
|
configuredIssuer: string | undefined,
|
|
1247
|
+
readExpose: () => string | undefined = defaultExposeHubOriginRead,
|
|
1097
1248
|
): string {
|
|
1098
1249
|
if (db !== undefined) {
|
|
1099
1250
|
const stored = getHubOrigin(db);
|
|
1100
1251
|
if (stored) return stored;
|
|
1101
1252
|
}
|
|
1102
1253
|
if (configuredIssuer) return configuredIssuer;
|
|
1254
|
+
const exposed = exposeIssuerOrigin(readExpose);
|
|
1255
|
+
if (exposed) return exposed;
|
|
1103
1256
|
// Reverse-proxy aware: Render / Tailscale Funnel / cloudflared terminate
|
|
1104
1257
|
// TLS at the edge and forward plain HTTP to hub. Without X-Forwarded-Proto
|
|
1105
1258
|
// honoring, `req.url.origin` is `http://...` and hub publishes mixed-content
|
|
@@ -1128,23 +1281,96 @@ export function resolveIssuer(
|
|
|
1128
1281
|
/**
|
|
1129
1282
|
* Where did the resolved issuer come from? Drives the source-label
|
|
1130
1283
|
* surfaced in the admin SPA so operators can tell which precedence
|
|
1131
|
-
* layer they're on without inspecting the DB or env.
|
|
1284
|
+
* layer they're on without inspecting the DB or env. Mirrors the
|
|
1285
|
+
* precedence in `resolveIssuer` exactly — settings > env > expose >
|
|
1286
|
+
* request — so the attribution can't drift from the resolved value.
|
|
1132
1287
|
*/
|
|
1133
|
-
export type IssuerSource = "settings" | "env" | "request";
|
|
1288
|
+
export type IssuerSource = "settings" | "env" | "expose" | "request";
|
|
1134
1289
|
|
|
1135
1290
|
export function resolveIssuerSource(
|
|
1136
1291
|
db: Database | undefined,
|
|
1137
1292
|
configuredIssuer: string | undefined,
|
|
1293
|
+
readExpose: () => string | undefined = defaultExposeHubOriginRead,
|
|
1138
1294
|
): IssuerSource {
|
|
1139
1295
|
if (db !== undefined && getHubOrigin(db)) return "settings";
|
|
1140
1296
|
if (configuredIssuer) return "env";
|
|
1297
|
+
if (exposeIssuerOrigin(readExpose)) return "expose";
|
|
1141
1298
|
return "request";
|
|
1142
1299
|
}
|
|
1143
1300
|
|
|
1301
|
+
/**
|
|
1302
|
+
* Minimal structural type for the Bun `Server` handle the fetch callback
|
|
1303
|
+
* receives as its 2nd argument. We only need `requestIP` (item E / #526) to
|
|
1304
|
+
* resolve the peer address for `layerOf`. Typed structurally (rather than
|
|
1305
|
+
* importing Bun's full `Server`) so tests can pass a tiny fake and so the
|
|
1306
|
+
* signature stays robust to Bun type-shape churn. Optional in the callback
|
|
1307
|
+
* because a direct unit call to the returned fetch fn may omit it — in which
|
|
1308
|
+
* case `peerAddr` is null and `layerOf` fails closed to `public`.
|
|
1309
|
+
*/
|
|
1310
|
+
interface PeerIpResolver {
|
|
1311
|
+
requestIP(req: Request): { address: string } | null;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Per-request force-change-password gate (P0-1 / hub#469).
|
|
1316
|
+
*
|
|
1317
|
+
* Before #469, `password_changed === false` only triggered a redirect at the
|
|
1318
|
+
* `/login` step. A signed-in user handed an admin-set temp password could then
|
|
1319
|
+
* navigate DIRECTLY to `/account/`, `/account/vault-token/<name>`, a vault-admin
|
|
1320
|
+
* deep-link, or any per-vault proxy URL and operate INDEFINITELY on the
|
|
1321
|
+
* un-rotated secret. Invites = temp-credential handoff at scale, so that gap
|
|
1322
|
+
* scales with every invited user. This makes the gate per-request.
|
|
1323
|
+
*
|
|
1324
|
+
* Returns a 302/403 Response when the request must be bounced; `null` when the
|
|
1325
|
+
* request may proceed (no session, password already rotated, or — for callers
|
|
1326
|
+
* that pre-check — an exempt path). Resolution is a single session→user lookup,
|
|
1327
|
+
* mirroring the per-route gates in `account-vault-{token,admin-token}.ts` so we
|
|
1328
|
+
* don't add a second DB read on those paths (they keep their own gate; this is
|
|
1329
|
+
* the broad net for everything else under `/account/*` + the vault proxy).
|
|
1330
|
+
*
|
|
1331
|
+
* EXEMPT (NOT gated — the rotation/exit path; callers must route these BEFORE
|
|
1332
|
+
* invoking this gate): `/account/change-password` (GET+POST) and `/logout`.
|
|
1333
|
+
* Everything else under `/account/*`, the per-vault `/vault/<name>/*` proxy,
|
|
1334
|
+
* and the session-backed `/oauth/authorize` consent path is gated. The decision
|
|
1335
|
+
* is deliberate: a pre-rotation user can ONLY rotate or sign out — no vault
|
|
1336
|
+
* reads, no token mints, no account home, and no OAuth authorize → auth code →
|
|
1337
|
+
* `/oauth/token` exchange for a vault-scoped access token.
|
|
1338
|
+
*
|
|
1339
|
+
* Browser GETs (Accept: text/html) get a 302 to `/account/change-password`;
|
|
1340
|
+
* non-GET and API-style requests get a 403 with the same JSON error shape the
|
|
1341
|
+
* per-route mints use, so a scripted client gets a clear machine-readable
|
|
1342
|
+
* refusal rather than an HTML redirect it can't follow.
|
|
1343
|
+
*/
|
|
1344
|
+
export function forceChangePasswordGate(db: Database, req: Request): Response | null {
|
|
1345
|
+
const session = findActiveSession(db, req);
|
|
1346
|
+
if (!session) return null; // unauthenticated → downstream handler decides (401/login)
|
|
1347
|
+
const user = getUserById(db, session.userId);
|
|
1348
|
+
if (!user || user.passwordChanged) return null; // rotated (or vanished) → proceed
|
|
1349
|
+
|
|
1350
|
+
const wantsHtml = req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html");
|
|
1351
|
+
if (wantsHtml) {
|
|
1352
|
+
return new Response(null, {
|
|
1353
|
+
status: 302,
|
|
1354
|
+
headers: { location: "/account/change-password", "cache-control": "no-store" },
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
return new Response(
|
|
1358
|
+
JSON.stringify({
|
|
1359
|
+
error: "force_change_password",
|
|
1360
|
+
error_description:
|
|
1361
|
+
"You must change your temporary password before using this surface. Visit /account/change-password.",
|
|
1362
|
+
}),
|
|
1363
|
+
{
|
|
1364
|
+
status: 403,
|
|
1365
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
1366
|
+
},
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1144
1370
|
export function hubFetch(
|
|
1145
1371
|
wellKnownDir: string,
|
|
1146
1372
|
deps?: HubFetchDeps,
|
|
1147
|
-
): (req: Request) => Response | Promise<Response> {
|
|
1373
|
+
): (req: Request, server?: PeerIpResolver) => Response | Promise<Response> {
|
|
1148
1374
|
const hubHtmlPath = join(wellKnownDir, "hub.html");
|
|
1149
1375
|
const getDb = deps?.getDb;
|
|
1150
1376
|
const configuredIssuer = deps?.issuer;
|
|
@@ -1164,7 +1390,7 @@ export function hubFetch(
|
|
|
1164
1390
|
});
|
|
1165
1391
|
|
|
1166
1392
|
const oauthDeps = (req: Request) => {
|
|
1167
|
-
const issuer = resolveIssuer(req, getDb?.(), configuredIssuer);
|
|
1393
|
+
const issuer = resolveIssuer(req, getDb?.(), configuredIssuer, loadExposeHubOrigin);
|
|
1168
1394
|
return {
|
|
1169
1395
|
issuer,
|
|
1170
1396
|
// Per-request resolution (closes #245): expose-state.json can change
|
|
@@ -1191,10 +1417,18 @@ export function hubFetch(
|
|
|
1191
1417
|
};
|
|
1192
1418
|
};
|
|
1193
1419
|
|
|
1194
|
-
return async (req) => {
|
|
1420
|
+
return async (req, server) => {
|
|
1195
1421
|
const url = new URL(req.url);
|
|
1196
1422
|
const pathname = url.pathname;
|
|
1197
1423
|
|
|
1424
|
+
// Resolve the peer address ONCE per request (item E / #526). Bun's
|
|
1425
|
+
// `requestIP` lives on the Server handle, not the Request. It's the
|
|
1426
|
+
// loopback discriminator for `layerOf` on the loopback-exposure cloak —
|
|
1427
|
+
// a header-absent non-loopback peer must NOT be treated as loopback.
|
|
1428
|
+
// `server` is absent when a unit test calls this fn directly; `peerAddr`
|
|
1429
|
+
// is then null and `layerOf` fails closed to `public`.
|
|
1430
|
+
const peerAddr = server?.requestIP(req)?.address ?? null;
|
|
1431
|
+
|
|
1198
1432
|
// 301 back-compat for the pre-hub#231 admin-SPA mounts:
|
|
1199
1433
|
//
|
|
1200
1434
|
// `/vault` → `/admin/vaults`
|
|
@@ -1353,6 +1587,11 @@ export function hubFetch(
|
|
|
1353
1587
|
configDir: CONFIG_DIR,
|
|
1354
1588
|
issuer: oauthDeps(req).issuer,
|
|
1355
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",
|
|
1356
1595
|
};
|
|
1357
1596
|
if (deps?.supervisor !== undefined) wizardDeps.supervisor = deps.supervisor;
|
|
1358
1597
|
if (pathname === "/admin/setup") {
|
|
@@ -1525,7 +1764,12 @@ export function hubFetch(
|
|
|
1525
1764
|
// `/.well-known/parachute.json` while their JWTs carry the
|
|
1526
1765
|
// canonical URL, and discovery clients would split-brain on
|
|
1527
1766
|
// which one to trust.
|
|
1528
|
-
const canonicalOrigin = resolveIssuer(
|
|
1767
|
+
const canonicalOrigin = resolveIssuer(
|
|
1768
|
+
req,
|
|
1769
|
+
getDb?.(),
|
|
1770
|
+
configuredIssuer,
|
|
1771
|
+
loadExposeHubOrigin,
|
|
1772
|
+
);
|
|
1529
1773
|
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
1530
1774
|
const [managementUrlByName, serviceUiMeta] = await Promise.all([
|
|
1531
1775
|
loadManagementUrls(manifest.services, readManifestFn),
|
|
@@ -1651,6 +1895,17 @@ export function hubFetch(
|
|
|
1651
1895
|
// See `src/cors.ts` for the wildcard-origin rationale.
|
|
1652
1896
|
if (pathname === "/oauth/authorize") {
|
|
1653
1897
|
if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
|
|
1898
|
+
// Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 3:
|
|
1899
|
+
// a signed-in pre-rotation user must NOT be able to ride the consent flow
|
|
1900
|
+
// to an auth code → `/oauth/token` exchange → vault-scoped access token
|
|
1901
|
+
// without rotating the temp password. Gating `/oauth/authorize` (the
|
|
1902
|
+
// session-backed consent path) is sufficient — no code is issued without
|
|
1903
|
+
// it, so `/oauth/token` (back-channel code exchange, no session cookie)
|
|
1904
|
+
// is intentionally NOT gated (gating it would break the legitimate
|
|
1905
|
+
// exchange). An UNAUTHENTICATED authorize request returns null from the
|
|
1906
|
+
// gate and falls through to render the login form, unchanged.
|
|
1907
|
+
const oauthGate = forceChangePasswordGate(getDb(), req);
|
|
1908
|
+
if (oauthGate) return applyCorsHeaders(req, oauthGate);
|
|
1654
1909
|
if (req.method === "GET") {
|
|
1655
1910
|
// handleAuthorizeGet is sync (returns Response, not Promise<Response>).
|
|
1656
1911
|
// handleAuthorizePost is async — keep the await on POST only.
|
|
@@ -1826,8 +2081,8 @@ export function hubFetch(
|
|
|
1826
2081
|
return handleApiSettingsHubOrigin(req, {
|
|
1827
2082
|
db,
|
|
1828
2083
|
issuer: oauthDeps(req).issuer,
|
|
1829
|
-
resolvedIssuer: resolveIssuer(req, db, configuredIssuer),
|
|
1830
|
-
resolvedSource: resolveIssuerSource(db, configuredIssuer),
|
|
2084
|
+
resolvedIssuer: resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin),
|
|
2085
|
+
resolvedSource: resolveIssuerSource(db, configuredIssuer, loadExposeHubOrigin),
|
|
1831
2086
|
});
|
|
1832
2087
|
}
|
|
1833
2088
|
|
|
@@ -1933,9 +2188,20 @@ export function hubFetch(
|
|
|
1933
2188
|
|
|
1934
2189
|
if (pathname === "/api/auth/mint-token") {
|
|
1935
2190
|
if (!getDb) return dbNotConfigured();
|
|
2191
|
+
// Derive the set of registered vault names so the handler can reject a
|
|
2192
|
+
// `vault:<typo>:admin` mint (item D / hub#450) — same source + shape the
|
|
2193
|
+
// session-cookie `/admin/vault-admin-token/<name>` path uses. Lenient
|
|
2194
|
+
// read so a malformed manifest doesn't 500 the mint endpoint.
|
|
2195
|
+
const mintManifest = readManifestLenient(manifestPath);
|
|
2196
|
+
const mintKnownVaultNames = new Set<string>();
|
|
2197
|
+
for (const s of mintManifest.services) {
|
|
2198
|
+
if (!isVaultEntry(s)) continue;
|
|
2199
|
+
for (const path of s.paths) mintKnownVaultNames.add(vaultInstanceNameFor(s.name, path));
|
|
2200
|
+
}
|
|
1936
2201
|
return handleApiMintToken(req, {
|
|
1937
2202
|
db: getDb(),
|
|
1938
2203
|
issuer: oauthDeps(req).issuer,
|
|
2204
|
+
knownVaultNames: mintKnownVaultNames,
|
|
1939
2205
|
});
|
|
1940
2206
|
}
|
|
1941
2207
|
|
|
@@ -2083,6 +2349,29 @@ export function hubFetch(
|
|
|
2083
2349
|
});
|
|
2084
2350
|
}
|
|
2085
2351
|
|
|
2352
|
+
// One-time invite links (design §7). host:admin-gated, same gate flavor
|
|
2353
|
+
// as /api/users. POST creates (returns the single-emit token + URL), GET
|
|
2354
|
+
// lists (status-annotated), DELETE /:id revokes by sha256 hash.
|
|
2355
|
+
if (pathname === "/api/invites") {
|
|
2356
|
+
if (!getDb) return dbNotConfigured();
|
|
2357
|
+
const invitesDeps = { db: getDb(), issuer: oauthDeps(req).issuer, manifestPath };
|
|
2358
|
+
if (req.method === "GET") return handleListInvites(req, invitesDeps);
|
|
2359
|
+
if (req.method === "POST") return handleCreateInvite(req, invitesDeps);
|
|
2360
|
+
return new Response("method not allowed", { status: 405 });
|
|
2361
|
+
}
|
|
2362
|
+
if (pathname.startsWith("/api/invites/")) {
|
|
2363
|
+
if (!getDb) return dbNotConfigured();
|
|
2364
|
+
const id = decodeURIComponent(pathname.slice("/api/invites/".length));
|
|
2365
|
+
if (!id || id.includes("/")) {
|
|
2366
|
+
return new Response("not found", { status: 404 });
|
|
2367
|
+
}
|
|
2368
|
+
return handleRevokeInvite(req, id, {
|
|
2369
|
+
db: getDb(),
|
|
2370
|
+
issuer: oauthDeps(req).issuer,
|
|
2371
|
+
manifestPath,
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2086
2375
|
// Canonical login/logout. The handlers themselves are unchanged from
|
|
2087
2376
|
// when they lived at /admin/login + /admin/logout; the rename surfaced
|
|
2088
2377
|
// via #231-followup so the URL reflects the surface's actual scope
|
|
@@ -2113,6 +2402,27 @@ export function hubFetch(
|
|
|
2113
2402
|
return handleAdminLogoutPost(getDb(), req);
|
|
2114
2403
|
}
|
|
2115
2404
|
|
|
2405
|
+
// Invite redemption — `/account/setup/<token>` (design §7). Un-authed
|
|
2406
|
+
// onboarding surface (the invitee has no session yet); routed BEFORE the
|
|
2407
|
+
// per-request force-change choke point and the `/account/` matches below.
|
|
2408
|
+
// GET renders the claim form; POST redeems → creates account + own vault,
|
|
2409
|
+
// mints a session, 302 → /account/. The handler validates the invite
|
|
2410
|
+
// (sha256 lookup, expiry/used/revoked), CSRF, and rate-limits on the
|
|
2411
|
+
// /login IP bucket. The invite alone is the authorization — no host:admin.
|
|
2412
|
+
if (pathname.startsWith("/account/setup/")) {
|
|
2413
|
+
if (!getDb) return dbNotConfigured();
|
|
2414
|
+
const rawToken = decodeURIComponent(pathname.slice("/account/setup/".length));
|
|
2415
|
+
if (!rawToken || rawToken.includes("/")) {
|
|
2416
|
+
return new Response("not found", { status: 404 });
|
|
2417
|
+
}
|
|
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
|
+
|
|
2116
2426
|
// Multi-user Phase 1 PR 3 — user self-service change-password surface
|
|
2117
2427
|
// (hub#252, design §sign-in flow change). Both GET (render form) and
|
|
2118
2428
|
// POST (apply change) require a session cookie. The handler itself
|
|
@@ -2134,6 +2444,28 @@ export function hubFetch(
|
|
|
2134
2444
|
return new Response("method not allowed", { status: 405 });
|
|
2135
2445
|
}
|
|
2136
2446
|
|
|
2447
|
+
// Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
|
|
2448
|
+
// every `/account/*` route BELOW this line is gated. `/logout` and
|
|
2449
|
+
// `/account/change-password` (the rotation/exit path) ran above and already
|
|
2450
|
+
// returned, so they're never reached here — they stay reachable
|
|
2451
|
+
// pre-rotation by construction. A signed-in user with
|
|
2452
|
+
// `password_changed === false` is bounced (302 → change-password for
|
|
2453
|
+
// browsers, 403 JSON for API clients) before any account surface
|
|
2454
|
+
// (2fa, vault-token, vault-admin-token, account home) resolves. DRY: one
|
|
2455
|
+
// gate for the whole `/account/*` family rather than per-route. The
|
|
2456
|
+
// per-route mints in `account-vault-{token,admin-token}.ts` keep their own
|
|
2457
|
+
// gate as defence-in-depth (they're also reachable in tests directly).
|
|
2458
|
+
//
|
|
2459
|
+
// The bare `/account` (no trailing slash) is matched explicitly too —
|
|
2460
|
+
// otherwise it would slip past `startsWith("/account/")` to its 301 →
|
|
2461
|
+
// `/account/` below, and a pre-rotation user wouldn't be gated until the
|
|
2462
|
+
// second hop. Exact-match `/account` (not `startsWith("/account")`) so
|
|
2463
|
+
// unrelated paths like `/accounts-something` aren't caught.
|
|
2464
|
+
if (getDb && (pathname === "/account" || pathname.startsWith("/account/"))) {
|
|
2465
|
+
const gate = forceChangePasswordGate(getDb(), req);
|
|
2466
|
+
if (gate) return gate;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2137
2469
|
// /account/2fa — user self-service TOTP 2FA enroll / disenroll (hub#473).
|
|
2138
2470
|
// Both GET (render state) and POST (start/confirm/disable) require an
|
|
2139
2471
|
// active session; the handler does the session check + 302 to /login when
|
|
@@ -2146,6 +2478,50 @@ export function hubFetch(
|
|
|
2146
2478
|
return new Response("method not allowed", { status: 405 });
|
|
2147
2479
|
}
|
|
2148
2480
|
|
|
2481
|
+
// /account/vault-admin-token/<name> — friend-facing vault ADMIN deep-link.
|
|
2482
|
+
// POST-only, session-gated, assignment-capped to the `admin` verb: an
|
|
2483
|
+
// assigned non-admin user mints a `vault:<name>:admin` bootstrap token and
|
|
2484
|
+
// 303-redirects into the vault's own admin SPA (`#token=<jwt>`), where they
|
|
2485
|
+
// can rotate vault tokens AND configure Git backup / mirror. The non-admin
|
|
2486
|
+
// sibling of `/admin/vault-admin-token/<name>` (which is first-admin-gated
|
|
2487
|
+
// and returns JSON for the hub SPA). The handler enforces session →
|
|
2488
|
+
// assignment-grants-admin → CSRF → force-change-password (item F / #469)
|
|
2489
|
+
// before minting. Must precede `/account/vault-token/` (it isn't a prefix
|
|
2490
|
+
// of it, but keep the more-specific admin path first for clarity) and the
|
|
2491
|
+
// `/account/` match below. See `account-vault-admin-token.ts`.
|
|
2492
|
+
if (pathname.startsWith("/account/vault-admin-token/")) {
|
|
2493
|
+
if (!getDb) return dbNotConfigured();
|
|
2494
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
2495
|
+
const vaultName = decodeURIComponent(pathname.slice("/account/vault-admin-token/".length));
|
|
2496
|
+
const db = getDb();
|
|
2497
|
+
const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
|
|
2498
|
+
// Resolve the vault's declared `managementUrl` at request time (same
|
|
2499
|
+
// source the well-known doc reads — `installDir/.parachute/module.json`)
|
|
2500
|
+
// so the deep-link lands on the vault admin SPA's real entry point. Quiet
|
|
2501
|
+
// on a malformed/absent manifest: the handler defaults to `/admin/`
|
|
2502
|
+
// (vault's canonical value), the same target the admin sibling uses.
|
|
2503
|
+
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
2504
|
+
const manifest = readManifestLenient(manifestPath);
|
|
2505
|
+
let managementUrl: string | undefined;
|
|
2506
|
+
for (const s of manifest.services) {
|
|
2507
|
+
if (!isVaultEntry(s) || !s.installDir) continue;
|
|
2508
|
+
const instanceNames = new Set(s.paths.map((p) => vaultInstanceNameFor(s.name, p)));
|
|
2509
|
+
if (!instanceNames.has(vaultName)) continue;
|
|
2510
|
+
try {
|
|
2511
|
+
const m = await readManifestFn(s.installDir);
|
|
2512
|
+
if (m?.managementUrl) managementUrl = m.managementUrl;
|
|
2513
|
+
} catch {
|
|
2514
|
+
// Leave undefined → handler defaults to /admin/.
|
|
2515
|
+
}
|
|
2516
|
+
break;
|
|
2517
|
+
}
|
|
2518
|
+
return handleAccountVaultAdminTokenPost(req, vaultName, {
|
|
2519
|
+
db,
|
|
2520
|
+
hubOrigin,
|
|
2521
|
+
...(managementUrl !== undefined ? { managementUrl } : {}),
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2149
2525
|
// /account/vault-token/<name> — friend-facing scoped vault token mint.
|
|
2150
2526
|
// POST-only, session-gated, assignment-capped: a non-admin friend mints a
|
|
2151
2527
|
// `vault:<name>:read|write` bearer for a vault they're ASSIGNED to, for
|
|
@@ -2159,7 +2535,7 @@ export function hubFetch(
|
|
|
2159
2535
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
2160
2536
|
const vaultName = decodeURIComponent(pathname.slice("/account/vault-token/".length));
|
|
2161
2537
|
const db = getDb();
|
|
2162
|
-
const hubOrigin = resolveIssuer(req, db, configuredIssuer);
|
|
2538
|
+
const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
|
|
2163
2539
|
return handleAccountVaultTokenPost(req, vaultName, { db, hubOrigin });
|
|
2164
2540
|
}
|
|
2165
2541
|
|
|
@@ -2177,8 +2553,17 @@ export function hubFetch(
|
|
|
2177
2553
|
}
|
|
2178
2554
|
if (!getDb) return dbNotConfigured();
|
|
2179
2555
|
const db = getDb();
|
|
2180
|
-
const hubOrigin = resolveIssuer(req, db, configuredIssuer);
|
|
2181
|
-
|
|
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 });
|
|
2182
2567
|
}
|
|
2183
2568
|
|
|
2184
2569
|
// Legacy `/admin/config` (server-rendered module-config portal, #46)
|
|
@@ -2202,7 +2587,19 @@ export function hubFetch(
|
|
|
2202
2587
|
// here anymore (the SPA moved to /admin), so we can't accidentally
|
|
2203
2588
|
// mask a backend 404 with HTML.
|
|
2204
2589
|
if (pathname.startsWith("/vault/")) {
|
|
2205
|
-
|
|
2590
|
+
// Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 2:
|
|
2591
|
+
// a pre-rotation signed-in user can't reach a per-vault user surface
|
|
2592
|
+
// (Notes PWA, MCP, vault API) on the un-rotated temp password — they're
|
|
2593
|
+
// bounced to change-password (browser) / 403 (API). Same posture as the
|
|
2594
|
+
// `/account/*` gate above. An UNAUTHENTICATED proxy request (no hub
|
|
2595
|
+
// session — the common Notes/MCP case carrying its own bearer) passes the
|
|
2596
|
+
// gate untouched (`forceChangePasswordGate` returns null with no session)
|
|
2597
|
+
// and is handled by the vault's own auth downstream.
|
|
2598
|
+
if (getDb) {
|
|
2599
|
+
const gate = forceChangePasswordGate(getDb(), req);
|
|
2600
|
+
if (gate) return gate;
|
|
2601
|
+
}
|
|
2602
|
+
const proxied = await proxyToVault(req, manifestPath, deps?.supervisor, peerAddr);
|
|
2206
2603
|
if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
|
|
2207
2604
|
return new Response("not found", { status: 404 });
|
|
2208
2605
|
}
|
|
@@ -2229,7 +2626,7 @@ export function hubFetch(
|
|
|
2229
2626
|
// here only after every hub-owned prefix above has had its turn — so
|
|
2230
2627
|
// `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
|
|
2231
2628
|
// `/api/*` are excluded by ordering, not by an explicit denylist (#182).
|
|
2232
|
-
const proxied = await proxyToService(req, manifestPath, deps?.supervisor);
|
|
2629
|
+
const proxied = await proxyToService(req, manifestPath, deps?.supervisor, peerAddr);
|
|
2233
2630
|
if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
|
|
2234
2631
|
|
|
2235
2632
|
// Branded fall-through 404 (closes hub#392) — the operator who mistyped
|