@teamscala/service-boot 0.23.546 → 0.23.548
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 +312 -312
- package/src/inject-declared-env.ts +32 -0
- package/src/load-service-install-row.ts +5 -3
- package/src/mount-orpc-router.test.ts +2 -2
- package/src/mount-orpc-router.ts +1 -1
- package/src/orpc-contract-rows.json +369 -0
- package/src/register-api-routes.ts +89 -83
- package/src/select-service-api-routes.ts +82 -0
|
@@ -3,112 +3,118 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import { getEntriesByType } from "@teamscala/db/registry/accessors";
|
|
7
|
+
import { ApiRouteConfigSchema } from "@teamscala/registry-schemas/registry-schemas/api-route";
|
|
7
8
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
8
|
-
import {
|
|
9
|
+
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
9
10
|
import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
|
|
10
11
|
import type { Context, Hono } from "hono";
|
|
11
12
|
import { getConnInfo } from "hono/bun";
|
|
12
13
|
import { CALLER_HEADERS } from "@teamscala/os/contracts/mcp";
|
|
14
|
+
import {
|
|
15
|
+
selectServiceApiRoutes,
|
|
16
|
+
type ApiRouteRowShape,
|
|
17
|
+
} from "./select-service-api-routes.ts";
|
|
18
|
+
import * as v from "valibot";
|
|
13
19
|
|
|
14
|
-
// From CALLER_HEADERS, never a literal: this name is one end of a contract the
|
|
15
|
-
// gateway's caller-resolution reads, and a literal stores it twice. The
|
|
16
|
-
// 2026-08-02 outage was that exact drift on a sibling header (reader on
|
|
17
|
-
// `x-user-id`, writer on `x-caller-user-id`), and it took every tool on the
|
|
18
|
-
// cloud MCP down once the branch masking it was closed.
|
|
19
20
|
const REMOTE_PORT_HEADER = CALLER_HEADERS.REMOTE_PORT;
|
|
20
|
-
// Verified socket-peer address (unforgeable transport origin). Sanitized
|
|
21
|
-
// (client-supplied value deleted) then set from getConnInfo so handlers that
|
|
22
|
-
// receive only a Request (e.g. mcp-tool-runtime/mount.ts) can gate on the
|
|
23
|
-
// genuine transport origin per no-client-controlled-trust-signals — an external
|
|
24
|
-
// attacker cannot make their TCP connection appear on the server's loopback.
|
|
25
21
|
const REMOTE_ADDR_HEADER = CALLER_HEADERS.REMOTE_ADDRESS;
|
|
26
22
|
|
|
23
|
+
function readApiRouteRows(log: ReturnType<typeof getAppLogger>): ApiRouteRowShape[] {
|
|
24
|
+
const rows: ApiRouteRowShape[] = [];
|
|
25
|
+
for (const entry of getEntriesByType("api_route")) {
|
|
26
|
+
if (!entry.is_active) continue;
|
|
27
|
+
const parsed = v.safeParse(ApiRouteConfigSchema, entry.config);
|
|
28
|
+
if (!parsed.success) {
|
|
29
|
+
const issues = parsed.issues
|
|
30
|
+
.map((issue) => {
|
|
31
|
+
const path = v.getDotPath(issue);
|
|
32
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
33
|
+
})
|
|
34
|
+
.join("; ");
|
|
35
|
+
log.error(
|
|
36
|
+
`[register-api-routes] api_route "${entry.slug}" does not satisfy its own schema, so none of its routes are served: ${issues}`,
|
|
37
|
+
);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
rows.push({ slug: entry.slug, config: parsed.output });
|
|
41
|
+
}
|
|
42
|
+
return rows;
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
export function registerApiRoutes(
|
|
28
46
|
app: Hono,
|
|
29
|
-
|
|
47
|
+
_config: ServiceInstallConfig,
|
|
30
48
|
mountRegistry: MountRegistry,
|
|
31
49
|
serviceSlug: string,
|
|
32
50
|
): number {
|
|
33
51
|
const log = getAppLogger();
|
|
52
|
+
const decisions = selectServiceApiRoutes(
|
|
53
|
+
readApiRouteRows(log),
|
|
54
|
+
serviceSlug,
|
|
55
|
+
[...mountRegistry.handlers.keys()],
|
|
56
|
+
);
|
|
34
57
|
let count = 0;
|
|
35
|
-
for (const
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// resolution preserves a chained value). Address: sanitize +
|
|
65
|
-
// re-set ONLY when getConnInfo returned one — the canonical
|
|
66
|
-
// address injection lives in service-runtime's serve.ts
|
|
67
|
-
// (server.requestIP, which reliably returns address; Hono's
|
|
68
|
-
// getConnInfo does not on Bun). Touching the address here
|
|
69
|
-
// only when we can re-verify avoids clobbering serve.ts's
|
|
70
|
-
// injection.
|
|
71
|
-
const needsPort =
|
|
72
|
-
typeof port === "number" &&
|
|
73
|
-
port > 0 &&
|
|
74
|
-
!req.headers.has(REMOTE_PORT_HEADER);
|
|
75
|
-
if (address !== null || needsPort) {
|
|
76
|
-
const headers = new Headers(req.headers);
|
|
77
|
-
if (address) {
|
|
78
|
-
headers.delete(REMOTE_ADDR_HEADER);
|
|
79
|
-
headers.set(REMOTE_ADDR_HEADER, String(address));
|
|
80
|
-
}
|
|
81
|
-
if (needsPort) headers.set(REMOTE_PORT_HEADER, String(port));
|
|
82
|
-
req = new Request(req.url, {
|
|
83
|
-
method: req.method,
|
|
84
|
-
headers,
|
|
85
|
-
body: req.body,
|
|
86
|
-
});
|
|
58
|
+
for (const decision of decisions) {
|
|
59
|
+
const mount = decision.mount;
|
|
60
|
+
if (!mount) continue;
|
|
61
|
+
const handler = mountRegistry.handlers.get(mount.handlerSlug);
|
|
62
|
+
if (!handler) continue;
|
|
63
|
+
const register = app as unknown as Record<
|
|
64
|
+
string,
|
|
65
|
+
(path: string, handler: (c: Context) => Promise<Response>) => void
|
|
66
|
+
>;
|
|
67
|
+
register[mount.method](mount.path, async (c: Context) => {
|
|
68
|
+
let req: Request = c.req.raw;
|
|
69
|
+
try {
|
|
70
|
+
if (typeof c.env !== "object" || c.env === null) {
|
|
71
|
+
log.debug(
|
|
72
|
+
`[register-api-routes] getConnInfo skipped: c.env is ${typeof c.env}`,
|
|
73
|
+
);
|
|
74
|
+
} else {
|
|
75
|
+
const info = getConnInfo(c);
|
|
76
|
+
const port = info?.remote?.port;
|
|
77
|
+
const address = info?.remote?.address ?? null;
|
|
78
|
+
const needsPort =
|
|
79
|
+
typeof port === "number" &&
|
|
80
|
+
port > 0 &&
|
|
81
|
+
!req.headers.has(REMOTE_PORT_HEADER);
|
|
82
|
+
if (address !== null || needsPort) {
|
|
83
|
+
const headers = new Headers(req.headers);
|
|
84
|
+
if (address) {
|
|
85
|
+
headers.delete(REMOTE_ADDR_HEADER);
|
|
86
|
+
headers.set(REMOTE_ADDR_HEADER, String(address));
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
if (needsPort) headers.set(REMOTE_PORT_HEADER, String(port));
|
|
89
|
+
req = new Request(req.url, {
|
|
90
|
+
method: req.method,
|
|
91
|
+
headers,
|
|
92
|
+
body: req.body,
|
|
93
|
+
});
|
|
91
94
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
`[register-api-routes] getConnInfo failed: ${e}`,
|
|
95
|
+
log.debug(
|
|
96
|
+
`[register-api-routes] getConnInfo port=${port ?? "undefined"} address=${address ?? "undefined"}`,
|
|
95
97
|
);
|
|
96
98
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
99
|
+
} catch (e) {
|
|
100
|
+
log.error(`[register-api-routes] getConnInfo failed: ${e}`);
|
|
101
|
+
}
|
|
102
|
+
return handler(req, c.req.param());
|
|
103
|
+
});
|
|
101
104
|
count++;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
const unserved = decisions.filter((decision) => decision.mount === null);
|
|
108
|
+
if (unserved.length > 0) {
|
|
109
|
+
log.warn(
|
|
110
|
+
`[scala-install:${serviceSlug}] ${unserved.length} api_route row(s) naming this service are NOT served: ${unserved
|
|
111
|
+
.map((decision) => `"${decision.slug}" (${decision.notMounted})`)
|
|
112
|
+
.join(", ")}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
log.info(
|
|
116
|
+
`[scala-install:${serviceSlug}] registered ${count} api_route mount(s) from registry rows`,
|
|
117
|
+
);
|
|
112
118
|
|
|
113
119
|
return count;
|
|
114
120
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { toCapabilitySlug } from "./capability-slug.ts";
|
|
7
|
+
|
|
8
|
+
export interface ApiRouteRowShape {
|
|
9
|
+
slug: string;
|
|
10
|
+
config: {
|
|
11
|
+
service: string;
|
|
12
|
+
method: string;
|
|
13
|
+
path: string;
|
|
14
|
+
handler?: string;
|
|
15
|
+
handler_slug?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type MountMethod = "get" | "post" | "put" | "delete" | "patch";
|
|
20
|
+
|
|
21
|
+
export interface MountShape {
|
|
22
|
+
method: MountMethod;
|
|
23
|
+
path: string;
|
|
24
|
+
handlerSlug: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type NotMountedBecause = "declares-no-mount-handler" | "handler-not-mounted";
|
|
28
|
+
|
|
29
|
+
export interface ApiRouteDecision {
|
|
30
|
+
slug: string;
|
|
31
|
+
service: string;
|
|
32
|
+
mount: MountShape | null;
|
|
33
|
+
notMounted: NotMountedBecause | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MOUNT_METHODS: ReadonlySet<string> = new Set([
|
|
37
|
+
"get",
|
|
38
|
+
"post",
|
|
39
|
+
"put",
|
|
40
|
+
"delete",
|
|
41
|
+
"patch",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export function selectServiceApiRoutes<T extends ApiRouteRowShape>(
|
|
45
|
+
rows: readonly T[],
|
|
46
|
+
serviceSlug: string,
|
|
47
|
+
mountedHandlers: readonly string[],
|
|
48
|
+
): ApiRouteDecision[] {
|
|
49
|
+
const capability = toCapabilitySlug(serviceSlug);
|
|
50
|
+
const mounted = new Set(mountedHandlers);
|
|
51
|
+
const decisions: ApiRouteDecision[] = [];
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const service = row.config.service;
|
|
54
|
+
if (toCapabilitySlug(service) !== capability) continue;
|
|
55
|
+
const handlerSlug = row.config.handler ?? row.config.handler_slug;
|
|
56
|
+
if (handlerSlug === undefined) {
|
|
57
|
+
decisions.push({
|
|
58
|
+
slug: row.slug,
|
|
59
|
+
service,
|
|
60
|
+
mount: null,
|
|
61
|
+
notMounted: "declares-no-mount-handler",
|
|
62
|
+
});
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const method = row.config.method.toLowerCase();
|
|
66
|
+
if (!MOUNT_METHODS.has(method)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`api_route "${row.slug}" declares method "${row.config.method}", which no HTTP surface can serve`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const isMounted = mounted.has(handlerSlug);
|
|
72
|
+
decisions.push({
|
|
73
|
+
slug: row.slug,
|
|
74
|
+
service,
|
|
75
|
+
mount: isMounted
|
|
76
|
+
? { method: method as MountMethod, path: row.config.path, handlerSlug }
|
|
77
|
+
: null,
|
|
78
|
+
notMounted: isMounted ? null : "handler-not-mounted",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return decisions;
|
|
82
|
+
}
|