@nanobpm/nano-workforce 0.150.1 → 0.150.3

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.150.3](https://github.com/nanobpm/nano-workforce/compare/v0.150.2...v0.150.3) (2026-08-28)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **compileDeliveryGraph:** key reviewUrl to the request origin ([#577](https://github.com/nanobpm/nano-workforce/issues/577)) ([#581](https://github.com/nanobpm/nano-workforce/issues/581)) ([8c04c95](https://github.com/nanobpm/nano-workforce/commit/8c04c95b9d3617b3d71d28e54a7f24cae54c6e2c))
6
+
7
+ ## [0.150.2](https://github.com/nanobpm/nano-workforce/compare/v0.150.1...v0.150.2) (2026-08-28)
8
+
9
+ ### Bug Fixes
10
+
11
+ * honour X-Forwarded-Prefix in resolveApiBase so the operator guide baseUrl resolves behind the app-view proxy ([#580](https://github.com/nanobpm/nano-workforce/issues/580)) ([21fdeca](https://github.com/nanobpm/nano-workforce/commit/21fdecac2d7c383cca066fa47950d9b6d28f41fc)), closes [#578](https://github.com/nanobpm/nano-workforce/issues/578)
12
+
1
13
  ## [0.150.1](https://github.com/nanobpm/nano-workforce/compare/v0.150.0...v0.150.1) (2026-08-28)
2
14
 
3
15
  ### Bug Fixes
@@ -25,6 +25,7 @@ import {
25
25
  stageProposal,
26
26
  sweepExpiredProposals,
27
27
  } from "./deliveryGraphProposals.ts";
28
+ import { publicBaseUrl } from "./blackboard.ts";
28
29
 
29
30
  const APP_ROOT = resolve(import.meta.dirname, "..");
30
31
 
@@ -87,6 +88,12 @@ test("proposalReviewUrl: a navigational deep-link to the cockpit page — NOT a
87
88
  assert(!/\/actions\//.test(url), "reviewUrl points at a page, never an API action");
88
89
  });
89
90
 
91
+ test("proposalReviewUrl: with no base falls back to publicBaseUrl() — the text-ingress (no request) path (#577)", () => {
92
+ // The staging text-ingress path has no HTTP request to derive an origin from, so the default base
93
+ // must stay the deployment-wide NANO_WORKFORCE_BASE_URL via publicBaseUrl().
94
+ assertEquals(proposalReviewUrl("abc123"), `${publicBaseUrl()}/app/pages/delivery-graphs#proposal-abc123`);
95
+ });
96
+
90
97
  test("buildProposalRow: stamps status staged, boolean→0/1, and TTL from createdAt", () => {
91
98
  const r = row({ sideEffecting: true, createdAt: "2024-01-01T00:00:00.000Z" });
92
99
  assertEquals(r.status, "staged");
@@ -1,9 +1,10 @@
1
1
  // Tests for app/resolveApiBase.ts — the single canonical control-API base reconstruction shared by
2
- // getAgentInstructions and getAgentSkill. Covers proxy-header handling, scheme restriction,
2
+ // getAgentInstructions and getAgentSkill, plus the human-facing resolvePublicOrigin (#577). Covers
3
+ // proxy-header handling, scheme restriction, host sanitisation, x-forwarded-prefix sanitisation,
3
4
  // host-absent fallback, and mount-suffix stripping for both mount depths.
4
5
  import { test } from "node:test";
5
6
  import { assertEquals } from "#test-assert";
6
- import { resolveApiBase } from "./resolveApiBase.ts";
7
+ import { resolveApiBase, resolvePublicOrigin } from "./resolveApiBase.ts";
7
8
 
8
9
  function req(headers: Record<string, string>, path: string) {
9
10
  return { path, headers: new Headers(headers) };
@@ -41,3 +42,136 @@ test("tolerates a leading slash on the mount suffix", () => {
41
42
  test("strips multiple trailing slashes after the mount suffix", () => {
42
43
  assertEquals(resolveApiBase(req({ host: "h" }, "/app/api/agent/skill///"), "agent/skill"), "http://h/app/api");
43
44
  });
45
+
46
+ // ── resolveApiBase: x-forwarded-prefix sanitisation (#580) via the shared sanitiseForwardedPrefix ──
47
+ test("prepends a validated x-forwarded-prefix to the reconstructed base", () => {
48
+ const r = req(
49
+ { host: "nano.ngrok-free.dev", "x-forwarded-prefix": "/console/app-view/Workforce" },
50
+ "/app/api/agent",
51
+ );
52
+ assertEquals(resolveApiBase(r, "agent"), "http://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
53
+ });
54
+
55
+ test("normalises a trailing slash on x-forwarded-prefix", () => {
56
+ const r = req({ host: "h", "x-forwarded-prefix": "/console/app-view/Workforce/" }, "/app/api/agent");
57
+ assertEquals(resolveApiBase(r, "agent"), "http://h/console/app-view/Workforce/app/api");
58
+ });
59
+
60
+ test("ignores a x-forwarded-prefix carrying a scheme", () => {
61
+ const r = req({ host: "h", "x-forwarded-prefix": "https://evil.test" }, "/app/api/agent");
62
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
63
+ });
64
+
65
+ test("ignores a x-forwarded-prefix carrying an authority", () => {
66
+ const r = req({ host: "h", "x-forwarded-prefix": "//evil.test" }, "/app/api/agent");
67
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
68
+ });
69
+
70
+ test("ignores a x-forwarded-prefix with .. traversal", () => {
71
+ const r = req({ host: "h", "x-forwarded-prefix": "/a/../.." }, "/app/api/agent");
72
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
73
+ });
74
+
75
+ test("ignores a x-forwarded-prefix with percent-encoded .. traversal", () => {
76
+ const r = req({ host: "h", "x-forwarded-prefix": "/a/%2e%2e/%2e%2e" }, "/app/api/agent");
77
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
78
+ });
79
+
80
+ test("ignores a x-forwarded-prefix with a percent-encoded authority", () => {
81
+ const r = req({ host: "h", "x-forwarded-prefix": "/%2F%2Fevil.test" }, "/app/api/agent");
82
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
83
+ });
84
+
85
+ test("ignores a relative (non-absolute) x-forwarded-prefix", () => {
86
+ const r = req({ host: "h", "x-forwarded-prefix": "console/app-view/Workforce" }, "/app/api/agent");
87
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
88
+ });
89
+
90
+ test("prefix composes with x-forwarded-proto and x-forwarded-host", () => {
91
+ const r = req(
92
+ {
93
+ host: "internal",
94
+ "x-forwarded-host": "nano.ngrok-free.dev",
95
+ "x-forwarded-proto": "https",
96
+ "x-forwarded-prefix": "/console/app-view/Workforce",
97
+ },
98
+ "/app/api/agent/skill",
99
+ );
100
+ assertEquals(resolveApiBase(r, "agent/skill"), "https://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
101
+ });
102
+
103
+ // ── resolvePublicOrigin: the human-facing ORIGIN (+ proxy prefix), no /app/api suffix (#577) ──
104
+ // Shares sanitiseForwardedPrefix with resolveApiBase, so the prefix policy (absolute-only, reject
105
+ // scheme/authority/traversal entirely, percent-aware) is identical on both surfaces — no drift.
106
+ test("resolvePublicOrigin: bare origin from the host header", () => {
107
+ assertEquals(resolvePublicOrigin(req({ host: "wf.example.com" }, "/app/api/actions/compile-delivery-graph")), "http://wf.example.com");
108
+ });
109
+
110
+ test("resolvePublicOrigin: honours x-forwarded-proto and x-forwarded-host", () => {
111
+ const r = req({ host: "internal", "x-forwarded-host": "example.test", "x-forwarded-proto": "https" }, "/app/api/actions/compile-delivery-graph");
112
+ assertEquals(resolvePublicOrigin(r), "https://example.test");
113
+ });
114
+
115
+ test("resolvePublicOrigin: restricts x-forwarded-proto to http/https", () => {
116
+ const r = req({ host: "wf.example.com", "x-forwarded-proto": "javascript" }, "/app/api/actions/compile-delivery-graph");
117
+ assertEquals(resolvePublicOrigin(r), "http://wf.example.com");
118
+ });
119
+
120
+ test("resolvePublicOrigin: appends the reverse-proxy x-forwarded-prefix", () => {
121
+ const r = req(
122
+ { "x-forwarded-host": "nano.ngrok-free.dev", "x-forwarded-proto": "https", "x-forwarded-prefix": "/console/app-view/Workforce" },
123
+ "/app/api/actions/compile-delivery-graph",
124
+ );
125
+ assertEquals(resolvePublicOrigin(r), "https://nano.ngrok-free.dev/console/app-view/Workforce");
126
+ });
127
+
128
+ test("resolvePublicOrigin: normalises a trailing slash on x-forwarded-prefix", () => {
129
+ const r = req({ host: "h", "x-forwarded-prefix": "/console/app-view/Workforce/" }, "/app/api/actions/compile-delivery-graph");
130
+ assertEquals(resolvePublicOrigin(r), "http://h/console/app-view/Workforce");
131
+ });
132
+
133
+ test("resolvePublicOrigin: treats a slash-only x-forwarded-prefix as empty (no double slash)", () => {
134
+ const r = req({ host: "h", "x-forwarded-prefix": "///" }, "/app/api/actions/compile-delivery-graph");
135
+ assertEquals(resolvePublicOrigin(r), "http://h");
136
+ });
137
+
138
+ test("resolvePublicOrigin: rejects a path-traversal x-forwarded-prefix entirely", () => {
139
+ const r = req({ host: "h", "x-forwarded-prefix": "/console/../../etc" }, "/app/api/actions/compile-delivery-graph");
140
+ assertEquals(resolvePublicOrigin(r), "http://h");
141
+ });
142
+
143
+ test("resolvePublicOrigin: rejects a scheme/authority x-forwarded-prefix", () => {
144
+ const r = req({ host: "h", "x-forwarded-prefix": "https://evil.example/hijack" }, "/app/api/actions/compile-delivery-graph");
145
+ assertEquals(resolvePublicOrigin(r), "http://h");
146
+ });
147
+
148
+ test("resolvePublicOrigin: rejects a relative (non-absolute) x-forwarded-prefix", () => {
149
+ const r = req({ host: "h", "x-forwarded-prefix": "@evil.example" }, "/app/api/actions/compile-delivery-graph");
150
+ assertEquals(resolvePublicOrigin(r), "http://h");
151
+ });
152
+
153
+ test("resolvePublicOrigin: falls back to a localhost origin when the Host header is absent", () => {
154
+ assertEquals(resolvePublicOrigin(req({}, "/app/api/actions/compile-delivery-graph")), "http://localhost:3000");
155
+ });
156
+
157
+ // ── host sanitisation: the untrusted x-forwarded-host/host authority is reflected into the URL ──
158
+ test("rejects a userinfo-injecting host (falls back to localhost)", () => {
159
+ const r = req({ "x-forwarded-host": "evil.com@real.example" }, "/app/api/actions/compile-delivery-graph");
160
+ assertEquals(resolvePublicOrigin(r), "http://localhost:3000");
161
+ assertEquals(resolveApiBase(req({ "x-forwarded-host": "evil.com@real.example" }, "/app/api/agent"), "agent"), "http://localhost:3000/app/api");
162
+ });
163
+
164
+ test("rejects a path-injecting host (falls back to localhost)", () => {
165
+ const r = req({ "x-forwarded-host": "real.example/extra-path" }, "/app/api/actions/compile-delivery-graph");
166
+ assertEquals(resolvePublicOrigin(r), "http://localhost:3000");
167
+ });
168
+
169
+ test("accepts a host:port authority", () => {
170
+ const r = req({ "x-forwarded-host": "wf.example.com:8443", "x-forwarded-proto": "https" }, "/app/api/actions/compile-delivery-graph");
171
+ assertEquals(resolvePublicOrigin(r), "https://wf.example.com:8443");
172
+ });
173
+
174
+ test("accepts a bracketed IPv6 host authority", () => {
175
+ const r = req({ "x-forwarded-host": "[2001:db8::1]:3000" }, "/app/api/actions/compile-delivery-graph");
176
+ assertEquals(resolvePublicOrigin(r), "http://[2001:db8::1]:3000");
177
+ });
@@ -4,8 +4,9 @@
4
4
  // to the request base (getAgentInstructions, getAgentSkill, …) — per AGENTS.md "Derivation over
5
5
  // duplication: no drift surfaces", proxy-header handling and base-path stripping must not fork.
6
6
  //
7
- // Honour reverse-proxy forwarding headers; fall back to a localhost default when the Host header is
8
- // absent (e.g. a raw unit-test request).
7
+ // Honour reverse-proxy forwarding headers proto, host, and the external path prefix
8
+ // (X-Forwarded-Prefix, e.g. the console app-view proxy's "/console/app-view/{project}") — and fall
9
+ // back to a localhost default when the Host header is absent (e.g. a raw unit-test request).
9
10
 
10
11
  /**
11
12
  * Recover the control-API base from a request, stripping the operation's own mount suffix.
@@ -16,13 +17,72 @@
16
17
  * "/app/api" when the path is nothing but the suffix.
17
18
  */
18
19
  export function resolveApiBase(req: { path: string; headers: Headers }, mountSuffix: string): string {
19
- const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
20
- // x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
21
- const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
22
- const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
20
+ const { proto, host } = requestProtoHost(req);
21
+ const prefix = sanitiseForwardedPrefix(req.headers.get("x-forwarded-prefix"));
23
22
  // The op is mounted at "<base>/<mountSuffix>"; strip the trailing segments to recover the base path.
24
23
  const suffix = mountSuffix.replace(/^\/+/, "").replace(/\/+$/, "");
25
24
  const stripRe = new RegExp(`/${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/*$`);
26
25
  const basePath = req.path.replace(stripRe, "") || "/app/api";
27
- return host ? `${proto}://${host}${basePath}` : `http://localhost:3000${basePath}`;
26
+ return host ? `${proto}://${host}${prefix}${basePath}` : `http://localhost:3000${prefix}${basePath}`;
27
+ }
28
+
29
+ /** The public ORIGIN (+ proxy prefix) this request arrived on — the base for a navigational link
30
+ * handed back to the caller (e.g. a cockpit deep-link), WITHOUT the `/app/api` mount suffix that
31
+ * {@link resolveApiBase} keeps. Where `resolveApiBase` reconstructs the control-API base an *agent*
32
+ * calls back on, this reconstructs the human-facing origin: proto + host (same proxy-header trust as
33
+ * `resolveApiBase`) plus any reverse-proxy path prefix advertised via `x-forwarded-prefix`, so a
34
+ * link built as `${resolvePublicOrigin(req)}/app/pages/…` opens on the exact origin the operator is
35
+ * driving this app from (e.g. a tunnel), not a static deployment-wide base. Falls back to a
36
+ * localhost origin when the Host header is absent (a raw unit-test request). */
37
+ export function resolvePublicOrigin(req: { path: string; headers: Headers }): string {
38
+ const { proto, host } = requestProtoHost(req);
39
+ const prefix = sanitiseForwardedPrefix(req.headers.get("x-forwarded-prefix"));
40
+ return host ? `${proto}://${host}${prefix}` : `http://localhost:3000${prefix}`;
41
+ }
42
+
43
+ /** Sanitise the untrusted, proxy/user-controlled `x-forwarded-prefix` into a safe leading-slash,
44
+ * no-trailing-slash path segment (or ""). The ONE canonical prefix sanitiser shared by
45
+ * {@link resolveApiBase} and {@link resolvePublicOrigin} (AGENTS.md "derivation over duplication") —
46
+ * the prefix is the reverse-proxy path the public URL was mounted under (e.g.
47
+ * "/console/app-view/Workforce") and is reflected into a caller-facing URL, so it must not smuggle a
48
+ * scheme, an authority ("//host"), or a "."/".." traversal segment into the URL. Accept only an
49
+ * absolute path of URL-safe path characters, then drop trailing slashes so it composes cleanly with
50
+ * the base path; anything else falls back to an empty prefix. Percent-encoding can smuggle those
51
+ * forms past a literal check ("%2e%2e" decodes to "..", "%2f%2f" to an authority-introducing "//"),
52
+ * so normalise the common encoded spellings of "." and "/" (case-insensitively) before rejecting
53
+ * dot-segments and "//"; the still-encoded raw value is what we reflect once it validates. Because
54
+ * the return is always either "" or a leading-"/" path, it can never alter the `${proto}://${host}`
55
+ * authority. */
56
+ function sanitiseForwardedPrefix(raw: string | null): string {
57
+ const rawPrefix = (raw ?? "").split(",")[0].trim();
58
+ const decodedPrefix = rawPrefix.replace(/%2e/gi, ".").replace(/%2f/gi, "/");
59
+ return /^\/(?!\/)[A-Za-z0-9._~\-/%]*$/.test(rawPrefix) &&
60
+ !decodedPrefix.includes("//") &&
61
+ !/(^|\/)\.\.?(\/|$)/.test(decodedPrefix)
62
+ ? rawPrefix.replace(/\/+$/, "")
63
+ : "";
64
+ }
65
+
66
+ /** The trusted (proto, host) pair for a request — the ONE place proxy-header handling lives so
67
+ * `resolveApiBase` and `resolvePublicOrigin` can't drift (AGENTS.md "derivation over duplication").
68
+ * Only `http`/`https` are trusted from the user-controlled `x-forwarded-proto`; the host prefers
69
+ * `x-forwarded-host` over `host`. `host` is "" when neither header is present or the advertised host
70
+ * is not a valid authority (see {@link sanitiseHost}). */
71
+ function requestProtoHost(req: { headers: Headers }): { proto: string; host: string } {
72
+ const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
73
+ const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
74
+ const rawHost = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
75
+ return { proto, host: sanitiseHost(rawHost) };
76
+ }
77
+
78
+ /** Sanitise the untrusted, proxy/user-controlled host (`x-forwarded-host`/`host`) into a bare
79
+ * authority — a registered name or IPv4 with an optional `:port`, or a bracketed IPv6 literal with
80
+ * an optional `:port` — or "" when it carries anything else. The host is reflected verbatim into the
81
+ * `${proto}://${host}` authority of a caller-facing URL, so a hostile value like
82
+ * `evil.com@real.example` (userinfo injection) or `real.example/extra-path` (path injection) must be
83
+ * rejected outright rather than smuggled through. */
84
+ function sanitiseHost(host: string): string {
85
+ if (!host) return "";
86
+ const valid = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::\d+)?$/.test(host);
87
+ return valid ? host : "";
28
88
  }
@@ -30,8 +30,9 @@ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Pro
30
30
  }
31
31
  }
32
32
 
33
- async function call(app: AppApi, body: unknown) {
34
- return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
33
+ async function call(app: AppApi, body: unknown, headers: Record<string, string> = {}) {
34
+ const req = { path: "/app/api/actions/compile-delivery-graph", headers: new Headers(headers) };
35
+ return (await handler({ req: req as any, params: {}, query: {}, body } as any, app)) as any;
35
36
  }
36
37
 
37
38
  const GOOD = {
@@ -79,6 +80,33 @@ test("compile-delivery-graph: the response exposes NO dispatch handle — no run
79
80
  });
80
81
  });
81
82
 
83
+ // ── #577: reviewUrl is a caller-facing link → keyed to the request origin, not the static base ──
84
+ test("compile-delivery-graph: reviewUrl is on the request's forwarded origin, not NANO_WORKFORCE_BASE_URL", async () => {
85
+ await withApp(async (app) => {
86
+ const res = await call(app, GOOD, { "x-forwarded-proto": "https", "x-forwarded-host": "example.test" });
87
+ assertEquals(res.status, 200);
88
+ assertEquals(
89
+ res.body.reviewUrl,
90
+ `https://example.test/app/pages/delivery-graphs#proposal-${res.body.digest}`,
91
+ );
92
+ });
93
+ });
94
+
95
+ test("compile-delivery-graph: reviewUrl honours the reverse-proxy x-forwarded-prefix", async () => {
96
+ await withApp(async (app) => {
97
+ const res = await call(app, GOOD, {
98
+ "x-forwarded-proto": "https",
99
+ "x-forwarded-host": "nano.ngrok-free.dev",
100
+ "x-forwarded-prefix": "/console/app-view/Workforce",
101
+ });
102
+ assertEquals(res.status, 200);
103
+ assertEquals(
104
+ res.body.reviewUrl,
105
+ `https://nano.ngrok-free.dev/console/app-view/Workforce/app/pages/delivery-graphs#proposal-${res.body.digest}`,
106
+ );
107
+ });
108
+ });
109
+
82
110
  test("compile-delivery-graph: re-compiling the same graph is idempotent — one staged proposal, TTL anchored to the first stage", async () => {
83
111
  await withApp(async (app, data) => {
84
112
  const first = await call(app, GOOD);
@@ -21,12 +21,13 @@ import {
21
21
  stageProposal,
22
22
  } from "../app/deliveryGraphProposals.ts";
23
23
  import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
24
+ import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
24
25
  import { defineOperation } from "../nano-generated/operations.ts";
25
26
 
26
27
  const STAGED_MESSAGE =
27
28
  "The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
28
29
 
29
- export default defineOperation("compileDeliveryGraph", async ({ body }, app) => {
30
+ export default defineOperation("compileDeliveryGraph", async ({ body, req }, app) => {
30
31
  // The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the compiler adds
31
32
  // the SEMANTIC checks (acyclicity, edge integrity, fact resolution). A directly-invoked delegate
32
33
  // could still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
@@ -75,7 +76,10 @@ export default defineOperation("compileDeliveryGraph", async ({ body }, app) =>
75
76
  message: STAGED_MESSAGE,
76
77
  digest,
77
78
  preview,
78
- reviewUrl: proposalReviewUrl(digest),
79
+ // Navigational, human-facing link → keyed to the ORIGIN this request arrived on (tunnel,
80
+ // proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the operator
81
+ // driving this instance can actually open it (#577).
82
+ reviewUrl: proposalReviewUrl(digest, resolvePublicOrigin(req)),
79
83
  },
80
84
  };
81
85
  });
@@ -86,6 +86,30 @@ test("examples are keyed to the request's control-API base and leave no placehol
86
86
  assert(!md.includes("__ENGINE__"), "no unsubstituted __ENGINE__ placeholder");
87
87
  });
88
88
 
89
+ test("x-forwarded-prefix is prepended to the baseUrl and rendered examples", async () => {
90
+ const proxied = input(
91
+ {
92
+ host: "internal",
93
+ "x-forwarded-host": "nano.ngrok-free.dev",
94
+ "x-forwarded-proto": "https",
95
+ "x-forwarded-prefix": "/console/app-view/Workforce",
96
+ },
97
+ );
98
+ const body = (await handler(proxied, app)) as any;
99
+ assertEquals(body.body.baseUrl, "https://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
100
+ const md = body.body.instructions as string;
101
+ assert(
102
+ md.includes("https://nano.ngrok-free.dev/console/app-view/Workforce/app/api/version"),
103
+ "prefixed base URL substituted into examples",
104
+ );
105
+ });
106
+
107
+ test("a hostile x-forwarded-prefix is ignored rather than reflected into the baseUrl", async () => {
108
+ const hostile = input({ host: "wf.example.com", "x-forwarded-prefix": "https://evil.test" });
109
+ const body = (await handler(hostile, app)) as any;
110
+ assertEquals(body.body.baseUrl, "http://wf.example.com/app/api", "hostile prefix falls back to today's behaviour");
111
+ });
112
+
89
113
  test("x-forwarded-proto is restricted to http/https", async () => {
90
114
  const spoofed = input({ host: "wf.example.com", "x-forwarded-proto": "javascript" });
91
115
  const body = (await handler(spoofed, app)) as any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.150.1",
3
+ "version": "0.150.3",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",