@teamscala/service-boot 0.23.299 → 0.23.300
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/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.300",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"files": [
|
|
12
12
|
"src"
|
|
@@ -970,6 +970,14 @@
|
|
|
970
970
|
"types": "./src/serve/serve.d.ts",
|
|
971
971
|
"default": "./src/serve/serve.ts"
|
|
972
972
|
},
|
|
973
|
+
"./serve/stamp-transport-identity": {
|
|
974
|
+
"types": "./src/serve/stamp-transport-identity.d.ts",
|
|
975
|
+
"default": "./src/serve/stamp-transport-identity.ts"
|
|
976
|
+
},
|
|
977
|
+
"./serve/stamp-transport-identity.ts": {
|
|
978
|
+
"types": "./src/serve/stamp-transport-identity.d.ts",
|
|
979
|
+
"default": "./src/serve/stamp-transport-identity.ts"
|
|
980
|
+
},
|
|
973
981
|
"./server-error-page": {
|
|
974
982
|
"types": "./src/server-error-page.d.ts",
|
|
975
983
|
"default": "./src/server-error-page.ts"
|
package/src/mutable-app-ref.d.ts
CHANGED
|
@@ -34,5 +34,11 @@ export declare function swapApp(newApp: Hono, newLabel: string): string;
|
|
|
34
34
|
/**
|
|
35
35
|
* Fetch handler that routes through the mutable app reference.
|
|
36
36
|
* This replaces the direct app.fetch call in the serve layer.
|
|
37
|
+
*
|
|
38
|
+
* Stamps transport-derived identity (x-caller-remote-address/-port + the
|
|
39
|
+
* loopback local-caller comm) via the SAME stampTransportIdentity helper as
|
|
40
|
+
* direct mode, so two-phase-boot (mutable-ref) UI services carry identical
|
|
41
|
+
* identity on every request — closing the gap where the mutable-ref path
|
|
42
|
+
* previously served requests with no transport headers stamped at all.
|
|
37
43
|
*/
|
|
38
44
|
export declare function fetchWithMutableApp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response>;
|
package/src/mutable-app-ref.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { Hono } from "hono";
|
|
17
|
+
import { stampTransportIdentity } from "./serve/stamp-transport-identity.ts";
|
|
17
18
|
|
|
18
19
|
let currentApp: Hono | null = null;
|
|
19
20
|
let appLabel = "initial";
|
|
@@ -58,11 +59,17 @@ export function swapApp(newApp: Hono, newLabel: string): string {
|
|
|
58
59
|
/**
|
|
59
60
|
* Fetch handler that routes through the mutable app reference.
|
|
60
61
|
* This replaces the direct app.fetch call in the serve layer.
|
|
62
|
+
*
|
|
63
|
+
* Stamps transport-derived identity (x-caller-remote-address/-port + the
|
|
64
|
+
* loopback local-caller comm) via the SAME stampTransportIdentity helper as
|
|
65
|
+
* direct mode, so two-phase-boot (mutable-ref) UI services carry identical
|
|
66
|
+
* identity on every request — closing the gap where the mutable-ref path
|
|
67
|
+
* previously served requests with no transport headers stamped at all.
|
|
61
68
|
*/
|
|
62
69
|
export function fetchWithMutableApp(
|
|
63
70
|
req: Request,
|
|
64
71
|
server: ReturnType<typeof Bun.serve>,
|
|
65
72
|
): Promise<Response> {
|
|
66
73
|
const app = getApp();
|
|
67
|
-
return Promise.resolve(app.fetch(req, { server }));
|
|
74
|
+
return Promise.resolve(app.fetch(stampTransportIdentity(req, server), { server }));
|
|
68
75
|
}
|
package/src/serve/serve.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { Hono } from "hono";
|
|
12
12
|
import { createBunWebSocket } from "hono/bun";
|
|
13
|
-
import {
|
|
13
|
+
import { stampTransportIdentity } from "./stamp-transport-identity.ts";
|
|
14
14
|
|
|
15
15
|
const _bunWs = createBunWebSocket();
|
|
16
16
|
|
|
@@ -38,105 +38,20 @@ export interface ServeHandle {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Create the fetch handler with
|
|
42
|
-
*
|
|
41
|
+
* Create the fetch handler with transport-identity stamping. Used in direct
|
|
42
|
+
* mode (mutable-ref mode uses fetchWithMutableApp, which also stamps via the
|
|
43
|
+
* same stampTransportIdentity helper — one stamp point for every request).
|
|
43
44
|
*/
|
|
44
|
-
function createFetchHandler(
|
|
45
|
-
app: Hono,
|
|
46
|
-
serviceSlug: string,
|
|
47
|
-
logger: { info(msg: string): void; warn(msg: string): void },
|
|
48
|
-
) {
|
|
49
|
-
let peerPortFailures = 0;
|
|
45
|
+
function createFetchHandler(app: Hono) {
|
|
50
46
|
return async (
|
|
51
47
|
req: Request,
|
|
52
48
|
server: ReturnType<typeof Bun.serve>,
|
|
53
49
|
) => {
|
|
54
50
|
// app.fetch's second arg becomes c.env. Hono's bun WebSocket upgrade
|
|
55
|
-
// adapter checks `"server" in c.env`
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
// WebSocket upgrade requests MUST NOT be cloned — Bun's server.upgrade()
|
|
60
|
-
// tracks pending upgrades by Request identity, so a cloned Request can
|
|
61
|
-
// never upgrade. For WS requests we skip the peer-port clone and pass
|
|
62
|
-
// the original Request through.
|
|
63
|
-
const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
64
|
-
if (isUpgrade) {
|
|
65
|
-
return app.fetch(req, { server });
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
const peer = (
|
|
69
|
-
server as unknown as {
|
|
70
|
-
requestIP(r: Request): { port?: number; address?: string } | null;
|
|
71
|
-
}
|
|
72
|
-
).requestIP?.(req);
|
|
73
|
-
const port = peer?.port;
|
|
74
|
-
// x-caller-remote-address: the unforgeable transport-origin trust
|
|
75
|
-
// signal (no-client-controlled-trust-signals). server.requestIP is the
|
|
76
|
-
// ONE reliable address source (Hono's getConnInfo does not expose
|
|
77
|
-
// address on Bun). ALWAYS sanitize (delete any client-supplied spoof)
|
|
78
|
-
// then set the verified socket peer — the @teamscala/mcp-tool-runtime
|
|
79
|
-
// /mcp discriminator reads this to gate the bearer on loopback-only.
|
|
80
|
-
const address = peer?.address ?? null;
|
|
81
|
-
const needsPort =
|
|
82
|
-
typeof port === "number" &&
|
|
83
|
-
port > 0 &&
|
|
84
|
-
!req.headers.has("x-caller-remote-port");
|
|
85
|
-
if (needsPort || address !== null) {
|
|
86
|
-
const headers = new Headers(req.headers);
|
|
87
|
-
if (needsPort) headers.set("x-caller-remote-port", String(port));
|
|
88
|
-
if (address) {
|
|
89
|
-
headers.delete("x-caller-remote-address");
|
|
90
|
-
headers.set("x-caller-remote-address", String(address));
|
|
91
|
-
}
|
|
92
|
-
// Local-caller attribution: a local `fetch` carries no forwarding
|
|
93
|
-
// header, so for a loopback peer (no CF-Connecting-IP / X-Forwarded-
|
|
94
|
-
// For) resolve the owning local PROCESS from the socket + stamp it for
|
|
95
|
-
// the access log. Names the daemon behind every local request —
|
|
96
|
-
// including an unidentified sweeper keeping scale-to-zero apps awake.
|
|
97
|
-
// Trust stays transport-derived (no-client-controlled-trust-signals);
|
|
98
|
-
// the comm is attribution only. resolveSocketPeer is TTL-cached + never
|
|
99
|
-
// throws (returns null on any miss); skipped for external callers.
|
|
100
|
-
if (
|
|
101
|
-
address &&
|
|
102
|
-
(address === "127.0.0.1" ||
|
|
103
|
-
address === "::1" ||
|
|
104
|
-
address === "::ffff:127.0.0.1") &&
|
|
105
|
-
!headers.has("cf-connecting-ip") &&
|
|
106
|
-
!headers.get("x-forwarded-for") &&
|
|
107
|
-
typeof port === "number" &&
|
|
108
|
-
port > 0
|
|
109
|
-
) {
|
|
110
|
-
const owner = resolveSocketPeer(port);
|
|
111
|
-
if (owner) {
|
|
112
|
-
headers.set("x-caller-local-comm", owner.comm);
|
|
113
|
-
const cmd = owner.cmdline.join(" ").trim();
|
|
114
|
-
if (cmd) headers.set("x-caller-local-cmd", cmd.slice(0, 160));
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
const cloned = new Request(req.url, {
|
|
118
|
-
method: req.method,
|
|
119
|
-
headers,
|
|
120
|
-
body: req.body,
|
|
121
|
-
signal: req.signal,
|
|
122
|
-
// @ts-expect-error duplex required by spec when body is a stream
|
|
123
|
-
duplex: "half",
|
|
124
|
-
});
|
|
125
|
-
return app.fetch(cloned, { server });
|
|
126
|
-
}
|
|
127
|
-
} catch (err) {
|
|
128
|
-
// Fail-open NOISY (protective-layers-fail-explicitly): the request
|
|
129
|
-
// must always be served, but a broken peer-port resolution degrades
|
|
130
|
-
// caller-identity to baked-stale headers. Aggregated: first +
|
|
131
|
-
// every 1000th, never per-request spam.
|
|
132
|
-
peerPortFailures++;
|
|
133
|
-
if (peerPortFailures === 1 || peerPortFailures % 1000 === 0) {
|
|
134
|
-
logger.warn(
|
|
135
|
-
`[boot-serve:${serviceSlug}] peer-port resolution failed ${peerPortFailures}x — caller-identity degrades to header fallback: ${err instanceof Error ? err.message : String(err)}`,
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return app.fetch(req, { server });
|
|
51
|
+
// adapter checks `"server" in c.env` for the Bun server reference (WS
|
|
52
|
+
// upgrade throws TypeError without it). stampTransportIdentity handles
|
|
53
|
+
// the WS-upgrade no-clone rule + best-effort identity stamping internally.
|
|
54
|
+
return app.fetch(stampTransportIdentity(req, server), { server });
|
|
140
55
|
};
|
|
141
56
|
}
|
|
142
57
|
|
|
@@ -211,6 +126,6 @@ export function serveStart(
|
|
|
211
126
|
}
|
|
212
127
|
|
|
213
128
|
// Direct mode: use the app directly
|
|
214
|
-
const fetchHandler = createFetchHandler(app
|
|
129
|
+
const fetchHandler = createFetchHandler(app);
|
|
215
130
|
return serveStartInternal(fetchHandler, options, log, "direct");
|
|
216
131
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Stamp transport-derived identity onto a request — the single shared stamp
|
|
7
|
+
* point used by BOTH serve paths (direct `createFetchHandler` + mutable-ref
|
|
8
|
+
* `fetchWithMutableApp`), so every request on every UI service carries the
|
|
9
|
+
* same identity regardless of boot mode.
|
|
10
|
+
*
|
|
11
|
+
* Two identity layers, both transport-derived (no-client-controlled-trust-
|
|
12
|
+
* signals — never from a client-controllable header):
|
|
13
|
+
* 1. `x-caller-remote-address` / `-port` — the unforgeable socket peer, from
|
|
14
|
+
* `Bun.serve.requestIP`. ALWAYS sanitised (any client-supplied spoof
|
|
15
|
+
* deleted) then set to the verified peer. The /mcp + fleet loopback gates
|
|
16
|
+
* read this.
|
|
17
|
+
* 2. `x-caller-local-comm` / `-cmd` — for a LOOPBACK peer with no forwarding
|
|
18
|
+
* header (a local `fetch` carries no CF-Connecting-IP), the owning local
|
|
19
|
+
* PROCESS resolved via /proc (@teamscala/proc-walker). Names the daemon
|
|
20
|
+
* behind every local request on the access log — including an unidentified
|
|
21
|
+
* sweeper. Attribution only (trust stays transport-derived).
|
|
22
|
+
*
|
|
23
|
+
* Best-effort + never throws: a resolution miss returns the original request
|
|
24
|
+
* unchanged (the request is always served; identity degrades to address-only).
|
|
25
|
+
* WebSocket upgrades are returned untouched (Bun tracks upgrades by Request
|
|
26
|
+
* identity — a clone can never upgrade).
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Return a Request with transport-identity headers stamped, or the original
|
|
30
|
+
* `req` when no stamping is needed/possible. Never throws.
|
|
31
|
+
*/
|
|
32
|
+
export declare function stampTransportIdentity(req: Request, server: ReturnType<typeof Bun.serve>): Request;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Stamp transport-derived identity onto a request — the single shared stamp
|
|
7
|
+
* point used by BOTH serve paths (direct `createFetchHandler` + mutable-ref
|
|
8
|
+
* `fetchWithMutableApp`), so every request on every UI service carries the
|
|
9
|
+
* same identity regardless of boot mode.
|
|
10
|
+
*
|
|
11
|
+
* Two identity layers, both transport-derived (no-client-controlled-trust-
|
|
12
|
+
* signals — never from a client-controllable header):
|
|
13
|
+
* 1. `x-caller-remote-address` / `-port` — the unforgeable socket peer, from
|
|
14
|
+
* `Bun.serve.requestIP`. ALWAYS sanitised (any client-supplied spoof
|
|
15
|
+
* deleted) then set to the verified peer. The /mcp + fleet loopback gates
|
|
16
|
+
* read this.
|
|
17
|
+
* 2. `x-caller-local-comm` / `-cmd` — for a LOOPBACK peer with no forwarding
|
|
18
|
+
* header (a local `fetch` carries no CF-Connecting-IP), the owning local
|
|
19
|
+
* PROCESS resolved via /proc (@teamscala/proc-walker). Names the daemon
|
|
20
|
+
* behind every local request on the access log — including an unidentified
|
|
21
|
+
* sweeper. Attribution only (trust stays transport-derived).
|
|
22
|
+
*
|
|
23
|
+
* Best-effort + never throws: a resolution miss returns the original request
|
|
24
|
+
* unchanged (the request is always served; identity degrades to address-only).
|
|
25
|
+
* WebSocket upgrades are returned untouched (Bun tracks upgrades by Request
|
|
26
|
+
* identity — a clone can never upgrade).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { resolveSocketPeer } from "@teamscala/proc-walker/socket-peer";
|
|
30
|
+
|
|
31
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Return a Request with transport-identity headers stamped, or the original
|
|
35
|
+
* `req` when no stamping is needed/possible. Never throws.
|
|
36
|
+
*/
|
|
37
|
+
export function stampTransportIdentity(
|
|
38
|
+
req: Request,
|
|
39
|
+
server: ReturnType<typeof Bun.serve>,
|
|
40
|
+
): Request {
|
|
41
|
+
// WebSocket upgrade requests MUST NOT be cloned — Bun's server.upgrade()
|
|
42
|
+
// tracks pending upgrades by Request identity, so a cloned Request can
|
|
43
|
+
// never upgrade.
|
|
44
|
+
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
45
|
+
return req;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const peer = (
|
|
49
|
+
server as unknown as {
|
|
50
|
+
requestIP(r: Request): { port?: number; address?: string } | null;
|
|
51
|
+
}
|
|
52
|
+
).requestIP?.(req);
|
|
53
|
+
const port = peer?.port;
|
|
54
|
+
const address = peer?.address ?? null;
|
|
55
|
+
const needsPort =
|
|
56
|
+
typeof port === "number" && port > 0 && !req.headers.has("x-caller-remote-port");
|
|
57
|
+
// Nothing to stamp when there is no peer address and no port to add.
|
|
58
|
+
if (!needsPort && address === null) return req;
|
|
59
|
+
|
|
60
|
+
const headers = new Headers(req.headers);
|
|
61
|
+
if (needsPort) headers.set("x-caller-remote-port", String(port));
|
|
62
|
+
if (address) {
|
|
63
|
+
headers.delete("x-caller-remote-address");
|
|
64
|
+
headers.set("x-caller-remote-address", String(address));
|
|
65
|
+
}
|
|
66
|
+
// Local-caller attribution: loopback peer + no forwarding header => a
|
|
67
|
+
// direct local fetch whose owning process we can name via /proc.
|
|
68
|
+
if (
|
|
69
|
+
address &&
|
|
70
|
+
LOOPBACK.has(address) &&
|
|
71
|
+
!headers.has("cf-connecting-ip") &&
|
|
72
|
+
!headers.get("x-forwarded-for") &&
|
|
73
|
+
typeof port === "number" &&
|
|
74
|
+
port > 0
|
|
75
|
+
) {
|
|
76
|
+
const owner = resolveSocketPeer(port);
|
|
77
|
+
if (owner) {
|
|
78
|
+
headers.set("x-caller-local-comm", owner.comm);
|
|
79
|
+
const cmd = owner.cmdline.join(" ").trim();
|
|
80
|
+
if (cmd) headers.set("x-caller-local-cmd", cmd.slice(0, 160));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return new Request(req.url, {
|
|
84
|
+
method: req.method,
|
|
85
|
+
headers,
|
|
86
|
+
body: req.body,
|
|
87
|
+
signal: req.signal,
|
|
88
|
+
// @ts-expect-error duplex is required by spec when body is a stream
|
|
89
|
+
duplex: "half",
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// A broken peer resolution must never block serving — degrade to the
|
|
93
|
+
// original request (identity is best-effort, the request always served).
|
|
94
|
+
return req;
|
|
95
|
+
}
|
|
96
|
+
}
|