@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,765 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Reads `page_route` registry rows for the current service slug and registers
7
+ * Hono routes that resolve data via `resource_loader` graph-based sources.
8
+ *
9
+ * Every page_route uses pattern `/:id` and every resource_loader uses a
10
+ * `sources[]` dependency graph. When header/footer sources are present in
11
+ * the resolved graph, the output includes a `regions` array for shell rendering.
12
+ * Otherwise it produces single-page puckData.
13
+ */
14
+
15
+ import type { RenderModeLiteral } from "./page-route-shell-types.ts";
16
+ import { PageRouteConfigSchema } from "@teamscala/db-validation/registry-schemas/page-route";
17
+ import { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
18
+ import {
19
+ loadRegistryConfig,
20
+ loadRegistryConfigsByType,
21
+ } from "@teamscala/db/registry/load-config";
22
+ import * as v from "valibot";
23
+ import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
24
+ import { getPlatformOrgId } from "@teamscala/db/registry/platform";
25
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
26
+ import type { Hono } from "hono";
27
+ import { bindPageHandler } from "./bind-page-handler.ts";
28
+ import { getServerClient } from "./page-server-orpc/server-client.ts";
29
+ import { resolveFromLoaderConfig } from "./resolve-from-loader-config.ts";
30
+ import { runContentGate } from "./content-gate/gate.ts";
31
+ import { agreementGateConfig } from "./agreement-gate.ts";
32
+ import { proposalGateConfig } from "./proposal-gate.ts";
33
+ import { gptGateConfig } from "./gpt-gate.ts";
34
+ import { extractOrgIdFromCallbackPath } from "./resolvers/portal-route-paths.ts";
35
+ import { PrimaryResourceMissingError } from "./resource-not-found.ts";
36
+ import { decideContentPageFloor } from "./content-page-floor.ts";
37
+ import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
38
+ import {
39
+ staticShellGraphCache,
40
+ paramRouteGraphCache,
41
+ readCachedGraph,
42
+ } from "./register-registry-page-route/graph-cache.ts";
43
+ import { resolvedToPageData } from "./register-registry-page-route/resolve-page-data.ts";
44
+ import {
45
+ buildMetaFromContext,
46
+ resolveTemplateTitle,
47
+ } from "./register-registry-page-route/meta-title.ts";
48
+ import { prefetchAiChatSession } from "./register-registry-page-route/ai-chat-prefetch.ts";
49
+ import {
50
+ applyApprovalEmailToken,
51
+ } from "./register-registry-page-route/proposal-approval-email-token.ts";
52
+ import {
53
+ applyPartnerBrand,
54
+ applyClientLogo,
55
+ } from "./register-registry-page-route/proposal-brand-stamping.ts";
56
+
57
+ interface PageRouteConfig {
58
+ pattern: string;
59
+ service: string;
60
+ loader_slug: string;
61
+ meta_context?: Record<string, Record<string, string>>;
62
+ resource_type?: string;
63
+ providers?: string[];
64
+ }
65
+
66
+ interface RegistryRouteArgs {
67
+ app: Hono;
68
+ serviceSlug: string;
69
+ components: Record<string, RendererComponentEntry>;
70
+ createPageHandler: CreatePageHandler;
71
+ authMode: string;
72
+ renderMode: RenderModeLiteral;
73
+ providers: string[];
74
+ }
75
+
76
+ function extractUrlParams(
77
+ pattern: string,
78
+ pathname: string,
79
+ ): Record<string, string> {
80
+ const patternParts = pattern.split("/").filter(Boolean);
81
+ const pathParts = pathname.split("/").filter(Boolean);
82
+ const params: Record<string, string> = {};
83
+ for (let i = 0; i < patternParts.length; i++) {
84
+ const pp = patternParts[i];
85
+ if (pp.startsWith(":")) {
86
+ params[pp.slice(1)] = pathParts[i] ?? "";
87
+ } else if (pp === "*") {
88
+ params._wildcard = pathParts.slice(i).join("/");
89
+ break;
90
+ }
91
+ }
92
+ return params;
93
+ }
94
+
95
+ export async function registerRegistryPageRoute(
96
+ args: RegistryRouteArgs,
97
+ ): Promise<string[]> {
98
+ const {
99
+ app,
100
+ serviceSlug,
101
+ components,
102
+ createPageHandler,
103
+ authMode,
104
+ renderMode,
105
+ providers: baseProviders,
106
+ } = args;
107
+ const log = getAppLogger();
108
+
109
+ const allRoutes = await loadRegistryConfigsByType(
110
+ "page_route",
111
+ PageRouteConfigSchema,
112
+ );
113
+ const serviceRoutes = allRoutes.filter(
114
+ (r) => r.config.service === serviceSlug,
115
+ );
116
+ if (serviceRoutes.length === 0) return [];
117
+
118
+ // Only a service serving arbitrary portal paths (a `/*` catchall route —
119
+ // the portal) redirects signed-in users from `/` into
120
+ // /<website>/<org>/dashboard. Single-site portal-mode services (the
121
+ // fleet dashboard) serve their real page AT `/` — the unconditional
122
+ // redirect 404-looped them (2026-06-06; service-runtime@1.15.82 made
123
+ // the redirect opt-in via this flag).
124
+ const servesPortalCatchall = serviceRoutes.some(
125
+ (r) => r.config.pattern === "/*",
126
+ );
127
+
128
+ // Sort routes so more specific patterns register before wildcard/param routes.
129
+ // Hono matches by registration order, so `/*` (the portal catchall) registered
130
+ // before `/sign-in` would swallow the sign-in page and render it inside the
131
+ // PortalTemplate instead of the standalone PublicTemplate shell.
132
+ serviceRoutes.sort((a, b) => {
133
+ const pa = a.config.pattern;
134
+ const pb = b.config.pattern;
135
+ // Wildcard `/*` goes last
136
+ if (pa === "/*") return 1;
137
+ if (pb === "/*") return -1;
138
+ // Param routes `/:param` sort after literal routes
139
+ const aIsLiteral = !pa.includes(":") && !pa.includes("*");
140
+ const bIsLiteral = !pb.includes(":") && !pb.includes("*");
141
+ if (aIsLiteral && !bIsLiteral) return -1;
142
+ if (!aIsLiteral && bIsLiteral) return 1;
143
+ return 0;
144
+ });
145
+
146
+ const registeredPatterns: string[] = [];
147
+
148
+ for (const routeRow of serviceRoutes) {
149
+ const routeConfig = routeRow.config;
150
+ const pattern = routeConfig.pattern;
151
+ const loaderSlug = routeConfig.loader_slug;
152
+ const routeProviders = routeConfig.providers ?? baseProviders;
153
+
154
+ let loaderConfig: Awaited<
155
+ ReturnType<typeof loadRegistryConfig<typeof ResourceLoaderConfigSchema>>
156
+ > | null = null;
157
+ try {
158
+ loaderConfig = await loadRegistryConfig(
159
+ "resource_loader",
160
+ loaderSlug,
161
+ ResourceLoaderConfigSchema,
162
+ );
163
+ } catch (err) {
164
+ if (
165
+ err instanceof Error &&
166
+ err.constructor.name === "RegistryConfigMissingError"
167
+ ) {
168
+ log.warn(
169
+ `[scala-install:${serviceSlug}] page_route "${routeRow.slug}" references resource_loader "${loaderSlug}" which does not exist — skipping`,
170
+ );
171
+ continue;
172
+ }
173
+ throw err;
174
+ }
175
+
176
+ // Gate for the static-shell graph cache (see module header).
177
+ const isStaticShell =
178
+ !routeConfig.resource_type &&
179
+ !pattern.includes(":") &&
180
+ !pattern.includes("*");
181
+
182
+ const loadData = async (request: Request) => {
183
+ // A bare root ("/") that declares a resource_type is a UUID-driven
184
+ // public site (proposals/forms/quiz) with no resource selected — it
185
+ // must render the branded holding/not-found page ("no <resource> at
186
+ // this link"), never fall through the shared public loader to the
187
+ // marketing site. PrimaryResourceMissingError is the existing signal
188
+ // the page binder maps to buildResourceNotFoundResponse. A "/" route
189
+ // WITHOUT a resource_type (e.g. scala-public's marketing root) is
190
+ // unaffected and still renders its content.
191
+ if (pattern === "/" && routeConfig.resource_type) {
192
+ // Status 200: the bare root is a valid "no <resource> selected"
193
+ // landing, not an error — and kind:ui services must answer GET /
194
+ // with 200 (verify-prod-persistent-slot). An invalid /:id keeps
195
+ // the default 404 (genuinely not found).
196
+ throw new PrimaryResourceMissingError(routeConfig.resource_type, 200);
197
+ }
198
+ const url = new URL(request.url);
199
+ const params = extractUrlParams(pattern, url.pathname);
200
+ params.host = request.headers.get("host")?.split(":")[0] || url.hostname;
201
+ params.path = url.pathname || "/";
202
+ const graphKey = `graph:${routeRow.slug}:${params.host}:${params.path}`;
203
+ const primaryType = routeConfig.resource_type;
204
+ const hasPrimarySource =
205
+ !!primaryType &&
206
+ !!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
207
+ (s) => s.key === primaryType,
208
+ );
209
+ const graphCache = isStaticShell
210
+ ? staticShellGraphCache
211
+ : paramRouteGraphCache;
212
+ const typedLoaderConfig = loaderConfig as v.InferOutput<
213
+ typeof ResourceLoaderConfigSchema
214
+ > & {
215
+ sources: NonNullable<
216
+ v.InferOutput<typeof ResourceLoaderConfigSchema>["sources"]
217
+ >;
218
+ };
219
+ let resolved: Record<string, unknown>;
220
+ // SWR read over @teamscala/cache: fresh hit serves the cached graph, stale
221
+ // hit serves it AND fires ONE background refresh (refreshInBackground,
222
+ // single-flight via the cache's in-flight map), miss returns undefined.
223
+ // The refresh callback re-resolves + stores a clone; a primary-missing
224
+ // (dead-link) graph THROWS so the cache is not updated — a dead link must
225
+ // stay per-request so a just-created record appears immediately
226
+ // (refreshInBackground catches, leaves the stale entry, logs one line).
227
+ const cachedGraph = readCachedGraph(
228
+ graphCache,
229
+ graphKey,
230
+ () =>
231
+ resolveFromLoaderConfig(typedLoaderConfig, params).then(
232
+ (freshGraph) => {
233
+ const freshPrimaryMissing =
234
+ hasPrimarySource &&
235
+ primaryType &&
236
+ freshGraph[primaryType] == null;
237
+ if (freshPrimaryMissing) {
238
+ throw new Error(
239
+ `primary resource "${primaryType}" missing — skip cache update for ${graphKey}`,
240
+ );
241
+ }
242
+ return structuredClone(freshGraph);
243
+ },
244
+ ),
245
+ );
246
+ if (cachedGraph !== undefined) {
247
+ // Clone-on-get: gateAgreementContent + block resolution MUTATE the
248
+ // graph in place, so each request gets its own copy.
249
+ resolved = structuredClone(cachedGraph) as Record<string, unknown>;
250
+ } else {
251
+ resolved = await resolveFromLoaderConfig(typedLoaderConfig, params);
252
+ const primaryMissing =
253
+ hasPrimarySource && primaryType && resolved[primaryType] == null;
254
+ if (!primaryMissing) {
255
+ // Defensive clone: structuredClone throws on non-cloneable loader
256
+ // graphs (functions/Proxies/Prisma instances). A swallowed throw
257
+ // here used to SILENTLY disable the graph cache for this key
258
+ // (fail-open-silent — forbidden by protective-layers-fail-explicitly),
259
+ // which left affected routes re-resolving the loader graph every
260
+ // request (measured ~1.2s on the projects utility shell). JSON-clone
261
+ // preserves the data graph (dropping functions/undefined a loader
262
+ // graph shouldn't carry) so the cache stays effective; both fallbacks
263
+ // are LOGGED so the offending non-cloneable source is observable +
264
+ // fixable, never silently disabling the cache again.
265
+ let cloned: unknown;
266
+ try {
267
+ cloned = structuredClone(resolved);
268
+ } catch (err) {
269
+ log.warn(
270
+ `[scala-install:${serviceSlug}] graph-cache structuredClone failed for ${graphKey} — falling back to JSON-clone (non-cloneable value in loader graph): ${err instanceof Error ? err.message : err}`,
271
+ );
272
+ try {
273
+ cloned = JSON.parse(JSON.stringify(resolved));
274
+ } catch (err2) {
275
+ log.warn(
276
+ `[scala-install:${serviceSlug}] graph-cache JSON-clone also failed for ${graphKey} — cache disabled for this key (observable): ${err2 instanceof Error ? err2.message : err2}`,
277
+ );
278
+ cloned = undefined;
279
+ }
280
+ }
281
+ if (cloned !== undefined) {
282
+ graphCache.set(graphKey, cloned);
283
+ }
284
+ }
285
+ }
286
+ // A route that declares resource_type is meaningless without that
287
+ // entity: a dead link (wrong id, deleted row) must 404 with the
288
+ // branded not-found page — never fall through to default content
289
+ // (the "Vision Toolbar" leak, operator-observed 2026-06-05).
290
+ if (hasPrimarySource && primaryType && resolved[primaryType] == null) {
291
+ throw new PrimaryResourceMissingError(primaryType);
292
+ }
293
+ // A CONTENT route (no resource_type) whose loader resolves a `page`
294
+ // body source must not synthesize an empty page for an unmatched path
295
+ // and serve a cacheable 200 — the generalized "Vision Toolbar" leak
296
+ // (operator-reported 5×: a deleted page, e.g. /roadmap, returned 200
297
+ // with an empty body instead of not-found). No page row → dead link →
298
+ // branded 404 floor. A page row that exists but has zero blocks → a
299
+ // content gap → the floor's empty-page surface (200). Both carry the
300
+ // "Take me home" action. The pure decision lives in content-page-floor.ts
301
+ // (unit-tested without the Hono/DB/graph machinery). Region-slot
302
+ // (utility/portal) shells without a `page` source are untouched.
303
+ const contentFloorError = decideContentPageFloor({
304
+ hasResourceType: !!primaryType,
305
+ hasPageContentSource: !!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
306
+ (s) => s.key === "page",
307
+ ),
308
+ page: resolved.page,
309
+ });
310
+ if (contentFloorError) throw contentFloorError;
311
+ // Withhold gated content from the SSR unless the request carries a valid
312
+ // signed access token — the single server-side scrape gate. One engine
313
+ // (runContentGate), one config per content type. Proposals run first
314
+ // (an authorized viewer's gate is removed, so the agreement gate then
315
+ // no-ops); each config scopes to its own resource/field so they never
316
+ // collide.
317
+ const gateCookie = request.headers.get("cookie");
318
+ const gateNow = Date.now();
319
+ // Resolve the signed-in viewer from the unified auth session (local
320
+ // verify, works on Railway) and stamp it on the graph, so a gate can
321
+ // release on a standard OTP-login session (config.sessionAuthorize) —
322
+ // the unified path, no per-gate OTP code. Anonymous if no session.
323
+ let sessionUserId: string | undefined;
324
+ let sessionOrgId: string | undefined;
325
+ try {
326
+ const { getSession } = await import("@teamscala/auth/get-session.ts");
327
+ const sess = await getSession(request.headers);
328
+ if (sess?.user?.email) {
329
+ (resolved as { sessionVisitor?: { email: string; name?: string } }).sessionVisitor =
330
+ {
331
+ email: sess.user.email,
332
+ name: sess.user.name ?? undefined,
333
+ };
334
+ sessionUserId = sess.user.id;
335
+ sessionOrgId = sess.session?.activeOrganizationId ?? undefined;
336
+ // Stamp the per-request portal identity so block portal context
337
+ // (useBlockPortal().userId) is populated for authed users on
338
+ // org/hub pages — the SSR-side WRITER the connections-block reader
339
+ // (ClientBlockServicesProvider → __PAGE_DATA__.portalContext.user.id)
340
+ // depends on. Rides the same per-request path as sessionVisitor
341
+ // above (never the cached loader graph); merge preserves any
342
+ // portalContext fields set elsewhere (e.g. viewAsMembers). Without
343
+ // this, connections-block short-circuits at SignInPrompt.
344
+ const rc = resolved as {
345
+ portalContext?: {
346
+ user?: { id?: string } | null;
347
+ orgId?: string;
348
+ };
349
+ };
350
+ rc.portalContext = {
351
+ ...(rc.portalContext ?? {}),
352
+ user: sessionUserId ? { id: sessionUserId } : null,
353
+ orgId: sessionOrgId,
354
+ };
355
+ // Read pane widths from the portal cookie (the client writes
356
+ // it immediately on resize via setPortalCookie; the cookie is
357
+ // sent with every HTTP request so the SSR reads it here).
358
+ // This renders the correct pane width from the first byte —
359
+ // no post-hydration shift, no client-side state read.
360
+ const paneWidthsMatch = gateCookie?.match(
361
+ /portal_pane_widths=([^;]+)/,
362
+ );
363
+ if (paneWidthsMatch) {
364
+ try {
365
+ const parsed = JSON.parse(
366
+ decodeURIComponent(paneWidthsMatch[1]),
367
+ );
368
+ if (parsed.left)
369
+ (rc.portalContext as Record<string, unknown>).paneLeftWidth =
370
+ parsed.left;
371
+ if (parsed.right)
372
+ (rc.portalContext as Record<string, unknown>).paneRightWidth =
373
+ parsed.right;
374
+ } catch {
375
+ // Malformed cookie — fall back to defaults.
376
+ }
377
+ }
378
+ if (sessionUserId) {
379
+ try {
380
+ const dbClient = getServerClient() as unknown as {
381
+ user: {
382
+ findUnique: (args: {
383
+ where: { id: string };
384
+ select: { preferences: boolean };
385
+ }) => Promise<{ preferences: unknown } | null>;
386
+ };
387
+ };
388
+ const userRow = await dbClient.user.findUnique({
389
+ where: { id: sessionUserId },
390
+ select: { preferences: true },
391
+ });
392
+ const portalPrefs = (
393
+ (userRow?.preferences as Record<string, unknown> | null)
394
+ ?.portal as Record<string, unknown> | undefined
395
+ );
396
+ if (portalPrefs?.paneLeftWidth) {
397
+ (rc.portalContext as Record<string, unknown>).paneLeftWidth =
398
+ portalPrefs.paneLeftWidth;
399
+ }
400
+ if (portalPrefs?.paneRightWidth) {
401
+ (rc.portalContext as Record<string, unknown>).paneRightWidth =
402
+ portalPrefs.paneRightWidth;
403
+ }
404
+ } catch {
405
+ // Pane widths are non-critical — fall back to defaults.
406
+ }
407
+ }
408
+ }
409
+ } catch {
410
+ // No auth factory / no session → anonymous; gates fall back to token.
411
+ }
412
+ await runContentGate(resolved, gateCookie, gateNow, proposalGateConfig);
413
+ await runContentGate(resolved, gateCookie, gateNow, agreementGateConfig);
414
+ // The public GPT identity gate: seeds resolved.initialVisitor from the
415
+ // signed gpt_token cookie so a returning visitor's conversation renders
416
+ // at SSR (no capture-gate→chat flash). Open capture, seed-only — it
417
+ // never withholds page content (gpt-gate.ts).
418
+ await runContentGate(resolved, gateCookie, gateNow, gptGateConfig);
419
+ const meta = buildMetaFromContext(routeConfig.meta_context, resolved);
420
+ // /sign-in brand override: a shared org-scoped link 302→/sign-in for the
421
+ // unauthed crawler (WhatsApp), which saw the service's default website
422
+ // brand (the platform owner). The callbackURL carries the destination org
423
+ // — override resolved.brand (before the title/meta/og are built from it)
424
+ // so the page reflects the org the visitor is signing in to. Scoped to
425
+ // /sign-in; resource pages keep their own brand.
426
+ //
427
+ // The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
428
+ // <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
429
+ // into fetchOrganisationPageBranding, which resolved no org and left the
430
+ // default (super-admin) brand — the cross-tenant mis-branding this override
431
+ // exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
432
+ // Shared helper so this and bind-page-handler cannot drift on the answer.
433
+ try {
434
+ const reqUrl = new URL(request.url);
435
+ if (reqUrl.pathname === "/sign-in") {
436
+ const cb = reqUrl.searchParams.get("callbackURL") ?? "";
437
+ const cbOrgId = extractOrgIdFromCallbackPath(cb);
438
+ const currentBrandOrgId =
439
+ (resolved.brand as { orgId?: string } | undefined)?.orgId ??
440
+ (resolved as { orgId?: string }).orgId;
441
+ if (cbOrgId && cbOrgId !== currentBrandOrgId) {
442
+ const branding = await fetchOrganisationPageBranding(
443
+ getServerClient() as never,
444
+ cbOrgId,
445
+ );
446
+ if (branding) {
447
+ const existing = (resolved.brand as Record<string, unknown>) ?? {};
448
+ const existingLogo =
449
+ (existing.logo as Record<string, unknown> | undefined) ?? {};
450
+ resolved.brand = {
451
+ ...existing,
452
+ companyName: branding.company_name,
453
+ companyFriendlyName: branding.company_name,
454
+ primaryColor: branding.brand_primary_color ?? existing.primaryColor,
455
+ headingFont: branding.brand_heading_font ?? existing.headingFont,
456
+ bodyFont: branding.brand_body_font ?? existing.bodyFont,
457
+ logo: {
458
+ light: branding.company_logo_for_light_theme,
459
+ dark: branding.company_logo_for_dark_theme,
460
+ brand: existingLogo.brand ?? null,
461
+ },
462
+ };
463
+ }
464
+ }
465
+ }
466
+ } catch {
467
+ // Branding fetch failed — keep the default brand (no regression).
468
+ }
469
+ const pageMetaTitle =
470
+ (
471
+ (resolved.loaderData as { page?: { meta_title?: unknown } } | undefined)
472
+ ?.page?.meta_title ??
473
+ (resolved.page as { meta_title?: unknown } | undefined)?.meta_title
474
+ );
475
+ if (
476
+ typeof pageMetaTitle === "string" &&
477
+ pageMetaTitle.trim() &&
478
+ new URL(request.url).pathname === "/sign-in"
479
+ ) {
480
+ meta.title = pageMetaTitle.trim();
481
+ }
482
+ const templateTitle = resolveTemplateTitle(serviceSlug, resolved);
483
+ if (templateTitle && !meta.title) {
484
+ const resolvedEntity =
485
+ (resolved.agent as { name?: string } | undefined)?.name ||
486
+ (resolved.proposal as { name?: string } | undefined)?.name ||
487
+ (resolved.form as { name?: string } | undefined)?.name ||
488
+ (resolved.quiz as { title?: string } | undefined)?.title;
489
+ if (!resolvedEntity) {
490
+ meta.title = templateTitle;
491
+ }
492
+ }
493
+ const regionSlots = (
494
+ loaderConfig as v.InferOutput<typeof ResourceLoaderConfigSchema> & {
495
+ region_slots?: string[];
496
+ }
497
+ ).region_slots;
498
+ const themeParam = url.searchParams.get("theme");
499
+ // ?theme is the EMBED override — only honor it when this page is
500
+ // actually framed (a form/quiz in a GPT tool panel, sec-fetch-dest:
501
+ // iframe). A DIRECT visit carrying ?theme= (e.g. copied from a GPT
502
+ // embed URL) must NOT override the org's DB theme — what the visitor
503
+ // sees is org-driven, never URL-driven.
504
+ const isFramedEmbed =
505
+ request.headers.get("sec-fetch-dest") === "iframe";
506
+ const themeOverride =
507
+ isFramedEmbed &&
508
+ (themeParam === "light" || themeParam === "dark")
509
+ ? (themeParam as "light" | "dark")
510
+ : undefined;
511
+ const pageData = resolvedToPageData(
512
+ resolved,
513
+ meta,
514
+ request,
515
+ routeProviders !== baseProviders ? routeProviders : undefined,
516
+ regionSlots,
517
+ themeOverride,
518
+ );
519
+ const panelSlug = new URL(request.url).searchParams.get("panel");
520
+ if (panelSlug) {
521
+ try {
522
+ const dbReg = getServerClient() as unknown as {
523
+ registry_entries: { findFirst: (a: { where: unknown; select: unknown }) => Promise<{ config: Record<string, unknown> } | null> };
524
+ };
525
+ const pv = await dbReg.registry_entries.findFirst({
526
+ where: { type: "panel_view", slug: panelSlug, is_active: true },
527
+ select: { config: true },
528
+ });
529
+ const pageSlug = (pv?.config?.content as { page_slug?: string })?.page_slug;
530
+ if (pageSlug) {
531
+ const dbPages = getServerClient() as unknown as {
532
+ builda_pages: { findFirst: (a: { where: unknown; select: unknown }) => Promise<{ puck_data: unknown } | null> };
533
+ };
534
+ const panelPage = await dbPages.builda_pages.findFirst({
535
+ where: { path: `/${pageSlug}`, is_active: true },
536
+ select: { puck_data: true },
537
+ });
538
+ if (panelPage?.puck_data) {
539
+ (pageData as Record<string, unknown>).panelPageData = panelPage.puck_data;
540
+ (pageData as Record<string, unknown>).activePanel = panelSlug;
541
+ }
542
+ }
543
+ } catch { /* non-critical */ }
544
+ }
545
+ if (
546
+ new URL(request.url).pathname === "/sign-in" &&
547
+ typeof pageMetaTitle === "string" &&
548
+ pageMetaTitle.trim()
549
+ ) {
550
+ pageData.meta = {
551
+ ...(pageData.meta as Record<string, unknown>),
552
+ title: pageMetaTitle.trim(),
553
+ };
554
+ }
555
+ // brand-is-org-owned-full-scheme, reconciled with page-org-first
556
+ // (portal-branding-resolves-page-org-first): the session override now
557
+ // personalizes ONLY platform pages (page org === website owning-org).
558
+ // Tenant pages keep their resolved page-org brand — the page is
559
+ // authoritative, the menu switcher changes which tenant's pages you
560
+ // view. Anonymous/public (no session) keeps the resolved brand. Same
561
+ // fetchOrganisationPageBranding pattern as the /sign-in brand override.
562
+ if (
563
+ sessionOrgId &&
564
+ sessionOrgId !== (pageData.orgId as string | undefined) &&
565
+ // Page-org-first (portal-branding-resolves-page-org-first): the
566
+ // session override personalizes ONLY the shared PLATFORM website
567
+ // (super-admin org). A TENANT-owned site (a customer GPT/quiz/portal)
568
+ // keeps its page-org brand regardless of the viewer session org. The
569
+ // old pageData.orgId===website.org mis-fired for a tenant whose page org equals their own website org.
570
+ getPlatformOrgId() ===
571
+ ((pageData.website as { organisation_id?: string | null } | undefined)
572
+ ?.organisation_id ?? undefined)
573
+ ) {
574
+ try {
575
+ const branding = await fetchOrganisationPageBranding(
576
+ getServerClient() as never,
577
+ sessionOrgId,
578
+ );
579
+ if (branding) {
580
+ pageData.orgId = sessionOrgId;
581
+ const existing = (pageData.brand as Record<string, unknown>) ?? {};
582
+ const existingLogo =
583
+ (existing.logo as Record<string, unknown>) ?? {};
584
+ pageData.brand = {
585
+ ...existing,
586
+ companyName: branding.company_name,
587
+ companyFriendlyName: branding.company_name,
588
+ primaryColor: branding.brand_primary_color ?? existing.primaryColor,
589
+ headingFont: branding.brand_heading_font ?? existing.headingFont,
590
+ bodyFont: branding.brand_body_font ?? existing.bodyFont,
591
+ logo: {
592
+ light: branding.company_logo_for_light_theme,
593
+ dark: branding.company_logo_for_dark_theme,
594
+ brand: existingLogo.brand ?? null,
595
+ },
596
+ };
597
+ }
598
+ } catch {
599
+ // Branding fetch failed — keep the default brand (no regression).
600
+ }
601
+ }
602
+ warnUncoveredBlocks(pageData, components, serviceSlug, params.path, log);
603
+ // Fire the AI-chat conversation prefetch NOW (in parallel with the
604
+ // proposal/partner/client post-processors below) so its ~eager cost is
605
+ // hidden in their window, then await it just before return. orgId is the
606
+ // URL :orgId (org-scoped routing — the same source AiChat uses); pageData
607
+ // .orgId is stamped later in service-runtime, not yet set here.
608
+ // A canonical portal URL is /<websiteId>/<organisationId>/<path> — the
609
+ // org is segment 1, NOT segment 0. Reading segment 0 captured the
610
+ // WEBSITE id and passed it as an organisation id, which failed silently
611
+ // because both are well-formed UUIDs: the prefetch simply warmed a
612
+ // session against the wrong tenant (found 2026-07-21).
613
+ //
614
+ // Prefer segment 1; fall back to segment 0 only when segment 1 is not a
615
+ // UUID (the single-UUID shape, which is live and supported — NOT
616
+ // transitional). The fallback is deliberately kept
617
+ // UNCHANGED-BEHAVIOUR rather than "fixed": a UUID in segment 0 is
618
+ // genuinely ambiguous — of the 40 UUID-first page paths in builda_pages,
619
+ // ZERO are websites and ZERO are organisations, so segment 0 can also be
620
+ // an unrelated record id. Position alone cannot resolve that; only
621
+ // validating the id against organisation_profile can, and that is a DB
622
+ // read this hot prefetch path should not take without caching
623
+ // (`hot-path-resolutions-are-cached`). Preferring segment 1 is strictly
624
+ // better for the canonical shape and no worse for any other.
625
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
626
+ const pathSegments = new URL(request.url).pathname.split("/").filter(Boolean);
627
+ const aiOrgId = UUID_RE.test(pathSegments[1] ?? "")
628
+ ? pathSegments[1]
629
+ : UUID_RE.test(pathSegments[0] ?? "")
630
+ ? pathSegments[0]
631
+ : undefined;
632
+ // Detached (NOT awaited into the response). prefetchAiChatSession ->
633
+ // createOrRestore is DB-heavy (~1.1s), per-user, and uncached, and was
634
+ // awaited on EVERY loadData — the dominant board loadMs cost (confirmed
635
+ // aiChatPrefetch=1145ms = the full loadMs). Fire it in the background so
636
+ // it still warms the server-side session (users who open chat find it
637
+ // warm via their live fetch) without blocking the page response. The SSR
638
+ // chat seed (pageData.aiChatSession) is sacrificed — chat opens with a
639
+ // live fetch instead of SSR-instant — the right trade: most loads never
640
+ // open chat, so eager-awaited prefetch was the waste.
641
+ void prefetchAiChatSession(resolved, sessionUserId, aiOrgId).catch((err) =>
642
+ log.warn(
643
+ `[scala-install:${serviceSlug}] background ai-chat prefetch failed: ${err instanceof Error ? err.message : err}`,
644
+ ),
645
+ );
646
+ // Proposal pages: resolve the {{approvalEmail}} button token to the
647
+ // org's registered company email (creator email as fallback).
648
+ if (resolved.proposal) {
649
+ await applyApprovalEmailToken(
650
+ pageData as Record<string, unknown>,
651
+ resolved.proposal as { created_by?: string } | undefined,
652
+ );
653
+ // Stash the partner logo from the partnerOrg loader source (CRM record
654
+ // loaded by partner_id) onto pageData.partnerLogo — header fills
655
+ // automatically, no inline body-Hero prop needed.
656
+ applyPartnerBrand(
657
+ pageData as Record<string, unknown>,
658
+ resolved.partnerOrg as
659
+ | { logo_url?: string | null; brand_primary_color?: string | null }
660
+ | undefined
661
+ | null,
662
+ );
663
+ // Auto-resolve the client's company logo from the clientOrg loader
664
+ // source (contact → company → crm_organisations.logo_url). Only
665
+ // injects when the Hero's clientLogo prop is empty — a manually set
666
+ // logo always wins.
667
+ applyClientLogo(
668
+ pageData as Record<string, unknown>,
669
+ resolved.clientOrg as
670
+ | { logo_url?: string | null; name?: string | null }
671
+ | undefined
672
+ | null,
673
+ );
674
+ }
675
+ return pageData;
676
+ };
677
+
678
+ const routeRenderMode: "public" | "portals" | "utility" =
679
+ routeConfig.render_mode === "portal"
680
+ ? "portals"
681
+ : routeConfig.render_mode === "public"
682
+ ? "public"
683
+ : routeConfig.render_mode === "utility"
684
+ ? "utility"
685
+ : renderMode;
686
+
687
+ const handler = createPageHandler({
688
+ components,
689
+ providers: routeProviders,
690
+ renderMode: routeRenderMode,
691
+ loadData: loadData as never,
692
+ portalHomeRedirect: servesPortalCatchall,
693
+ serviceSlug,
694
+ });
695
+
696
+ app.get(pattern, async (c) => {
697
+ const routeOrgId = c.req.param("orgId");
698
+ return bindPageHandler(
699
+ c,
700
+ handler as never,
701
+ authMode,
702
+ serviceSlug,
703
+ `registry:${routeRow.slug}`,
704
+ routeOrgId,
705
+ );
706
+ });
707
+
708
+ registeredPatterns.push(pattern);
709
+ log.info(
710
+ `[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}"`,
711
+ );
712
+ }
713
+
714
+ return registeredPatterns;
715
+ }
716
+
717
+ /**
718
+ * Block-coverage guard (protective-layers-fail-explicitly). A page that renders
719
+ * a block whose component is NOT registered — because its package is missing
720
+ * from service_install.config.block_packages — silently renders BLANK. That is
721
+ * the exact failure behind the 2026-06-04 scala-sign regression (sign-in-block
722
+ * dropped from block_packages → the gate + signature fields vanished) which left
723
+ * only a benign boot WARN. This turns that silent blank into a loud,
724
+ * GlitchTip-visible ERROR naming the page + block, throttled to once per
725
+ * (service, block type) so a hot path can never spam it.
726
+ */
727
+ const uncoveredBlocksLogged = new Set<string>();
728
+
729
+ function collectBlockTypes(node: unknown, out: Set<string>): void {
730
+ if (Array.isArray(node)) {
731
+ for (const child of node) collectBlockTypes(child, out);
732
+ return;
733
+ }
734
+ if (node && typeof node === "object") {
735
+ const obj = node as Record<string, unknown>;
736
+ if (
737
+ typeof obj.type === "string" &&
738
+ obj.props &&
739
+ typeof obj.props === "object"
740
+ ) {
741
+ out.add(obj.type);
742
+ }
743
+ for (const value of Object.values(obj)) collectBlockTypes(value, out);
744
+ }
745
+ }
746
+
747
+ function warnUncoveredBlocks(
748
+ pageData: unknown,
749
+ components: Record<string, RendererComponentEntry>,
750
+ serviceSlug: string,
751
+ path: unknown,
752
+ log: ReturnType<typeof getAppLogger>,
753
+ ): void {
754
+ const used = new Set<string>();
755
+ collectBlockTypes(pageData, used);
756
+ for (const type of used) {
757
+ if (type in components) continue;
758
+ const key = `${serviceSlug}:${type}`;
759
+ if (uncoveredBlocksLogged.has(key)) continue;
760
+ uncoveredBlocksLogged.add(key);
761
+ log.error(
762
+ `[scala-install:${serviceSlug}] page "${String(path)}" renders block "${type}" but NO component is registered for it — its package is almost certainly missing from service_install.config.block_packages, so it renders BLANK. Add the owning @teamscala/* package to block_packages and restart. (block-coverage guard)`,
763
+ );
764
+ }
765
+ }