@openparachute/hub 0.6.3 → 0.6.4-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +609 -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 +125 -4
- package/src/__tests__/api-invites.test.ts +180 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- 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__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -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__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +342 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +94 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +347 -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 +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- 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/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -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 +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- 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,8 +634,13 @@ 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
|
}
|
|
603
646
|
// Symmetry with proxyToService (#196): honor `stripPrefix` with FIRST_-
|
|
@@ -671,6 +714,7 @@ async function proxyToService(
|
|
|
671
714
|
req: Request,
|
|
672
715
|
manifestPath: string,
|
|
673
716
|
supervisor: Supervisor | undefined,
|
|
717
|
+
peerAddr: string | null,
|
|
674
718
|
): Promise<Response | undefined> {
|
|
675
719
|
// Lenient read on the hot-path — a single malformed services.json
|
|
676
720
|
// entry (e.g. a module installed at a buggy version that wrote
|
|
@@ -692,8 +736,11 @@ async function proxyToService(
|
|
|
692
736
|
// tailnet/public caller, a loopback-only service must be indistinguishable
|
|
693
737
|
// from "not installed" — 404, not 403, so we don't leak the existence of
|
|
694
738
|
// the route. "allowed" / "auth-required" pass through; the service does
|
|
695
|
-
// its own auth.
|
|
696
|
-
if (
|
|
739
|
+
// its own auth. `peerAddr` (item E / #526) is the loopback discriminator.
|
|
740
|
+
if (
|
|
741
|
+
effectivePublicExposure(match.entry) === "loopback" &&
|
|
742
|
+
layerOf(req, peerAddr) !== "loopback"
|
|
743
|
+
) {
|
|
697
744
|
return new Response("not found", { status: 404 });
|
|
698
745
|
}
|
|
699
746
|
// Consult FIRST_PARTY_FALLBACKS as a fallback for `stripPrefix` (#196).
|
|
@@ -788,12 +835,20 @@ export interface HubFetchDeps {
|
|
|
788
835
|
*/
|
|
789
836
|
loopbackPort?: number;
|
|
790
837
|
/**
|
|
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).
|
|
838
|
+
* Test seam for reading `expose-state.json`'s `hubOrigin`. Production reads
|
|
839
|
+
* the operator's `~/.parachute/expose-state.json` via `readExposeState`;
|
|
840
|
+
* tests inject a fake to drive tailnet/funnel origins into the bound set
|
|
841
|
+
* without standing up real exposes. Returns `undefined` when no state file
|
|
842
|
+
* is present (pre-`parachute expose` state — fine, the issuer + loopback
|
|
843
|
+
* still cover legitimate access).
|
|
844
|
+
*
|
|
845
|
+
* This single seam now feeds BOTH (a) the same-origin bound set via
|
|
846
|
+
* `buildHubBoundOrigins`, and (b) the issuer resolution via `resolveIssuer`
|
|
847
|
+
* (#531): on the reboot-persistent owner-operated path the launchd /
|
|
848
|
+
* systemd unit carries no `PARACHUTE_HUB_ORIGIN`, so the hub boots with no
|
|
849
|
+
* `configuredIssuer` and falls back to this exposed origin rather than
|
|
850
|
+
* stamping `iss` from the per-request (loopback) origin — which exposed
|
|
851
|
+
* resource servers (vault) reject until they restart.
|
|
797
852
|
*/
|
|
798
853
|
loadExposeHubOrigin?: () => string | undefined;
|
|
799
854
|
/**
|
|
@@ -1038,6 +1093,13 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
|
|
|
1038
1093
|
* regular dispatch continues.
|
|
1039
1094
|
*/
|
|
1040
1095
|
function shouldGateForSetup(pathname: string): boolean {
|
|
1096
|
+
// Invite redemption (`/account/setup/<token>`) is an un-authed onboarding
|
|
1097
|
+
// surface like the wizard — it must pass through the pre-admin lockout so a
|
|
1098
|
+
// recipient can claim an invite. (In practice invites can only be issued
|
|
1099
|
+
// after an admin exists, so the no-admin lockout rarely coincides; the
|
|
1100
|
+
// explicit pass-through keeps the surface's posture clear + matches the
|
|
1101
|
+
// wizard's `/admin/setup/*` exemption.)
|
|
1102
|
+
if (pathname === "/account/setup" || pathname.startsWith("/account/setup/")) return false;
|
|
1041
1103
|
if (pathname === "/login" || pathname === "/logout") return true;
|
|
1042
1104
|
// The wizard itself + its POST endpoints are the *only* way to exit
|
|
1043
1105
|
// the pre-admin lockout from a browser — they must pass through. Any
|
|
@@ -1068,38 +1130,95 @@ function dbNotConfigured(): Response {
|
|
|
1068
1130
|
);
|
|
1069
1131
|
}
|
|
1070
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* Read the exposed public origin off `expose-state.json` for the issuer
|
|
1135
|
+
* fallback, guarded to a non-loopback http(s) origin and malformed-safe.
|
|
1136
|
+
* Returns undefined when no exposure is recorded, the file is corrupt, or
|
|
1137
|
+
* the recorded origin is loopback / not an http(s) URL (a loopback value
|
|
1138
|
+
* here would re-pin the degraded request-origin mode — expose-state should
|
|
1139
|
+
* never carry one, but we defend anyway). This is the seam the launchd /
|
|
1140
|
+
* systemd reboot path leans on: those units carry no `PARACHUTE_HUB_ORIGIN`,
|
|
1141
|
+
* so without it the hub boots issuer-less and stamps `iss` from the
|
|
1142
|
+
* per-request origin, which exposed resource servers (vault) reject.
|
|
1143
|
+
*
|
|
1144
|
+
* The `readExpose()` call is itself wrapped in try/catch so ANY reader —
|
|
1145
|
+
* the default OR an injected one — that throws yields undefined rather than
|
|
1146
|
+
* propagating into the request path. The default reader self-wraps too, but
|
|
1147
|
+
* the seam must not depend on that: a future caller passing a non-swallowing
|
|
1148
|
+
* reader still can't 500 the hub. Origin sanitization is delegated to the
|
|
1149
|
+
* shared `sanitizePublicOrigin` helper (strips trailing slashes, validates
|
|
1150
|
+
* http(s), rejects loopback), kept identical with resolveStartupIssuer (#531).
|
|
1151
|
+
*/
|
|
1152
|
+
export function exposeIssuerOrigin(
|
|
1153
|
+
readExpose: () => string | undefined = defaultExposeHubOriginRead,
|
|
1154
|
+
): string | undefined {
|
|
1155
|
+
let raw: string | undefined;
|
|
1156
|
+
try {
|
|
1157
|
+
raw = readExpose();
|
|
1158
|
+
} catch {
|
|
1159
|
+
return undefined;
|
|
1160
|
+
}
|
|
1161
|
+
return sanitizePublicOrigin(raw);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function defaultExposeHubOriginRead(): string | undefined {
|
|
1165
|
+
try {
|
|
1166
|
+
return readExposeState()?.hubOrigin;
|
|
1167
|
+
} catch {
|
|
1168
|
+
return undefined;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1071
1172
|
/**
|
|
1072
1173
|
* Resolve the OAuth issuer URL for this request. Precedence, highest
|
|
1073
|
-
* first (hub#298):
|
|
1174
|
+
* first (hub#298, expose tier added #531):
|
|
1074
1175
|
*
|
|
1075
1176
|
* 1. `hub_settings.hub_origin` — operator-set canonical URL from the
|
|
1076
1177
|
* admin SPA. Wins when present.
|
|
1077
1178
|
* 2. `configuredIssuer` — `--issuer` flag or `PARACHUTE_HUB_ORIGIN`
|
|
1078
1179
|
* env var captured at hub start. The deploy-time setting.
|
|
1079
|
-
* 3. `
|
|
1180
|
+
* 3. `expose-state.json`'s `hubOrigin` — the canonical public origin a
|
|
1181
|
+
* live tailscale/cloudflare exposure recorded. Load-bearing for the
|
|
1182
|
+
* reboot-persistent owner-operated path: the launchd plist / systemd
|
|
1183
|
+
* unit carries no `PARACHUTE_HUB_ORIGIN`, so a hub booted off it has
|
|
1184
|
+
* no `configuredIssuer` and would otherwise stamp `iss` from the
|
|
1185
|
+
* per-request origin — which exposed resource servers (vault) reject
|
|
1186
|
+
* with `unexpected "iss" claim value` until they restart. Reading the
|
|
1187
|
+
* exposed origin off disk closes that. Guarded non-loopback http(s)
|
|
1188
|
+
* (see `exposeIssuerOrigin`).
|
|
1189
|
+
* 4. `new URL(req.url).origin` — the request's own origin. Local dev
|
|
1080
1190
|
* + Render-assigned subdomains land here when nothing's been
|
|
1081
1191
|
* configured.
|
|
1082
1192
|
*
|
|
1083
1193
|
* Per-request (not cached at hub start) so a PUT to
|
|
1084
1194
|
* `/api/settings/hub-origin` takes effect on the next request without a
|
|
1085
|
-
* restart.
|
|
1086
|
-
* relative to JWT signing on
|
|
1195
|
+
* restart. Both the hub_settings read and the expose-state read are cheap
|
|
1196
|
+
* (a small SQLite query / a small JSON parse) relative to JWT signing on
|
|
1197
|
+
* the same request path — and the expose read is per-request so an operator
|
|
1198
|
+
* who runs `parachute expose` while the hub is up gets the new origin on the
|
|
1199
|
+
* next request without a restart.
|
|
1087
1200
|
*
|
|
1088
1201
|
* `db` is optional because the wellknown / discovery surfaces are
|
|
1089
1202
|
* reachable on a hub with no DB configured (the dbNotConfigured 503
|
|
1090
1203
|
* gate sits behind these in the dispatcher). In that case we skip the
|
|
1091
|
-
* settings layer and fall through to env/request precedence.
|
|
1204
|
+
* settings layer and fall through to env/expose/request precedence.
|
|
1205
|
+
*
|
|
1206
|
+
* `readExpose` is injectable so tests exercise the expose tier without
|
|
1207
|
+
* touching the real `~/.parachute`.
|
|
1092
1208
|
*/
|
|
1093
1209
|
export function resolveIssuer(
|
|
1094
1210
|
req: Request,
|
|
1095
1211
|
db: Database | undefined,
|
|
1096
1212
|
configuredIssuer: string | undefined,
|
|
1213
|
+
readExpose: () => string | undefined = defaultExposeHubOriginRead,
|
|
1097
1214
|
): string {
|
|
1098
1215
|
if (db !== undefined) {
|
|
1099
1216
|
const stored = getHubOrigin(db);
|
|
1100
1217
|
if (stored) return stored;
|
|
1101
1218
|
}
|
|
1102
1219
|
if (configuredIssuer) return configuredIssuer;
|
|
1220
|
+
const exposed = exposeIssuerOrigin(readExpose);
|
|
1221
|
+
if (exposed) return exposed;
|
|
1103
1222
|
// Reverse-proxy aware: Render / Tailscale Funnel / cloudflared terminate
|
|
1104
1223
|
// TLS at the edge and forward plain HTTP to hub. Without X-Forwarded-Proto
|
|
1105
1224
|
// honoring, `req.url.origin` is `http://...` and hub publishes mixed-content
|
|
@@ -1128,23 +1247,96 @@ export function resolveIssuer(
|
|
|
1128
1247
|
/**
|
|
1129
1248
|
* Where did the resolved issuer come from? Drives the source-label
|
|
1130
1249
|
* surfaced in the admin SPA so operators can tell which precedence
|
|
1131
|
-
* layer they're on without inspecting the DB or env.
|
|
1250
|
+
* layer they're on without inspecting the DB or env. Mirrors the
|
|
1251
|
+
* precedence in `resolveIssuer` exactly — settings > env > expose >
|
|
1252
|
+
* request — so the attribution can't drift from the resolved value.
|
|
1132
1253
|
*/
|
|
1133
|
-
export type IssuerSource = "settings" | "env" | "request";
|
|
1254
|
+
export type IssuerSource = "settings" | "env" | "expose" | "request";
|
|
1134
1255
|
|
|
1135
1256
|
export function resolveIssuerSource(
|
|
1136
1257
|
db: Database | undefined,
|
|
1137
1258
|
configuredIssuer: string | undefined,
|
|
1259
|
+
readExpose: () => string | undefined = defaultExposeHubOriginRead,
|
|
1138
1260
|
): IssuerSource {
|
|
1139
1261
|
if (db !== undefined && getHubOrigin(db)) return "settings";
|
|
1140
1262
|
if (configuredIssuer) return "env";
|
|
1263
|
+
if (exposeIssuerOrigin(readExpose)) return "expose";
|
|
1141
1264
|
return "request";
|
|
1142
1265
|
}
|
|
1143
1266
|
|
|
1267
|
+
/**
|
|
1268
|
+
* Minimal structural type for the Bun `Server` handle the fetch callback
|
|
1269
|
+
* receives as its 2nd argument. We only need `requestIP` (item E / #526) to
|
|
1270
|
+
* resolve the peer address for `layerOf`. Typed structurally (rather than
|
|
1271
|
+
* importing Bun's full `Server`) so tests can pass a tiny fake and so the
|
|
1272
|
+
* signature stays robust to Bun type-shape churn. Optional in the callback
|
|
1273
|
+
* because a direct unit call to the returned fetch fn may omit it — in which
|
|
1274
|
+
* case `peerAddr` is null and `layerOf` fails closed to `public`.
|
|
1275
|
+
*/
|
|
1276
|
+
interface PeerIpResolver {
|
|
1277
|
+
requestIP(req: Request): { address: string } | null;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Per-request force-change-password gate (P0-1 / hub#469).
|
|
1282
|
+
*
|
|
1283
|
+
* Before #469, `password_changed === false` only triggered a redirect at the
|
|
1284
|
+
* `/login` step. A signed-in user handed an admin-set temp password could then
|
|
1285
|
+
* navigate DIRECTLY to `/account/`, `/account/vault-token/<name>`, a vault-admin
|
|
1286
|
+
* deep-link, or any per-vault proxy URL and operate INDEFINITELY on the
|
|
1287
|
+
* un-rotated secret. Invites = temp-credential handoff at scale, so that gap
|
|
1288
|
+
* scales with every invited user. This makes the gate per-request.
|
|
1289
|
+
*
|
|
1290
|
+
* Returns a 302/403 Response when the request must be bounced; `null` when the
|
|
1291
|
+
* request may proceed (no session, password already rotated, or — for callers
|
|
1292
|
+
* that pre-check — an exempt path). Resolution is a single session→user lookup,
|
|
1293
|
+
* mirroring the per-route gates in `account-vault-{token,admin-token}.ts` so we
|
|
1294
|
+
* don't add a second DB read on those paths (they keep their own gate; this is
|
|
1295
|
+
* the broad net for everything else under `/account/*` + the vault proxy).
|
|
1296
|
+
*
|
|
1297
|
+
* EXEMPT (NOT gated — the rotation/exit path; callers must route these BEFORE
|
|
1298
|
+
* invoking this gate): `/account/change-password` (GET+POST) and `/logout`.
|
|
1299
|
+
* Everything else under `/account/*`, the per-vault `/vault/<name>/*` proxy,
|
|
1300
|
+
* and the session-backed `/oauth/authorize` consent path is gated. The decision
|
|
1301
|
+
* is deliberate: a pre-rotation user can ONLY rotate or sign out — no vault
|
|
1302
|
+
* reads, no token mints, no account home, and no OAuth authorize → auth code →
|
|
1303
|
+
* `/oauth/token` exchange for a vault-scoped access token.
|
|
1304
|
+
*
|
|
1305
|
+
* Browser GETs (Accept: text/html) get a 302 to `/account/change-password`;
|
|
1306
|
+
* non-GET and API-style requests get a 403 with the same JSON error shape the
|
|
1307
|
+
* per-route mints use, so a scripted client gets a clear machine-readable
|
|
1308
|
+
* refusal rather than an HTML redirect it can't follow.
|
|
1309
|
+
*/
|
|
1310
|
+
export function forceChangePasswordGate(db: Database, req: Request): Response | null {
|
|
1311
|
+
const session = findActiveSession(db, req);
|
|
1312
|
+
if (!session) return null; // unauthenticated → downstream handler decides (401/login)
|
|
1313
|
+
const user = getUserById(db, session.userId);
|
|
1314
|
+
if (!user || user.passwordChanged) return null; // rotated (or vanished) → proceed
|
|
1315
|
+
|
|
1316
|
+
const wantsHtml = req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html");
|
|
1317
|
+
if (wantsHtml) {
|
|
1318
|
+
return new Response(null, {
|
|
1319
|
+
status: 302,
|
|
1320
|
+
headers: { location: "/account/change-password", "cache-control": "no-store" },
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
return new Response(
|
|
1324
|
+
JSON.stringify({
|
|
1325
|
+
error: "force_change_password",
|
|
1326
|
+
error_description:
|
|
1327
|
+
"You must change your temporary password before using this surface. Visit /account/change-password.",
|
|
1328
|
+
}),
|
|
1329
|
+
{
|
|
1330
|
+
status: 403,
|
|
1331
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
1332
|
+
},
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1144
1336
|
export function hubFetch(
|
|
1145
1337
|
wellKnownDir: string,
|
|
1146
1338
|
deps?: HubFetchDeps,
|
|
1147
|
-
): (req: Request) => Response | Promise<Response> {
|
|
1339
|
+
): (req: Request, server?: PeerIpResolver) => Response | Promise<Response> {
|
|
1148
1340
|
const hubHtmlPath = join(wellKnownDir, "hub.html");
|
|
1149
1341
|
const getDb = deps?.getDb;
|
|
1150
1342
|
const configuredIssuer = deps?.issuer;
|
|
@@ -1164,7 +1356,7 @@ export function hubFetch(
|
|
|
1164
1356
|
});
|
|
1165
1357
|
|
|
1166
1358
|
const oauthDeps = (req: Request) => {
|
|
1167
|
-
const issuer = resolveIssuer(req, getDb?.(), configuredIssuer);
|
|
1359
|
+
const issuer = resolveIssuer(req, getDb?.(), configuredIssuer, loadExposeHubOrigin);
|
|
1168
1360
|
return {
|
|
1169
1361
|
issuer,
|
|
1170
1362
|
// Per-request resolution (closes #245): expose-state.json can change
|
|
@@ -1191,10 +1383,18 @@ export function hubFetch(
|
|
|
1191
1383
|
};
|
|
1192
1384
|
};
|
|
1193
1385
|
|
|
1194
|
-
return async (req) => {
|
|
1386
|
+
return async (req, server) => {
|
|
1195
1387
|
const url = new URL(req.url);
|
|
1196
1388
|
const pathname = url.pathname;
|
|
1197
1389
|
|
|
1390
|
+
// Resolve the peer address ONCE per request (item E / #526). Bun's
|
|
1391
|
+
// `requestIP` lives on the Server handle, not the Request. It's the
|
|
1392
|
+
// loopback discriminator for `layerOf` on the loopback-exposure cloak —
|
|
1393
|
+
// a header-absent non-loopback peer must NOT be treated as loopback.
|
|
1394
|
+
// `server` is absent when a unit test calls this fn directly; `peerAddr`
|
|
1395
|
+
// is then null and `layerOf` fails closed to `public`.
|
|
1396
|
+
const peerAddr = server?.requestIP(req)?.address ?? null;
|
|
1397
|
+
|
|
1198
1398
|
// 301 back-compat for the pre-hub#231 admin-SPA mounts:
|
|
1199
1399
|
//
|
|
1200
1400
|
// `/vault` → `/admin/vaults`
|
|
@@ -1525,7 +1725,12 @@ export function hubFetch(
|
|
|
1525
1725
|
// `/.well-known/parachute.json` while their JWTs carry the
|
|
1526
1726
|
// canonical URL, and discovery clients would split-brain on
|
|
1527
1727
|
// which one to trust.
|
|
1528
|
-
const canonicalOrigin = resolveIssuer(
|
|
1728
|
+
const canonicalOrigin = resolveIssuer(
|
|
1729
|
+
req,
|
|
1730
|
+
getDb?.(),
|
|
1731
|
+
configuredIssuer,
|
|
1732
|
+
loadExposeHubOrigin,
|
|
1733
|
+
);
|
|
1529
1734
|
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
1530
1735
|
const [managementUrlByName, serviceUiMeta] = await Promise.all([
|
|
1531
1736
|
loadManagementUrls(manifest.services, readManifestFn),
|
|
@@ -1651,6 +1856,17 @@ export function hubFetch(
|
|
|
1651
1856
|
// See `src/cors.ts` for the wildcard-origin rationale.
|
|
1652
1857
|
if (pathname === "/oauth/authorize") {
|
|
1653
1858
|
if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
|
|
1859
|
+
// Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 3:
|
|
1860
|
+
// a signed-in pre-rotation user must NOT be able to ride the consent flow
|
|
1861
|
+
// to an auth code → `/oauth/token` exchange → vault-scoped access token
|
|
1862
|
+
// without rotating the temp password. Gating `/oauth/authorize` (the
|
|
1863
|
+
// session-backed consent path) is sufficient — no code is issued without
|
|
1864
|
+
// it, so `/oauth/token` (back-channel code exchange, no session cookie)
|
|
1865
|
+
// is intentionally NOT gated (gating it would break the legitimate
|
|
1866
|
+
// exchange). An UNAUTHENTICATED authorize request returns null from the
|
|
1867
|
+
// gate and falls through to render the login form, unchanged.
|
|
1868
|
+
const oauthGate = forceChangePasswordGate(getDb(), req);
|
|
1869
|
+
if (oauthGate) return applyCorsHeaders(req, oauthGate);
|
|
1654
1870
|
if (req.method === "GET") {
|
|
1655
1871
|
// handleAuthorizeGet is sync (returns Response, not Promise<Response>).
|
|
1656
1872
|
// handleAuthorizePost is async — keep the await on POST only.
|
|
@@ -1826,8 +2042,8 @@ export function hubFetch(
|
|
|
1826
2042
|
return handleApiSettingsHubOrigin(req, {
|
|
1827
2043
|
db,
|
|
1828
2044
|
issuer: oauthDeps(req).issuer,
|
|
1829
|
-
resolvedIssuer: resolveIssuer(req, db, configuredIssuer),
|
|
1830
|
-
resolvedSource: resolveIssuerSource(db, configuredIssuer),
|
|
2045
|
+
resolvedIssuer: resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin),
|
|
2046
|
+
resolvedSource: resolveIssuerSource(db, configuredIssuer, loadExposeHubOrigin),
|
|
1831
2047
|
});
|
|
1832
2048
|
}
|
|
1833
2049
|
|
|
@@ -1933,9 +2149,20 @@ export function hubFetch(
|
|
|
1933
2149
|
|
|
1934
2150
|
if (pathname === "/api/auth/mint-token") {
|
|
1935
2151
|
if (!getDb) return dbNotConfigured();
|
|
2152
|
+
// Derive the set of registered vault names so the handler can reject a
|
|
2153
|
+
// `vault:<typo>:admin` mint (item D / hub#450) — same source + shape the
|
|
2154
|
+
// session-cookie `/admin/vault-admin-token/<name>` path uses. Lenient
|
|
2155
|
+
// read so a malformed manifest doesn't 500 the mint endpoint.
|
|
2156
|
+
const mintManifest = readManifestLenient(manifestPath);
|
|
2157
|
+
const mintKnownVaultNames = new Set<string>();
|
|
2158
|
+
for (const s of mintManifest.services) {
|
|
2159
|
+
if (!isVaultEntry(s)) continue;
|
|
2160
|
+
for (const path of s.paths) mintKnownVaultNames.add(vaultInstanceNameFor(s.name, path));
|
|
2161
|
+
}
|
|
1936
2162
|
return handleApiMintToken(req, {
|
|
1937
2163
|
db: getDb(),
|
|
1938
2164
|
issuer: oauthDeps(req).issuer,
|
|
2165
|
+
knownVaultNames: mintKnownVaultNames,
|
|
1939
2166
|
});
|
|
1940
2167
|
}
|
|
1941
2168
|
|
|
@@ -2083,6 +2310,29 @@ export function hubFetch(
|
|
|
2083
2310
|
});
|
|
2084
2311
|
}
|
|
2085
2312
|
|
|
2313
|
+
// One-time invite links (design §7). host:admin-gated, same gate flavor
|
|
2314
|
+
// as /api/users. POST creates (returns the single-emit token + URL), GET
|
|
2315
|
+
// lists (status-annotated), DELETE /:id revokes by sha256 hash.
|
|
2316
|
+
if (pathname === "/api/invites") {
|
|
2317
|
+
if (!getDb) return dbNotConfigured();
|
|
2318
|
+
const invitesDeps = { db: getDb(), issuer: oauthDeps(req).issuer, manifestPath };
|
|
2319
|
+
if (req.method === "GET") return handleListInvites(req, invitesDeps);
|
|
2320
|
+
if (req.method === "POST") return handleCreateInvite(req, invitesDeps);
|
|
2321
|
+
return new Response("method not allowed", { status: 405 });
|
|
2322
|
+
}
|
|
2323
|
+
if (pathname.startsWith("/api/invites/")) {
|
|
2324
|
+
if (!getDb) return dbNotConfigured();
|
|
2325
|
+
const id = decodeURIComponent(pathname.slice("/api/invites/".length));
|
|
2326
|
+
if (!id || id.includes("/")) {
|
|
2327
|
+
return new Response("not found", { status: 404 });
|
|
2328
|
+
}
|
|
2329
|
+
return handleRevokeInvite(req, id, {
|
|
2330
|
+
db: getDb(),
|
|
2331
|
+
issuer: oauthDeps(req).issuer,
|
|
2332
|
+
manifestPath,
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2086
2336
|
// Canonical login/logout. The handlers themselves are unchanged from
|
|
2087
2337
|
// when they lived at /admin/login + /admin/logout; the rename surfaced
|
|
2088
2338
|
// via #231-followup so the URL reflects the surface's actual scope
|
|
@@ -2113,6 +2363,27 @@ export function hubFetch(
|
|
|
2113
2363
|
return handleAdminLogoutPost(getDb(), req);
|
|
2114
2364
|
}
|
|
2115
2365
|
|
|
2366
|
+
// Invite redemption — `/account/setup/<token>` (design §7). Un-authed
|
|
2367
|
+
// onboarding surface (the invitee has no session yet); routed BEFORE the
|
|
2368
|
+
// per-request force-change choke point and the `/account/` matches below.
|
|
2369
|
+
// GET renders the claim form; POST redeems → creates account + own vault,
|
|
2370
|
+
// mints a session, 302 → /account/. The handler validates the invite
|
|
2371
|
+
// (sha256 lookup, expiry/used/revoked), CSRF, and rate-limits on the
|
|
2372
|
+
// /login IP bucket. The invite alone is the authorization — no host:admin.
|
|
2373
|
+
if (pathname.startsWith("/account/setup/")) {
|
|
2374
|
+
if (!getDb) return dbNotConfigured();
|
|
2375
|
+
const rawToken = decodeURIComponent(pathname.slice("/account/setup/".length));
|
|
2376
|
+
if (!rawToken || rawToken.includes("/")) {
|
|
2377
|
+
return new Response("not found", { status: 404 });
|
|
2378
|
+
}
|
|
2379
|
+
const db = getDb();
|
|
2380
|
+
const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
|
|
2381
|
+
const setupDeps = { db, hubOrigin, manifestPath };
|
|
2382
|
+
if (req.method === "GET") return handleAccountSetupGet(req, rawToken, setupDeps);
|
|
2383
|
+
if (req.method === "POST") return handleAccountSetupPost(req, rawToken, setupDeps);
|
|
2384
|
+
return new Response("method not allowed", { status: 405 });
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2116
2387
|
// Multi-user Phase 1 PR 3 — user self-service change-password surface
|
|
2117
2388
|
// (hub#252, design §sign-in flow change). Both GET (render form) and
|
|
2118
2389
|
// POST (apply change) require a session cookie. The handler itself
|
|
@@ -2134,6 +2405,28 @@ export function hubFetch(
|
|
|
2134
2405
|
return new Response("method not allowed", { status: 405 });
|
|
2135
2406
|
}
|
|
2136
2407
|
|
|
2408
|
+
// Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
|
|
2409
|
+
// every `/account/*` route BELOW this line is gated. `/logout` and
|
|
2410
|
+
// `/account/change-password` (the rotation/exit path) ran above and already
|
|
2411
|
+
// returned, so they're never reached here — they stay reachable
|
|
2412
|
+
// pre-rotation by construction. A signed-in user with
|
|
2413
|
+
// `password_changed === false` is bounced (302 → change-password for
|
|
2414
|
+
// browsers, 403 JSON for API clients) before any account surface
|
|
2415
|
+
// (2fa, vault-token, vault-admin-token, account home) resolves. DRY: one
|
|
2416
|
+
// gate for the whole `/account/*` family rather than per-route. The
|
|
2417
|
+
// per-route mints in `account-vault-{token,admin-token}.ts` keep their own
|
|
2418
|
+
// gate as defence-in-depth (they're also reachable in tests directly).
|
|
2419
|
+
//
|
|
2420
|
+
// The bare `/account` (no trailing slash) is matched explicitly too —
|
|
2421
|
+
// otherwise it would slip past `startsWith("/account/")` to its 301 →
|
|
2422
|
+
// `/account/` below, and a pre-rotation user wouldn't be gated until the
|
|
2423
|
+
// second hop. Exact-match `/account` (not `startsWith("/account")`) so
|
|
2424
|
+
// unrelated paths like `/accounts-something` aren't caught.
|
|
2425
|
+
if (getDb && (pathname === "/account" || pathname.startsWith("/account/"))) {
|
|
2426
|
+
const gate = forceChangePasswordGate(getDb(), req);
|
|
2427
|
+
if (gate) return gate;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2137
2430
|
// /account/2fa — user self-service TOTP 2FA enroll / disenroll (hub#473).
|
|
2138
2431
|
// Both GET (render state) and POST (start/confirm/disable) require an
|
|
2139
2432
|
// active session; the handler does the session check + 302 to /login when
|
|
@@ -2146,6 +2439,50 @@ export function hubFetch(
|
|
|
2146
2439
|
return new Response("method not allowed", { status: 405 });
|
|
2147
2440
|
}
|
|
2148
2441
|
|
|
2442
|
+
// /account/vault-admin-token/<name> — friend-facing vault ADMIN deep-link.
|
|
2443
|
+
// POST-only, session-gated, assignment-capped to the `admin` verb: an
|
|
2444
|
+
// assigned non-admin user mints a `vault:<name>:admin` bootstrap token and
|
|
2445
|
+
// 303-redirects into the vault's own admin SPA (`#token=<jwt>`), where they
|
|
2446
|
+
// can rotate vault tokens AND configure Git backup / mirror. The non-admin
|
|
2447
|
+
// sibling of `/admin/vault-admin-token/<name>` (which is first-admin-gated
|
|
2448
|
+
// and returns JSON for the hub SPA). The handler enforces session →
|
|
2449
|
+
// assignment-grants-admin → CSRF → force-change-password (item F / #469)
|
|
2450
|
+
// before minting. Must precede `/account/vault-token/` (it isn't a prefix
|
|
2451
|
+
// of it, but keep the more-specific admin path first for clarity) and the
|
|
2452
|
+
// `/account/` match below. See `account-vault-admin-token.ts`.
|
|
2453
|
+
if (pathname.startsWith("/account/vault-admin-token/")) {
|
|
2454
|
+
if (!getDb) return dbNotConfigured();
|
|
2455
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
2456
|
+
const vaultName = decodeURIComponent(pathname.slice("/account/vault-admin-token/".length));
|
|
2457
|
+
const db = getDb();
|
|
2458
|
+
const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
|
|
2459
|
+
// Resolve the vault's declared `managementUrl` at request time (same
|
|
2460
|
+
// source the well-known doc reads — `installDir/.parachute/module.json`)
|
|
2461
|
+
// so the deep-link lands on the vault admin SPA's real entry point. Quiet
|
|
2462
|
+
// on a malformed/absent manifest: the handler defaults to `/admin/`
|
|
2463
|
+
// (vault's canonical value), the same target the admin sibling uses.
|
|
2464
|
+
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
2465
|
+
const manifest = readManifestLenient(manifestPath);
|
|
2466
|
+
let managementUrl: string | undefined;
|
|
2467
|
+
for (const s of manifest.services) {
|
|
2468
|
+
if (!isVaultEntry(s) || !s.installDir) continue;
|
|
2469
|
+
const instanceNames = new Set(s.paths.map((p) => vaultInstanceNameFor(s.name, p)));
|
|
2470
|
+
if (!instanceNames.has(vaultName)) continue;
|
|
2471
|
+
try {
|
|
2472
|
+
const m = await readManifestFn(s.installDir);
|
|
2473
|
+
if (m?.managementUrl) managementUrl = m.managementUrl;
|
|
2474
|
+
} catch {
|
|
2475
|
+
// Leave undefined → handler defaults to /admin/.
|
|
2476
|
+
}
|
|
2477
|
+
break;
|
|
2478
|
+
}
|
|
2479
|
+
return handleAccountVaultAdminTokenPost(req, vaultName, {
|
|
2480
|
+
db,
|
|
2481
|
+
hubOrigin,
|
|
2482
|
+
...(managementUrl !== undefined ? { managementUrl } : {}),
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2149
2486
|
// /account/vault-token/<name> — friend-facing scoped vault token mint.
|
|
2150
2487
|
// POST-only, session-gated, assignment-capped: a non-admin friend mints a
|
|
2151
2488
|
// `vault:<name>:read|write` bearer for a vault they're ASSIGNED to, for
|
|
@@ -2159,7 +2496,7 @@ export function hubFetch(
|
|
|
2159
2496
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
2160
2497
|
const vaultName = decodeURIComponent(pathname.slice("/account/vault-token/".length));
|
|
2161
2498
|
const db = getDb();
|
|
2162
|
-
const hubOrigin = resolveIssuer(req, db, configuredIssuer);
|
|
2499
|
+
const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
|
|
2163
2500
|
return handleAccountVaultTokenPost(req, vaultName, { db, hubOrigin });
|
|
2164
2501
|
}
|
|
2165
2502
|
|
|
@@ -2177,8 +2514,17 @@ export function hubFetch(
|
|
|
2177
2514
|
}
|
|
2178
2515
|
if (!getDb) return dbNotConfigured();
|
|
2179
2516
|
const db = getDb();
|
|
2180
|
-
const hubOrigin = resolveIssuer(req, db, configuredIssuer);
|
|
2181
|
-
|
|
2517
|
+
const hubOrigin = resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin);
|
|
2518
|
+
// Resolve each assigned vault's loopback port from services.json so the
|
|
2519
|
+
// home can fetch per-vault usage. Read at request time (same dynamism as
|
|
2520
|
+
// proxyToVault) — a vault created seconds ago surfaces a stat without a
|
|
2521
|
+
// restart. Returns null for an unknown name → that tile skips the stat.
|
|
2522
|
+
const resolveVaultPort = (vaultName: string): number | null => {
|
|
2523
|
+
const services = readManifestLenient(manifestPath).services;
|
|
2524
|
+
const match = findVaultUpstream(services, `/vault/${vaultName}`);
|
|
2525
|
+
return match ? match.port : null;
|
|
2526
|
+
};
|
|
2527
|
+
return handleAccountHomeGet(req, { db, hubOrigin, resolveVaultPort });
|
|
2182
2528
|
}
|
|
2183
2529
|
|
|
2184
2530
|
// Legacy `/admin/config` (server-rendered module-config portal, #46)
|
|
@@ -2202,7 +2548,19 @@ export function hubFetch(
|
|
|
2202
2548
|
// here anymore (the SPA moved to /admin), so we can't accidentally
|
|
2203
2549
|
// mask a backend 404 with HTML.
|
|
2204
2550
|
if (pathname.startsWith("/vault/")) {
|
|
2205
|
-
|
|
2551
|
+
// Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 2:
|
|
2552
|
+
// a pre-rotation signed-in user can't reach a per-vault user surface
|
|
2553
|
+
// (Notes PWA, MCP, vault API) on the un-rotated temp password — they're
|
|
2554
|
+
// bounced to change-password (browser) / 403 (API). Same posture as the
|
|
2555
|
+
// `/account/*` gate above. An UNAUTHENTICATED proxy request (no hub
|
|
2556
|
+
// session — the common Notes/MCP case carrying its own bearer) passes the
|
|
2557
|
+
// gate untouched (`forceChangePasswordGate` returns null with no session)
|
|
2558
|
+
// and is handled by the vault's own auth downstream.
|
|
2559
|
+
if (getDb) {
|
|
2560
|
+
const gate = forceChangePasswordGate(getDb(), req);
|
|
2561
|
+
if (gate) return gate;
|
|
2562
|
+
}
|
|
2563
|
+
const proxied = await proxyToVault(req, manifestPath, deps?.supervisor, peerAddr);
|
|
2206
2564
|
if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
|
|
2207
2565
|
return new Response("not found", { status: 404 });
|
|
2208
2566
|
}
|
|
@@ -2229,7 +2587,7 @@ export function hubFetch(
|
|
|
2229
2587
|
// here only after every hub-owned prefix above has had its turn — so
|
|
2230
2588
|
// `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
|
|
2231
2589
|
// `/api/*` are excluded by ordering, not by an explicit denylist (#182).
|
|
2232
|
-
const proxied = await proxyToService(req, manifestPath, deps?.supervisor);
|
|
2590
|
+
const proxied = await proxyToService(req, manifestPath, deps?.supervisor, peerAddr);
|
|
2233
2591
|
if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
|
|
2234
2592
|
|
|
2235
2593
|
// Branded fall-through 404 (closes hub#392) — the operator who mistyped
|