@telorun/http-server 0.10.0 → 0.11.0

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,5 +1,11 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b1dd65c: `Http.Server`: the generated OpenAPI `servers` URL now defaults to a **relative** path (the mount prefix), so the reference UI and spec are correct behind any proxy/ingress/origin with no configuration — fixing the previous hardcoded `http://<bind-host>:<port>` that didn't match the reachable URL. Add an opt-in `trustForwardedHeaders` boolean: when enabled the server honors the standard `X-Forwarded-Proto` / `X-Forwarded-Host` headers and advertises an absolute `servers` URL (and request protocol/host) matching the fronting proxy. An explicit `baseUrl` still overrides both.
8
+
3
9
  ## 0.10.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -24,8 +24,8 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
24
24
  kind: Telo.Application
25
25
  metadata: { name: hello-http, version: 1.0.0 }
26
26
  imports:
27
- Http: std/http-server@0.9.0
28
- JS: std/javascript@0.4.1
27
+ Http: std/http-server@0.11.0
28
+ JS: std/javascript@0.5.0
29
29
  targets: [ !ref Server ]
30
30
  ---
31
31
  kind: Http.Server
@@ -206,3 +206,26 @@ request:
206
206
  description: "Validation schema for the request payload"
207
207
  required: ["path", "method"]
208
208
  ```
209
+
210
+ ### 5. External URL & OpenAPI `servers`
211
+
212
+ A server is usually reached through a reverse proxy / ingress, so its own bound
213
+ `host:port` is not the URL clients use. The generated OpenAPI `servers` block MUST
214
+ follow this resolution, identically across runtimes (Node/Rust/Go) — the inputs
215
+ are standard HTTP, never a framework's proxy-config object:
216
+
217
+ | Manifest | `servers[].url` |
218
+ | --- | --- |
219
+ | `baseUrl: <url>` | `<url><mountPrefix>` — explicit, fixed; wins over everything |
220
+ | `trustForwardedHeaders: true` | `<X-Forwarded-Proto>://<X-Forwarded-Host><mountPrefix>`, derived per request |
221
+ | neither (default) | `<mountPrefix>` — **relative**; the client resolves it against the origin the document was loaded from |
222
+
223
+ - The default is **relative** so the document is correct behind any proxy, ingress,
224
+ or origin with zero configuration.
225
+ - `trustForwardedHeaders` is a **boolean** on purpose: the only portable cross-runtime
226
+ signal is the standard `X-Forwarded-Proto` / `X-Forwarded-Host` (RFC 7239 `Forwarded`)
227
+ headers. Fine-grained "trusted proxy IP/CIDR/hop" lists are framework-specific and
228
+ MUST NOT leak into the manifest. Default `false`; only enable behind a trusted proxy
229
+ (a client with direct network access could otherwise spoof the headers).
230
+ - When `trustForwardedHeaders` is set, the request protocol/host exposed to handlers
231
+ MUST also reflect the forwarded headers.
@@ -25,6 +25,7 @@ type HttpServerResource = RuntimeResource & {
25
25
  host?: string;
26
26
  port?: number;
27
27
  baseUrl?: string;
28
+ trustForwardedHeaders?: boolean;
28
29
  logger?: boolean;
29
30
  cors?: CorsOptions;
30
31
  contentTypeParsers?: Array<{
@@ -13,6 +13,7 @@ class HttpServer {
13
13
  host;
14
14
  port;
15
15
  baseUrl;
16
+ trustForwardedHeaders;
16
17
  resource;
17
18
  ctx;
18
19
  resolvedNotFoundHandler;
@@ -22,12 +23,16 @@ class HttpServer {
22
23
  this.host = resource.host || "0.0.0.0";
23
24
  this.port = Number(resource.port || 0);
24
25
  this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
26
+ this.trustForwardedHeaders = resource.trustForwardedHeaders === true;
25
27
  this.resolvedNotFoundHandler = resolvedNotFoundHandler;
26
28
  if (!this.port) {
27
29
  throw new Error("Http.Server port is required");
28
30
  }
29
31
  this.app = Fastify({
30
32
  logger: resource.logger,
33
+ // Honour X-Forwarded-Proto / X-Forwarded-Host so request.protocol/host (and
34
+ // the OpenAPI servers derived from them) reflect a fronting proxy's URL.
35
+ trustProxy: this.trustForwardedHeaders,
31
36
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default] },
32
37
  });
33
38
  }
@@ -91,16 +96,15 @@ class HttpServer {
91
96
  throw error;
92
97
  });
93
98
  if (this.resource.openapi) {
94
- const servers = [];
95
- // const routesByName = new Map<string, HttpRouteResource>();
96
99
  const mounts = this.resource.mounts || [];
97
- const prefixes = new Set();
98
- for (const mount of mounts) {
99
- prefixes.add(mount.path || "");
100
- }
101
- for (const prefix of prefixes) {
102
- servers.push({ url: this.baseUrl + prefix });
103
- }
100
+ const prefixes = [...new Set(mounts.map((mount) => mount.path || ""))];
101
+ // Server URL precedence: an explicit `baseUrl` is an absolute, fixed
102
+ // override; otherwise the URLs are relative (just the mount prefix) so the
103
+ // doc is correct behind any proxy/ingress/origin without configuration —
104
+ // the client resolves them against wherever the reference was loaded.
105
+ const servers = prefixes.map((prefix) => ({
106
+ url: this.resource.baseUrl ? this.resource.baseUrl + prefix : prefix || "/",
107
+ }));
104
108
  await this.app.register(swagger, {
105
109
  openapi: {
106
110
  openapi: "3.0.0",
@@ -108,8 +112,45 @@ class HttpServer {
108
112
  servers,
109
113
  },
110
114
  });
115
+ const referencePrefix = "/reference";
116
+ // `trustForwardedHeaders` (and no fixed baseUrl) upgrades the relative
117
+ // default to absolute URLs built per-request from the now-trusted
118
+ // X-Forwarded-* headers, so the served spec advertises the real proxy URL.
119
+ if (this.trustForwardedHeaders && !this.resource.baseUrl) {
120
+ // Couples to the Scalar plugin's default spec endpoint
121
+ // (`<routePrefix>/openapi.json`); if it ever served the doc elsewhere the
122
+ // rewrite would no-op and the relative default would still apply. The
123
+ // `openapi-server-url` integration test guards this path.
124
+ const specPath = `${referencePrefix}/openapi.json`;
125
+ this.app.addHook("onSend", async (request, reply, payload) => {
126
+ if (request.url.split("?")[0] !== specPath)
127
+ return payload;
128
+ const text = typeof payload === "string"
129
+ ? payload
130
+ : Buffer.isBuffer(payload)
131
+ ? payload.toString("utf8")
132
+ : null;
133
+ if (text === null)
134
+ return payload;
135
+ try {
136
+ const doc = JSON.parse(text);
137
+ if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
138
+ doc.servers = prefixes.map((prefix) => ({
139
+ url: `${request.protocol}://${request.host}${prefix}`,
140
+ }));
141
+ const out = JSON.stringify(doc);
142
+ reply.header("content-length", Buffer.byteLength(out));
143
+ return out;
144
+ }
145
+ }
146
+ catch {
147
+ // Not a JSON document we can rewrite — leave the response untouched.
148
+ }
149
+ return payload;
150
+ });
151
+ }
111
152
  await this.app.register(apiReference, {
112
- routePrefix: "/reference",
153
+ routePrefix: referencePrefix,
113
154
  });
114
155
  }
115
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -49,7 +49,7 @@
49
49
  "@types/node": "^20.0.0",
50
50
  "typescript": "^5.0.0",
51
51
  "vitest": "^2.1.8",
52
- "@telorun/sdk": "0.23.0"
52
+ "@telorun/sdk": "0.26.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@telorun/sdk": "*"
@@ -45,6 +45,7 @@ type HttpServerResource = RuntimeResource & {
45
45
  host?: string;
46
46
  port?: number;
47
47
  baseUrl?: string;
48
+ trustForwardedHeaders?: boolean;
48
49
  logger?: boolean;
49
50
  cors?: CorsOptions;
50
51
  contentTypeParsers?: Array<{ contentType: string; parser?: Invocable; stream?: boolean }>;
@@ -83,6 +84,7 @@ class HttpServer implements ResourceInstance {
83
84
  private readonly host: string;
84
85
  private readonly port: number;
85
86
  private readonly baseUrl: string;
87
+ private readonly trustForwardedHeaders: boolean;
86
88
  private readonly resource: HttpServerResource;
87
89
  private readonly ctx: ResourceContext;
88
90
  private readonly resolvedNotFoundHandler: ResolvedHandler | null;
@@ -97,6 +99,7 @@ class HttpServer implements ResourceInstance {
97
99
  this.host = resource.host || "0.0.0.0";
98
100
  this.port = Number(resource.port || 0);
99
101
  this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
102
+ this.trustForwardedHeaders = resource.trustForwardedHeaders === true;
100
103
  this.resolvedNotFoundHandler = resolvedNotFoundHandler;
101
104
 
102
105
  if (!this.port) {
@@ -104,6 +107,9 @@ class HttpServer implements ResourceInstance {
104
107
  }
105
108
  this.app = Fastify({
106
109
  logger: resource.logger,
110
+ // Honour X-Forwarded-Proto / X-Forwarded-Host so request.protocol/host (and
111
+ // the OpenAPI servers derived from them) reflect a fronting proxy's URL.
112
+ trustProxy: this.trustForwardedHeaders,
107
113
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default as any] },
108
114
  });
109
115
  }
@@ -172,16 +178,15 @@ class HttpServer implements ResourceInstance {
172
178
  throw error;
173
179
  });
174
180
  if (this.resource.openapi) {
175
- const servers = [];
176
- // const routesByName = new Map<string, HttpRouteResource>();
177
181
  const mounts = this.resource.mounts || [];
178
- const prefixes = new Set();
179
- for (const mount of mounts) {
180
- prefixes.add(mount.path || "");
181
- }
182
- for (const prefix of prefixes) {
183
- servers.push({ url: this.baseUrl + prefix });
184
- }
182
+ const prefixes = [...new Set(mounts.map((mount) => mount.path || ""))];
183
+ // Server URL precedence: an explicit `baseUrl` is an absolute, fixed
184
+ // override; otherwise the URLs are relative (just the mount prefix) so the
185
+ // doc is correct behind any proxy/ingress/origin without configuration —
186
+ // the client resolves them against wherever the reference was loaded.
187
+ const servers = prefixes.map((prefix) => ({
188
+ url: this.resource.baseUrl ? this.resource.baseUrl + prefix : prefix || "/",
189
+ }));
185
190
  await this.app.register(swagger, {
186
191
  openapi: {
187
192
  openapi: "3.0.0",
@@ -189,8 +194,43 @@ class HttpServer implements ResourceInstance {
189
194
  servers,
190
195
  },
191
196
  });
197
+ const referencePrefix = "/reference";
198
+ // `trustForwardedHeaders` (and no fixed baseUrl) upgrades the relative
199
+ // default to absolute URLs built per-request from the now-trusted
200
+ // X-Forwarded-* headers, so the served spec advertises the real proxy URL.
201
+ if (this.trustForwardedHeaders && !this.resource.baseUrl) {
202
+ // Couples to the Scalar plugin's default spec endpoint
203
+ // (`<routePrefix>/openapi.json`); if it ever served the doc elsewhere the
204
+ // rewrite would no-op and the relative default would still apply. The
205
+ // `openapi-server-url` integration test guards this path.
206
+ const specPath = `${referencePrefix}/openapi.json`;
207
+ this.app.addHook("onSend", async (request, reply, payload) => {
208
+ if (request.url.split("?")[0] !== specPath) return payload;
209
+ const text =
210
+ typeof payload === "string"
211
+ ? payload
212
+ : Buffer.isBuffer(payload)
213
+ ? payload.toString("utf8")
214
+ : null;
215
+ if (text === null) return payload;
216
+ try {
217
+ const doc = JSON.parse(text);
218
+ if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
219
+ doc.servers = prefixes.map((prefix) => ({
220
+ url: `${request.protocol}://${request.host}${prefix}`,
221
+ }));
222
+ const out = JSON.stringify(doc);
223
+ reply.header("content-length", Buffer.byteLength(out));
224
+ return out;
225
+ }
226
+ } catch {
227
+ // Not a JSON document we can rewrite — leave the response untouched.
228
+ }
229
+ return payload;
230
+ });
231
+ }
192
232
  await this.app.register(apiReference, {
193
- routePrefix: "/reference",
233
+ routePrefix: referencePrefix,
194
234
  });
195
235
  }
196
236
  }