@teamscala/service-boot 0.23.447 → 0.23.449

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.447",
9
+ "version": "0.23.449",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -41,13 +41,11 @@ describe("mountOrpcRouter generated-artifact seam", () => {
41
41
  );
42
42
  });
43
43
 
44
- test("the configured getMcpGatewayUrl returns undefined without throwing (ctx regression)", async () => {
45
- // Regression: the previous form read an out-of-scope `ctx` (commit
46
- // c39d279) and threw ReferenceError on first invoke. A getter that
47
- // crashes when called can hide indefinitely — the regression is a test
48
- // that actually CALLS it. Returning undefined explicitly matches what
49
- // callers (sdk-mcp-forwarder) already handle: an absent gateway URL
50
- // means "SDK tools unusable in this service".
44
+ test("the generic mount no longer injects a gateway URL — the executor derives its host from the registry", async () => {
45
+ // The retired MCP_GATEWAY_URL env name and its injector are gone: the
46
+ // sdk-mcp-forwarder resolves the SDK execution host from the
47
+ // mcp_server/scala-mcp registry row via loadRegistryConfig. A missing
48
+ // row refuses at the boundary (assertSdkHostResolved), never via env.
51
49
  const service = await mkdtemp(join(tmpdir(), "mount-orpc-router-test-"));
52
50
  tempDirs.push(service);
53
51
  await mkdir(join(service, "prisma/generated/orpc/routers"), {
@@ -64,8 +62,9 @@ describe("mountOrpcRouter generated-artifact seam", () => {
64
62
  process.chdir(service);
65
63
 
66
64
  // Stub the collaborators the mount reaches BEFORE configureToolExecutor
67
- // — the point is to capture the opts object and call the getter.
68
- const capture: { getMcpGatewayUrl?: () => unknown } = {};
65
+ // — the point is to capture the opts object and assert the injector is
66
+ // absent.
67
+ let seenKeys: Array<string> = [];
69
68
  mock.module("@teamscala/orpc/app-router", () => ({
70
69
  getAppRouter: async () => ({
71
70
  healthz: { health: async () => ({ ok: true }) },
@@ -75,18 +74,14 @@ describe("mountOrpcRouter generated-artifact seam", () => {
75
74
  getPrisma: () => ({}),
76
75
  }));
77
76
  mock.module("@teamscala/tool-executor/configure", () => ({
78
- configure: (opts: { getMcpGatewayUrl?: () => unknown }) => {
79
- capture.getMcpGatewayUrl = opts.getMcpGatewayUrl;
77
+ configure: (opts: Record<string, unknown>) => {
78
+ seenKeys = Object.keys(opts);
80
79
  },
81
80
  }));
82
81
 
83
82
  const app = { post: () => {} };
84
83
  await mountOrpcRouter(app as never, "scala-mcp");
85
84
 
86
- expect(typeof capture.getMcpGatewayUrl).toBe("function");
87
- // THE regression: invoking the getter must not throw.
88
- expect(() => capture.getMcpGatewayUrl!()).not.toThrow();
89
- // And it returns undefined — explicit, never a ReferenceError.
90
- expect(capture.getMcpGatewayUrl!()).toBeUndefined();
85
+ expect(seenKeys).not.toContain("getMcpGatewayUrl");
91
86
  });
92
87
  });
@@ -12,7 +12,7 @@
12
12
  import { join } from "node:path";
13
13
  import { getAppLogger } from "@teamscala/logger/app-loggers";
14
14
  import { registerFnRouters } from "./fn-routers/index.ts";
15
- import type { InjectedDb8 } from "@teamscala/orpc/configure";
15
+ import type { InjectedDb8 } from "@teamscala/db/db8-registry";
16
16
  import type { Hono } from "hono";
17
17
  import { bodyLimit } from "hono/body-limit";
18
18
 
@@ -86,6 +86,11 @@ export async function mountOrpcRouter(
86
86
  connectionString: process.env.DATABASE_URL,
87
87
  serviceName: serviceSlug,
88
88
  })) as never;
89
+ // Register for db-core internals (registry loaders, raw fallbacks)
90
+ // routing v8-first via getDb8() — the same coexistence shape as
91
+ // the RPC context injection above.
92
+ const { registerDb8 } = await import("@teamscala/db/db8-registry");
93
+ registerDb8(db8 as never);
89
94
  }
90
95
  } catch (err) {
91
96
  log.info(
@@ -122,14 +127,6 @@ export async function mountOrpcRouter(
122
127
  configureToolExecutor({
123
128
  getAppRouter: () => getAppRouter(),
124
129
  getPrisma: () => getConfiguredPrisma(),
125
- // Central MCP gateway URL — sdk:* tool calls from any non-host service
126
- // forward here (scala-mcp owns every @teamscala/api-* adapter per
127
- // operator ruling 2026-08-26). Returns undefined explicitly: callers
128
- // (sdk-mcp-forwarder) treat absent as "SDK tool unusable in this
129
- // service" and refuse with that message. The previous form read an
130
- // out-of-scope `ctx` (c39d279) and would have thrown ReferenceError
131
- // on first invoke instead of returning undefined.
132
- getMcpGatewayUrl: () => undefined,
133
130
  createHostCommandInvoker: (config) => createHostCommandInvoker(config as never),
134
131
  getCallerContext: () => getCallerContext(),
135
132
  decryptSecretConfig: (raw) => decryptSecretConfig(raw as never),
@@ -53,6 +53,7 @@
53
53
 
54
54
  import { createCache } from "@teamscala/cache/create-cache";
55
55
  import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
56
+ import { themeFontsLink } from "./theme-fonts-link.ts";
56
57
  import { join } from "node:path";
57
58
 
58
59
  interface AssetManifest {
@@ -67,7 +68,7 @@ const PAGE_DOCUMENT = `<!doctype html>
67
68
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
68
69
  <link rel="preconnect" href="https://fonts.googleapis.com" />
69
70
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
70
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=optional" />
71
+ <!--theme-fonts-->
71
72
  <link rel="stylesheet" href="{{stylesPath}}" />
72
73
  <!--head-outlet-->
73
74
  </head>
@@ -140,7 +141,11 @@ export async function readHtmlTemplate(): Promise<string> {
140
141
  }
141
142
  let html = PAGE_DOCUMENT
142
143
  .replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
143
- .replace(/\{\{hydratePath\}\}/g, manifest.hydratePath);
144
+ .replace(/\{\{hydratePath\}\}/g, manifest.hydratePath)
145
+ // The head's font families derive from the css_theme row (one source);
146
+ // captured at cache build — boot-time semantics, a row edit reaches the
147
+ // envelope on the next service restart.
148
+ .replace("<!--theme-fonts-->", () => themeFontsLink());
144
149
  // Tombstone stamp — surfaces a failed client build at the served layer.
145
150
  // `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
146
151
  // JS dependency, visible via view-source / devtools / a future status
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The served <head> carries every font family the css_theme row declares —
7
+ * derived from the row (one source), never a hard-coded family list. The old
8
+ * envelope loaded only Inter, so the proposal heading variant's manuscript
9
+ * serif never loaded and fell back to Times (2026-09-02).
10
+ */
11
+
12
+ import { describe, expect, mock, test } from "bun:test";
13
+
14
+ type ConfigRow = { fonts?: Record<string, string> } | undefined;
15
+ let injectedConfig: ConfigRow;
16
+
17
+ mock.module("@teamscala/db/registry/accessors", () => ({
18
+ getEntryConfig: (_type: string, _slug: string) => injectedConfig,
19
+ }));
20
+
21
+ const { readHtmlTemplate } = await import("./read-html-template.ts");
22
+ const { primaryFontFamily, themeFontFamilies, themeFontsLink } = await import(
23
+ "./theme-fonts-link.ts"
24
+ );
25
+
26
+ const { mkdir, mkdtemp, rm } = await import("node:fs/promises");
27
+ const { tmpdir } = await import("node:os");
28
+ const { join } = await import("node:path");
29
+ const { beforeEach, afterEach } = await import("bun:test");
30
+
31
+ let dir: string;
32
+ let origCwd: string;
33
+
34
+ beforeEach(async () => {
35
+ origCwd = process.cwd();
36
+ dir = await mkdtemp(join(tmpdir(), "theme-fonts-link-"));
37
+ await mkdir(join(dir, "dist", "client"), { recursive: true });
38
+ process.chdir(dir);
39
+ });
40
+
41
+ afterEach(async () => {
42
+ process.chdir(origCwd);
43
+ await rm(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ async function writeManifest(): Promise<void> {
47
+ await Bun.file(
48
+ join(dir, "dist", "client", "asset-manifest.json"),
49
+ ).write(
50
+ `${JSON.stringify({ stylesPath: "/assets/styles.css", hydratePath: "/assets/hydrate.js" }, null, 2)}\n`,
51
+ );
52
+ }
53
+
54
+ async function servedHead(): Promise<string> {
55
+ await writeManifest();
56
+ const html = await readHtmlTemplate();
57
+ return html.slice(0, html.indexOf("</head>"));
58
+ }
59
+
60
+ describe("theme-fonts-link", () => {
61
+ test("primaryFontFamily takes the first family of a stack", () => {
62
+ expect(primaryFontFamily("Inter, sans-serif")).toBe("Inter");
63
+ expect(primaryFontFamily("'Playfair Display'")).toBe("Playfair Display");
64
+ });
65
+
66
+ test("families derive from the css_theme row, deduplicated", () => {
67
+ injectedConfig = {
68
+ fonts: {
69
+ body: "Inter, sans-serif",
70
+ heading: "Inter, sans-serif",
71
+ manuscript: "Playfair Display",
72
+ },
73
+ };
74
+ expect(themeFontFamilies()).toEqual(["Inter", "Playfair Display"]);
75
+ });
76
+
77
+ test("the served head carries every family the row declares", async () => {
78
+ injectedConfig = {
79
+ fonts: {
80
+ body: "Inter, sans-serif",
81
+ heading: "Inter, sans-serif",
82
+ manuscript: "Playfair Display",
83
+ },
84
+ };
85
+ const head = await servedHead();
86
+ expect(head).toContain("family=Inter");
87
+ expect(head).toContain(encodeURIComponent("Playfair Display"));
88
+ expect(head).not.toContain("<!--theme-fonts-->");
89
+ });
90
+
91
+ test("an absent row degrades to no fonts link — never throws", async () => {
92
+ injectedConfig = undefined;
93
+ expect(themeFontsLink()).toBe("");
94
+ const head = await servedHead();
95
+ expect(head).not.toContain("fonts.googleapis.com/css2");
96
+ expect(head).toContain("<!--head-outlet-->");
97
+ });
98
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Derives the document head's Google Fonts link from the `css_theme` row's
7
+ * `fonts` map — the ONE source for the families a page's variants reference
8
+ * (`the-theme-decides-colour-not-the-component` extends to type). The old
9
+ * envelope hard-coded Inter, so a proposal heading naming a manuscript serif
10
+ * ("Playfair Display") never had its family loaded and fell back to Times
11
+ * (2026-09-02). Now every family the row declares is loaded; a family a
12
+ * variant names that the row does not declare is a row defect, caught at the
13
+ * row's schema boundary.
14
+ *
15
+ * Read from the boot registry cache (captured at service boot, the same
16
+ * semantics as the resource_loader capture) and guarded: a missing or
17
+ * unreadable row degrades to NO fonts link — system fonts render, the page
18
+ * still works. This module must never throw into the document builder.
19
+ */
20
+
21
+ import { getEntryConfig } from "@teamscala/db/registry/accessors";
22
+
23
+ interface ThemeFontsConfig {
24
+ fonts?: Record<string, string>;
25
+ }
26
+
27
+ /** The primary family name of a CSS font stack: "Inter, sans-serif" →
28
+ * "Inter"; "Playfair Display" → "Playfair Display". */
29
+ export function primaryFontFamily(stack: string): string {
30
+ const first = stack.split(",")[0] ?? "";
31
+ return first.trim().replace(/^['"]|['"]$/g, "");
32
+ }
33
+
34
+ /** The declared theme families, deduplicated by primary family, in row
35
+ * order (body, heading, manuscript, mono…). Empty when the row is absent. */
36
+ export function themeFontFamilies(): string[] {
37
+ try {
38
+ const config = getEntryConfig<ThemeFontsConfig>("css_theme", "default");
39
+ const fonts = config?.fonts ?? {};
40
+ const seen = new Set<string>();
41
+ const families: string[] = [];
42
+ for (const value of Object.values(fonts)) {
43
+ if (typeof value !== "string" || value.trim() === "") continue;
44
+ const primary = primaryFontFamily(value);
45
+ if (primary === "" || seen.has(primary)) continue;
46
+ seen.add(primary);
47
+ families.push(primary);
48
+ }
49
+ return families;
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ /** The <link> tag loading every declared theme family, or "" when none. */
56
+ export function themeFontsLink(): string {
57
+ const families = themeFontFamilies();
58
+ if (families.length === 0) return "";
59
+ const WEIGHTS = "400;500;600;700";
60
+ const params = families
61
+ .map((family) => `family=${encodeURIComponent(family)}:wght@${WEIGHTS}`)
62
+ .join("&");
63
+ return `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${params}&display=optional" />`;
64
+ }