@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,518 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Converts the resolved loader-graph into the pageData shape the renderer
7
+ * consumes — three branches: arbitrary region-slots (portal/utility), the
8
+ * header/body/footer public shell, and the single-page fallback. Extracted
9
+ * from register-registry-page-route.ts (single-purpose-file split).
10
+ */
11
+ import { resolveThemeMode } from "../resolve-theme-mode.ts";
12
+ import { composePageTitle } from "../compose-page-title.ts";
13
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
14
+
15
+ /**
16
+ * Local structural type for Puck page data. Avoids a type-only import of
17
+ * `@puckeditor/core`, which is NOT a runtime dependency — `deps-derived-from-source`
18
+ * excludes whole-statement type-only imports, so the bare specifier has no
19
+ * installed package to resolve against at publish typecheck time. The shape used
20
+ * here (root/content/zones) is all this module reads or constructs.
21
+ */
22
+ type PuckData = {
23
+ root: { props?: Record<string, unknown> };
24
+ content: unknown[];
25
+ zones?: Record<string, unknown>;
26
+ };
27
+
28
+ const EMPTY_PUCK_DATA: PuckData = { root: { props: {} }, content: [] };
29
+
30
+ // Returns null on malformed JSON (observed via warn log) instead of a silent
31
+ // masking empty fallback — protective-layers-fail-explicitly. Callers default
32
+ // to EMPTY_PUCK_DATA where an empty render is the intended degradation.
33
+ function extractPuckData(source: Record<string, unknown>): PuckData | null {
34
+ const raw = source.puck_data ?? source.puckData;
35
+ if (!raw) return EMPTY_PUCK_DATA;
36
+ if (typeof raw === "string") {
37
+ try {
38
+ return JSON.parse(raw) as PuckData;
39
+ } catch (err) {
40
+ getAppLogger().warn(
41
+ "[extractPuckData] malformed puck_data JSON — rendering empty",
42
+ { err },
43
+ );
44
+ return null;
45
+ }
46
+ }
47
+ return raw as PuckData;
48
+ }
49
+
50
+ function applyLayoutDefaults(
51
+ puckData: PuckData,
52
+ defaults: Record<string, string>,
53
+ ): void {
54
+ if (!defaults || Object.keys(defaults).length === 0) return;
55
+ const data = puckData as { root?: { props?: Record<string, unknown> } };
56
+ if (!data.root) data.root = { props: {} };
57
+ if (!data.root.props) data.root.props = {};
58
+ for (const [key, value] of Object.entries(defaults)) {
59
+ if (data.root.props[key] == null) data.root.props[key] = value;
60
+ }
61
+ }
62
+
63
+ function pickRootProp<T>(regions: RegionEntry[], key: string): T | undefined {
64
+ for (const region of regions) {
65
+ const props = (
66
+ region.puckData as { root?: { props?: Record<string, unknown> } } | null
67
+ )?.root?.props;
68
+ const value = props?.[key];
69
+ if (value != null && value !== "") return value as T;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ interface RegionEntry {
75
+ slot: string;
76
+ puckData: PuckData;
77
+ pageId: string;
78
+ website?: { slug: string; name: string; organisation_id?: string | null };
79
+ metaTitle?: string | null;
80
+ metaDescription?: string | null;
81
+ }
82
+
83
+ export function resolvedToPageData(
84
+ resolved: Record<string, unknown>,
85
+ meta: Record<string, unknown>,
86
+ request: Request,
87
+ providers: string[] | undefined,
88
+ regionSlots?: string[],
89
+ // ?theme=light|dark from the URL — wins over per-page/brand/website theme so
90
+ // a page framed inside another shell (e.g. a form in a GPT tool panel)
91
+ // matches the host's light/dark scheme so an embedded page doesn't flash.
92
+ themeOverride?: "light" | "dark",
93
+ ): Record<string, unknown> {
94
+ const brand = (resolved.brand as Record<string, unknown>) ?? {};
95
+ const primaryColor = (brand.primaryColor as string) ?? "";
96
+ const headingFont = (brand.headingFont as string) ?? null;
97
+ const bodyFont = (brand.bodyFont as string) ?? null;
98
+ const logo = (brand.logo as {
99
+ light: string | null;
100
+ dark: string | null;
101
+ brand: string | null;
102
+ }) ?? { light: null, dark: null, brand: null };
103
+
104
+ const brandTheme = typeof brand.theme === "string" ? brand.theme : undefined;
105
+
106
+ // Branch 1: arbitrary regions (portal/agents/docs) — region_slots from loader config
107
+ if (regionSlots && regionSlots.length > 0) {
108
+ const website = (resolved.website as Record<string, unknown>) ?? {};
109
+ const layoutDefaults =
110
+ (website.layout_defaults as Record<string, string>) ??
111
+ (website.layout_settings as Record<string, string>) ??
112
+ {};
113
+ const layoutSettings = website.layout_settings as
114
+ | { theme?: unknown }
115
+ | null
116
+ | undefined;
117
+ const websiteTheme =
118
+ layoutSettings && typeof layoutSettings.theme === "string"
119
+ ? layoutSettings.theme
120
+ : undefined;
121
+
122
+ // Title derives from the primary resolved entity (e.g. the GPT agent),
123
+ // matching the flat-page branch — so a utility-shell GPT shows the agent
124
+ // name in <title>/OG, not the generic "Portal". Falls back to the website
125
+ // name, then "Portal". Without this, the region-slots branch returned
126
+ // early with a hardcoded "Portal" title (gpt-test: SSR/DOM/OG title FAIL).
127
+ const entityTitle =
128
+ (resolved.form as { name?: string })?.name ||
129
+ (resolved.quiz as { title?: string })?.title ||
130
+ (resolved.proposal as { name?: string })?.name ||
131
+ (resolved.agent as { name?: string })?.name;
132
+
133
+ const regions: RegionEntry[] = [];
134
+ for (const slot of regionSlots) {
135
+ const source = resolved[slot] as Record<string, unknown> | undefined;
136
+ if (!source) continue;
137
+ const puckData = extractPuckData(source) ?? EMPTY_PUCK_DATA;
138
+ applyLayoutDefaults(puckData, layoutDefaults);
139
+ regions.push({
140
+ slot,
141
+ puckData,
142
+ pageId: (source.id as string) ?? "",
143
+ });
144
+ }
145
+
146
+ if (regions.length === 0) {
147
+ return {
148
+ puckData: { root: { props: {} }, content: [] } as PuckData,
149
+ theme: "dark" as const,
150
+ brand: {
151
+ primaryColor,
152
+ backgroundColor: (brand.backgroundColor as string | null) ?? null,
153
+ lightSurface: (brand.lightSurface as string | null) ?? null,
154
+ darkSurface: (brand.darkSurface as string | null) ?? null,
155
+ headingFont,
156
+ bodyFont,
157
+ logo,
158
+ companyName: (brand.companyName as string) ?? null,
159
+ companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
160
+ contactEmail: (brand.contactEmail as string) ?? null,
161
+ favicon: (brand.favicon as string) ?? null,
162
+ },
163
+ meta: {
164
+ title: composePageTitle({
165
+ templateTitle: (meta.title as string) || undefined,
166
+ websiteName: (website.name as string) || undefined,
167
+ entityTitle: entityTitle || undefined,
168
+ }),
169
+ },
170
+ };
171
+ }
172
+
173
+ const rootTheme = pickRootProp<string>(regions, "theme");
174
+ const { theme } = resolveThemeMode({
175
+ override: themeOverride,
176
+ candidates: [rootTheme, brandTheme, websiteTheme],
177
+ });
178
+
179
+ const overrideColor = pickRootProp<string>(regions, "primaryColor");
180
+ const overrideHeading = pickRootProp<string>(regions, "headingFont");
181
+ const overrideBody = pickRootProp<string>(regions, "bodyFont");
182
+ // The page the user is viewing is the main content region (center for
183
+ // portal, body for utility). Its name ("Dashboard") is the title subject.
184
+ const contentSlot = regionSlots.find((s) => s === "center" || s === "body");
185
+ const contentPageName = contentSlot
186
+ ? ((resolved[contentSlot] as Record<string, unknown> | undefined)?.name as
187
+ | string
188
+ | undefined)
189
+ : undefined;
190
+
191
+ return {
192
+ puckData: { root: { props: {} }, content: [] } as PuckData,
193
+ regions,
194
+ // Region-shell blocks (e.g. the GPT chat in the center region) read
195
+ // their resolved entities from pageData.loaderData — same surface the
196
+ // flat single-page branch exposes. Without it the chat block has no
197
+ // agent and renders "Custom GPT not found".
198
+ loaderData: resolved,
199
+ website: {
200
+ slug: website.slug as string,
201
+ name: website.name as string,
202
+ organisation_id: website.organisation_id as string | null,
203
+ // header_settings carries the DB-driven header config (e.g.
204
+ // backdropBlur) the public shell applies — PublicTemplate reads
205
+ // pageData.website.header_settings.backdropBlur. Dropping it here
206
+ // silently kills the header blur even though the DB switch is set
207
+ // (the 2026-06-17 regression). Pass it through verbatim.
208
+ header_settings:
209
+ (website.header_settings as Record<string, unknown> | null) ?? null,
210
+ },
211
+ orgId:
212
+ ((resolved.center as Record<string, unknown> | undefined)
213
+ ?.organisation_id as string | null) ??
214
+ ((resolved.agent as Record<string, unknown> | undefined)
215
+ ?.organisation_id as string | null) ??
216
+ null,
217
+ brand: {
218
+ primaryColor: overrideColor ?? primaryColor,
219
+ backgroundColor: (brand.backgroundColor as string | null) ?? null,
220
+ lightSurface: (brand.lightSurface as string | null) ?? null,
221
+ darkSurface: (brand.darkSurface as string | null) ?? null,
222
+ headingFont: overrideHeading ?? headingFont,
223
+ bodyFont: overrideBody ?? bodyFont,
224
+ logo,
225
+ companyName: (brand.companyName as string) ?? null,
226
+ companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
227
+ contactEmail: (brand.contactEmail as string) ?? null,
228
+ favicon: (brand.favicon as string) ?? null,
229
+ },
230
+ theme,
231
+ meta: {
232
+ title: composePageTitle({
233
+ templateTitle: (meta.title as string) || undefined,
234
+ websiteName: (website.name as string) || undefined,
235
+ pageName: contentPageName,
236
+ entityTitle: entityTitle || undefined,
237
+ }),
238
+ },
239
+ };
240
+ }
241
+
242
+ // Branch 2: public shell (header/body/footer regions from header/footer sources)
243
+ const headerSource = resolved.header as Record<string, unknown> | undefined;
244
+ const footerSource = resolved.footer as Record<string, unknown> | undefined;
245
+
246
+ if (headerSource || footerSource) {
247
+ const page = (resolved.page as Record<string, unknown>) ?? {};
248
+ const website =
249
+ (resolved.website_by_host as Record<string, unknown>) ??
250
+ (resolved.website as Record<string, unknown>) ??
251
+ {};
252
+ const layoutDefaults =
253
+ (website.layout_defaults as Record<string, string>) ??
254
+ (website.layout_settings as Record<string, string>) ??
255
+ {};
256
+ const layoutSettings = website.layout_settings as
257
+ | { theme?: unknown }
258
+ | null
259
+ | undefined;
260
+ const websiteTheme =
261
+ layoutSettings && typeof layoutSettings.theme === "string"
262
+ ? layoutSettings.theme
263
+ : undefined;
264
+
265
+ const regions: RegionEntry[] = [];
266
+
267
+ if (headerSource) {
268
+ const headerPuck = extractPuckData(headerSource) ?? EMPTY_PUCK_DATA;
269
+ applyLayoutDefaults(headerPuck, layoutDefaults);
270
+ regions.push({
271
+ slot: "header",
272
+ puckData: headerPuck,
273
+ pageId: (headerSource.id as string) ?? "",
274
+ });
275
+ }
276
+
277
+ const bodyPuck = extractPuckData(page) ?? EMPTY_PUCK_DATA;
278
+ applyLayoutDefaults(bodyPuck, layoutDefaults);
279
+ regions.push({
280
+ slot: "body",
281
+ puckData: bodyPuck,
282
+ pageId: (page.id as string) ?? "",
283
+ metaTitle: (page.meta_title as string) ?? null,
284
+ metaDescription: (page.meta_description as string) ?? null,
285
+ });
286
+
287
+ if (footerSource) {
288
+ const footerPuck = extractPuckData(footerSource) ?? EMPTY_PUCK_DATA;
289
+ applyLayoutDefaults(footerPuck, layoutDefaults);
290
+ regions.push({
291
+ slot: "footer",
292
+ puckData: footerPuck,
293
+ pageId: (footerSource.id as string) ?? "",
294
+ });
295
+ }
296
+
297
+ const rootTheme = pickRootProp<string>(regions, "theme");
298
+ const { theme } = resolveThemeMode({
299
+ override: themeOverride,
300
+ candidates: [rootTheme, brandTheme, websiteTheme],
301
+ });
302
+
303
+ const overrideColor = pickRootProp<string>(regions, "primaryColor");
304
+ const overrideBg = pickRootProp<string>(regions, "backgroundColor");
305
+ const overrideHeading = pickRootProp<string>(regions, "headingFont");
306
+ const overrideBody = pickRootProp<string>(regions, "bodyFont");
307
+
308
+ const pageName = (page.name as string) || null;
309
+ const entityTitle =
310
+ (resolved.proposal as { name?: string })?.name ||
311
+ (resolved.form as { name?: string })?.name ||
312
+ (resolved.quiz as { title?: string })?.title ||
313
+ (resolved.agent as { name?: string })?.name;
314
+ const bodyMetaTitle = regions.find((r) => r.slot === "body")?.metaTitle;
315
+ const pageRootTitle = pickRootProp<string>(regions, "title");
316
+ const pageDescription =
317
+ regions.find((r) => r.slot === "body")?.metaDescription ??
318
+ pickRootProp<string>(regions, "description") ??
319
+ null;
320
+
321
+ return {
322
+ puckData: { root: { props: {} }, content: [] } as PuckData,
323
+ regions,
324
+ // Region-shell blocks (e.g. the header Navigation deriving the body's
325
+ // document sections) read the resolved entity graph from
326
+ // pageData.loaderData — the same surface Branch 1 exposes. Without it a
327
+ // header/footer block serverLoader gets ctx.loaderData=undefined and
328
+ // cannot see the body page, so a server-derived nav produced no items
329
+ // and the menu was client-only (vanished on stale tabs / narrow views).
330
+ loaderData: resolved,
331
+ website: {
332
+ slug: website.slug as string,
333
+ name: website.name as string,
334
+ organisation_id: website.organisation_id as string | null,
335
+ // header_settings carries the DB-driven header config (e.g.
336
+ // backdropBlur) the public shell applies — PublicTemplate reads
337
+ // pageData.website.header_settings.backdropBlur. Dropping it here
338
+ // silently kills the header blur even though the DB switch is set
339
+ // (the 2026-06-17 regression). Pass it through verbatim.
340
+ header_settings:
341
+ (website.header_settings as Record<string, unknown> | null) ?? null,
342
+ },
343
+ orgId:
344
+ (page.organisation_id as string) ??
345
+ null,
346
+ brand: {
347
+ primaryColor: overrideColor ?? primaryColor,
348
+ // Thread the org brand's dark surface (brand_background_color /
349
+ // brand_dark_color) when the page sets no explicit override — so
350
+ // dark-mode pages use the brand's real dark colour, not a
351
+ // primary-derived tint. Uses the org brand's real dark colour.
352
+ backgroundColor:
353
+ overrideBg ?? (brand.backgroundColor as string | null) ?? null,
354
+ lightSurface: (brand.lightSurface as string | null) ?? null,
355
+ darkSurface: (brand.darkSurface as string | null) ?? null,
356
+ accentColor: (brand.accentColor as string | null) ?? null,
357
+ secondaryColor: (brand.secondaryColor as string | null) ?? null,
358
+ mutedColor: (brand.mutedColor as string | null) ?? null,
359
+ headingFont: overrideHeading ?? headingFont,
360
+ bodyFont: overrideBody ?? bodyFont,
361
+ logo,
362
+ companyName: (brand.companyName as string) ?? null,
363
+ companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
364
+ contactEmail: (brand.contactEmail as string) ?? null,
365
+ favicon: (brand.favicon as string) ?? null,
366
+ },
367
+ theme,
368
+ meta: {
369
+ title: composePageTitle({
370
+ pageMetaTitle: bodyMetaTitle,
371
+ templateTitle: (meta.title as string) || undefined,
372
+ websiteName: (website.name as string) || undefined,
373
+ pageName: pageName ?? pageRootTitle,
374
+ entityTitle: entityTitle || undefined,
375
+ }),
376
+ ...(pageDescription ? { description: pageDescription } : {}),
377
+ },
378
+ };
379
+ }
380
+
381
+ const page = (resolved.page as Record<string, unknown>) ?? null;
382
+ const puckData = page
383
+ ? extractPuckData(page) ?? EMPTY_PUCK_DATA
384
+ : ({ root: { props: {} }, content: [] } as PuckData);
385
+
386
+ const website = (resolved.website as Record<string, unknown>) ?? {};
387
+ const layoutSettings = (website.layout_settings ??
388
+ resolved.websiteDefaults) as { theme?: unknown } | null | undefined;
389
+ const websiteTheme =
390
+ layoutSettings && typeof layoutSettings.theme === "string"
391
+ ? layoutSettings.theme
392
+ : undefined;
393
+
394
+ const pageRoot = (
395
+ puckData as { root?: { props?: Record<string, unknown> } } | null
396
+ )?.root?.props;
397
+ const rootTheme =
398
+ pageRoot && typeof pageRoot.theme === "string" ? pageRoot.theme : undefined;
399
+ const agent = (resolved.agent as Record<string, unknown>) ?? null;
400
+ // A GPT page's mode is the agent's dark_theme flag (the DB switch). It's the
401
+ // top candidate so the SSR :root matches the GPT shell's own scope (both
402
+ // derive from the same ai_agents.dark_theme) — flip the flag, the whole GPT
403
+ // + everything it frames flips. themeOverride (?theme=) still wins for an
404
+ // embedded render.
405
+ const agentTheme =
406
+ typeof agent?.dark_theme === "boolean"
407
+ ? agent.dark_theme
408
+ ? "dark"
409
+ : "light"
410
+ : undefined;
411
+ // themeOverride (?theme=) wins so a form/quiz framed in a GPT panel matches
412
+ // the host shell's light/dark scheme — this single-page branch serves
413
+ // form/quiz/proposal/gpt pages.
414
+ const { theme } = resolveThemeMode({
415
+ override: themeOverride,
416
+ candidates: [agentTheme, rootTheme, brandTheme, websiteTheme],
417
+ });
418
+ const orgId =
419
+ (page?.organisation_id as string) ??
420
+ (agent?.organisation_id as string) ??
421
+ null;
422
+
423
+ const overrideColor =
424
+ pageRoot &&
425
+ typeof pageRoot.primaryColor === "string" &&
426
+ pageRoot.primaryColor
427
+ ? pageRoot.primaryColor
428
+ : null;
429
+ const overrideHeading =
430
+ pageRoot && typeof pageRoot.headingFont === "string" && pageRoot.headingFont
431
+ ? pageRoot.headingFont
432
+ : null;
433
+ const overrideBody =
434
+ pageRoot && typeof pageRoot.bodyFont === "string" && pageRoot.bodyFont
435
+ ? pageRoot.bodyFont
436
+ : null;
437
+
438
+ const screenMode = pageRoot?.screenMode as string | undefined;
439
+
440
+ const pageName = (page?.name as string) || null;
441
+ const entityTitle =
442
+ (resolved.form as { name?: string })?.name ||
443
+ (resolved.quiz as { title?: string })?.title ||
444
+ (resolved.proposal as { name?: string })?.name ||
445
+ (resolved.agent as { name?: string })?.name;
446
+ const entityDescription =
447
+ ((resolved.form as Record<string, unknown>)?.description as string) ||
448
+ ((resolved.quiz as Record<string, unknown>)?.description as string) ||
449
+ ((resolved.proposal as Record<string, unknown>)?.description as string) ||
450
+ ((resolved.agent as Record<string, unknown>)?.description as string);
451
+ const pageDescription =
452
+ (page?.meta_description as string) ||
453
+ entityDescription ||
454
+ (pageRoot?.description as string) ||
455
+ null;
456
+ const loaderDataPageMetaTitle = (
457
+ resolved.loaderData as { page?: { meta_title?: unknown } } | undefined
458
+ )?.page?.meta_title;
459
+ const pageMetaTitle =
460
+ (typeof loaderDataPageMetaTitle === "string" &&
461
+ loaderDataPageMetaTitle.trim()
462
+ ? loaderDataPageMetaTitle.trim()
463
+ : typeof page?.meta_title === "string" && page.meta_title.trim()
464
+ ? page.meta_title.trim()
465
+ : null) || null;
466
+
467
+ // Title is DERIVED via the single composer: meta_title is an explicit
468
+ // per-page OVERRIDE (priority 1), a curated resource template wins for
469
+ // resource surfaces (priority 2), otherwise <website>: <subject>. The
470
+ // /sign-in special-case is gone — pageMetaTitle now wins uniformly, so a
471
+ // sign-in page with a meta_title is honoured on every path, not just /sign-in.
472
+ const mergedMeta: Record<string, unknown> = {
473
+ ...(pageDescription ? { description: pageDescription } : {}),
474
+ ...meta,
475
+ };
476
+ mergedMeta.title = composePageTitle({
477
+ pageMetaTitle,
478
+ templateTitle: (meta.title as string) || undefined,
479
+ websiteName: (website.name as string) || undefined,
480
+ pageName: pageName ?? ((pageRoot?.title as string) || undefined),
481
+ entityTitle: entityTitle || undefined,
482
+ });
483
+
484
+ return {
485
+ puckData,
486
+ website: {
487
+ slug: website.slug as string,
488
+ name: website.name as string,
489
+ organisation_id: website.organisation_id as string | null,
490
+ // Pass header_settings through (DB-driven header blur etc.) — see the
491
+ // other two website assignments above. Dropping it kills the blur.
492
+ header_settings:
493
+ (website.header_settings as Record<string, unknown> | null) ?? null,
494
+ },
495
+ brand: {
496
+ primaryColor: overrideColor ?? primaryColor,
497
+ backgroundColor: (brand.backgroundColor as string | null) ?? null,
498
+ lightSurface: (brand.lightSurface as string | null) ?? null,
499
+ darkSurface: (brand.darkSurface as string | null) ?? null,
500
+ headingFont: overrideHeading ?? headingFont,
501
+ bodyFont: overrideBody ?? bodyFont,
502
+ logo,
503
+ companyName: (brand.companyName as string) ?? null,
504
+ companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
505
+ contactEmail: (brand.contactEmail as string) ?? null,
506
+ favicon: (brand.favicon as string) ?? null,
507
+ },
508
+ theme,
509
+ orgId,
510
+ loaderData: resolved,
511
+ ...(screenMode ? { fullScreen: screenMode === "full-screen" } : {}),
512
+ ...(pageRoot?.standalone != null
513
+ ? { standalone: pageRoot.standalone === false ? false : undefined }
514
+ : {}),
515
+ ...(providers ? { providerKeys: providers } : {}),
516
+ meta: mergedMeta,
517
+ };
518
+ }