@teamscala/service-boot 0.23.539 → 0.23.540

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
@@ -19,6 +19,7 @@
19
19
  "@teamscala/content-gate-token": "*",
20
20
  "@teamscala/data-resolvers": "*",
21
21
  "@teamscala/db": "*",
22
+ "@teamscala/db-org-branding": "*",
22
23
  "@teamscala/document-signing": "*",
23
24
  "@teamscala/email-client": "*",
24
25
  "@teamscala/encryption": "*",
@@ -35,6 +36,7 @@
35
36
  "@teamscala/orpc-openapi": "*",
36
37
  "@teamscala/orpc-ws": "*",
37
38
  "@teamscala/os": "*",
39
+ "@teamscala/process-spawn": "*",
38
40
  "@teamscala/publish-registry": "*",
39
41
  "@teamscala/puck-boundary": "*",
40
42
  "@teamscala/puck-tree": "*",
@@ -303,5 +305,5 @@
303
305
  "repository": "https://github.com/teamgbg/service-boot",
304
306
  "sideEffects": false,
305
307
  "type": "module",
306
- "version": "0.23.539"
308
+ "version": "0.23.540"
307
309
  }
@@ -6,6 +6,8 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import * as __scalaReal_teamscala_db_query_rows from "@teamscala/db/query-rows";
10
+ import * as __scalaReal_teamscala_auth_get_session from "@teamscala/auth/get-session";
9
11
 
10
12
  import { describe, expect, mock, test } from "bun:test";
11
13
  import { Hono } from "hono";
@@ -19,10 +21,12 @@ let failMemberQuery = false;
19
21
  const queryCalls: QueryRowsCall[] = [];
20
22
 
21
23
  mock.module("@teamscala/auth/get-session", () => ({
24
+ ...__scalaReal_teamscala_auth_get_session,
22
25
  getSession: async () => session,
23
26
  }));
24
27
 
25
28
  mock.module("@teamscala/db/query-rows", () => ({
29
+ ...__scalaReal_teamscala_db_query_rows,
26
30
  queryRows: (strings: TemplateStringsArray, ...values: unknown[]) => {
27
31
  const sql = strings.join("?");
28
32
  queryCalls.push({ sql, values });
@@ -6,13 +6,14 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
-
10
9
  import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises";
11
10
  import { tmpdir } from "node:os";
12
11
  import { join } from "node:path";
13
- import { afterAll, afterEach, describe, expect, test } from "bun:test";
12
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
14
13
  import {
15
14
  releaseFromHydratePath,
15
+ sentryTarget,
16
+ sourceMapArtifactName,
16
17
  sourceMapsCacheDir,
17
18
  uploadCachedSourceMaps,
18
19
  uploadSourceMaps,
@@ -33,21 +34,37 @@ async function seedMaps(outDir: string, names: string[]): Promise<void> {
33
34
  }
34
35
  }
35
36
 
37
+ // Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
38
+ // double breaks every suite that imports the logger later (measured 2026-09-30).
36
39
  const noopLog = {
40
+ debug: () => {},
37
41
  info: () => {},
38
42
  warn: () => {},
39
43
  error: () => {},
44
+ child: () => noopLog,
40
45
  } as unknown as ReturnType<typeof import("@teamscala/logger/app-loggers").getAppLogger>;
41
46
 
47
+ // The uploader's gate is the DSN the client already reports to, and temps
48
+ // authenticates that endpoint with the DSN PUBLIC KEY — never an API token —
49
+ // so the suite seeds SENTRY_DSN (and clears it) rather than the retired
50
+ // SENTRY_AUTH_TOKEN / SENTRY_URL pair the GlitchTip-era uploader read.
51
+ const UPLOAD_DSN = "https://0123456789abcdef0123456789abcdef@temps.test/12";
52
+
42
53
  afterAll(async () => {
43
54
  for (const d of dirs) await rm(d, { recursive: true, force: true });
44
55
  });
45
56
 
57
+ beforeEach(() => {
58
+ delete process.env.SENTRY_DSN;
59
+ delete process.env.SENTRY_ORG;
60
+ });
61
+
46
62
  afterEach(() => {
47
63
  delete process.env.SENTRY_DSN;
64
+ delete process.env.SENTRY_ORG;
48
65
  });
49
66
 
50
- describe("uploadSourceMaps — skip path (absent env)", () => {
67
+ describe("uploadSourceMaps — skip path (absent SENTRY_DSN)", () => {
51
68
  const currentOutputs = new Set(["hydrate-abc123.js", "chunk-xyz789.js"]);
52
69
  test("maps STILL leave the served root and are retained in the cache", async () => {
53
70
  const cwd = await makeDir();
@@ -80,20 +97,48 @@ describe("uploadSourceMaps — skip path (absent env)", () => {
80
97
 
81
98
  describe("uploadCachedSourceMaps — the fingerprint-skip retry path", () => {
82
99
  test("no cached maps → failed, naming the release and the reason", async () => {
83
- process.env.SENTRY_DSN = "https://cred@glitchtip.test/scala";
100
+ process.env.SENTRY_DSN = UPLOAD_DSN;
84
101
  const cwd = await makeDir();
85
102
  const result = await uploadCachedSourceMaps(cwd, "deadbeef", noopLog);
86
103
  expect(result.status).toBe("failed");
87
104
  expect(result.failures[0]).toContain("deadbeef");
88
105
  });
89
106
 
90
- test("absent env → skipped without any upload attempt", async () => {
107
+ test("absent SENTRY_DSN → skipped without any upload attempt", async () => {
91
108
  const cwd = await makeDir();
92
109
  const result = await uploadCachedSourceMaps(cwd, "abc123", noopLog);
93
110
  expect(result.status).toBe("skipped");
94
111
  });
95
112
  });
96
113
 
114
+ describe("sourceMapArtifactName — the name temps stores and is looked up by", () => {
115
+ test("names the SERVED JS path, with .map NOT appended", () => {
116
+ // temps stores the multipart `name` field verbatim as source_maps.file_path
117
+ // and matches a frame's abs_path against it (normalized to ~/<path>); it
118
+ // never appends ".map", so a .map-suffixed name could not symbolicate.
119
+ expect(sourceMapArtifactName("hydrate-abc123.js.map")).toBe("~/assets/hydrate-abc123.js");
120
+ expect(sourceMapArtifactName("chunk-xyz789.js.map")).toBe("~/assets/chunk-xyz789.js");
121
+ });
122
+ });
123
+
124
+ describe("sentryTarget — one DSN, never a second host/token/project triple", () => {
125
+ test("derives origin, credential and project from the DSN", () => {
126
+ expect(sentryTarget(UPLOAD_DSN)).toEqual({
127
+ origin: "https://temps.test",
128
+ credential: "0123456789abcdef0123456789abcdef",
129
+ project: "12",
130
+ });
131
+ });
132
+
133
+ test("a malformed DSN yields null rather than a partial target", () => {
134
+ expect(sentryTarget("")).toBeNull();
135
+ expect(sentryTarget(undefined)).toBeNull();
136
+ expect(sentryTarget("https://temps.test/12")).toBeNull();
137
+ expect(sentryTarget("https://key@temps.test")).toBeNull();
138
+ expect(sentryTarget("https://key@temps.test/")).toBeNull();
139
+ });
140
+ });
141
+
97
142
  describe("releaseFromHydratePath", () => {
98
143
  test("extracts the bare content hash from a manifest hydratePath", () => {
99
144
  expect(releaseFromHydratePath("/assets/hydrate-9df3aab732.js")).toBe("9df3aab732");
@@ -109,4 +154,4 @@ describe("releaseFromHydratePath", () => {
109
154
  expect(releaseFromHydratePath("/assets/hydrate-9DF3AAB732.js")).toBeNull();
110
155
  expect(releaseFromHydratePath("/assets/hydrate-9df3aab73 2.js")).toBeNull();
111
156
  });
112
- });
157
+ });
@@ -6,6 +6,10 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import * as __scalaReal_teamscala_event_log_record_event from "@teamscala/event-log/record-event";
10
+ import * as __scalaReal_teamscala_tool_executor_configure from "@teamscala/tool-executor/configure";
11
+ import * as __scalaReal_teamscala_orpc_configure from "@teamscala/orpc/configure";
12
+ import * as __scalaReal_teamscala_orpc_app_router from "@teamscala/orpc/app-router";
9
13
 
10
14
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
11
15
  import { afterEach, describe, expect, mock, test } from "bun:test";
@@ -72,14 +76,17 @@ describe("mountOrpcRouter generated-artifact seam", () => {
72
76
  // injectors are absent.
73
77
  let seenKeys: Array<string> = [];
74
78
  mock.module("@teamscala/orpc/app-router", () => ({
79
+ ...__scalaReal_teamscala_orpc_app_router,
75
80
  getAppRouter: async () => ({
76
81
  healthz: { health: async () => ({ ok: true }) },
77
82
  }),
78
83
  }));
79
84
  mock.module("@teamscala/orpc/configure", () => ({
85
+ ...__scalaReal_teamscala_orpc_configure,
80
86
  getPrisma: () => ({}),
81
87
  }));
82
88
  mock.module("@teamscala/tool-executor/configure", () => ({
89
+ ...__scalaReal_teamscala_tool_executor_configure,
83
90
  configure: (opts: Record<string, unknown>) => {
84
91
  seenKeys = Object.keys(opts);
85
92
  },
@@ -137,6 +144,7 @@ describe("mountOrpcRouter procedure error hook", () => {
137
144
  }),
138
145
  }));
139
146
  mock.module("@teamscala/event-log/record-event", () => ({
147
+ ...__scalaReal_teamscala_event_log_record_event,
140
148
  recordEvent: (input: { kind: string; payload: unknown }) => {
141
149
  recordedEvents.push(input);
142
150
  },
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @status generated — ts_function_contract_runner template (scala-test-suites
3
- * templates/ts_function_contract_runner.pkl @1.15.0) over @teamscala/service-boot's
3
+ * templates/ts_function_contract_runner.pkl @1.16.0) over @teamscala/service-boot's
4
4
  * test_vector rows: one `it` per function_call row (args -> value or
5
5
  * expect_error) and one per fs_flow row (ordered steps in a temp
6
6
  * workspace). This runner carries NO facts: every case is the registry
@@ -6,12 +6,20 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import { afterAll as __scalaRestoreGlobals } from "bun:test";
10
+ import { GlobalRegistrator } from "@happy-dom/global-registrator";
11
+ const __scalaDomWasMissing = !globalThis.document;
12
+ if (__scalaDomWasMissing) GlobalRegistrator.register();
13
+ __scalaRestoreGlobals(async () => {
14
+ if (__scalaDomWasMissing) await GlobalRegistrator.unregister();
15
+ });
16
+
9
17
 
10
18
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
11
19
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
20
  import { tmpdir } from "node:os";
13
21
  import { join } from "node:path";
14
- import { readHtmlTemplate } from "./read-html-template.ts";
22
+ const { readHtmlTemplate } = await import("./read-html-template.ts");
15
23
 
16
24
  let dir: string;
17
25
  let origCwd: string;
@@ -6,6 +6,16 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import * as __scalaReal_teamscala_db_org_branding_fetch_organisation_page_branding from "@teamscala/db-org-branding/fetch-organisation-page-branding";
10
+ import * as __scalaReal_teamscala_db_registry_platform from "@teamscala/db/registry/platform";
11
+ import { afterAll as __scalaRestoreGlobals } from "bun:test";
12
+ import { GlobalRegistrator } from "@happy-dom/global-registrator";
13
+ const __scalaDomWasMissing = !globalThis.document;
14
+ if (__scalaDomWasMissing) GlobalRegistrator.register();
15
+ __scalaRestoreGlobals(async () => {
16
+ if (__scalaDomWasMissing) await GlobalRegistrator.unregister();
17
+ });
18
+
9
19
 
10
20
  import { describe, expect, mock, test } from "bun:test";
11
21
 
@@ -14,6 +24,7 @@ const TENANT_ORG = "5b742740-0f37-494c-a059-d8399ae4b350";
14
24
  const VIEWER_ORG = PLATFORM_ORG;
15
25
 
16
26
  mock.module("@teamscala/db/registry/platform", () => ({
27
+ ...__scalaReal_teamscala_db_registry_platform,
17
28
  getPlatformOrgId: () => PLATFORM_ORG,
18
29
  }));
19
30
 
@@ -28,7 +39,8 @@ const fetchBranding = mock(async () => ({
28
39
  company_logo_for_light_theme: "/viewer-light.png",
29
40
  company_logo_for_dark_theme: "/viewer-dark.png",
30
41
  }));
31
- mock.module("@teamscala/db/fetch-organisation-page-branding", () => ({
42
+ mock.module("@teamscala/db-org-branding/fetch-organisation-page-branding", () => ({
43
+ ...__scalaReal_teamscala_db_org_branding_fetch_organisation_page_branding,
32
44
  fetchOrganisationPageBranding: fetchBranding,
33
45
  }));
34
46
  mock.module("../page-server-orpc/server-client.ts", () => ({
@@ -6,7 +6,7 @@
6
6
 
7
7
 
8
8
 
9
- import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
9
+ import { fetchOrganisationPageBranding } from "@teamscala/db-org-branding/fetch-organisation-page-branding";
10
10
  import { getPlatformOrgId } from "@teamscala/db/registry/platform";
11
11
  import { getAppLogger } from "@teamscala/logger/app-loggers";
12
12
  import { getServerClient } from "../page-server-orpc/server-client.ts";
@@ -6,6 +6,7 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import * as __scalaReal_teamscala_logger_app_loggers from "@teamscala/logger/app-loggers";
9
10
 
10
11
  import { GlobalRegistrator } from "@happy-dom/global-registrator";
11
12
  import { afterEach, describe, expect, mock, test } from "bun:test";
@@ -44,16 +45,14 @@ mock.module("@teamscala/db/registry/load-config", () => {
44
45
  });
45
46
 
46
47
  // ── The heavy collaborators (recorded, not executed) ───────────────────
47
- const graphCalls: { loaderConfig: Record<string, unknown>; configFingerprint?: string }[] = [];
48
+ const graphCalls: { loaderConfig: Record<string, unknown> }[] = [];
48
49
 
49
50
  mock.module("./resolve-request-graph.ts", () => ({
50
51
  resolveRequestGraph: async (args: {
51
52
  loaderConfig: Record<string, unknown>;
52
- configFingerprint?: string;
53
53
  }) => {
54
54
  graphCalls.push({
55
55
  loaderConfig: args.loaderConfig,
56
- configFingerprint: args.configFingerprint,
57
56
  });
58
57
  return {};
59
58
  },
@@ -93,8 +92,18 @@ mock.module("../bind-page-handler.ts", () => ({
93
92
  _request: Request,
94
93
  ) => c.json(await handler.loadData(c.req.raw)),
95
94
  }));
95
+ // Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
96
+ // double breaks every suite that imports the logger later (measured 2026-09-30).
97
+ const loggerDouble = {
98
+ debug: () => {},
99
+ info: () => {},
100
+ warn: () => {},
101
+ error: () => {},
102
+ child: () => loggerDouble,
103
+ };
96
104
  mock.module("@teamscala/logger/app-loggers", () => ({
97
- getAppLogger: () => ({ info: () => {}, warn: () => {} }),
105
+ ...__scalaReal_teamscala_logger_app_loggers,
106
+ getAppLogger: () => loggerDouble,
98
107
  }));
99
108
 
100
109
  const { registerRegistryPageRoute } = await import(
@@ -6,16 +6,23 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import * as __scalaReal_teamscala_logger_app_loggers from "@teamscala/logger/app-loggers";
9
10
 
10
11
  import { describe, expect, mock, test } from "bun:test";
11
12
 
12
13
  const warns: string[] = [];
14
+ // Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
15
+ // double breaks every suite that imports the logger later (measured 2026-09-30).
16
+ const loggerDouble = {
17
+ debug: () => {},
18
+ info: () => {},
19
+ warn: (message: string) => warns.push(message),
20
+ error: () => {},
21
+ child: () => loggerDouble,
22
+ };
13
23
  mock.module("@teamscala/logger/app-loggers", () => ({
14
- getAppLogger: () => ({
15
- warn: (message: string) => warns.push(message),
16
- info: () => {},
17
- error: () => {},
18
- }),
24
+ ...__scalaReal_teamscala_logger_app_loggers,
25
+ getAppLogger: () => loggerDouble,
19
26
  }));
20
27
 
21
28
  import {
@@ -6,9 +6,17 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import { afterAll as __scalaRestoreGlobals } from "bun:test";
10
+ import { GlobalRegistrator } from "@happy-dom/global-registrator";
11
+ const __scalaDomWasMissing = !globalThis.document;
12
+ if (__scalaDomWasMissing) GlobalRegistrator.register();
13
+ __scalaRestoreGlobals(async () => {
14
+ if (__scalaDomWasMissing) await GlobalRegistrator.unregister();
15
+ });
16
+
9
17
 
10
18
  import { describe, expect, test } from "bun:test";
11
- import { resolveEntityTitle } from "./meta-title.ts";
19
+ const { resolveEntityTitle } = await import("./meta-title.ts");
12
20
 
13
21
  describe("resolveEntityTitle", () => {
14
22
  test("a report is named by its own document name, not the diagnostic", () => {
@@ -6,17 +6,32 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import * as __scalaReal_teamscala_service_boot_page_server_orpc_server_client from "@teamscala/service-boot/page-server-orpc/server-client";
10
+ import * as __scalaReal_teamscala_db_registry_load_config from "@teamscala/db/registry/load-config";
11
+ import * as __scalaReal_teamscala_logger_app_loggers from "@teamscala/logger/app-loggers";
9
12
 
10
13
  import { describe, expect, mock, test } from "bun:test";
11
14
 
12
15
  const warn = mock((_message: string) => {});
16
+ // Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
17
+ // double breaks every suite that imports the logger later (measured 2026-09-30).
18
+ const loggerDouble = {
19
+ debug: () => {},
20
+ info: () => {},
21
+ warn,
22
+ error: () => {},
23
+ child: () => loggerDouble,
24
+ };
13
25
  mock.module("@teamscala/logger/app-loggers", () => ({
14
- getAppLogger: () => ({ warn, info: () => {} }),
26
+ ...__scalaReal_teamscala_logger_app_loggers,
27
+ getAppLogger: () => loggerDouble,
15
28
  }));
16
29
  mock.module("@teamscala/db/registry/load-config", () => ({
30
+ ...__scalaReal_teamscala_db_registry_load_config,
17
31
  loadRegistryConfig: async () => ({}),
18
32
  }));
19
33
  mock.module("@teamscala/service-boot/page-server-orpc/server-client", () => ({
34
+ ...__scalaReal_teamscala_service_boot_page_server_orpc_server_client,
20
35
  getServerClient: () => ({}),
21
36
  }));
22
37
 
@@ -6,18 +6,25 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import * as __scalaReal_teamscala_logger_app_loggers from "@teamscala/logger/app-loggers";
9
10
 
10
11
  import { describe, expect, mock, test } from "bun:test";
11
12
 
12
13
  let loaderGraph: Record<string, unknown> = {};
13
14
  const resolveCalls: Array<{ params: Record<string, string> }> = [];
14
15
 
16
+ // Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
17
+ // double breaks every suite that imports the logger later (measured 2026-09-30).
18
+ const loggerDouble = {
19
+ debug: () => {},
20
+ info: () => {},
21
+ warn: () => {},
22
+ error: () => {},
23
+ child: () => loggerDouble,
24
+ };
15
25
  mock.module("@teamscala/logger/app-loggers", () => ({
16
- getAppLogger: () => ({
17
- warn: () => {},
18
- info: () => {},
19
- error: () => {},
20
- }),
26
+ ...__scalaReal_teamscala_logger_app_loggers,
27
+ getAppLogger: () => loggerDouble,
21
28
  }));
22
29
 
23
30
  mock.module("../page-server-loader-graph.ts", () => ({
@@ -60,12 +67,6 @@ mock.module("../content-page-floor.ts", () => ({
60
67
  },
61
68
  }));
62
69
 
63
- mock.module("./graph-cache.ts", () => ({
64
- staticShellGraphCache: { getWithMeta: () => undefined, set: () => {}, refreshInBackground: () => {} },
65
- paramRouteGraphCache: { getWithMeta: () => undefined, set: () => {}, refreshInBackground: () => {} },
66
- readCachedGraph: () => undefined,
67
- }));
68
-
69
70
  import { resolveRequestGraph, type ResolveRequestGraphArgs } from "./resolve-request-graph.ts";
70
71
  import type { PageRouteConfig } from "../register-registry-page-route.ts";
71
72
 
@@ -88,12 +89,9 @@ describe("resolveRequestGraph", () => {
88
89
  test("a bare root with a resource_type throws the 200 holding-page signal", async () => {
89
90
  const err = (await resolveRequestGraph({
90
91
  pattern: "/",
91
- routeSlug: "proposal",
92
92
  routeConfig: route({ pattern: "/", resource_type: "proposal" }),
93
93
  loaderConfig: loader([{ key: "proposal" }]),
94
94
  params: { host: "proposals.scala.business", path: "/" },
95
- isStaticShell: false,
96
- serviceSlug: "scala-proposals",
97
95
  }).catch((e) => e)) as Error & { httpStatus: number; resourceType: string };
98
96
  expect(err).toBeInstanceOf(Error);
99
97
  expect((err as { httpStatus?: number }).httpStatus).toBe(200);
@@ -104,12 +102,9 @@ describe("resolveRequestGraph", () => {
104
102
  loaderGraph = { proposal: null };
105
103
  const err = (await resolveRequestGraph({
106
104
  pattern: "/:id",
107
- routeSlug: "proposal",
108
105
  routeConfig: route({ resource_type: "proposal" }),
109
106
  loaderConfig: loader([{ key: "proposal" }]),
110
107
  params: { host: "proposals.scala.business", path: "/missing" },
111
- isStaticShell: false,
112
- serviceSlug: "scala-proposals",
113
108
  }).catch((e) => e)) as Error & { httpStatus: number; resourceType: string };
114
109
  expect((err as { httpStatus?: number }).httpStatus).toBe(404);
115
110
  expect((err as { resourceType?: string }).resourceType).toBe("proposal");
@@ -119,12 +114,9 @@ describe("resolveRequestGraph", () => {
119
114
  loaderGraph = { page: null };
120
115
  const err = (await resolveRequestGraph({
121
116
  pattern: "/:id",
122
- routeSlug: "public-page",
123
117
  routeConfig: route({ resource_type: undefined }),
124
118
  loaderConfig: loader([{ key: "page" }]),
125
119
  params: { host: "public.scala.business", path: "/deleted" },
126
- isStaticShell: false,
127
- serviceSlug: "scala-public",
128
120
  }).catch((e) => e)) as Error & { httpStatus: number; empty: boolean };
129
121
  expect((err as { httpStatus?: number }).httpStatus).toBe(404);
130
122
  expect((err as { empty?: boolean }).empty).toBe(false);
@@ -134,12 +126,9 @@ describe("resolveRequestGraph", () => {
134
126
  loaderGraph = { page: { content: [] } };
135
127
  const err = (await resolveRequestGraph({
136
128
  pattern: "/:id",
137
- routeSlug: "public-page",
138
129
  routeConfig: route({ resource_type: undefined }),
139
130
  loaderConfig: loader([{ key: "page" }]),
140
131
  params: { host: "public.scala.business", path: "/empty" },
141
- isStaticShell: false,
142
- serviceSlug: "scala-public",
143
132
  }).catch((e) => e)) as Error & { httpStatus: number; empty: boolean };
144
133
  expect((err as { httpStatus?: number }).httpStatus).toBe(200);
145
134
  expect((err as { empty?: boolean }).empty).toBe(true);
@@ -149,12 +138,9 @@ describe("resolveRequestGraph", () => {
149
138
  loaderGraph = { proposal: { id: "p1", name: "Q3" } };
150
139
  const resolved = await resolveRequestGraph({
151
140
  pattern: "/:id",
152
- routeSlug: "proposal",
153
141
  routeConfig: route({ resource_type: "proposal" }),
154
142
  loaderConfig: loader([{ key: "proposal" }]),
155
143
  params: { host: "proposals.scala.business", path: "/p1" },
156
- isStaticShell: false,
157
- serviceSlug: "scala-proposals",
158
144
  });
159
145
  expect((resolved.proposal as { id: string }).id).toBe("p1");
160
146
  });
@@ -10,7 +10,7 @@
10
10
  import { describe, expect, it } from "bun:test";
11
11
  import { inlineLogoAsDataUri } from "./error-page-logo.ts";
12
12
  import { PrimaryResourceMissingError, buildResourceNotFoundResponse } from "./resource-not-found.ts";
13
- import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
13
+ import { buildWakingPageHtml } from "@teamscala/brand/brand/waking-page-html";
14
14
 
15
15
  describe("PrimaryResourceMissingError", () => {
16
16
  it("is an Error carrying resourceType + httpStatus", () => {
@@ -51,6 +51,24 @@ describe("buildResourceNotFoundResponse", () => {
51
51
  expect(logo).toBe("data:image/png;base64,iVBORw==");
52
52
  });
53
53
 
54
+ it("passes the resolved logo to the waking page instead of its placeholder", async () => {
55
+ const logo = await inlineLogoAsDataUri(
56
+ "/api/assets/c503629a/logos/cropped.png",
57
+ async () => ({
58
+ file() {
59
+ return {
60
+ stat: async () => ({}),
61
+ arrayBuffer: async () => new Uint8Array([137, 80, 78, 71]).buffer,
62
+ };
63
+ },
64
+ }),
65
+ );
66
+ const html = buildWakingPageHtml({ logoUrl: logo });
67
+ expect(logo).toStartWith("data:image/png;base64,");
68
+ expect(html).toContain('<img src="data:image/png;base64,iVBORw=="');
69
+ expect(html).not.toContain('class="uif-logo-loading-pulse" aria-hidden="true"');
70
+ });
71
+
54
72
  it("returns a 404 Response for a dead link", () => {
55
73
  const res = buildResourceNotFoundResponse("project");
56
74
  expect(res).toBeInstanceOf(Response);
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { recordEvent } from "@teamscala/event-log/record-event";
7
7
  import { getAppLogger } from "@teamscala/logger/app-loggers";
8
- import { spawn } from "@teamscala/os/spawn";
8
+ import { spawn } from "@teamscala/process-spawn/spawn";
9
9
  import { createWatchdog } from "@teamscala/watchdog/create-watchdog";
10
10
  import type { WatchdogHandle } from "@teamscala/watchdog/types";
11
11
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
@@ -6,6 +6,7 @@
6
6
  // carries them verbatim, this file is the emission, and hand edits here are
7
7
  // overwritten on the next run. The rationale each assertion carries moved
8
8
  // with it into the band.
9
+ import * as __scalaReal_teamscala_db_registry_accessors from "@teamscala/db/registry/accessors";
9
10
 
10
11
  import { describe, expect, mock, test } from "bun:test";
11
12
 
@@ -13,6 +14,7 @@ type ConfigRow = { fonts?: Record<string, string> } | undefined;
13
14
  let injectedConfig: ConfigRow;
14
15
 
15
16
  mock.module("@teamscala/db/registry/accessors", () => ({
17
+ ...__scalaReal_teamscala_db_registry_accessors,
16
18
  getEntryConfig: (_type: string, _slug: string) => injectedConfig,
17
19
  }));
18
20
 
@@ -99,11 +99,57 @@ describe("phase-2 step isolation", () => {
99
99
  expect(markFullAppReadyCall).toBeGreaterThan(gate);
100
100
  });
101
101
 
102
- test("a failed verification throws — the boot never continues past an unverified manifest", () => {
102
+ /*
103
+ * THE 2026-10-13 RULING (client-build failure must not kill the process).
104
+ *
105
+ * The gate used to THROW on a failed verification, and the caller in ui-service
106
+ * turns any phase-2 throw into process.exit(1). Measured on portal-dev: a
107
+ * hydrate build that failed on unresolved @teamscala/ui-foundation block
108
+ * imports exited the process, supervise-daemon respawned until it gave up, and
109
+ * the service was parked stopped — so a broken CLIENT BUNDLE took down the
110
+ * API, MCP and job surfaces too, and nothing rendered for an error boundary to
111
+ * catch. A build failure is a fault to REPORT, not a reason to exit.
112
+ *
113
+ * The gate now returns with clientBuildFailed set and never throws, so the
114
+ * process stays up serving its non-page surfaces while pages stay unready.
115
+ */
116
+ test("a failed verification returns degraded instead of throwing out of phase 2", () => {
103
117
  const body = buildFullAppBody();
104
118
  const gate = body.indexOf("verifyClientBuildAssets()");
105
119
  const slice = body.slice(gate, gate + 1200);
106
120
  expect(slice).toContain("!clientBuild.ok");
107
- expect(slice).toContain("throw new Error");
121
+ expect(slice).not.toContain("throw new Error");
122
+ expect(slice).toContain("clientBuildFailed");
123
+ });
124
+
125
+ /*
126
+ * The failure must REACH US. The reason is logged and recorded as a durable
127
+ * boot.client-build-failed event, so the degraded state is observable on our
128
+ * end rather than a silent page outage.
129
+ */
130
+ test("a failed verification records the fault durably, so the degraded state is reported", () => {
131
+ const body = buildFullAppBody();
132
+ const gate = body.indexOf("verifyClientBuildAssets()");
133
+ const slice = body.slice(gate, gate + 1200);
134
+ expect(slice).toContain("log.error");
135
+ expect(slice).toContain("recordEventDurable");
136
+ expect(slice).toContain("boot.client-build-failed");
137
+ });
138
+
139
+ /*
140
+ * PAGES STAY UNAVAILABLE. The early return must sit BEFORE both readiness flip
141
+ * call sites, or the 2026-09-01 unstyled-proposal incident returns: a service
142
+ * whose client build never ran flips ready, serves pages, and answers /health
143
+ * 200 while every asset URL 404s. Degrading the PROCESS is not degrading READINESS.
144
+ */
145
+ test("the degraded return precedes both readiness flips, so pages stay unready", () => {
146
+ const body = buildFullAppBody();
147
+ const gate = body.indexOf("verifyClientBuildAssets()");
148
+ const degraded = body.indexOf("clientBuildFailed: reason");
149
+ const setPageHandlerCall = body.indexOf("setPageHandler(deps.createPageHandler)");
150
+ const markFullAppReadyCall = body.indexOf("markFullAppReady()");
151
+ expect(degraded).toBeGreaterThan(gate);
152
+ expect(setPageHandlerCall).toBeGreaterThan(degraded);
153
+ expect(markFullAppReadyCall).toBeGreaterThan(degraded);
108
154
  });
109
155
  });
@@ -6,6 +6,7 @@
6
6
 
7
7
 
8
8
 
9
+ import { recordEventDurable } from "@teamscala/event-log/record-event";
9
10
  import { getAppLogger } from "@teamscala/logger/app-loggers";
10
11
  import type { Hono } from "hono";
11
12
  import { buildClientAssets } from "./build-client-assets.ts";
@@ -21,6 +22,7 @@ export interface FullAppResult {
21
22
  blocks: { slug: string; packagePath: string }[];
22
23
  routes: string[];
23
24
  pgBossJobs: Array<{ handlerSlug: string }>;
25
+ clientBuildFailed?: string;
24
26
  }
25
27
 
26
28
  export async function buildFullApp(
@@ -63,12 +65,29 @@ export async function buildFullApp(
63
65
 
64
66
  const clientBuild = await verifyClientBuildAssets();
65
67
  if (!clientBuild.ok) {
66
- throw new Error(
67
- `[two-phase-boot:${serviceSlug}] refusing readiness — ${clientBuild.reason}. ` +
68
- `Serving pages here would emit asset URLs that 404 while every health surface reports ok. ` +
69
- `Background work is mounted and unaffected; pages and /health stay unavailable until a real client build exists. ` +
70
- `Fix the client build (see build-client-assets logs above) and restart.`,
71
- );
68
+ const reason =
69
+ `[two-phase-boot:${serviceSlug}] client build is not servable — ${clientBuild.reason}. ` +
70
+ `Pages stay unavailable: serving here would emit asset URLs that 404 while every health surface reports ok. ` +
71
+ `Background work is mounted and unaffected, and the server keeps serving its API, MCP and job surfaces. ` +
72
+ `Fix the client build (see build-client-assets logs above); pages recover on the next restart.`;
73
+ log.error(reason);
74
+ await recordEventDurable({
75
+ id: crypto.randomUUID(),
76
+ kind: "boot.client-build-failed",
77
+ payload: {
78
+ at: new Date().toISOString(),
79
+ service: serviceSlug,
80
+ reason: reason.slice(0, 2000),
81
+ },
82
+ occurredAt: new Date(),
83
+ });
84
+ return {
85
+ app: minimalApp,
86
+ blocks: [],
87
+ routes: [],
88
+ pgBossJobs,
89
+ clientBuildFailed: reason,
90
+ };
72
91
  }
73
92
 
74
93
  log.info(
package/src/ui-service.ts CHANGED
@@ -101,6 +101,13 @@ export async function bootUiService(
101
101
 
102
102
  swapToFullApp(fullAppResult.app, serviceSlug);
103
103
 
104
+ if (fullAppResult.clientBuildFailed !== undefined) {
105
+ log.error(
106
+ `[ui-service-boot:${serviceSlug}] Full app swapped in after ${fullDuration}ms with NO servable client build — the process stays up serving API, MCP and jobs; pages and /health stay unready. buildFullApp recorded boot.client-build-failed with the reason.`,
107
+ );
108
+ return;
109
+ }
110
+
104
111
  log.info(
105
112
  `[ui-service-boot:${serviceSlug}] Full app swapped in after ${fullDuration}ms (${fullDuration - minimalDuration}ms after minimal app)`,
106
113
  );