@teamscala/service-boot 0.23.430 → 0.23.432

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.
Files changed (82) hide show
  1. package/package.json +12 -19
  2. package/src/agreement-gate/signing-shell.ts +19 -15
  3. package/src/attach-proposal-accept.ts +5 -2
  4. package/src/auth-gate/public-paths.ts +7 -16
  5. package/src/auth-gate.ts +54 -42
  6. package/src/bind-page-handler.ts +20 -32
  7. package/src/build-client-assets/build-lock.ts +15 -6
  8. package/src/build-client-assets/hashed-output.ts +3 -1
  9. package/src/build-client-assets/hydrate-bundle.ts +59 -57
  10. package/src/build-client-assets/input-fingerprint.ts +50 -57
  11. package/src/build-client-assets/input-hashing.ts +2 -4
  12. package/src/build-client-assets/manifest.ts +8 -11
  13. package/src/build-client-assets/source-candidates.ts +8 -16
  14. package/src/build-client-assets/sourcemaps-upload.ts +17 -10
  15. package/src/build-client-assets/styles-build.ts +17 -19
  16. package/src/build-client-assets.test.ts +2 -2
  17. package/src/build-client-assets.ts +16 -22
  18. package/src/content-gate/access.ts +5 -2
  19. package/src/content-gate/catalog.ts +26 -18
  20. package/src/content-gate/gate.ts +7 -3
  21. package/src/discover-blocks.ts +5 -7
  22. package/src/fn-routers/agreement-certificate.ts +1 -1
  23. package/src/fn-routers/agreement-send-router.ts +5 -4
  24. package/src/fn-routers/agreement-sign-side-effects.ts +27 -22
  25. package/src/fn-routers/assessment-report-email.ts +3 -2
  26. package/src/fn-routers/auth-router.ts +20 -21
  27. package/src/fn-routers/chat-session-router-shared.ts +12 -3
  28. package/src/fn-routers/chat-session-router.ts +8 -6
  29. package/src/fn-routers/conversation-ownership.ts +3 -2
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
  31. package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
  32. package/src/fn-routers/persist-chat-messages.ts +6 -4
  33. package/src/fn-routers/proposal-send-router.ts +4 -3
  34. package/src/fn-routers/public-agreement-router.ts +5 -4
  35. package/src/fn-routers/public-assessment-router.ts +5 -4
  36. package/src/fn-routers/public-gpt-session-router.ts +17 -12
  37. package/src/fn-routers/user-preferences-router.ts +6 -15
  38. package/src/fn-routers/work-items-router.ts +21 -17
  39. package/src/handlers/platform-fault-escalation/delivery.ts +36 -0
  40. package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
  41. package/src/handlers/platform-fault-escalation-logic.ts +26 -5
  42. package/src/handlers/platform-fault-escalation.test.ts +67 -15
  43. package/src/handlers/platform-fault-escalation.ts +16 -0
  44. package/src/handlers/projects-digest.ts +1 -1
  45. package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
  46. package/src/handlers/scala-tools-currency-watch.ts +23 -32
  47. package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
  48. package/src/handlers/singleton-tools-currency-watch.ts +27 -19
  49. package/src/handlers/upstream-issue-sync.ts +11 -4
  50. package/src/handlers/whatsapp-liveness.ts +14 -4
  51. package/src/install-harness.ts +1 -1
  52. package/src/install-logging.ts +1 -1
  53. package/src/mount-orpc-router.test.ts +56 -10
  54. package/src/mount-orpc-router.ts +23 -9
  55. package/src/mount-server-packages.ts +17 -15
  56. package/src/pg-boss-mount.ts +5 -12
  57. package/src/proposal-gate.ts +7 -2
  58. package/src/read-html-template.test.ts +12 -15
  59. package/src/read-html-template.ts +2 -1
  60. package/src/readiness-gate.ts +2 -1
  61. package/src/register-crash-handlers.ts +4 -8
  62. package/src/register-page-server-client.ts +15 -19
  63. package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
  64. package/src/register-registry-page-route/apply-page-branding.ts +68 -56
  65. package/src/register-registry-page-route/graph-cache.ts +22 -18
  66. package/src/register-registry-page-route/meta-title.ts +24 -20
  67. package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
  68. package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -3
  69. package/src/register-registry-page-route/run-content-gates.ts +2 -4
  70. package/src/register-registry-page-route.ts +1 -2
  71. package/src/register-static-assets.ts +6 -5
  72. package/src/resolve-app-base-url.ts +12 -5
  73. package/src/resolvers/portal-route-paths.ts +15 -11
  74. package/src/resource-chat-inject.ts +84 -72
  75. package/src/resource-not-found.ts +23 -19
  76. package/src/scala-tools-currency-watchdog.ts +41 -12
  77. package/src/two-phase-full-app.ts +61 -29
  78. package/src/two-phase-minimal-app.ts +39 -31
  79. package/src/two-phase-readiness.ts +50 -38
  80. package/src/ui-service.ts +50 -38
  81. package/src/handlers/pane-outbox-drain.ts +0 -39
  82. package/src/handlers/prune-stale-data.ts +0 -110
@@ -32,6 +32,7 @@ import { spawn } from "@teamscala/os/spawn";
32
32
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
33
33
  import * as v from "valibot";
34
34
  import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
35
+ import { createCache } from "@teamscala/cache/create-cache";
35
36
 
36
37
  interface GhIssue {
37
38
  state: string;
@@ -52,12 +53,18 @@ interface CapturedResponse {
52
53
  }
53
54
 
54
55
  const DEFAULT_GH_TIMEOUT_MS = 20_000;
55
- let cachedGhTimeoutMs: number | undefined;
56
+ const ghTimeoutCache = createCache<number>("service-boot:gh-timeout", {
57
+ ttlMs: Number.POSITIVE_INFINITY,
58
+ maxSize: 1,
59
+ });
60
+
56
61
  async function getGhTimeoutMs(): Promise<number> {
57
- if (cachedGhTimeoutMs !== undefined) return cachedGhTimeoutMs;
62
+ const hit = ghTimeoutCache.get("default");
63
+ if (hit !== undefined) return hit;
58
64
  const row = await loadRegistryConfig("config", "upstream-issue-sync", v.looseObject({}));
59
- cachedGhTimeoutMs = (row?.gh_timeout_ms as number | undefined) ?? DEFAULT_GH_TIMEOUT_MS;
60
- return cachedGhTimeoutMs;
65
+ const timeoutMs = (row?.gh_timeout_ms as number | undefined) ?? DEFAULT_GH_TIMEOUT_MS;
66
+ ghTimeoutCache.set("default", timeoutMs);
67
+ return timeoutMs;
61
68
  }
62
69
  const EXCERPT_MAX = 160;
63
70
 
@@ -32,6 +32,7 @@ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
32
32
  import { detectLivenessTransitions, type WhatsAppDeviceState } from "./whatsapp-liveness-logic.ts";
33
33
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
34
34
  import * as v from "valibot";
35
+ import { createCache } from "@teamscala/cache/create-cache";
35
36
 
36
37
  /** In-process last-known device state, keyed by GOWA device id. */
37
38
  const lastKnown = new Map<string, string>();
@@ -46,18 +47,27 @@ interface GowaListDevicesResponse {
46
47
  }>;
47
48
  }
48
49
 
49
- let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
50
+ const systemIdentityCache = createCache<{
51
+ userId: string | undefined;
52
+ organisationId: string | undefined;
53
+ }>("service-boot:whatsapp-liveness-system-identity", {
54
+ ttlMs: Number.POSITIVE_INFINITY,
55
+ maxSize: 1,
56
+ });
57
+
50
58
  /** The system-caller identity for platform-action tool dispatches — shared by
51
59
  * the sibling handlers that dispatch GOWA reads through the central /mcp
52
60
  * (messaging-send-recording-watch reconcile sweep). */
53
61
  export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
54
- if (cachedSystemIdentity) return cachedSystemIdentity;
62
+ const hit = systemIdentityCache.get("default");
63
+ if (hit) return hit;
55
64
  const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
56
- cachedSystemIdentity = {
65
+ const identity = {
57
66
  userId: row?.system_user_id as string | undefined,
58
67
  organisationId: row?.system_org_id as string | undefined,
59
68
  };
60
- return cachedSystemIdentity;
69
+ systemIdentityCache.set("default", identity);
70
+ return identity;
61
71
  }
62
72
 
63
73
  export async function pollWhatsAppLiveness({
@@ -2,7 +2,7 @@
2
2
  * @system server-boot
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
- * @indivisible-unit
5
+ * @indivisible-unit owner=unassigned reason=81 lines; pre-2026-08-27 exemption, unreviewed
6
6
  *
7
7
  * Install harness — sandbox-mounts each declared package and captures
8
8
  * registry.require() calls via AsyncLocalStorage. Validates all required
@@ -2,7 +2,7 @@
2
2
  * @system server-boot
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
- * @indivisible-unit
5
+ * @indivisible-unit owner=unassigned reason=188 lines; pre-2026-08-27 exemption, unreviewed
6
6
  *
7
7
  * One-call install of the entire logging pipeline. After installLogging()
8
8
  * runs, every error in the service — process-level, HTTP middleware, Hono
@@ -8,8 +8,8 @@
8
8
  * service, so production boot must fail before it can report healthy.
9
9
  */
10
10
 
11
- import { afterEach, describe, expect, test } from "bun:test";
12
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
12
+ import { afterEach, describe, expect, mock, test } from "bun:test";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { mountOrpcRouter } from "./mount-orpc-router.ts";
@@ -17,22 +17,19 @@ import { mountOrpcRouter } from "./mount-orpc-router.ts";
17
17
  const originalCwd = process.cwd();
18
18
  const tempDirs: string[] = [];
19
19
 
20
- afterEach(() => {
20
+ afterEach(async () => {
21
21
  process.chdir(originalCwd);
22
22
  for (const directory of tempDirs.splice(0)) {
23
- rmSync(directory, { recursive: true, force: true });
23
+ await rm(directory, { recursive: true, force: true });
24
24
  }
25
25
  });
26
26
 
27
27
  describe("mountOrpcRouter generated-artifact seam", () => {
28
28
  test("fails when schema exists but generated router is absent", async () => {
29
- const service = mkdtempSync(join(tmpdir(), "mount-orpc-router-test-"));
29
+ const service = await mkdtemp(join(tmpdir(), "mount-orpc-router-test-"));
30
30
  tempDirs.push(service);
31
- mkdirSync(join(service, "prisma"), { recursive: true });
32
- writeFileSync(
33
- join(service, "prisma/schema.prisma"),
34
- 'datasource db { provider = "postgresql" }',
35
- );
31
+ await mkdir(join(service, "prisma"), { recursive: true });
32
+ await Bun.file(join(service, "prisma/schema.prisma")).write('datasource db { provider = "postgresql" }');
36
33
  process.chdir(service);
37
34
 
38
35
  await expect(
@@ -43,4 +40,53 @@ describe("mountOrpcRouter generated-artifact seam", () => {
43
40
  " but the generated ORPC router is missing",
44
41
  );
45
42
  });
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".
51
+ const service = await mkdtemp(join(tmpdir(), "mount-orpc-router-test-"));
52
+ tempDirs.push(service);
53
+ await mkdir(join(service, "prisma/generated/orpc/routers"), {
54
+ recursive: true,
55
+ });
56
+ await Bun.file(join(service, "prisma/schema.prisma")).write(
57
+ 'datasource db { provider = "postgresql" }',
58
+ );
59
+ await Bun.file(
60
+ join(service, "prisma/generated/orpc/routers/index.ts"),
61
+ ).write(
62
+ "export const router = { healthz: { health: async () => ({ ok: true }) } };\n",
63
+ );
64
+ process.chdir(service);
65
+
66
+ // 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 } = {};
69
+ mock.module("@teamscala/orpc/app-router", () => ({
70
+ getAppRouter: async () => ({
71
+ healthz: { health: async () => ({ ok: true }) },
72
+ }),
73
+ }));
74
+ mock.module("@teamscala/orpc/configure", () => ({
75
+ getPrisma: () => ({}),
76
+ }));
77
+ mock.module("@teamscala/tool-executor/configure", () => ({
78
+ configure: (opts: { getMcpGatewayUrl?: () => unknown }) => {
79
+ capture.getMcpGatewayUrl = opts.getMcpGatewayUrl;
80
+ },
81
+ }));
82
+
83
+ const app = { post: () => {} };
84
+ await mountOrpcRouter(app as never, "scala-mcp");
85
+
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();
91
+ });
46
92
  });
@@ -9,7 +9,6 @@
9
9
  * routers with custom routers registered via registerCustomRouter().
10
10
  */
11
11
 
12
- import { existsSync } from "node:fs";
13
12
  import { join } from "node:path";
14
13
  import { getAppLogger } from "@teamscala/logger/app-loggers";
15
14
  import { registerFnRouters } from "./fn-routers/index.ts";
@@ -45,8 +44,8 @@ export async function mountOrpcRouter(
45
44
  // reserved for GENUINE mount failures (router present but fails to load).
46
45
  // A service that SHOULD have a router but doesn't is a codegen gap caught by
47
46
  // API verification, not a boot warning.
48
- if (!existsSync(routerPath)) {
49
- if (existsSync(schemaPath)) {
47
+ if (!await Bun.file(routerPath).exists()) {
48
+ if (await Bun.file(schemaPath).exists()) {
50
49
  throw new Error(
51
50
  `[scala-install:${serviceSlug}] Prisma schema exists at ${schemaPath} ` +
52
51
  `but the generated ORPC router is missing at ${routerPath}. ` +
@@ -84,17 +83,32 @@ export async function mountOrpcRouter(
84
83
  const { configure: configureToolExecutor } = await import(
85
84
  "@teamscala/tool-executor/configure"
86
85
  );
86
+ // The host-command-bus collaborators arrive here (the boot layer) —
87
+ // tool-executor cannot import its tier-2 siblings statically
88
+ // (vertical-dependency-only: a same-tier edge). Resolved eagerly at
89
+ // mount so the injected factories stay synchronous; the modules are
90
+ // small and their postgres dependency is already loaded by the db
91
+ // primitive.
92
+ const [{ createHostCommandInvoker }, { getCallerContext }, { decryptSecretConfig }] =
93
+ await Promise.all([
94
+ import("@teamscala/host-command-bus/invoker"),
95
+ import("@teamscala/mcp-multi-session/caller-context"),
96
+ import("@teamscala/encryption/crypto/secret-config"),
97
+ ]);
87
98
  configureToolExecutor({
88
99
  getAppRouter: () => getAppRouter(),
89
100
  getPrisma: () => getConfiguredPrisma(),
90
101
  // Central MCP gateway URL — sdk:* tool calls from any non-host service
91
102
  // forward here (scala-mcp owns every @teamscala/api-* adapter per
92
- // operator ruling 2026-08-26). The HTTP layer reads from the
93
- // per-request config surface (the same place getAppRouter / getPrisma
94
- // are resolved from), not the ambient environment, to honour
95
- // `configured-primitives`.
96
- getMcpGatewayUrl: () =>
97
- (ctx as { config?: { MCP_GATEWAY_URL?: string } }).config?.MCP_GATEWAY_URL,
103
+ // operator ruling 2026-08-26). Returns undefined explicitly: callers
104
+ // (sdk-mcp-forwarder) treat absent as "SDK tool unusable in this
105
+ // service" and refuse with that message. The previous form read an
106
+ // out-of-scope `ctx` (c39d279) and would have thrown ReferenceError
107
+ // on first invoke instead of returning undefined.
108
+ getMcpGatewayUrl: () => undefined,
109
+ createHostCommandInvoker: (config) => createHostCommandInvoker(config as never),
110
+ getCallerContext: () => getCallerContext(),
111
+ decryptSecretConfig: (raw) => decryptSecretConfig(raw as never),
98
112
  });
99
113
 
100
114
  const resolveUser = async (headers: Headers) => {
@@ -10,6 +10,23 @@
10
10
  * register-ws-routes / run-boot-hook-list / start-scheduled-jobs.
11
11
  */
12
12
 
13
+ /**
14
+ * Speculatively import every server_package's mount module so Bun compiles
15
+ * the module graphs into its cache NOW, overlapping the (CPU-bound)
16
+ * compilation with whatever I/O the caller runs next. When mountServerPackages
17
+ * later imports the same paths they hit the cache (instant).
18
+ *
19
+ * Same pattern as create-boot-pipeline's speculative preloads — the
20
+ * cross-internet DB I/O in the gate chain (~3.4s) is dead time the compiler
21
+ * can use. Best-effort + non-fatal: a failed import here (wrong candidate
22
+ * path, transient resolution race) is silently swallowed because
23
+ * mountServerPackages will re-attempt with full error surfacing; the preload
24
+ * only warms the cache, never gates boot.
25
+ */
26
+
27
+ // Why: Speculatively import every server_package's mount module so …
28
+ // (full note hoisted to this file's leading header — kept out of the body per
29
+ // no-inline-prose-blocks / prose-stays-in-docs)
13
30
  import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
14
31
  import { getAppLogger } from "@teamscala/logger/app-loggers";
15
32
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
@@ -31,19 +48,6 @@ function mountCandidates(pkg: string): string[] {
31
48
  return [`${pkg}/mount.ts`, `${pkg}/boot/mount.ts`, pkg];
32
49
  }
33
50
 
34
- /**
35
- * Speculatively import every server_package's mount module so Bun compiles
36
- * the module graphs into its cache NOW, overlapping the (CPU-bound)
37
- * compilation with whatever I/O the caller runs next. When mountServerPackages
38
- * later imports the same paths they hit the cache (instant).
39
- *
40
- * Same pattern as create-boot-pipeline's speculative preloads — the
41
- * cross-internet DB I/O in the gate chain (~3.4s) is dead time the compiler
42
- * can use. Best-effort + non-fatal: a failed import here (wrong candidate
43
- * path, transient resolution race) is silently swallowed because
44
- * mountServerPackages will re-attempt with full error surfacing; the preload
45
- * only warms the cache, never gates boot.
46
- */
47
51
  export async function preloadServerPackages(packages: string[]): Promise<void> {
48
52
  await Promise.allSettled(
49
53
  packages.flatMap((pkg) =>
@@ -70,8 +74,6 @@ export async function mountServerPackages(
70
74
  return mountRegistry;
71
75
  }
72
76
 
73
-
74
-
75
77
  for (const pkg of serverPackages) {
76
78
  try {
77
79
  let mod: {
@@ -23,7 +23,6 @@ import { pgBossHandlerRegistry } from "./pg-boss-handler-registry.ts";
23
23
 
24
24
  // Bundled pg-boss handlers self-register on import (package-subscriber shape).
25
25
  // External handler packages register via their own boot hook / subscriber import.
26
- import "./handlers/prune-stale-data.ts";
27
26
  import "./handlers/projects-digest.ts";
28
27
  import "./handlers/sweep-event-log.ts";
29
28
  // `sweep-host-cache` is Rust-native: loga (the job's service_slug) registers it
@@ -36,7 +35,6 @@ import "./handlers/upstream-issue-sync.ts";
36
35
  import "./handlers/scala-tools-currency-watch.ts";
37
36
  import "./handlers/singleton-tools-currency-watch.ts";
38
37
  import "./handlers/registry-stale-inactive-sweep.ts";
39
- import "./handlers/pane-outbox-drain.ts";
40
38
  import "./handlers/platform-fault-escalation.ts";
41
39
 
42
40
  /**
@@ -298,16 +296,11 @@ export async function mountPgBoss(
298
296
  }
299
297
  }
300
298
 
301
- // Reconcile orphaned cron schedules: unschedule any pg-boss schedule that no
302
- // longer corresponds to an ACTIVE registry row. Deactivating/deleting a
303
- // scheduled_job (or sync_job_handler) previously left its cron lingering in
304
- // pgboss.schedule, firing indefinitely — 2 stale web-extractor schedules
305
- // found 2026-07-27, still scraping every 3/15min months after the job was meant
306
- // to be one-off. Deactivation must actually stop the cron. A schedule is
307
- // "backed" iff ANY active registry row's slug OR queue_name equals its name —
308
- // this covers scheduled_job AND sync_job_handler (and any future
309
- // schedule-owning type) without hardcoding the type list, so live sync jobs
310
- // (zoho_assist_sync, trackabi, …) are never touched. Runs on every pg-boss
299
+ // Reconcile orphaned cron schedules: unschedule any pg-boss schedule that no longer corresponds to an ACTIVE registry row. Deactivating/deleting a
300
+ // scheduled_job (or sync_job_handler) previously left its cron lingering in pgboss.schedule, firing indefinitely — 2 stale web-extractor schedules
301
+ // found 2026-07-27, still scraping every 3/15min months after the job was meant to be one-off. Deactivation must actually stop the cron. A schedule is
302
+ // "backed" iff ANY active registry row's slug OR queue_name equals its name — this covers scheduled_job AND sync_job_handler (and any future
303
+ // schedule-owning type) without hardcoding the type list, so live sync jobs (zoho_assist_sync, trackabi, …) are never touched. Runs on every pg-boss
311
304
  // mount and reconciles GLOBALLY: an orphan is removed regardless of which
312
305
  // service created it, since a cron with no active backing row is drift by
313
306
  // definition. A service with zero active scheduled jobs early-returns above
@@ -18,6 +18,7 @@ import type { AccessConfig, ContentGateConfig } from "./content-gate/config-type
18
18
  import { signAccessToken, verifyAccessToken } from "./content-gate/token.ts";
19
19
  import { resolveProposalAuthorizedEmails } from "./proposal-authorized-emails.ts";
20
20
  import { propTtlMs } from "./gate-ttls.ts";
21
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
21
22
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
22
23
  import * as v from "valibot";
23
24
 
@@ -49,15 +50,19 @@ const ProposalGateSettingsSchema = v.object({
49
50
  });
50
51
 
51
52
  async function loadProposalGateSettings(): Promise<ProposalGateSettings> {
53
+ let settingsFailed = false;
52
54
  try {
53
55
  return await loadRegistryConfig(
54
56
  "config",
55
57
  "content-gate-proposal",
56
58
  ProposalGateSettingsSchema,
57
59
  );
58
- } catch {
59
- return PROPOSAL_GATE_DEFAULTS;
60
+ } catch (error) {
61
+ getAppLogger().warn("[proposal-gate] settings read failed — defaults", error);
62
+ settingsFailed = true;
60
63
  }
64
+ if (settingsFailed) return PROPOSAL_GATE_DEFAULTS;
65
+ return PROPOSAL_GATE_DEFAULTS;
61
66
  }
62
67
 
63
68
  const PROP_COOKIE = "prop_token";
@@ -11,8 +11,8 @@
11
11
  * hydration. These tests pin that a manifest change re-substitutes the HTML.
12
12
  */
13
13
 
14
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
14
15
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
16
  import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
  import { readHtmlTemplate } from "./read-html-template.ts";
@@ -20,28 +20,25 @@ import { readHtmlTemplate } from "./read-html-template.ts";
20
20
  let dir: string;
21
21
  let origCwd: string;
22
22
 
23
- function writeManifest(hydrate: string, styles: string): void {
24
- writeFileSync(
25
- join(dir, "dist", "client", "asset-manifest.json"),
26
- `${JSON.stringify({ stylesPath: styles, hydratePath: hydrate }, null, 2)}\n`,
27
- );
23
+ async function writeManifest(hydrate: string, styles: string): Promise<void> {
24
+ await Bun.file(join(dir, "dist", "client", "asset-manifest.json")).write(`${JSON.stringify({ stylesPath: styles, hydratePath: hydrate }, null, 2)}\n`);
28
25
  }
29
26
 
30
- beforeEach(() => {
27
+ beforeEach(async () => {
31
28
  origCwd = process.cwd();
32
- dir = mkdtempSync(join(tmpdir(), "read-html-template-"));
33
- mkdirSync(join(dir, "dist", "client"), { recursive: true });
29
+ dir = await mkdtemp(join(tmpdir(), "read-html-template-"));
30
+ await mkdir(join(dir, "dist", "client"), { recursive: true });
34
31
  process.chdir(dir);
35
32
  });
36
33
 
37
- afterEach(() => {
34
+ afterEach(async () => {
38
35
  process.chdir(origCwd);
39
- rmSync(dir, { recursive: true, force: true });
36
+ await rm(dir, { recursive: true, force: true });
40
37
  });
41
38
 
42
39
  describe("readHtmlTemplate manifest-keyed cache", () => {
43
40
  test("renders the shared Puck document without a per-service HTML file", async () => {
44
- writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
41
+ await writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
45
42
  const html = await readHtmlTemplate();
46
43
  expect(html).toContain('<div id="__page"><!--ssr-outlet--></div>');
47
44
  expect(html).toContain("<!--head-outlet-->");
@@ -50,21 +47,21 @@ describe("readHtmlTemplate manifest-keyed cache", () => {
50
47
  });
51
48
 
52
49
  test("substitutes the current manifest hashes", async () => {
53
- writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
50
+ await writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
54
51
  const html = await readHtmlTemplate();
55
52
  expect(html).toContain("/assets/hydrate-aaa.js");
56
53
  expect(html).toContain("/assets/styles-aaa.css");
57
54
  });
58
55
 
59
56
  test("re-substitutes after a post-boot manifest change (the 404 regression)", async () => {
60
- writeManifest("/assets/hydrate-old.js", "/assets/styles-old.css");
57
+ await writeManifest("/assets/hydrate-old.js", "/assets/styles-old.css");
61
58
  const first = await readHtmlTemplate();
62
59
  expect(first).toContain("/assets/hydrate-old.js");
63
60
 
64
61
  // Simulate build-client-assets rebuilding: new manifest, old bundle gone.
65
62
  // mtime must advance for the stat-keyed cache to invalidate.
66
63
  await Bun.sleep(10);
67
- writeManifest("/assets/hydrate-new.js", "/assets/styles-new.css");
64
+ await writeManifest("/assets/hydrate-new.js", "/assets/styles-new.css");
68
65
 
69
66
  const second = await readHtmlTemplate();
70
67
  expect(second).toContain("/assets/hydrate-new.js");
@@ -101,7 +101,8 @@ async function loadManifest(): Promise<AssetManifest> {
101
101
  if (parsed.stylesPath && parsed.hydratePath) {
102
102
  return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
103
103
  }
104
- } catch {}
104
+ } catch (error) {}
105
+ getAppLogger().warn("asset manifest unreadable — falling back to default template wiring", error);
105
106
  }
106
107
  return FALLBACK;
107
108
  }
@@ -11,6 +11,7 @@
11
11
  * the normal handler rather than producing a hard 500.
12
12
  */
13
13
 
14
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
14
15
  import type { Context, Next } from "hono";
15
16
 
16
17
  let _ready = false;
@@ -33,7 +34,7 @@ export async function readinessGate(c: Context, next: Next): Promise<Response |
33
34
  // handler — but the failure must be OBSERVABLE, not silent. Practically
34
35
  // unreachable (the body is pure reads of module flags + c.req.path); the
35
36
  // log surfaces a regression if it ever fires.
36
- console.error("[readiness-gate] gate threw — failing open to handler", err);
37
+ getAppLogger().error("[readiness-gate] gate threw — failing open to handler", { error: String(err) });
37
38
  return next();
38
39
  }
39
40
  }
@@ -8,6 +8,8 @@
8
8
  * identification. Per constitution crash-isolation.
9
9
  */
10
10
 
11
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
12
+
11
13
  export function registerCrashHandlers(opts: { serviceSlug: string }): {
12
14
  unhandledRejection: boolean;
13
15
  uncaughtException: boolean;
@@ -17,20 +19,14 @@ export function registerCrashHandlers(opts: { serviceSlug: string }): {
17
19
 
18
20
  if (process.listenerCount("unhandledRejection") === 0) {
19
21
  process.on("unhandledRejection", (reason) => {
20
- console.error(
21
- `[scala-install:${opts.serviceSlug}] unhandledRejection:`,
22
- reason,
23
- );
22
+ getAppLogger().error(`[scala-install:${opts.serviceSlug}] unhandledRejection:`, { error: String(reason) });
24
23
  });
25
24
  unhandledRejection = true;
26
25
  }
27
26
 
28
27
  if (process.listenerCount("uncaughtException") === 0) {
29
28
  process.on("uncaughtException", (err) => {
30
- console.error(
31
- `[scala-install:${opts.serviceSlug}] uncaughtException:`,
32
- err,
33
- );
29
+ getAppLogger().error(`[scala-install:${opts.serviceSlug}] uncaughtException:`, { error: String(err) });
34
30
  });
35
31
  uncaughtException = true;
36
32
  }
@@ -9,6 +9,21 @@
9
9
  * any page route handler fires.
10
10
  */
11
11
 
12
+ // REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall straight through with appRouter=null and still register a client + log
13
+ // success. That client carries only the three platform routers below and NO CRUD models, which is indistinguishable from a healthy one until a
14
+ // page asks it for a model — at which point resolve-from-loader-config throws MissingLoaderModelCapabilityError, far from the actual fault.
15
+ //
16
+ // Measured 2026-08-20: scala-quiz and scala-forms served 500s on every data-backed page while /health returned 200 and this line logged "ORPC
17
+ // serverClient registered". The build had produced no generated router (its railpack.json ran registry codegen against an unreachable
18
+ // *.railway.internal host), and nothing anywhere said so.
19
+ //
20
+ // A service whose service_schema DECLARES models and has no generated router is not degraded, it is non-functional — so this fails the boot
21
+ // rather than reporting a state it has not verified (`a-component-may-not-report-a-state-it-has-not-verified`). A service
22
+ // that declares no models (daemon/portal shells owning no DB schema) still passes through cleanly.
23
+
24
+ // Why: REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false us…
25
+ // (full note hoisted to this file's leading header — kept out of the body per
26
+ // no-inline-prose-blocks / prose-stays-in-docs)
12
27
  import { join } from "node:path";
13
28
  import { getAppLogger } from "@teamscala/logger/app-loggers";
14
29
 
@@ -52,25 +67,6 @@ export async function registerPageServerClient(
52
67
  );
53
68
  }
54
69
  }
55
- // REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall
56
- // straight through with appRouter=null and still register a client + log
57
- // success. That client carries only the three platform routers below and
58
- // NO CRUD models, which is indistinguishable from a healthy one until a
59
- // page asks it for a model — at which point resolve-from-loader-config
60
- // throws MissingLoaderModelCapabilityError, far from the actual fault.
61
- //
62
- // Measured 2026-08-20: scala-quiz and scala-forms served 500s on every
63
- // data-backed page while /health returned 200 and this line logged "ORPC
64
- // serverClient registered". The build had produced no generated router
65
- // (its railpack.json ran registry codegen against an unreachable
66
- // *.railway.internal host), and nothing anywhere said so.
67
- //
68
- // A service whose service_schema DECLARES models and has no generated
69
- // router is not degraded, it is non-functional — so this fails the boot
70
- // rather than reporting a state it has not verified
71
- // (`a-component-may-not-report-a-state-it-has-not-verified`). A service
72
- // that declares no models (daemon/portal shells owning no DB schema)
73
- // still passes through cleanly.
74
70
  const declaredModels = await (async () => {
75
71
  try {
76
72
  const { loadRegistryConfig: loadCfg } = await import(
@@ -7,6 +7,22 @@
7
7
  * panel renders the real conversation on first paint. Extracted from
8
8
  * register-registry-page-route.ts (single-purpose-file split).
9
9
  */
10
+ /**
11
+ * SSR-prefetch the signed-in user's AI-chat conversation so a CLICK-opened AI
12
+ * panel renders the real conversation on first paint — never a loading gate
13
+ * (ssr-then-live-handoff). The AiChat block lives on the /ai PANEL page, which
14
+ * isn't rendered when the panel is closed, so its block serverLoader can't run
15
+ * at page-load; this prefetches server-side (regardless of panel open-state) +
16
+ * stamps pageData.aiChatSession, which AiChat reads on mount. The agent is
17
+ * resolved from the /ai panel page's own AiChat block (single source of truth —
18
+ * no duplicate config to drift). Fired in parallel with the post-processors +
19
+ * bounded by their window; a failure degrades silently to the client-side
20
+ * createOrRestore fallback, never an error page.
21
+ */
22
+
23
+ // Why: SSR-prefetch the signed-in user's AI-chat conversation so a …
24
+ // (full note hoisted to this file's leading header — kept out of the body per
25
+ // no-inline-prose-blocks / prose-stays-in-docs)
10
26
  import { getServerClient } from "../page-server-orpc/server-client.ts";
11
27
 
12
28
  /**
@@ -33,18 +49,6 @@ function findWebsiteId(root: unknown): string | undefined {
33
49
  return undefined;
34
50
  }
35
51
 
36
- /**
37
- * SSR-prefetch the signed-in user's AI-chat conversation so a CLICK-opened AI
38
- * panel renders the real conversation on first paint — never a loading gate
39
- * (ssr-then-live-handoff). The AiChat block lives on the /ai PANEL page, which
40
- * isn't rendered when the panel is closed, so its block serverLoader can't run
41
- * at page-load; this prefetches server-side (regardless of panel open-state) +
42
- * stamps pageData.aiChatSession, which AiChat reads on mount. The agent is
43
- * resolved from the /ai panel page's own AiChat block (single source of truth —
44
- * no duplicate config to drift). Fired in parallel with the post-processors +
45
- * bounded by their window; a failure degrades silently to the client-side
46
- * createOrRestore fallback, never an error page.
47
- */
48
52
  export async function prefetchAiChatSession(
49
53
  resolved: Record<string, unknown>,
50
54
  sessionUserId: string | undefined,