@teamscala/service-boot 0.23.545 → 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
|
@@ -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":[],"
|
|
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
|
|
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
|
+
}
|