@teamscala/service-boot 0.23.431 → 0.23.433

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 (78) 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 +45 -42
  6. package/src/bind-page-handler.ts +17 -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 +50 -57
  10. package/src/build-client-assets/input-fingerprint.ts +41 -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 +14 -10
  15. package/src/build-client-assets/styles-build.ts +20 -19
  16. package/src/build-client-assets.test.ts +2 -2
  17. package/src/build-client-assets.ts +13 -22
  18. package/src/content-gate/access.ts +5 -2
  19. package/src/content-gate/catalog.ts +20 -18
  20. package/src/content-gate/gate.ts +4 -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 +24 -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 +18 -17
  39. package/src/handlers/platform-fault-escalation/orpc-client.ts +35 -18
  40. package/src/handlers/projects-digest.ts +1 -1
  41. package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
  42. package/src/handlers/scala-tools-currency-watch.ts +20 -32
  43. package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
  44. package/src/handlers/singleton-tools-currency-watch.ts +21 -19
  45. package/src/handlers/upstream-issue-sync.ts +11 -4
  46. package/src/handlers/whatsapp-liveness.ts +14 -4
  47. package/src/install-harness.ts +1 -1
  48. package/src/install-logging.ts +1 -1
  49. package/src/mount-orpc-router.test.ts +56 -10
  50. package/src/mount-orpc-router.ts +23 -9
  51. package/src/mount-server-packages.ts +14 -15
  52. package/src/pg-boss-mount.ts +5 -12
  53. package/src/proposal-gate.ts +7 -2
  54. package/src/read-html-template.test.ts +12 -15
  55. package/src/read-html-template.ts +2 -1
  56. package/src/readiness-gate.ts +2 -1
  57. package/src/register-crash-handlers.ts +4 -8
  58. package/src/register-page-server-client.ts +12 -19
  59. package/src/register-registry-page-route/ai-chat-prefetch.ts +13 -12
  60. package/src/register-registry-page-route/apply-page-branding.ts +59 -56
  61. package/src/register-registry-page-route/graph-cache.ts +19 -18
  62. package/src/register-registry-page-route/meta-title.ts +21 -20
  63. package/src/register-registry-page-route/prefetch-org-id.ts +3 -2
  64. package/src/register-registry-page-route/proposal-approval-email-token.ts +2 -3
  65. package/src/register-registry-page-route/run-content-gates.ts +2 -4
  66. package/src/register-registry-page-route.ts +1 -2
  67. package/src/register-static-assets.ts +6 -5
  68. package/src/resolve-app-base-url.ts +12 -5
  69. package/src/resolvers/portal-route-paths.ts +12 -11
  70. package/src/resource-chat-inject.ts +75 -72
  71. package/src/resource-not-found.ts +20 -19
  72. package/src/scala-tools-currency-watchdog.ts +41 -12
  73. package/src/two-phase-full-app.ts +52 -29
  74. package/src/two-phase-minimal-app.ts +33 -31
  75. package/src/two-phase-readiness.ts +41 -38
  76. package/src/ui-service.ts +41 -38
  77. package/src/handlers/pane-outbox-drain.ts +0 -39
  78. package/src/handlers/prune-stale-data.ts +0 -110
@@ -11,20 +11,6 @@
11
11
  * byte-for-byte move; import paths adjusted only.
12
12
  */
13
13
 
14
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
15
- import * as v from "valibot";
16
-
17
- let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
18
- export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
19
- if (cachedSystemIdentity) return cachedSystemIdentity;
20
- const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
21
- cachedSystemIdentity = {
22
- userId: row?.system_user_id as string | undefined,
23
- organisationId: row?.system_org_id as string | undefined,
24
- };
25
- return cachedSystemIdentity;
26
- }
27
-
28
14
  /**
29
15
  * The system-authed in-process ORPC client, built LAZILY on first use from the
30
16
  * SAME composed appRouter the /api/rpc mount uses (getAppRouter — never a raw
@@ -40,9 +26,39 @@ export async function getSystemIdentity(): Promise<{ userId: string | undefined;
40
26
  * the assigned-client form with $queryRawUnsafe. Timestamps are ISO strings,
41
27
  * never Date instances (the generated valibot schemas require it).
42
28
  */
43
- let cachedOrpc: OrpcSurface | null = null;
29
+
30
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
31
+ import * as v from "valibot";
32
+ import { createCache } from "@teamscala/cache/create-cache";
33
+
34
+ const systemIdentityCache = createCache<{
35
+ userId: string | undefined;
36
+ organisationId: string | undefined;
37
+ }>("service-boot:fault-escalation-system-identity", {
38
+ ttlMs: Number.POSITIVE_INFINITY,
39
+ maxSize: 1,
40
+ });
41
+
42
+ export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
43
+ const hit = systemIdentityCache.get("default");
44
+ if (hit) return hit;
45
+ const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
46
+ const identity = {
47
+ userId: row?.system_user_id as string | undefined,
48
+ organisationId: row?.system_org_id as string | undefined,
49
+ };
50
+ systemIdentityCache.set("default", identity);
51
+ return identity;
52
+ }
53
+
54
+ const orpcSurfaceCache = createCache<OrpcSurface>(
55
+ "service-boot:fault-escalation-orpc",
56
+ { ttlMs: Number.POSITIVE_INFINITY, maxSize: 1 },
57
+ );
58
+
44
59
  export async function orpc(): Promise<OrpcSurface> {
45
- if (cachedOrpc) return cachedOrpc;
60
+ const hit = orpcSurfaceCache.get("default");
61
+ if (hit) return hit;
46
62
  const { createRouterClient } = await import("@orpc/server");
47
63
  const { getAppRouter } = await import("@teamscala/orpc/app-router");
48
64
  const appRouter = (await getAppRouter()) as Record<string, unknown> | null;
@@ -61,7 +77,7 @@ export async function orpc(): Promise<OrpcSurface> {
61
77
  "platform-identity",
62
78
  PlatformIdentitySchema,
63
79
  );
64
- cachedOrpc = createRouterClient(
80
+ const surface = createRouterClient(
65
81
  { ...appRouter } as never,
66
82
  {
67
83
  context: {
@@ -74,7 +90,8 @@ export async function orpc(): Promise<OrpcSurface> {
74
90
  },
75
91
  } as never,
76
92
  ) as unknown as OrpcSurface;
77
- return cachedOrpc;
93
+ orpcSurfaceCache.set("default", surface);
94
+ return surface;
78
95
  }
79
96
 
80
97
  /** The router subset this handler uses — mirrors the generated procedure args. */
@@ -4,7 +4,7 @@
4
4
  * @edit edit directly
5
5
  *
6
6
  * The `projects-daily-digest` / `projects-weekly-digest` pg-boss handlers for
7
- * scala-projects. Same fixed-query shape as prune-stale-data: no row-driven
7
+ * scala-projects. Fixed-query shape: no row-driven
8
8
  * SQL, every query is a literal Prisma call against work_items; the
9
9
  * scheduled_job row only carries the cron.
10
10
  *
@@ -1,5 +1,4 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { readFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
 
5
4
  /**
@@ -20,10 +19,7 @@ import { join } from "node:path";
20
19
  * the module proves it cannot fire on any path. It also stays honest if someone
21
20
  * reintroduces the restart behind a new condition.
22
21
  */
23
- const SOURCE = readFileSync(
24
- join(import.meta.dir, "scala-tools-currency-watch.ts"),
25
- "utf-8",
26
- );
22
+ const SOURCE = await Bun.file(join(import.meta.dir, "scala-tools-currency-watch.ts")).text();
27
23
 
28
24
  /** Comment prose legitimately discusses the removed restart; code must not do it. */
29
25
  const CODE = SOURCE.split("\n")
@@ -25,6 +25,26 @@
25
25
  *
26
26
  * Registered under slug `@teamscala/service-boot:scala-tools-currency-watch`.
27
27
  */
28
+ // NO RESTART. The reinstall alone is sufficient, and restarting here was actively harmful.
29
+ //
30
+ // The premise this watch was written on — "restart so the fresh scala-tools loads" — does not hold for THIS package. scala-sentinel never
31
+ // imports `@teamscala/scala-tools`; it SPAWNS `node_modules/.bin/scala-tools` per invocation, so a freshly installed binary is picked up by the next
32
+ // spawn with no process restart at all. Verified 2026-07-27: the package is declared in scala-sentinel/package.json, yet scala-guard/src contains zero
33
+ // import statements resolving to it — it is spawned, never linked. dep-sync already encodes the same conclusion — HARNESS_CRITICAL_FAST_PATH
34
+ // maps this exact pair to install-WITHOUT-restart, while its PM2_CACHED sibling map exists for packages a daemon really does load at boot
35
+ // (@teamscala/codegen → codegen-daemon). This handler was the odd one out.
36
+ //
37
+ // The cost was not theoretical. Restarting the guard daemon takes enforcement offline for its warmup, and every shutdown logs "152 guard(s)
38
+ // failed to run (fail-open)" — so each unnecessary restart opened a window in which tool-check, turn-end and pre-push silently did not run. Three
39
+ // scala-tools versions published on 2026-07-26 alone, and this watch polls, so it re-fired for each. That inverts
40
+ // `infrastructure-outranks-application-durability`: a currency convenience was repeatedly disabling the thing it exists to keep current.
41
+ //
42
+ // It also used a bare `pm2 restart`, which is forbidden (`forbid-bare-pm2-restart`) precisely because it bypasses the
43
+ // currency-gated canonical pipeline — the guard blocks agents from that exact call.
44
+ //
45
+ // If a future package genuinely needs a restart to take effect, it does not belong here: add it to dep-sync's PM2_CACHED map, which already owns
46
+ // install-plus-restart and coalesces them.
47
+
28
48
  import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
29
49
  import { recordEvent } from "@teamscala/event-log/record-event";
30
50
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -124,38 +144,6 @@ async function scalaToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig }): Pr
124
144
  payload: { from: installed, to: latest },
125
145
  });
126
146
 
127
- // NO RESTART. The reinstall alone is sufficient, and restarting here was
128
- // actively harmful.
129
- //
130
- // The premise this watch was written on — "restart so the fresh
131
- // scala-tools loads" — does not hold for THIS package. scala-sentinel never
132
- // imports `@teamscala/scala-tools`; it SPAWNS `node_modules/.bin/scala-tools`
133
- // per invocation, so a freshly installed binary is picked up by the next
134
- // spawn with no process restart at all. Verified 2026-07-27: the package is
135
- // declared in scala-sentinel/package.json, yet scala-guard/src contains zero
136
- // import statements resolving to it — it is spawned, never linked.
137
- // dep-sync already encodes the same conclusion — HARNESS_CRITICAL_FAST_PATH
138
- // maps this exact pair to install-WITHOUT-restart, while its PM2_CACHED
139
- // sibling map exists for packages a daemon really does load at boot
140
- // (@teamscala/codegen → codegen-daemon). This handler was the odd one out.
141
- //
142
- // The cost was not theoretical. Restarting the guard daemon takes
143
- // enforcement offline for its warmup, and every shutdown logs "152 guard(s)
144
- // failed to run (fail-open)" — so each unnecessary restart opened a window
145
- // in which tool-check, turn-end and pre-push silently did not run. Three
146
- // scala-tools versions published on 2026-07-26 alone, and this watch polls,
147
- // so it re-fired for each. That inverts
148
- // `infrastructure-outranks-application-durability`: a currency convenience
149
- // was repeatedly disabling the thing it exists to keep current.
150
- //
151
- // It also used a bare `pm2 restart`, which is forbidden
152
- // (`forbid-bare-pm2-restart`) precisely because it bypasses the
153
- // currency-gated canonical pipeline — the guard blocks agents from that
154
- // exact call.
155
- //
156
- // If a future package genuinely needs a restart to take effect, it does not
157
- // belong here: add it to dep-sync's PM2_CACHED map, which already owns
158
- // install-plus-restart and coalesces them.
159
147
  log.info(
160
148
  `[scala-tools-currency-watch] reinstalled ${PKG} ${installed} → ${latest} (no restart: sentinel spawns the binary, it does not import it)`,
161
149
  );
@@ -1,11 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { readFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
 
5
- const SOURCE = readFileSync(
6
- join(import.meta.dir, "singleton-tools-currency-watch.ts"),
7
- "utf-8",
8
- );
4
+ const SOURCE = await Bun.file(join(import.meta.dir, "singleton-tools-currency-watch.ts")).text();
9
5
 
10
6
  const CODE = SOURCE.split("\n")
11
7
  .filter((l) => !l.trimStart().startsWith("//") && !l.trimStart().startsWith("*"))
@@ -23,14 +23,6 @@
23
23
  *
24
24
  * Registered under slug `@teamscala/service-boot:singleton-tools-currency-watch`.
25
25
  */
26
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
27
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
28
- import { recordEvent } from "@teamscala/event-log/record-event";
29
- import { getAppLogger } from "@teamscala/logger/app-loggers";
30
- import { spawn } from "@teamscala/os/spawn";
31
- import { getDistTagVersion } from "@teamscala/publish-registry";
32
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
33
-
34
26
  /**
35
27
  * Where a tool's "latest" comes from.
36
28
  *
@@ -47,6 +39,27 @@ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
47
39
  * never shadow a /usr/local/bin singleton wrapper. The gap was currency
48
40
  * tracking, never placement.
49
41
  */
42
+
43
+ // A GitHub-released binary is NOT installed by a package manager, so there
44
+ // is no "reinstall" verb to call — it would have to be downloaded, verified,
45
+ // made executable and swapped atomically over a file that may be RUNNING.
46
+ // That is a real install path with real failure modes (partial download,
47
+ // wrong architecture, replacing a binary mid-execution), and doing it badly
48
+ // is worse than not doing it: a half-written CLI is a broken CLI.
49
+ //
50
+ // So this reports the drift and stops. Detection is live from today; the
51
+ // automated swap is a separate, deliberate change. Reporting a version the
52
+ // watcher cannot actually install is honest — silently claiming currency
53
+ // would not be.
54
+
55
+ import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
56
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
57
+ import { recordEvent } from "@teamscala/event-log/record-event";
58
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
59
+ import { spawn } from "@teamscala/os/spawn";
60
+ import { getDistTagVersion } from "@teamscala/publish-registry";
61
+ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
62
+
50
63
  type VersionSource =
51
64
  | {
52
65
  type: "npm_package";
@@ -210,17 +223,6 @@ async function reinstallTool(tool: SingletonTool, latest: string): Promise<boole
210
223
  if (!vs) return false;
211
224
 
212
225
  if (vs.type === "github_release") {
213
- // A GitHub-released binary is NOT installed by a package manager, so there
214
- // is no "reinstall" verb to call — it would have to be downloaded, verified,
215
- // made executable and swapped atomically over a file that may be RUNNING.
216
- // That is a real install path with real failure modes (partial download,
217
- // wrong architecture, replacing a binary mid-execution), and doing it badly
218
- // is worse than not doing it: a half-written CLI is a broken CLI.
219
- //
220
- // So this reports the drift and stops. Detection is live from today; the
221
- // automated swap is a separate, deliberate change. Reporting a version the
222
- // watcher cannot actually install is honest — silently claiming currency
223
- // would not be.
224
226
  log.warn(
225
227
  `[singleton-tools-currency] ${tool.name}: STALE, latest ${latest} from ${vs.repo}. Automated github_release install is not implemented — update it manually.`,
226
228
  );
@@ -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,20 @@
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
+
13
27
  import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
14
28
  import { getAppLogger } from "@teamscala/logger/app-loggers";
15
29
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
@@ -31,19 +45,6 @@ function mountCandidates(pkg: string): string[] {
31
45
  return [`${pkg}/mount.ts`, `${pkg}/boot/mount.ts`, pkg];
32
46
  }
33
47
 
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
48
  export async function preloadServerPackages(packages: string[]): Promise<void> {
48
49
  await Promise.allSettled(
49
50
  packages.flatMap((pkg) =>
@@ -70,8 +71,6 @@ export async function mountServerPackages(
70
71
  return mountRegistry;
71
72
  }
72
73
 
73
-
74
-
75
74
  for (const pkg of serverPackages) {
76
75
  try {
77
76
  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";