@telorun/http-server 0.9.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,40 @@
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
+
9
+ ## 0.10.0
10
+
11
+ ### Minor Changes
12
+
13
+ - ee8926f: Unify resource references on the `!ref` YAML tag. The object form `{ kind, name }`
14
+ and bare-string references are removed: the analyzer rejects them up front
15
+ (`INVALID_REFERENCE_FORM`) and `!ref <name>` / `!ref <Alias>.<name>` is the only
16
+ authored shape. `resolveRefSentinels` now resolves `!ref` sentinels across the
17
+ whole manifest tree (including step `invoke`s and refs nested in inline
18
+ definitions), so every consumer sees the uniform resolved shape. The
19
+ http-server mount slot is renamed `mounts[].type` → `mounts[].mount`, and the
20
+ mcp transports / clients read their Phase-5-injected ref instances directly.
21
+
22
+ Schema validation (analyzer and kernel) now drops the stale scalar `type` a ref
23
+ slot may still pin (older published modules encode references as `type: string`)
24
+ before running AJV, so a resolved reference object validates against a legacy
25
+ `x-telo-ref` slot. This keeps an app that consumes a not-yet-republished
26
+ dependency analyzable and bootable during the migration. Object-typed ref slots
27
+ that also accept an inline value (e.g. `inputType` / `outputType`) are left
28
+ untouched.
29
+
30
+ `Run.Sequence` reference slots are brought onto the same enforcement path: a
31
+ step `invoke` and a scope `targets` entry now require a `!ref` (the `targets`
32
+ slot gains an `x-telo-ref` constraint and the `with` scope's visibility extends
33
+ to `/targets`), so a bare-string ref at either is rejected with
34
+ `INVALID_REFERENCE_FORM` at `telo check` — uniform with `Telo.Application`
35
+ targets — instead of failing as an obscure runtime error. The controller reads
36
+ the resolved reference rather than a bare name.
37
+
3
38
  ## 0.9.0
4
39
 
5
40
  ### Minor Changes
package/README.md CHANGED
@@ -24,16 +24,16 @@ 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: pkg:npm/@telorun/http-server@^1.0.0
28
- JS: pkg:npm/@telorun/javascript@^1.0.0
29
- targets: [Server]
27
+ Http: std/http-server@0.11.0
28
+ JS: std/javascript@0.5.0
29
+ targets: [ !ref Server ]
30
30
  ---
31
31
  kind: Http.Server
32
32
  metadata: { name: Server }
33
33
  port: 8080
34
34
  mounts:
35
35
  - path: /api
36
- type: Api
36
+ mount: !ref Api
37
37
  ---
38
38
  kind: Http.Api
39
39
  metadata: { name: Api }
@@ -54,7 +54,7 @@ routes:
54
54
  examples: [ "Ada" ]
55
55
  inputs:
56
56
  name: "${{ request.params.name }}"
57
- handler: { kind: JS.Script, name: Greet }
57
+ handler: !ref Greet
58
58
  returns:
59
59
  - status: 200
60
60
  content:
@@ -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.
@@ -2,7 +2,7 @@ import { CatchEntry, ReturnEntry } from "@telorun/http-dispatch";
2
2
  import { type Invocable, type KindRef, type ResourceContext, type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
3
3
  import { FastifyInstance } from "fastify";
4
4
  /** A mounted Telo.Mount instance (Http.Api, Mcp.HttpEndpoint, …). The kernel injects the
5
- * live instance into a mount's `type` slot (x-telo-ref "telo#Mount") — cross-module refs
5
+ * live instance into a mount's `mount` slot (x-telo-ref "telo#Mount") — cross-module refs
6
6
  * resolve to an imported library's exported mount — and every mountable exposes register(). */
7
7
  interface Mountable {
8
8
  register(app: FastifyInstance, prefix: string): void | Promise<void>;
@@ -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<{
@@ -40,7 +41,7 @@ type HttpServerResource = RuntimeResource & {
40
41
  };
41
42
  mounts?: Array<{
42
43
  path?: string;
43
- type?: Mountable;
44
+ mount?: Mountable;
44
45
  }>;
45
46
  notFoundHandler?: {
46
47
  invoke: KindRef<Invocable>;
@@ -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
  }
@@ -119,9 +160,9 @@ class HttpServer {
119
160
  // const resolveSchema = createSchemaResolver(this.ctx);
120
161
  for (const mount of mounts) {
121
162
  const prefix = mount.path || "";
122
- // `mount.type` is the live Telo.Mount instance injected by the kernel at Phase 5
163
+ // `mount.mount` is the live Telo.Mount instance injected by the kernel at Phase 5
123
164
  // (x-telo-ref "telo#Mount") — a same-module or imported-library mount, uniformly.
124
- const api = mount.type;
165
+ const api = mount.mount;
125
166
  if (!api || typeof api.register !== "function") {
126
167
  throw new Error(`Failed to mount at "${prefix}": mount target did not resolve to a Telo.Mount instance`);
127
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.9.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": "*"
@@ -20,7 +20,7 @@ import Fastify, { FastifyInstance } from "fastify";
20
20
  import { fastifyReplySink } from "./fastify-reply-sink.js";
21
21
 
22
22
  /** A mounted Telo.Mount instance (Http.Api, Mcp.HttpEndpoint, …). The kernel injects the
23
- * live instance into a mount's `type` slot (x-telo-ref "telo#Mount") — cross-module refs
23
+ * live instance into a mount's `mount` slot (x-telo-ref "telo#Mount") — cross-module refs
24
24
  * resolve to an imported library's exported mount — and every mountable exposes register(). */
25
25
  interface Mountable {
26
26
  register(app: FastifyInstance, prefix: string): void | Promise<void>;
@@ -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 }>;
@@ -58,7 +59,7 @@ type HttpServerResource = RuntimeResource & {
58
59
  path?: string;
59
60
  // x-telo-ref "telo#Mount": Phase 5 replaces this slot with the live mounted
60
61
  // instance (Http.Api, Mcp.HttpEndpoint, …), local or imported.
61
- type?: Mountable;
62
+ mount?: Mountable;
62
63
  }>;
63
64
  notFoundHandler?: {
64
65
  invoke: KindRef<Invocable>;
@@ -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
  }
@@ -201,9 +241,9 @@ class HttpServer implements ResourceInstance {
201
241
  // const resolveSchema = createSchemaResolver(this.ctx);
202
242
  for (const mount of mounts) {
203
243
  const prefix = mount.path || "";
204
- // `mount.type` is the live Telo.Mount instance injected by the kernel at Phase 5
244
+ // `mount.mount` is the live Telo.Mount instance injected by the kernel at Phase 5
205
245
  // (x-telo-ref "telo#Mount") — a same-module or imported-library mount, uniformly.
206
- const api = mount.type;
246
+ const api = mount.mount;
207
247
  if (!api || typeof api.register !== "function") {
208
248
  throw new Error(
209
249
  `Failed to mount at "${prefix}": mount target did not resolve to a Telo.Mount instance`,