@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.
Files changed (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. 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,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
- 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
  }
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 (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
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 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).
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. `new URL(req.url).origin` — the request's own origin. Local dev
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. The hub_settings read is a single small SQLite query — cheap
1086
- * relative to JWT signing on the same request path.
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(req, getDb?.(), configuredIssuer);
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
- return handleAccountHomeGet(req, { db, hubOrigin });
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
- const proxied = await proxyToVault(req, manifestPath, deps?.supervisor);
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