@teamscala/service-boot 0.23.284

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 (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Polls the installed scala-tools package against GitHub Packages and repairs
7
+ * stale daemon tooling by reinstalling and letting PM2 restart the process.
8
+ */
9
+
10
+ import { recordEvent } from "@teamscala/event-log/record-event";
11
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
12
+ import { spawn } from "@teamscala/os/spawn";
13
+ import { createWatchdog } from "@teamscala/watchdog/create-watchdog";
14
+ import type { WatchdogHandle } from "@teamscala/watchdog/types";
15
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
16
+ import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
17
+ import * as v from "valibot";
18
+
19
+ const PACKAGE_NAME = "@teamscala/scala-tools";
20
+ const PACKAGE_PATH = "node_modules/@teamscala/scala-tools/package.json";
21
+ const PACKAGE_URL = "https://npm.pkg.github.com/@teamgbg%2Fscala-tools";
22
+ const POLL_INTERVAL_MS = 120_000;
23
+
24
+ const DEFAULT_COMMAND_TIMEOUT_MS = 600_000;
25
+ const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
26
+ const CurrencyConfigSchema = v.looseObject({
27
+ command_timeout_ms: v.optional(v.number(), DEFAULT_COMMAND_TIMEOUT_MS),
28
+ fetch_timeout_ms: v.optional(v.number(), DEFAULT_FETCH_TIMEOUT_MS),
29
+ });
30
+ let cachedCurrencyConfig: { command_timeout_ms: number; fetch_timeout_ms: number } | null = null;
31
+ async function getCurrencyConfig(): Promise<{ command_timeout_ms: number; fetch_timeout_ms: number }> {
32
+ if (cachedCurrencyConfig) return cachedCurrencyConfig;
33
+ const cfg = await loadRegistryConfig("config", "scala-tools-currency", CurrencyConfigSchema);
34
+ cachedCurrencyConfig = {
35
+ command_timeout_ms: cfg?.command_timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS,
36
+ fetch_timeout_ms: cfg?.fetch_timeout_ms ?? DEFAULT_FETCH_TIMEOUT_MS,
37
+ };
38
+ return cachedCurrencyConfig;
39
+ }
40
+
41
+ let cachedGithubToken: string | null | undefined;
42
+ async function getGithubPackagesToken(): Promise<string | null> {
43
+ if (cachedGithubToken !== undefined) return cachedGithubToken;
44
+ const raw = await loadRegistryConfig("secret", "github-packages-token", v.unknown());
45
+ if (!raw) {
46
+ cachedGithubToken = null;
47
+ return null;
48
+ }
49
+ const decrypted = decryptSecretConfig(raw as never) as Record<string, unknown>;
50
+ cachedGithubToken = typeof decrypted.token === "string" ? decrypted.token : null;
51
+ return cachedGithubToken;
52
+ }
53
+
54
+ interface PackageManifest {
55
+ version?: unknown;
56
+ }
57
+
58
+ interface PackagePackument {
59
+ "dist-tags"?: { latest?: unknown };
60
+ }
61
+
62
+ let watchdog: WatchdogHandle | null = null;
63
+
64
+ async function readInstalledVersion(cwd: string): Promise<string | null> {
65
+ try {
66
+ const manifest = (await Bun.file(`${cwd}/${PACKAGE_PATH}`).json()) as PackageManifest;
67
+ return typeof manifest.version === "string" ? manifest.version : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ async function readLatestVersion(fetchTimeoutMs: number): Promise<string | null> {
74
+ const token = await getGithubPackagesToken();
75
+ if (!token) return null;
76
+ try {
77
+ const response = await fetch(PACKAGE_URL, {
78
+ headers: {
79
+ accept: "application/vnd.github+json",
80
+ authorization: `Bearer ${token}`,
81
+ },
82
+ signal: AbortSignal.timeout(fetchTimeoutMs),
83
+ });
84
+ if (!response.ok) return null;
85
+ const packument = (await response.json()) as PackagePackument;
86
+ const latest = packument["dist-tags"]?.latest;
87
+ return typeof latest === "string" ? latest : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ async function checkCurrency(): Promise<{ healthy: boolean; details?: Record<string, unknown> }> {
94
+ const logger = getAppLogger();
95
+ const cwd = process.cwd();
96
+ const cfg = await getCurrencyConfig();
97
+ const installed = await readInstalledVersion(cwd);
98
+ const latest = await readLatestVersion(cfg.fetch_timeout_ms);
99
+ if (!installed || !latest) {
100
+ return {
101
+ healthy: true,
102
+ details: { installed, latest, action: "skipped-unavailable-input" },
103
+ };
104
+ }
105
+ if (installed === latest) {
106
+ return { healthy: true, details: { installed, latest, action: "current" } };
107
+ }
108
+
109
+ logger.warn("scala-tools.currency.stale", { installed, latest, cwd });
110
+ recordEvent({
111
+ kind: "scala.tools.currency.stale",
112
+ payload: { package: PACKAGE_NAME, installed, latest, cwd },
113
+ });
114
+ const result = await spawn({
115
+ name: "scala-tools-currency-repair",
116
+ command: ["node_modules/.bin/scala-tools", "deps", "install", "--dir", cwd, "--force-resolve"],
117
+ cwd,
118
+ timeoutMs: cfg.command_timeout_ms,
119
+ captureStderr: true,
120
+ });
121
+ if (result.exitCode !== 0) {
122
+ logger.error("scala-tools.currency.repair_failed", {
123
+ installed,
124
+ latest,
125
+ exitCode: result.exitCode,
126
+ stderr: result.stderr.slice(0, 1000),
127
+ });
128
+ recordEvent({
129
+ kind: "scala.tools.currency.repair.failed",
130
+ payload: { package: PACKAGE_NAME, installed, latest, exitCode: result.exitCode },
131
+ });
132
+ return { healthy: false, details: { installed, latest, action: "repair-failed" } };
133
+ }
134
+
135
+ recordEvent({
136
+ kind: "scala.tools.currency.repaired",
137
+ payload: { package: PACKAGE_NAME, installed, latest },
138
+ });
139
+ logger.info("scala-tools.currency.repaired", { installed, latest });
140
+ setTimeout(() => process.exit(0), 25);
141
+ return { healthy: true, details: { installed, latest, action: "repaired-restarting" } };
142
+ }
143
+
144
+ export function startScalaToolsCurrencyWatchdog(): void {
145
+ if (watchdog) return;
146
+ watchdog = createWatchdog("scala-sentinel:scala-tools-currency", {
147
+ intervalMs: POLL_INTERVAL_MS,
148
+ runOnStart: false,
149
+ check: checkCurrency,
150
+ onDegraded: (result) => {
151
+ getAppLogger().error("scala-tools.currency.degraded", result.details ?? {});
152
+ },
153
+ });
154
+ watchdog.start();
155
+ }
156
+
157
+ export function stopScalaToolsCurrencyWatchdog(): void {
158
+ watchdog?.stop();
159
+ watchdog = null;
160
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Middleware that injects security headers into every response.
7
+ * Uses Hono's secureHeaders as the base for standard headers (X-Content-Type-Options,
8
+ * X-Frame-Options, Referrer-Policy, etc.), then layers registry-driven CSP with
9
+ * nonce injection on top.
10
+ */
11
+
12
+ import { getConfigObject } from "@teamscala/db/registry/config";
13
+ import type { Context, Next } from "hono";
14
+ import { secureHeaders } from "hono/secure-headers";
15
+
16
+ const FALLBACK_CSP = [
17
+ "script-src 'self'",
18
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
19
+ "default-src 'self'",
20
+ "img-src 'self' data:",
21
+ "connect-src 'self'",
22
+ "font-src 'self' https://fonts.gstatic.com",
23
+ "frame-ancestors 'none'",
24
+ "base-uri 'self'",
25
+ // Allow the platform's own *.scala.business origins as form targets so a
26
+ // cross-service form POST that 302-redirects (e.g. proposal Accept & Sign on
27
+ // proposals.scala.business redirecting to the agreement on sign.scala.business)
28
+ // is not blocked by form-action — CSP form-action governs the whole redirect
29
+ // chain, and 'self' alone blocks the cross-origin hop.
30
+ "form-action 'self' https://*.scala.business",
31
+ ].join("; ");
32
+
33
+ // X-Frame-Options is omitted: it only expresses DENY/SAMEORIGIN (can't
34
+ // allow-list the platform's own *.scala.business origins), and when both are
35
+ // present some browsers enforce it over CSP. Frame control is owned solely by
36
+ // CSP frame-ancestors (registry security-headers config), which allows
37
+ // platform self-framing — e.g. the GPT panel framing a forms page.
38
+ const baseSecureHeaders = secureHeaders({ xFrameOptions: false });
39
+
40
+ export async function injectSecurityHeaders(c: Context, next: Next) {
41
+ await baseSecureHeaders(c, async () => {});
42
+
43
+ const nonce = Bun.randomUUIDv7();
44
+ c.set("nonce", nonce);
45
+
46
+ const config = getConfigObject<Record<string, string>>("security-headers");
47
+ const cspTemplate = config?.["Content-Security-Policy"] || FALLBACK_CSP;
48
+ const csp = cspTemplate.replace(/\{\{nonce\}\}/g, nonce);
49
+
50
+ c.header("Content-Security-Policy", csp, { append: true });
51
+ if (config) {
52
+ for (const [key, value] of Object.entries(config)) {
53
+ if (key === "Content-Security-Policy") continue;
54
+ c.header(key, value);
55
+ }
56
+ }
57
+
58
+ await next();
59
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Unit test for serve.ts WebSocket upgrade path. Spins up a real
7
+ * Hono + createBunWebSocket + Bun.serve, registers a WS route, connects
8
+ * a client, and asserts message round-trip.
9
+ *
10
+ * Catches the class of bugs where fetchWithPeerPort clones the Request
11
+ * on WS upgrade (commit 43bb02b8e fixed this). If any future change to
12
+ * serve.ts breaks upgrade identity, this test fails.
13
+ */
14
+
15
+ import { afterEach, expect, test } from "bun:test";
16
+ import { Hono } from "hono";
17
+ import { createBunWebSocket } from "hono/bun";
18
+ import type { ServeHandle } from "./serve.ts";
19
+ import { serveStart } from "./serve.ts";
20
+
21
+ const TEST_PORT = 49152;
22
+ const WS_PATH = "/ws-test";
23
+
24
+ let handle: ServeHandle | null = null;
25
+
26
+ function setupTestServer(): ServeHandle {
27
+ const app = new Hono();
28
+ const { upgradeWebSocket } = createBunWebSocket();
29
+
30
+ app.get(
31
+ WS_PATH,
32
+ upgradeWebSocket(() => ({
33
+ onOpen(_evt, ws) {
34
+ const raw = ws.raw as { send: (data: string) => void } | undefined;
35
+ if (raw) {
36
+ raw.send("hello-from-server");
37
+ }
38
+ },
39
+ onMessage(evt, ws) {
40
+ const raw = ws.raw as { send: (data: string) => void } | undefined;
41
+ if (raw && typeof evt.data === "string") {
42
+ raw.send(`echo:${evt.data}`);
43
+ }
44
+ },
45
+ })),
46
+ );
47
+
48
+ const h = serveStart(app, {
49
+ port: TEST_PORT,
50
+ serviceSlug: "ws-test",
51
+ logger: { info: () => {}, warn: () => {} },
52
+ });
53
+ handle = h;
54
+ return h;
55
+ }
56
+
57
+ afterEach(() => {
58
+ if (handle) {
59
+ handle.stop();
60
+ handle = null;
61
+ }
62
+ });
63
+
64
+ test("WS upgrade succeeds and messages round-trip", async () => {
65
+ setupTestServer();
66
+
67
+ const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT}${WS_PATH}`);
68
+
69
+ const openPromise = new Promise<void>((resolve, reject) => {
70
+ const timer = setTimeout(
71
+ () => reject(new Error("WS connection timed out")),
72
+ 5000,
73
+ );
74
+ ws.addEventListener("open", () => {
75
+ clearTimeout(timer);
76
+ resolve();
77
+ });
78
+ ws.addEventListener("error", (evt) => {
79
+ clearTimeout(timer);
80
+ reject(new Error(`WS error: ${String(evt)}`));
81
+ });
82
+ });
83
+
84
+ await openPromise;
85
+
86
+ const greeting = await new Promise<string>((resolve, reject) => {
87
+ const timer = setTimeout(
88
+ () => reject(new Error("greeting message timed out")),
89
+ 5000,
90
+ );
91
+ ws.addEventListener("message", (evt) => {
92
+ clearTimeout(timer);
93
+ resolve(String(evt.data));
94
+ });
95
+ });
96
+ expect(greeting).toBe("hello-from-server");
97
+
98
+ const echoPromise = new Promise<string>((resolve, reject) => {
99
+ const timer = setTimeout(
100
+ () => reject(new Error("echo message timed out")),
101
+ 5000,
102
+ );
103
+ ws.addEventListener("message", (evt) => {
104
+ clearTimeout(timer);
105
+ resolve(String(evt.data));
106
+ });
107
+ });
108
+ ws.send("ping");
109
+ const echo = await echoPromise;
110
+ expect(echo).toBe("echo:ping");
111
+
112
+ ws.close();
113
+ });
114
+
115
+ test("WS upgrade rejects on unknown path", async () => {
116
+ setupTestServer();
117
+
118
+ const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT}/nonexistent`);
119
+
120
+ const result = await new Promise<{ ok: boolean; code?: number }>(
121
+ (resolve) => {
122
+ const timer = setTimeout(() => resolve({ ok: true }), 5000);
123
+ ws.addEventListener("error", () => {
124
+ clearTimeout(timer);
125
+ resolve({ ok: false });
126
+ });
127
+ ws.addEventListener("close", (evt) => {
128
+ clearTimeout(timer);
129
+ resolve({ ok: false, code: evt.code });
130
+ });
131
+ },
132
+ );
133
+
134
+ expect(result.ok).toBe(false);
135
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @system runtime-serve
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Listener primitives. Provides the Bun.serve production listener (serveStart)
7
+ * and the WebSocket upgrade helper (upgradeWebSocket) that work identically
8
+ * dev and prod.
9
+ */
10
+
11
+ import type { Hono } from "hono";
12
+ import { createBunWebSocket } from "hono/bun";
13
+
14
+ const _bunWs = createBunWebSocket();
15
+
16
+ /**
17
+ * Hono `upgradeWebSocket` helper bound to the same `createBunWebSocket()`
18
+ * pair that drives Bun.serve.websocket. Use exclusively from this package so
19
+ * the upgrade path and the message dispatch share state.
20
+ */
21
+ export const upgradeWebSocket = _bunWs.upgradeWebSocket;
22
+
23
+ export interface ServeOptions {
24
+ /** TCP port to bind. */
25
+ port: number;
26
+ /** For logging only. */
27
+ serviceSlug: string;
28
+ /**
29
+ * Optional logger; defaults to console.
30
+ */
31
+ logger?: { info(msg: string): void; warn(msg: string): void };
32
+ }
33
+
34
+ export interface ServeHandle {
35
+ server: ReturnType<typeof Bun.serve>;
36
+ stop(): void;
37
+ }
38
+
39
+ /**
40
+ * Create the fetch handler with peer-port injection. This function is
41
+ * shared between both the direct and mutable-ref modes.
42
+ */
43
+ function createFetchHandler(
44
+ app: Hono,
45
+ serviceSlug: string,
46
+ logger: { info(msg: string): void; warn(msg: string): void },
47
+ ) {
48
+ let peerPortFailures = 0;
49
+ return async (
50
+ req: Request,
51
+ server: ReturnType<typeof Bun.serve>,
52
+ ) => {
53
+ // app.fetch's second arg becomes c.env. Hono's bun WebSocket upgrade
54
+ // adapter checks `"server" in c.env` to find the Bun server reference;
55
+ // without { server }, /api/rpc/ws upgrade throws TypeError and the
56
+ // fleet relay can never subscribe.
57
+ //
58
+ // WebSocket upgrade requests MUST NOT be cloned — Bun's server.upgrade()
59
+ // tracks pending upgrades by Request identity, so a cloned Request can
60
+ // never upgrade. For WS requests we skip the peer-port clone and pass
61
+ // the original Request through.
62
+ const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
63
+ if (isUpgrade) {
64
+ return app.fetch(req, { server });
65
+ }
66
+ try {
67
+ const peer = (
68
+ server as unknown as {
69
+ requestIP(r: Request): { port?: number; address?: string } | null;
70
+ }
71
+ ).requestIP?.(req);
72
+ const port = peer?.port;
73
+ // x-caller-remote-address: the unforgeable transport-origin trust
74
+ // signal (no-client-controlled-trust-signals). server.requestIP is the
75
+ // ONE reliable address source (Hono's getConnInfo does not expose
76
+ // address on Bun). ALWAYS sanitize (delete any client-supplied spoof)
77
+ // then set the verified socket peer — the @teamscala/mcp-tool-runtime
78
+ // /mcp discriminator reads this to gate the bearer on loopback-only.
79
+ const address = peer?.address ?? null;
80
+ const needsPort =
81
+ typeof port === "number" &&
82
+ port > 0 &&
83
+ !req.headers.has("x-caller-remote-port");
84
+ if (needsPort || address !== null) {
85
+ const headers = new Headers(req.headers);
86
+ if (needsPort) headers.set("x-caller-remote-port", String(port));
87
+ if (address) {
88
+ headers.delete("x-caller-remote-address");
89
+ headers.set("x-caller-remote-address", String(address));
90
+ }
91
+ const cloned = new Request(req.url, {
92
+ method: req.method,
93
+ headers,
94
+ body: req.body,
95
+ signal: req.signal,
96
+ // @ts-expect-error duplex required by spec when body is a stream
97
+ duplex: "half",
98
+ });
99
+ return app.fetch(cloned, { server });
100
+ }
101
+ } catch (err) {
102
+ // Fail-open NOISY (protective-layers-fail-explicitly): the request
103
+ // must always be served, but a broken peer-port resolution degrades
104
+ // caller-identity to baked-stale headers. Aggregated: first +
105
+ // every 1000th, never per-request spam.
106
+ peerPortFailures++;
107
+ if (peerPortFailures === 1 || peerPortFailures % 1000 === 0) {
108
+ logger.warn(
109
+ `[boot-serve:${serviceSlug}] peer-port resolution failed ${peerPortFailures}x — caller-identity degrades to header fallback: ${err instanceof Error ? err.message : String(err)}`,
110
+ );
111
+ }
112
+ }
113
+ return app.fetch(req, { server });
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Internal serve setup that's shared between both modes.
119
+ */
120
+ function serveStartInternal(
121
+ fetchHandler: (req: Request, server: ReturnType<typeof Bun.serve>) => Promise<Response>,
122
+ options: ServeOptions,
123
+ logger: { info(msg: string): void; warn(msg: string): void },
124
+ mode: "direct" | "mutable-ref",
125
+ ): ServeHandle {
126
+ const server = Bun.serve({
127
+ port: options.port,
128
+ reusePort: true,
129
+ // Bun's default idleTimeout (~10s, swept on a coarse timer wheel)
130
+ // FIN-closes every pending connection in one ~2ms sweep once their
131
+ // handlers exceed it — packet-captured 2026-06-06: concurrent SSR
132
+ // requests all died at the same instant at 8.2-10.5s with clean
133
+ // server-side FINs while the renders completed and logged 200. THIS
134
+ // file is the serve path for kind:ui services (service-runtime's
135
+ // twin got the option first and the deploy changed nothing — the
136
+ // wrong twin). 120s bounds wedged connections without guillotining
137
+ // slow SSR.
138
+ idleTimeout: 120,
139
+ fetch: fetchHandler as never,
140
+ websocket: _bunWs.websocket,
141
+ });
142
+ // Export the actual listening port to process.env so loopback code reads the
143
+ // REAL port without a separate PORT injection. The central-tool-forwarder
144
+ // (chat tool dispatch) falls back to http://localhost:${PORT} for the
145
+ // co-located /mcp; without this export every chat tool call fails with
146
+ // "cannot resolve the /mcp base URL" (the highlight-ring end-to-end blocker,
147
+ // 2026-07-02). Respect Railway's $PORT when present.
148
+ if (!process.env.PORT) process.env.PORT = String(server.port);
149
+ logger.info(
150
+ `[runtime-serve:${options.serviceSlug}] Bun.serve listening on :${server.port} (${mode})`,
151
+ );
152
+ return {
153
+ server,
154
+ stop() {
155
+ server.stop(true);
156
+ },
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Start Bun.serve listening on the configured port. Used by:
162
+ * - bootUiService when running the production bundle (`bun dist/server/index.js`)
163
+ * - bootBackendService for non-UI services
164
+ *
165
+ * UI services in dev mode also call this — they run `bun src/index.ts`
166
+ * directly under PM2 with the same Bun.serve listener.
167
+ *
168
+ * Two-phase boot variant: when useMutableRef is true, `app` is used as the
169
+ * initial app for the mutable reference (minimal tool-only surface), and the
170
+ * caller can swap to the full app later via swapApp().
171
+ */
172
+ export function serveStart(
173
+ app: Hono,
174
+ options: ServeOptions,
175
+ useMutableRef = false,
176
+ ): ServeHandle {
177
+ const log = options.logger ?? console;
178
+
179
+ // For two-phase boot: initialize the mutable app reference with the
180
+ // minimal tool-only app. The caller will swap to the full app later.
181
+ if (useMutableRef) {
182
+ const { initMutableAppRef, fetchWithMutableApp } = require("../mutable-app-ref");
183
+ initMutableAppRef(app, "minimal");
184
+ return serveStartInternal(fetchWithMutableApp, options, log, "mutable-ref");
185
+ }
186
+
187
+ // Direct mode: use the app directly
188
+ const fetchHandler = createFetchHandler(app, options.serviceSlug, log);
189
+ return serveStartInternal(fetchHandler, options, log, "direct");
190
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Fixture suite for the branded HTML render-error page. Asserts the
7
+ * user-observable outcome (error-responses-via-error-report): a page render
8
+ * failure serves a branded HTML page — never JSON — carrying the status,
9
+ * Retry-After, and the errorId that ties the screen to GlitchTip + the log.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import type { ErrorReport } from "@teamscala/os/errors/error-report";
14
+ import { buildServerErrorPageResponse } from "./server-error-page.ts";
15
+
16
+ function transientReport(over: Partial<ErrorReport> = {}): ErrorReport {
17
+ return {
18
+ errorId: "abc-123",
19
+ name: "SafeFallbackTimeoutError",
20
+ code: "RENDER_TIMEOUT",
21
+ message: "safe-fallback exceeded 45000ms",
22
+ transient: true,
23
+ retryAfter: 5,
24
+ reason: "render timed out under load",
25
+ stackHead: "",
26
+ http: { status: 503, body: {}, headers: {} },
27
+ ...over,
28
+ };
29
+ }
30
+
31
+ function hardReport(over: Partial<ErrorReport> = {}): ErrorReport {
32
+ return {
33
+ errorId: "xyz-789",
34
+ name: "TypeError",
35
+ code: "UNKNOWN",
36
+ message: "cannot read properties of undefined",
37
+ transient: false,
38
+ stackHead: "",
39
+ http: { status: 500, body: {}, headers: {} },
40
+ ...over,
41
+ };
42
+ }
43
+
44
+ describe("buildServerErrorPageResponse", () => {
45
+ test("transient report → 503 HTML page with Retry-After + transient heading + errorId", async () => {
46
+ const res = buildServerErrorPageResponse(transientReport());
47
+ expect(res.status).toBe(503);
48
+ expect(res.headers.get("content-type")).toContain("text/html");
49
+ expect(res.headers.get("retry-after")).toBe("5");
50
+ expect(res.headers.get("cache-control")).toBe("no-store, no-transform");
51
+ const html = await res.text();
52
+ // transient wording, not the hard-error wording (apostrophe is escaped)
53
+ expect(html).toContain("taking longer");
54
+ expect(html).not.toContain("load this page");
55
+ // errorId present for browser↔GlitchTip correlation
56
+ expect(html).toContain("abc-123");
57
+ });
58
+
59
+ test("hard error report → 500 HTML page, no Retry-After, error heading", async () => {
60
+ const res = buildServerErrorPageResponse(hardReport());
61
+ expect(res.status).toBe(500);
62
+ expect(res.headers.get("retry-after")).toBeNull();
63
+ const html = await res.text();
64
+ expect(html).toContain("load this page");
65
+ // the real message is shown so the operator has an explanation
66
+ expect(html).toContain("cannot read properties of undefined");
67
+ expect(html).toContain("xyz-789");
68
+ });
69
+
70
+ test("logo is embedded when provided, omitted when not", async () => {
71
+ const withLogo = buildServerErrorPageResponse(transientReport(), "/assets/logo.png");
72
+ expect((await withLogo.text())).toContain('src="/assets/logo.png"');
73
+ const noLogo = buildServerErrorPageResponse(transientReport());
74
+ expect((await noLogo.text())).not.toContain("<img");
75
+ });
76
+
77
+ test("logo ships INLINE as a data URI with reserved width+height (no post-paint proxy pop-in)", async () => {
78
+ // resolveErrorPageLogo returns a data URI; the builder must emit it
79
+ // verbatim with reserved width+height so the logo paints in the INITIAL
80
+ // HTML bytes — never a /api/assets/ proxy <img> that pops in after paint
81
+ // and fails under degraded conditions (logos-inline-on-branded-server-pages,
82
+ // operator-observed 2026-07-27 on a DEGRADED render-layer holding page).
83
+ const dataUri = "data:image/png;base64,iVBORw0KGgoAAAENSUhEUg==";
84
+ const res = buildServerErrorPageResponse(transientReport(), dataUri);
85
+ const html = await res.text();
86
+ expect(html).toContain(`src="${dataUri}"`);
87
+ expect(html).toContain('width="160"');
88
+ expect(html).toContain('height="40"');
89
+ // no proxy fetch — the logo is in the bytes, not a second request
90
+ expect(html).not.toContain('src="/api/assets/');
91
+ });
92
+
93
+ test("html-escapes the explanation + errorId (no XSS injection)", async () => {
94
+ const res = buildServerErrorPageResponse(
95
+ hardReport({ message: "<script>alert(1)</script>", errorId: "<b>" }),
96
+ );
97
+ const html = await res.text();
98
+ expect(html).not.toContain("<script>alert(1)</script>");
99
+ expect(html).toContain("&lt;script&gt;");
100
+ expect(html).not.toContain("<b>");
101
+ });
102
+ });