@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,171 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Boots a `kind: "ui"` service. Reads the service_install registry row and
7
+ * orchestrates a fixed pipeline of single-purpose stages onto one Hono app
8
+ * served by @teamscala/service-runtime/serve (one Bun.serve in dev and prod).
9
+ */
10
+
11
+ import { recordEvent } from "@teamscala/event-log/record-event";
12
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
13
+ import { BootFatalError } from "@teamscala/os/errors/boot-error";
14
+ import { Hono } from "hono";
15
+ import type { ServiceInstallConfig } from "./load-service-install-row.ts";
16
+ import { setReady } from "./readiness-gate.ts";
17
+ import { serveStart } from "./serve/serve";
18
+ import { buildMinimalApp, buildFullApp, swapToFullApp } from "./two-phase-boot.ts";
19
+
20
+ export interface ResolvedBlock {
21
+ slug: string;
22
+ packagePath: string;
23
+ }
24
+
25
+ export interface UiServiceBootResult {
26
+ kind: "ui";
27
+ blocks: ResolvedBlock[];
28
+ routes: string[];
29
+ port: number;
30
+ app: Hono;
31
+ }
32
+
33
+ /**
34
+ * Page-server symbols injected by the caller. service-boot is tier 4
35
+ * (server) and must not import from tier 2 (`@teamscala/service-runtime`)
36
+ * per `installer-and-runtime` Rule 1. The runtime's bin entrypoint
37
+ * boot pipeline imports these from `page-server/` (same package —
38
+ * legal) and passes them down here.
39
+ */
40
+ export interface PageServerDeps {
41
+ createPageHandler: import("./types.ts").CreatePageHandler;
42
+ }
43
+
44
+ /**
45
+ * Boots a UI service using two-phase boot for fast availability.
46
+ *
47
+ * Phase 1 (fast): Build and serve minimal tool-only app (ORPC, data routes,
48
+ * auth gates). This becomes available within ~10s, enabling the MCP tool
49
+ * surface to accept requests immediately after restart.
50
+ *
51
+ * Phase 2 (background): Build full app (pages, hydration) and atomically swap
52
+ * it in. The readiness gate flips to ready after Phase 1, not Phase 2.
53
+ *
54
+ * The interim mitigation (503 + gateway retry + circuit breaker) remains in
55
+ * place and is NOT undone.
56
+ */
57
+ export async function bootUiService(
58
+ config: ServiceInstallConfig,
59
+ serviceSlug: string,
60
+ deps: PageServerDeps,
61
+ databaseUrl: string,
62
+ ): Promise<UiServiceBootResult> {
63
+ const log = getAppLogger();
64
+ const startTime = Date.now();
65
+
66
+ // Phase 1: Build minimal tool-only app
67
+ const minimalAppResult = await buildMinimalApp(config, serviceSlug, databaseUrl);
68
+
69
+ // Get port from config
70
+ const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
71
+ const port = requireValidPort(
72
+ envPort && Number.isFinite(envPort) && envPort > 0 ? envPort : config.port,
73
+ serviceSlug,
74
+ );
75
+
76
+ // Start serving with the minimal app (useMutableRef=true)
77
+ try {
78
+ serveStart(minimalAppResult.app, {
79
+ port,
80
+ serviceSlug,
81
+ }, true); // useMutableRef=true
82
+ } catch (err) {
83
+ throw new BootFatalError(
84
+ "ui-service.server-bind",
85
+ serviceSlug,
86
+ `Bun.serve failed to bind on port ${port}. Error: ${err instanceof Error ? err.message : err}`,
87
+ err,
88
+ );
89
+ }
90
+
91
+ const minimalReadyTime = Date.now();
92
+ const minimalDuration = minimalReadyTime - startTime;
93
+ log.info(
94
+ `[ui-service-boot:${serviceSlug}] Minimal app serving on port ${port} in ${minimalDuration}ms`,
95
+ );
96
+
97
+ // FLIP READY SWITCH: minimal app is ready, MCP tools can connect
98
+ setReady();
99
+ log.info(
100
+ `[ui-service-boot:${serviceSlug}] Ready gate flipped - MCP tools can now connect (full app building in background)`,
101
+ );
102
+
103
+ // Phase 2: Build full app in background (non-blocking)
104
+ ;(async () => {
105
+ try {
106
+ const fullAppResult = await buildFullApp(minimalAppResult, deps);
107
+ const fullBuildTime = Date.now();
108
+ const fullDuration = fullBuildTime - startTime;
109
+
110
+ // Swap the app reference atomically
111
+ swapToFullApp(fullAppResult.app, serviceSlug);
112
+
113
+ log.info(
114
+ `[ui-service-boot:${serviceSlug}] Full app swapped in after ${fullDuration}ms (${fullDuration - minimalDuration}ms after minimal app)`,
115
+ );
116
+ } catch (err) {
117
+ const reason = err instanceof Error ? err.message : String(err);
118
+ log.error(
119
+ `[ui-service-boot:${serviceSlug}] Failed to build full app, continuing with minimal app: ${reason}`,
120
+ );
121
+ // NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
122
+ // (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
123
+ // The minimal-app fallback keeps serving SSR from the LAST-successful
124
+ // manifest (now a tombstone, see build-client-assets.ts), so the page
125
+ // looks "up" while the client bundle is stale. Without this signal the
126
+ // failure is buried in boot stderr and a published fix that reached
127
+ // node_modules but failed to bundle ships invisibly stale (the
128
+ // scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
129
+ // served pre-fix bytes across restarts while node_modules was
130
+ // mid-install). The boot.client-build-failed event routes into
131
+ // event-log's 7-day retention → surfaces in GlitchTip as an incident
132
+ // trail the operator actually watches.
133
+ recordEvent({
134
+ kind: "boot.client-build-failed",
135
+ payload: {
136
+ service: serviceSlug,
137
+ reason: reason.slice(0, 2000),
138
+ at: new Date().toISOString(),
139
+ },
140
+ });
141
+ // Continue with minimal app - better than crashing
142
+ }
143
+ })();
144
+
145
+ // Return result compatible with the existing UiServiceBootResult interface
146
+ // Note: blocks/routes will be empty initially since they're built in the background.
147
+ // The service is operational for MCP tools.
148
+ return {
149
+ kind: "ui",
150
+ blocks: [], // Will be populated when full app builds
151
+ routes: [], // Will be populated when full app builds
152
+ port,
153
+ app: minimalAppResult.app,
154
+ };
155
+ }
156
+
157
+ function requireValidPort(port: unknown, serviceSlug: string): number {
158
+ if (
159
+ typeof port !== "number" ||
160
+ !Number.isFinite(port) ||
161
+ port <= 0 ||
162
+ port > 65535
163
+ ) {
164
+ throw new BootFatalError(
165
+ "ui-service.port-validation",
166
+ serviceSlug,
167
+ `service_install row has invalid port "${port}" (must be a number 1-65535). Fix the service_install row before boot.`,
168
+ );
169
+ }
170
+ return port;
171
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Post-listen WebSocket self-test. Runs after serveStart binds the port.
7
+ * For each declared websocket_routes path, opens a synthetic WebSocket
8
+ * connection and asserts 101 upgrade. Non-101 → BootFatalError.
9
+ *
10
+ * This catches the class of bugs where:
11
+ * - WS route registered after ORPC catch-all (order-dependent 404)
12
+ * - fetchWithPeerPort clones the Request on WS upgrade (breaks Bun.upgrade identity)
13
+ * - websocket_routes config row is stale / missing a handler
14
+ *
15
+ * Runs synchronously after serveStart so the boot aborts before the service
16
+ * reports healthy.
17
+ */
18
+
19
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
20
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
21
+ import { BootFatalError } from "@teamscala/os/errors/boot-error";
22
+
23
+ const CONNECT_TIMEOUT_MS = 5_000;
24
+ const WS_CLOSE_DELAY_MS = 500;
25
+
26
+ interface WsSelfTestResult {
27
+ path: string;
28
+ ok: boolean;
29
+ reason?: string;
30
+ }
31
+
32
+ function connectWs(url: string): Promise<WsSelfTestResult> {
33
+ return new Promise((resolve) => {
34
+ const timer = setTimeout(() => {
35
+ resolve({
36
+ path: new URL(url).pathname,
37
+ ok: false,
38
+ reason: "connection timed out",
39
+ });
40
+ }, CONNECT_TIMEOUT_MS);
41
+
42
+ try {
43
+ const ws = new WebSocket(url);
44
+ ws.addEventListener("open", () => {
45
+ clearTimeout(timer);
46
+ setTimeout(() => {
47
+ try {
48
+ ws.close();
49
+ } catch {}
50
+ }, WS_CLOSE_DELAY_MS);
51
+ resolve({ path: new URL(url).pathname, ok: true });
52
+ });
53
+ ws.addEventListener("error", () => {
54
+ clearTimeout(timer);
55
+ resolve({
56
+ path: new URL(url).pathname,
57
+ ok: false,
58
+ reason: "WebSocket error event",
59
+ });
60
+ });
61
+ ws.addEventListener("close", (evt) => {
62
+ clearTimeout(timer);
63
+ if (evt.code !== 1000 && evt.code !== 1005) {
64
+ resolve({
65
+ path: new URL(url).pathname,
66
+ ok: false,
67
+ reason: `closed unexpectedly: code=${evt.code} reason="${evt.reason}"`,
68
+ });
69
+ }
70
+ });
71
+ } catch (err) {
72
+ clearTimeout(timer);
73
+ resolve({
74
+ path: new URL(url).pathname,
75
+ ok: false,
76
+ reason: `exception: ${err instanceof Error ? err.message : err}`,
77
+ });
78
+ }
79
+ });
80
+ }
81
+
82
+ export async function runWsBootSelfTest(
83
+ config: ServiceInstallConfig,
84
+ serviceSlug: string,
85
+ port: number,
86
+ ): Promise<void> {
87
+ const log = getAppLogger();
88
+ const routes = config.websocket_routes ?? [];
89
+ if (routes.length === 0) return;
90
+
91
+ const results: WsSelfTestResult[] = [];
92
+ for (const route of routes) {
93
+ const typedRoute = route as { path: string };
94
+ const url = `ws://127.0.0.1:${port}${typedRoute.path}`;
95
+ const result = await connectWs(url);
96
+ results.push(result);
97
+ }
98
+
99
+ const failures = results.filter((r) => !r.ok);
100
+ if (failures.length === 0) {
101
+ log.info(
102
+ `[scala-install:${serviceSlug}] WS boot self-test: ${results.length}/${results.length} route(s) OK`,
103
+ );
104
+ return;
105
+ }
106
+
107
+ const details = failures.map((f) => ` ${f.path}: ${f.reason}`).join("\n");
108
+ throw new BootFatalError(
109
+ "ws-boot-self-test",
110
+ serviceSlug,
111
+ `WebSocket self-test failed for ${failures.length}/${results.length} route(s). ` +
112
+ `The server is listening but WS upgrade did not succeed:\n${details}`,
113
+ );
114
+ }