@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,180 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Routes every service exposes regardless of registry config: /health
7
+ * (liveness probe), /favicon.svg (default brand mark), /api/assets/*
8
+ * (S3 asset proxy), and /assets/* (client bundle + stylesheet produced
9
+ * by build-client-assets.ts into dist/client/, served identically in
10
+ * dev and prod per scala-os/ui.md `bun-build-only`).
11
+ */
12
+
13
+ import { join } from "node:path";
14
+ import type { Hono } from "hono";
15
+ import { etag } from "hono/etag";
16
+ import { buildErrorReport } from "@teamscala/os/errors/error-report";
17
+ import { handleAssetProxy } from "./asset-proxy";
18
+ import { resolveBrandFaviconPath, warmBrandFaviconCache } from "./resolve-brand-favicon";
19
+
20
+ const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#6366f1"/><text x="16" y="23" text-anchor="middle" font-size="20" font-family="system-ui" fill="white">S</text></svg>`;
21
+
22
+ export async function registerStaticAssets(
23
+ app: Hono,
24
+ serviceSlug: string,
25
+ brandOrg?: string,
26
+ ): Promise<void> {
27
+ // Warm the brand-favicon cache at boot — the S3 existence probe is a 6-21s
28
+ // cold call that otherwise stalls the first /favicon.ico after every
29
+ // restart. Fire-and-forget; never blocks boot or a request.
30
+ warmBrandFaviconCache(brandOrg);
31
+
32
+ app.get("/health", (c) =>
33
+ c.json({
34
+ status: "ok",
35
+ service: serviceSlug,
36
+ timestamp: new Date().toISOString(),
37
+ }),
38
+ );
39
+
40
+ // Localhost-gated heap-attribution probe — restores the object-composition
41
+ // data the removed /_healthz surface exposed: the heapSize-vs-RSS split
42
+ // (JS heap vs native — OTel/Prisma-engine/buffers/compiled code) plus the
43
+ // top object types by count. Read-only, no secrets; gated to a localhost
44
+ // Host so it never answers over the public domain. Registered here (before
45
+ // auth + page routes) so it is reachable on every service kind, like
46
+ // /health (`verify-via-127.0.0.1`, sibling of `runtime-metrics-via-railway-cli`).
47
+ app.get("/_heap", async (c) => {
48
+ const host = c.req.header("host") ?? "";
49
+ const isLocal =
50
+ host.startsWith("127.0.0.1") || host.startsWith("localhost") || host.startsWith("[::1]");
51
+ if (!isLocal) return c.json({ error: "localhost only" }, 403);
52
+ try {
53
+ const { heapStats } = await import("bun:jsc");
54
+ const s = heapStats() as unknown as {
55
+ heapSize: number;
56
+ heapCapacity: number;
57
+ objectCount: number;
58
+ objectTypeCounts?: Record<string, number>;
59
+ };
60
+ const rss = process.memoryUsage.rss();
61
+ const topObjectTypes = Object.fromEntries(
62
+ Object.entries(s.objectTypeCounts ?? {})
63
+ .sort((a, b) => b[1] - a[1])
64
+ .slice(0, 40),
65
+ );
66
+ return c.json({
67
+ service: serviceSlug,
68
+ rss,
69
+ heapSize: s.heapSize,
70
+ heapCapacity: s.heapCapacity,
71
+ objectCount: s.objectCount,
72
+ nativeApprox: Math.max(0, rss - s.heapSize),
73
+ topObjectTypes,
74
+ });
75
+ } catch (err) {
76
+ const report = buildErrorReport(err, { service: serviceSlug, url: c.req.url });
77
+ return new Response(JSON.stringify(report.http.body), {
78
+ status: report.http.status,
79
+ headers: report.http.headers,
80
+ });
81
+ }
82
+ });
83
+
84
+ // Build-id: the content hash of the CURRENT client bundle, read FRESH from
85
+ // the asset manifest on every call (never a process-pinned copy) so a
86
+ // post-restart rebuild reports its new id immediately. The client self-heal
87
+ // (page-client/boot/build-self-heal) polls this on tab refocus; if it differs
88
+ // from the build the tab booted with, a new build shipped and the open tab is
89
+ // stale → it reloads. no-store HTML + immutable hashed assets already prevent
90
+ // staleness on every FRESH load; this is what closes the one remaining hole —
91
+ // a tab left OPEN across a deploy, holding old HTML pinned to old asset
92
+ // hashes, with nothing telling it to re-fetch (the "my tablet still shows the
93
+ // old broken page" class). The id derives from hydratePath exactly as the
94
+ // SSR-stamped window.__SCALA_BUILD__ does (render-page-html), so they can
95
+ // never structurally disagree and trigger a reload loop.
96
+ app.get("/api/build-id", async (c) => {
97
+ const path = join(process.cwd(), "dist", "client", "asset-manifest.json");
98
+ let build = "dev";
99
+ try {
100
+ const raw = await Bun.file(path).text();
101
+ const manifest = JSON.parse(raw) as { hydratePath?: string };
102
+ build = /hydrate-([a-z0-9]+)\.js/.exec(manifest.hydratePath ?? "")?.[1] ?? "dev";
103
+ } catch {}
104
+ return c.json({ build }, 200, { "cache-control": "no-store" });
105
+ });
106
+
107
+ // A branded host (service_install.config.brand_org) never serves the
108
+ // generic platform mark — both favicon paths resolve the owning org's
109
+ // icon from its asset store; the generic SVG remains only for hosts
110
+ // with no declared owner (multi-tenant / infra).
111
+ app.get("/favicon.svg", async (_c) => {
112
+ const brand = await resolveBrandFaviconPath(brandOrg);
113
+ if (brand)
114
+ return new Response(null, {
115
+ status: 302,
116
+ headers: { location: brand, "cache-control": "public, max-age=300" },
117
+ });
118
+ return new Response(FAVICON_SVG, {
119
+ headers: {
120
+ "content-type": "image/svg+xml",
121
+ "cache-control": "public, max-age=3600",
122
+ },
123
+ });
124
+ });
125
+
126
+ app.get("/favicon.ico", async (c) => {
127
+ const brand = await resolveBrandFaviconPath(brandOrg);
128
+ // 302 (not 301): the brand asset is row-driven and must stay
129
+ // re-resolvable — a permanent redirect would pin stale targets in
130
+ // browser caches, the exact failure this route exists to end.
131
+ // cache-control lets the browser cache the redirect (5min) so it
132
+ // doesn't re-hit the origin per page load — mirrors /favicon.svg.
133
+ c.header("cache-control", "public, max-age=300");
134
+ return c.redirect(brand ?? "/favicon.svg", 302);
135
+ });
136
+
137
+ // Same-origin S3 asset proxy: /api/assets/<key> → bucket. Public,
138
+ // immutable cache, no auth. Canonical URL form for every logo, favicon,
139
+ // avatar, uploaded asset — emitted by toAssetPath() in @teamscala/core-ui/theme.
140
+ app.get("/api/assets/*", (c) => handleAssetProxy(c.req.raw));
141
+
142
+ app.use("/assets/*", etag());
143
+
144
+ app.get("/assets/*", async (c) => {
145
+ const distPath = join(
146
+ process.cwd(),
147
+ "dist",
148
+ "client",
149
+ c.req.path.replace(/^\/assets\//, ""),
150
+ );
151
+ const file = Bun.file(distPath);
152
+ if (await file.exists()) {
153
+ return new Response(file, {
154
+ headers: { "cache-control": "public, max-age=31536000, immutable" },
155
+ });
156
+ }
157
+ return c.notFound();
158
+ });
159
+
160
+ // Extension auto-update files (.crx + updates.xml) served from ./ext/.
161
+ // Chrome's extension updater fetches updates.xml over plain HTTPS with no
162
+ // auth, so this route is registered before any auth gate and on every UI
163
+ // service. Files are served with short-lived cache so updates propagate
164
+ // promptly. The ./ext/ directory is gitignored and populated by the
165
+ // extension build pipeline.
166
+ app.get("/ext/*", async (c) => {
167
+ const filePath = join(
168
+ process.cwd(),
169
+ "ext",
170
+ c.req.path.replace(/^\/ext\//, ""),
171
+ );
172
+ const file = Bun.file(filePath);
173
+ if (await file.exists()) {
174
+ return new Response(file, {
175
+ headers: { "cache-control": "public, max-age=3600" },
176
+ });
177
+ }
178
+ return c.notFound();
179
+ });
180
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Registers each `service_install.websocket_routes[]` entry as a Hono WS route.
7
+ * The ORPC WS (`/api/rpc/ws`) is mounted by the canonical primitive
8
+ * `mountOrpcWs` (@teamscala/orpc/ws/server) over the FULLY-COMPOSED app router —
9
+ * this runs AFTER registerFnRouters, so the eager RPCHandler snapshots every
10
+ * namespace, and any `output: eventIterator(...)` procedure dispatches (rule
11
+ * `orpc-ws-via-mount-orpc-ws`). It is NOT a server_package WsRouteHandler — it
12
+ * is exempt from the legacy invariant below.
13
+ *
14
+ * The legacy `WsRouteHandler` interface (open/message/close on a Bun
15
+ * ServerWebSocket) is preserved by an adapter for already-migrated
16
+ * server_packages (terminal-ws); new WS handlers should use Hono's WSEvents
17
+ * directly.
18
+ *
19
+ * INVARIANT (legacy paths only): every handler registered via
20
+ * mountCtx.registerWsRoute MUST have a matching entry in config.websocket_routes
21
+ * (minus /api/rpc/ws), and vice versa. A mismatch is fatal — the service cannot
22
+ * boot with handlers declared but never wired (silent 404 on upgrade), or routes
23
+ * that point at handlers no server_package registered.
24
+ */
25
+
26
+ import { getAppRouter } from "@teamscala/orpc/app-router";
27
+ import { mountOrpcWs } from "@teamscala/orpc/ws/server";
28
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
29
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
30
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
31
+ import { BootFatalError } from "@teamscala/os/errors/boot-error";
32
+ import type {
33
+ MountRegistry,
34
+ WsRouteHandler,
35
+ } from "@teamscala/os/runtime-contracts/mount-context";
36
+ import type { Context, Hono } from "hono";
37
+ import * as v from "valibot";
38
+ import { upgradeWebSocket } from "./serve/serve.ts";
39
+
40
+ const ORPC_WS_PATH = "/api/rpc/ws";
41
+
42
+ export async function registerWsRoutes(
43
+ app: Hono,
44
+ config: ServiceInstallConfig,
45
+ mountRegistry: MountRegistry,
46
+ serviceSlug: string,
47
+ ): Promise<number> {
48
+ const log = getAppLogger();
49
+ const allDeclared = new Set(
50
+ (config.websocket_routes ?? []).map((r) => (r as { path: string }).path),
51
+ );
52
+
53
+ // ORPC WS: mounted by mountOrpcWs over the app router. Not a legacy
54
+ // server_package handler — exempt from the invariant + the count below.
55
+ if (allDeclared.has(ORPC_WS_PATH)) {
56
+ const router = await getAppRouter();
57
+ // WS keepalive cadence is an operational tunable sourced from the
58
+ // registry (configured-primitives + operator ruling "move stuff out of
59
+ // code") — changing it is a registry_edit on config/orpc-ws, never a
60
+ // republish of @teamscala/orpc. Missing/empty row falls back to the
61
+ // primitive's default interval.
62
+ const wsCfg = await loadOrpcWsConfig(log, serviceSlug);
63
+ // Casts: mountOrpcWs's hand-rolled UpgradeWebSocket/Router types can't
64
+ // exactly mirror the injected hono upgradeWebSocket + getAppRouter's
65
+ // AppRouter at this configured-primitive boundary (mirroring would need
66
+ // importing hono/bun, unpairing createBunWebSocket). orpc type cleanup is
67
+ // a follow-up; the runtime shapes are correct.
68
+ mountOrpcWs(app, router as never, upgradeWebSocket as never, {
69
+ path: ORPC_WS_PATH,
70
+ pingIntervalMs: wsCfg.pingIntervalMs,
71
+ });
72
+ log.info(
73
+ `[scala-install:${serviceSlug}] /api/rpc/ws mounted via mountOrpcWs (${Object.keys(router).length} router keys)`,
74
+ );
75
+ }
76
+
77
+ // Legacy declared paths (every non-ORPC route): require a server_package
78
+ // WsRouteHandler + wire via the adapter.
79
+ const legacyPaths = [...allDeclared].filter((p) => p !== ORPC_WS_PATH);
80
+ for (const path of legacyPaths) {
81
+ const handler = mountRegistry.wsRoutes.get(path);
82
+ if (!handler) {
83
+ throw new BootFatalError(
84
+ "register-ws-routes",
85
+ serviceSlug,
86
+ `websocket_route "${path}" declared in service_install but no server_package registered a handler for it. ` +
87
+ `Either remove the route declaration or add a server_package that calls ctx.registerWsRoute("${path}", handler).`,
88
+ );
89
+ }
90
+ app.get(path, adaptLegacyWsHandler(handler, path));
91
+ }
92
+
93
+ // Unwired: handlers registered by a server_package for a path NOT declared
94
+ // in config.websocket_routes. (/api/rpc/ws is never registered by a
95
+ // server_package — it is mounted by mountOrpcWs above.)
96
+ const unwired = [...mountRegistry.wsRoutes.keys()].filter(
97
+ (p) => !allDeclared.has(p),
98
+ );
99
+ if (unwired.length > 0) {
100
+ throw new BootFatalError(
101
+ "register-ws-routes",
102
+ serviceSlug,
103
+ `server_package(s) registered WS handler(s) for path(s) not declared in config.websocket_routes: [${unwired.map((p) => `"${p}"`).join(", ")}]. ` +
104
+ `These handlers would never receive upgrades. Add the missing path(s) to service_install.websocket_routes or remove the registration.`,
105
+ );
106
+ }
107
+
108
+ const count = allDeclared.size;
109
+ log.info(
110
+ `[scala-install:${serviceSlug}] ${count} WS route(s) registered, 0 unwired handler(s)`,
111
+ );
112
+ return count;
113
+ }
114
+
115
+ function adaptLegacyWsHandler(legacy: WsRouteHandler, pattern: string) {
116
+ return upgradeWebSocket((c: Context) => {
117
+ const params = c.req.param() as Record<string, string>;
118
+ const upgradeReq = c.req.raw;
119
+ let extraData: Record<string, unknown> = {};
120
+ return {
121
+ async onOpen(_evt: Event, wsc: { raw?: unknown }) {
122
+ const ws = wsc.raw as never as { data: Record<string, unknown> };
123
+ if (legacy.upgradeData) {
124
+ extraData = await legacy.upgradeData(upgradeReq, params);
125
+ }
126
+ ws.data = { ...ws.data, route: pattern, params, ...extraData };
127
+ await legacy.open?.(ws as never);
128
+ },
129
+ async onMessage(
130
+ evt: MessageEvent<string | ArrayBuffer>,
131
+ wsc: { raw?: unknown },
132
+ ) {
133
+ const ws = wsc.raw as never;
134
+ const msg =
135
+ typeof evt.data === "string"
136
+ ? evt.data
137
+ : Buffer.from(evt.data as ArrayBuffer);
138
+ await legacy.message?.(ws, msg);
139
+ },
140
+ async onClose(evt: CloseEvent, wsc: { raw?: unknown }) {
141
+ const ws = wsc.raw as never;
142
+ await legacy.close?.(ws, evt.code, evt.reason);
143
+ },
144
+ } as Parameters<typeof upgradeWebSocket>[0] extends (
145
+ c: infer _Context,
146
+ ) => infer R
147
+ ? R
148
+ : never;
149
+ });
150
+ }
151
+
152
+ /**
153
+ * WS keepalive tunable, sourced from the `config/orpc-ws` registry row so the
154
+ * cadence is a `registry_edit` — never a republish of `@teamscala/orpc`
155
+ * (`configured-primitives` + operator ruling "move stuff out of code"). Loose
156
+ * schema: an absent `pingIntervalMs` (or a missing row on a fresh install)
157
+ * returns `{}`, and `mountOrpcWs` then falls back to its built-in default
158
+ * interval, so a misconfigured/missing row never fatals the WS mount.
159
+ */
160
+ const orpcWsConfigSchema = v.looseObject({
161
+ pingIntervalMs: v.optional(v.number()),
162
+ });
163
+
164
+ async function loadOrpcWsConfig(
165
+ log: { info(msg: string): void; warn(msg: string): void },
166
+ serviceSlug: string,
167
+ ): Promise<{ pingIntervalMs?: number }> {
168
+ try {
169
+ return (await loadRegistryConfig(
170
+ "config",
171
+ "orpc-ws",
172
+ orpcWsConfigSchema,
173
+ )) as { pingIntervalMs?: number };
174
+ } catch (err) {
175
+ log.warn(
176
+ `[scala-install:${serviceSlug}] config/orpc-ws not readable (${err instanceof Error ? err.message : String(err)}); /api/rpc/ws keepalive using the primitive default interval`,
177
+ );
178
+ return {};
179
+ }
180
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The scala-projects app's own public hostname, read from its `service_install`
7
+ * row rather than hardcoded — a service knows its own deploy config only
8
+ * through the registry (registries-are-source-of-truth), never a literal in
9
+ * source. Cached for the process lifetime: this changes only on a redeploy,
10
+ * not per-request. Shared by every server-side path that builds a link back
11
+ * into the app (approval emails, mention emails, digest emails).
12
+ */
13
+
14
+ import { ServiceInstallConfigSchema } from "@teamscala/db-validation/registry-schemas/service-install";
15
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
16
+
17
+ let cachedAppUrl: string | null = null;
18
+
19
+ export async function resolveAppBaseUrl(): Promise<string> {
20
+ if (cachedAppUrl) return cachedAppUrl;
21
+ try {
22
+ const config = await loadRegistryConfig("service_install", "scala-projects", ServiceInstallConfigSchema);
23
+ cachedAppUrl = `https://${config.public_hostname}`;
24
+ } catch {
25
+ cachedAppUrl = "https://projects.scala.business";
26
+ }
27
+ return cachedAppUrl;
28
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Host-level brand favicon resolution. A single-brand UI service's host has
7
+ * a KNOWN owner (`service_install.config.brand_org`) — serving the generic
8
+ * platform "S" at the implicit /favicon.ico path there is a defect: browsers
9
+ * and crawlers hit that path directly and their favicon caches are sticky
10
+ * (operator-observed 2026-06-05 on launcha's proposal pages).
11
+ *
12
+ * Resolution is by convention from the org's asset store:
13
+ * `<brand_org>/logos/favicon.png`. Existence is probed once through the
14
+ * in-process asset proxy and cached (positive AND negative) so the favicon
15
+ * route never adds S3 latency per request. No brand_org, or no asset at the
16
+ * convention key → null, and the caller serves the generic fallback.
17
+ */
18
+
19
+ import { createCache } from "@teamscala/cache/create-cache";
20
+ import { handleAssetProxy } from "./asset-proxy";
21
+
22
+ // The brand favicon path is a CONVENTION (`<brandOrg>/logos/favicon.png`) —
23
+ // the key never changes for a given host, only the image at that key does
24
+ // (served + cached separately by /api/assets/*). So the resolved PATH is
25
+ // stable for the process lifetime; a long TTL is safe + avoids re-probing S3
26
+ // (a cold stat takes 6-21s — see warmBrandFaviconCache).
27
+ const cache = createCache<string | null>("service-boot:brand-favicon", {
28
+ ttlMs: 30 * 60_000,
29
+ maxSize: 8,
30
+ });
31
+
32
+ export async function resolveBrandFaviconPath(
33
+ brandOrg: string | undefined,
34
+ ): Promise<string | null> {
35
+ if (!brandOrg) return null;
36
+ return cache.fetchOrCompute(brandOrg, async () => {
37
+ const key = `${brandOrg}/logos/favicon.png`;
38
+ const path = `/api/assets/${encodeURIComponent(key)}`;
39
+ try {
40
+ const probe = await handleAssetProxy(
41
+ new Request(`http://favicon.local${path}`),
42
+ );
43
+ return probe.ok ? path : null;
44
+ } catch {
45
+ // Asset store unreachable — degrade to the generic fallback rather
46
+ // than failing the favicon route (block-level fail-open, loud at
47
+ // the proxy's own logging).
48
+ return null;
49
+ }
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Eagerly resolve + cache the favicon at boot so the FIRST user request never
55
+ * pays the S3 existence-probe latency. The probe (`handleAssetProxy` →
56
+ * `s3.file(key).stat()`) is a COLD S3 call — the connection setup to the asset
57
+ * store takes 6s locally / up to 21s in prod, and the in-process cache resets
58
+ * on every restart (deploys, package-poller). Without warming, the first
59
+ * /favicon.ico after each restart stalls for that duration (operator-reported
60
+ * 23s). Fire-and-forget — runs in the background during boot, never blocks.
61
+ */
62
+ export function warmBrandFaviconCache(brandOrg: string | undefined): void {
63
+ if (!brandOrg) return;
64
+ void resolveBrandFaviconPath(brandOrg);
65
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * resolve-from-loader-config.test.ts — locks in the interpolation contract that
7
+ * resolver_fn sources depend on: a bare `:sourceKey` ref passes the WHOLE
8
+ * resolved source object (so a resolver_input like {response: ":response"}
9
+ * reaches the resolver), while a URL param (e.g. :id) still resolves from params.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { interpolateMeta } from "./resolve-from-loader-config";
14
+
15
+ describe("interpolateMeta (resolver_input ref contract)", () => {
16
+ test("a bare :sourceKey ref passes the whole resolved source object", () => {
17
+ const out = interpolateMeta(
18
+ { response: ":response", assessment: ":assessment" },
19
+ {
20
+ response: { total_score: 48, category_scores: { "Be Seen": 46 } },
21
+ assessment: { title: "Launcha" },
22
+ },
23
+ );
24
+ // Whole objects flow through — the bug that blanked every report was these
25
+ // resolving to undefined (bare refs read from URL params, not resolved).
26
+ expect(out.response).toEqual({ total_score: 48, category_scores: { "Be Seen": 46 } });
27
+ expect(out.assessment).toEqual({ title: "Launcha" });
28
+ });
29
+
30
+ test("a bare ref with no resolved source key is left as the literal (interpolateMeta fallback)", () => {
31
+ // `:id` has no resolved source named "id" and params is empty here, so it
32
+ // does not resolve — interpolateMeta leaves the literal in place.
33
+ const out = interpolateMeta({ id: ":id" }, {});
34
+ expect(out.id).toBe(":id");
35
+ });
36
+
37
+ test("a dotted ref still reads a field off a resolved source", () => {
38
+ const out = interpolateMeta(
39
+ { score: ":response.total_score" },
40
+ { response: { total_score: 48 } },
41
+ );
42
+ expect(out.score).toBe(48);
43
+ });
44
+ });