@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.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +609 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +180 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +342 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +94 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +347 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. 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: hub binds `127.0.0.1:1939`, so external requests
432
- * can't reach the listener except via these trusted forwarders. Tailscale
433
- * specifically strips the same headers from incoming requests before
434
- * re-injecting them, so even a malicious tailnet peer can't impersonate a
435
- * different user. We could mirror that strip-on-arrival defense, but it's
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
- * Default to "loopback" when no proxy headers are present that's the
439
- * direct-localhost case. Funnel without `Tailscale-Funnel-Request` would
440
- * also fall here, but Tailscale always sets the header on funneled
441
- * requests, so this branch only fires for true loopback callers.
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
- return "loopback";
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
- if (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
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 (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
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 the operator's
792
- * `~/.parachute/expose-state.json` via `readExposeState`; tests inject a
793
- * fake to drive tailnet/funnel origins into the bound set without standing
794
- * up real exposes. Returns `undefined` when no state file is present
795
- * (pre-`parachute expose` state — fine, the issuer + loopback still cover
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. `new URL(req.url).origin` — the request's own origin. Local dev
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. The hub_settings read is a single small SQLite query — cheap
1086
- * relative to JWT signing on the same request path.
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(req, getDb?.(), configuredIssuer);
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
- return handleAccountHomeGet(req, { db, hubOrigin });
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
- const proxied = await proxyToVault(req, manifestPath, deps?.supervisor);
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