@teamscala/service-boot 0.23.546 → 0.23.547

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
@@ -27,7 +27,7 @@
27
27
  "repository": "https://github.com/teamgbg/service-boot",
28
28
  "sideEffects": false,
29
29
  "type": "module",
30
- "version": "0.23.546",
30
+ "version": "0.23.547",
31
31
  "dependencies": {
32
32
  "@orpc/openapi": "*",
33
33
  "@orpc/server": "*",
@@ -18,7 +18,6 @@ import * as v from "valibot";
18
18
 
19
19
  const BOOT_ROW_KEYS = [
20
20
  "access",
21
- "api_routes",
22
21
  "app_port",
23
22
  "auth",
24
23
  "block_packages",
@@ -90,7 +89,7 @@ export async function loadServiceInstallRow(
90
89
  };
91
90
  }
92
91
  if (err instanceof RegistryMissingError) {
93
- const sql = `INSERT INTO registry_entries (type, slug, config, is_active) VALUES ('service_install', '${serviceSlug}', '{"kind":"ui","render_mode":"public","port":null,"page_routes":[],"providers":[],"api_routes":[],"block_packages":[],"teamgbg_packages":[],"env_vars":[]}'::jsonb, true);`;
92
+ const sql = `INSERT INTO registry_entries (type, slug, config, is_active) VALUES ('service_install', '${serviceSlug}', '{"kind":"ui","render_mode":"public","port":null,"page_routes":[],"providers":[],"block_packages":[],"teamgbg_packages":[],"env_vars":[]}'::jsonb, true);`;
94
93
  return {
95
94
  success: false,
96
95
  error: "row_not_found",
@@ -314,6 +314,213 @@
314
314
  "suite_type": "function_call",
315
315
  "slug": "service-boot:absent-manifest-verdict-reserves-never-ran"
316
316
  },
317
+ {
318
+ "args": [
319
+ [
320
+ {
321
+ "slug": "npm-registry-hook",
322
+ "config": {
323
+ "path": "/webhooks/npm/registry",
324
+ "method": "POST",
325
+ "handler": "npm-registry-hook",
326
+ "service": "scala-sync-worker",
327
+ "auth_required": false
328
+ }
329
+ },
330
+ {
331
+ "slug": "email.send",
332
+ "config": {
333
+ "path": "/api/rpc/email/send",
334
+ "method": "POST",
335
+ "service": "scala-email-sender",
336
+ "handler_slug": "email.send",
337
+ "auth_required": true
338
+ }
339
+ }
340
+ ],
341
+ "scala-sync-worker",
342
+ [
343
+ "npm-registry-hook"
344
+ ]
345
+ ],
346
+ "note": "Bug 01a15334 (Joe's Developer, delivery 01a15336): the shape of the row that was declared and never served - config.handler (not handler_slug) on POST /webhooks/npm/registry for scala-sync-worker.",
347
+ "label": "a row naming its mount handler in config.handler is selected for its own service and maps onto the handler registry",
348
+ "expect": [
349
+ {
350
+ "slug": "npm-registry-hook",
351
+ "mount": {
352
+ "path": "/webhooks/npm/registry",
353
+ "method": "post",
354
+ "handlerSlug": "npm-registry-hook"
355
+ },
356
+ "service": "scala-sync-worker",
357
+ "notMounted": null
358
+ }
359
+ ],
360
+ "export": "selectServiceApiRoutes",
361
+ "module": "src/select-service-api-routes.ts",
362
+ "package": "@teamscala/service-boot",
363
+ "suite_type": "function_call",
364
+ "slug": "service-boot:api-route-config-handler-maps-onto-the-mount-registry"
365
+ },
366
+ {
367
+ "args": [
368
+ [
369
+ {
370
+ "slug": "email.send",
371
+ "config": {
372
+ "path": "/api/rpc/email/send",
373
+ "method": "POST",
374
+ "service": "scala-email-sender",
375
+ "handler_slug": "email.send",
376
+ "auth_required": true
377
+ }
378
+ }
379
+ ],
380
+ "scala-email-sender",
381
+ [
382
+ "npm-registry-hook"
383
+ ]
384
+ ],
385
+ "note": "api_route/email.send names handler_slug email.send, but no server_package in the workspace registers it and service_install/scala-email-sender declares no server_packages. Boot reports it by name on every start instead of claiming the route is served.",
386
+ "label": "a row naming a handler no mounted package registered is named as unserved instead of silently 404ing or bricking the service",
387
+ "expect": [
388
+ {
389
+ "slug": "email.send",
390
+ "mount": null,
391
+ "service": "scala-email-sender",
392
+ "notMounted": "handler-not-mounted"
393
+ }
394
+ ],
395
+ "export": "selectServiceApiRoutes",
396
+ "module": "src/select-service-api-routes.ts",
397
+ "package": "@teamscala/service-boot",
398
+ "suite_type": "function_call",
399
+ "slug": "service-boot:api-route-row-naming-an-unmounted-handler-is-reported-unserved"
400
+ },
401
+ {
402
+ "args": [
403
+ [
404
+ {
405
+ "slug": "npm-registry-hook",
406
+ "config": {
407
+ "path": "/webhooks/npm/registry",
408
+ "method": "POST",
409
+ "handler": "npm-registry-hook",
410
+ "service": "scala-sync-worker",
411
+ "auth_required": false
412
+ }
413
+ }
414
+ ],
415
+ "scala-sync-worker-ts-dev",
416
+ [
417
+ "npm-registry-hook"
418
+ ]
419
+ ],
420
+ "note": "Raw string equality on the deployable name matched at most one lane and failed silently (capability-slug.ts incident): the row names the capability, the lane arrives as <capability>-<lang>-<tier>.",
421
+ "label": "a dev-lane service is served the rows its capability declares, the join select-service-routes.ts already makes for page_route",
422
+ "expect": [
423
+ {
424
+ "slug": "npm-registry-hook",
425
+ "mount": {
426
+ "path": "/webhooks/npm/registry",
427
+ "method": "post",
428
+ "handlerSlug": "npm-registry-hook"
429
+ },
430
+ "service": "scala-sync-worker",
431
+ "notMounted": null
432
+ }
433
+ ],
434
+ "export": "selectServiceApiRoutes",
435
+ "module": "src/select-service-api-routes.ts",
436
+ "package": "@teamscala/service-boot",
437
+ "suite_type": "function_call",
438
+ "slug": "service-boot:api-route-row-reaches-the-dev-lane-through-the-capability-slug"
439
+ },
440
+ {
441
+ "args": [
442
+ [
443
+ {
444
+ "slug": "fn-chat-complete",
445
+ "config": {
446
+ "path": "/api/rpc/fn/chat/complete",
447
+ "method": "POST",
448
+ "service": "scala-ai",
449
+ "handler_type": "orpc_fn",
450
+ "auth_required": true
451
+ }
452
+ }
453
+ ],
454
+ "scala-ai",
455
+ [
456
+ "chat-stream"
457
+ ]
458
+ ],
459
+ "note": "handler_type orpc_fn rows are served by the generated ORPC router (orpc-bundle.pkl), not by a mount handler; boot must not claim them or fail on them.",
460
+ "label": "a row that names no mount handler is reported as declared, never mounted onto the mount registry",
461
+ "expect": [
462
+ {
463
+ "slug": "fn-chat-complete",
464
+ "mount": null,
465
+ "service": "scala-ai",
466
+ "notMounted": "declares-no-mount-handler"
467
+ }
468
+ ],
469
+ "export": "selectServiceApiRoutes",
470
+ "module": "src/select-service-api-routes.ts",
471
+ "package": "@teamscala/service-boot",
472
+ "suite_type": "function_call",
473
+ "slug": "service-boot:api-route-row-without-a-mount-handler-is-declared-not-mounted"
474
+ },
475
+ {
476
+ "args": [
477
+ [
478
+ {
479
+ "slug": "npm-registry-hook",
480
+ "config": {
481
+ "path": "/webhooks/npm/registry",
482
+ "method": "POST",
483
+ "handler": "npm-registry-hook",
484
+ "service": "scala-sync-worker",
485
+ "auth_required": false
486
+ }
487
+ },
488
+ {
489
+ "slug": "scala-forms-get--api-places-autocomplete",
490
+ "config": {
491
+ "path": "/api/places/autocomplete",
492
+ "method": "GET",
493
+ "service": "scala-forms",
494
+ "handler_slug": "geocode-autocomplete",
495
+ "auth_required": false
496
+ }
497
+ }
498
+ ],
499
+ "scala-forms",
500
+ [
501
+ "geocode-autocomplete"
502
+ ]
503
+ ],
504
+ "note": "A row belonging to a different service must not leak into this service's route table or its boot log.",
505
+ "label": "another service's row is neither mounted nor reported - selection is by this service's capability alone",
506
+ "expect": [
507
+ {
508
+ "slug": "scala-forms-get--api-places-autocomplete",
509
+ "mount": {
510
+ "path": "/api/places/autocomplete",
511
+ "method": "get",
512
+ "handlerSlug": "geocode-autocomplete"
513
+ },
514
+ "service": "scala-forms",
515
+ "notMounted": null
516
+ }
517
+ ],
518
+ "export": "selectServiceApiRoutes",
519
+ "module": "src/select-service-api-routes.ts",
520
+ "package": "@teamscala/service-boot",
521
+ "suite_type": "function_call",
522
+ "slug": "service-boot:api-route-rows-of-another-service-are-not-selected"
523
+ },
317
524
  {
318
525
  "args": [
319
526
  "@teamscala/cli-session"
@@ -3,112 +3,118 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- import type { ServiceInstallConfig } from "./load-service-install-row";
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 { BootFatalError } from "@teamscala/os/errors/boot-error";
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
- config: ServiceInstallConfig,
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 route of config.api_routes ?? []) {
36
- const handler = mountRegistry.handlers.get(route.handler_slug);
37
- if (!handler) {
38
- throw new BootFatalError(
39
- "register-api-routes",
40
- serviceSlug,
41
- `api_route "${route.method} ${route.path}" references handler_slug "${route.handler_slug}" but no server_package registered it`,
42
- );
43
- }
44
- const method = route.method.toLowerCase() as
45
- | "get"
46
- | "post"
47
- | "put"
48
- | "delete"
49
- | "patch";
50
- (app as never as Record<string, (path: string, h: never) => void>)[method](
51
- route.path,
52
- (async (c: Context) => {
53
- let req: Request = c.req.raw;
54
- try {
55
- if (typeof c.env !== "object" || c.env === null) {
56
- log.debug(
57
- `[register-api-routes] getConnInfo skipped: c.env is ${typeof c.env}`,
58
- );
59
- } else {
60
- const info = getConnInfo(c);
61
- const port = info?.remote?.port;
62
- const address = info?.remote?.address ?? null;
63
- // Port: set when not already present (caller-identity pane
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
- log.debug(
89
- `[register-api-routes] getConnInfo port=${port ?? "undefined"} address=${address ?? "undefined"}`,
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
- } catch (e) {
93
- getAppLogger().error(
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
- const params = c.req.param();
98
- return handler(req, params);
99
- }) as never,
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
- // NO hand-rolled OPTIONS/CORS here: two-phase-minimal-app registers the
105
- // official hono/cors middleware GLOBALLY before this runs, and its OPTIONS
106
- // branch answers preflights without calling next() — a hand preflight
107
- // handler registered here is shadowed dead code (measured: zero
108
- // invocations across trusted/no/foreign-origin preflights, bun probe
109
- // 2026-09-27). The middleware also decorates ACTUAL cross-origin
110
- // responses and reflects origins only inside the cookie domain — the
111
- // dead copy reflected ANY origin with credentials.
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
+ }