@teamscala/service-boot 0.23.417 → 0.23.419

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.
@@ -1,411 +1,39 @@
1
1
  /**
2
2
  * @system server-boot
3
- * @status handwritten @indivisible-unit
3
+ * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
6
  * The `platform-fault-escalation` pg-boss handler. Full contract in curated
7
7
  * `apps/escalation.md` — episodes EMIT ON TRANSITIONS ONLY; evidence bounded
8
8
  * `occurred_at <= now()` and cited by resolvable event id; delivery verified
9
9
  * by WhatsApp message id before `escalated_at` is recorded.
10
+ *
11
+ * This module is the SWEEP unit (load config → aggregate → decide → deliver/
12
+ * resolve per kind, isolated). The machinery lives as siblings under
13
+ * ./platform-fault-escalation/ (config, event-log-queries, orpc-client,
14
+ * delivery) — split when the parent held them all at 500 lines; the pure
15
+ * decision logic was already platform-fault-escalation-logic.ts.
10
16
  */
11
- import type { ExecutionContext } from "@teamscala/os/contracts/mcp";
12
17
  import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
13
- import * as v from "valibot";
14
- import { getPrisma } from "@teamscala/db/prisma-registry";
15
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
16
18
  import { recordEvent } from "@teamscala/event-log/record-event";
17
19
  import { getAppLogger } from "@teamscala/logger/app-loggers";
18
20
  import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
19
21
  import {
20
- composeEscalationMessage,
21
22
  decideSweep,
22
- extractWhatsAppMessageId,
23
- formatEscalationSample,
24
23
  type EscalationEpisode,
25
- type FaultAggregate,
26
24
  type KindThresholdOverride,
27
25
  } from "./platform-fault-escalation-logic.ts";
28
-
29
- const CONFIG_SLUG = "platform-fault-escalation";
30
-
31
- const FaultEscalationConfigSchema = v.object({
32
- /** Postgres regex patterns selecting watched event_log kinds. */
33
- watched_kind_patterns: v.array(v.string()),
34
- /** Lookback window for the recurrence/duration aggregate, minutes. */
35
- window_minutes: v.number(),
36
- min_count: v.number(),
37
- min_span_minutes: v.number(),
38
- /** Per-kind recurrence/duration overrides — low-rate-but-persistent kinds. */
39
- kind_thresholds: v.optional(
40
- v.record(
41
- v.string(),
42
- v.object({
43
- min_count: v.optional(v.number()),
44
- min_span_minutes: v.optional(v.number()),
45
- }),
46
- ),
47
- ),
48
- resolve_after_minutes: v.number(),
49
- /** The orchestrator's work item — escalations are COMMENTS on it, never
50
- * task rows (the fleet-task-placement CHECK constraint refuses a system
51
- * task without session identity, and an escalation is a finding, not an
52
- * assigned unit of work). */
53
- orchestrator_work_item_id: v.string(),
54
- /** Per-kind copy for the message (impact + recommendation). Falls back to default_copy. */
55
- copy: v.record(v.string(), v.object({ impact: v.string(), recommendation: v.string() })),
56
- default_copy: v.object({ impact: v.string(), recommendation: v.string() }),
57
- /** Per-kind payload-field whitelist sampled into the message's evidence
58
- * line — named fields ONLY, never the whole payload: some kinds embed
59
- * request context, and the escalation channel is WhatsApp. */
60
- sample_fields: v.optional(v.record(v.string(), v.array(v.string()))),
61
- });
62
-
63
- type FaultEscalationConfig = v.InferOutput<typeof FaultEscalationConfigSchema>;
64
-
65
- interface EventLogAggregateRow {
66
- kind: string;
67
- n: bigint;
68
- first_seen: Date;
69
- last_seen: Date;
70
- }
71
-
72
- interface OperatorRelayBot {
73
- id: string;
74
- organisation_id: string;
75
- external_id: string | null;
76
- flow_config: { operator_phone?: string } | null;
77
- }
78
-
79
- let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
80
- async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
81
- if (cachedSystemIdentity) return cachedSystemIdentity;
82
- const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
83
- cachedSystemIdentity = {
84
- userId: row?.system_user_id as string | undefined,
85
- organisationId: row?.system_org_id as string | undefined,
86
- };
87
- return cachedSystemIdentity;
88
- }
89
-
90
- /**
91
- * The system-authed in-process ORPC client, built LAZILY on first use from the
92
- * SAME composed appRouter the /api/rpc mount uses (getAppRouter — never a raw
93
- * re-import of the generated routers, per register-page-server-client's
94
- * documented module-identity trap). Built here rather than read from
95
- * page-server-orpc/server-client because a pg-boss handler can fire on a
96
- * BACKEND service, where the page-server client is never registered — the
97
- * empty-router fallback returns a client with no model namespaces (the
98
- * TypeError this replaced). Per `no-direct-prisma-outside-orpc`, every WRITE
99
- * in this handler routes through this client; the two raw reads (the
100
- * event_log recurrence aggregate and the latest-payload sample, shapes no
101
- * generated CRUD expresses) use
102
- * the assigned-client form with $queryRawUnsafe. Timestamps are ISO strings,
103
- * never Date instances (the generated valibot schemas require it).
104
- */
105
- let cachedOrpc: OrpcSurface | null = null;
106
- async function orpc(): Promise<OrpcSurface> {
107
- if (cachedOrpc) return cachedOrpc;
108
- const { createRouterClient } = await import("@orpc/server");
109
- const { getAppRouter } = await import("@teamscala/orpc/app-router");
110
- const appRouter = (await getAppRouter()) as Record<string, unknown> | null;
111
- if (!appRouter) {
112
- throw new Error(
113
- "platform-fault-escalation: no ORPC appRouter — the service has no generated routers to write through",
114
- );
115
- }
116
- const prismaRegistry = await import("@teamscala/db/prisma-registry");
117
- const registryLoader = await import("@teamscala/db/registry/load-config");
118
- const { PlatformIdentitySchema } = await import(
119
- "@teamscala/db-validation/registry-schemas/platform-identity"
120
- );
121
- const platformIdentity = await registryLoader.loadRegistryConfig(
122
- "config",
123
- "platform-identity",
124
- PlatformIdentitySchema,
125
- );
126
- cachedOrpc = createRouterClient(
127
- { ...appRouter } as never,
128
- {
129
- context: {
130
- prisma: prismaRegistry.getPrisma(),
131
- user: {
132
- id: "system",
133
- isSuperAdmin: true,
134
- organisationId: platformIdentity.platformOrganisationId,
135
- },
136
- },
137
- } as never,
138
- ) as unknown as OrpcSurface;
139
- return cachedOrpc;
140
- }
141
-
142
- /** The router subset this handler uses — mirrors the generated procedure args. */
143
- interface OrpcSurface {
144
- platform_fault_escalations: {
145
- upsert(args: {
146
- where: { kind: string };
147
- create: Record<string, unknown>;
148
- update: Record<string, unknown>;
149
- }): Promise<unknown>;
150
- update(args: { where: { kind: string }; data: Record<string, unknown> }): Promise<unknown>;
151
- findMany(args?: { where?: Record<string, unknown> }): Promise<Array<Record<string, unknown>>>;
152
- };
153
- messaging_bots: {
154
- findFirst(args: Record<string, unknown>): Promise<Record<string, unknown> | null>;
155
- };
156
- comments: {
157
- create(args: { data: Record<string, unknown> }): Promise<{ id: string }>;
158
- };
159
- }
160
-
161
- async function loadConfig(): Promise<FaultEscalationConfig> {
162
- return loadRegistryConfig("config", CONFIG_SLUG, FaultEscalationConfigSchema);
163
- }
164
-
165
- async function fetchAggregates(patterns: string[], windowMinutes: number): Promise<FaultAggregate[]> {
166
- const prisma = getPrisma();
167
- // Patterns come from the valibot-validated registry row and are regex-only
168
- // by construction below; embedding them as one alternation keeps the query
169
- // parameter-free on the pattern side while the window is a real parameter.
170
- // The window is bounded on BOTH sides by the database's own statement
171
- // clock: `occurred_at` is producer-claimed, and an unbounded top edge
172
- // admits rows stamped in the future by a skewed or mocked producer clock —
173
- // which then fabricate "still recurring" for a quiet fault (measured
174
- // 2026-08-26). A future row is not evidence of anything happening now.
175
- const regexClass = /^[A-Za-z0-9_\\^$.*+?()[\]{}|-]+$/;
176
- for (const pattern of patterns) {
177
- if (!regexClass.test(pattern)) {
178
- throw new Error(`config/${CONFIG_SLUG}: watched_kind_pattern is not regex-only: ${pattern}`);
179
- }
180
- }
181
- const combined = `(${patterns.join("|")})`;
182
- const rows = await prisma.$queryRawUnsafe<EventLogAggregateRow[]>(
183
- `SELECT kind, count(*)::bigint AS n, min(occurred_at) AS first_seen, max(occurred_at) AS last_seen
184
- FROM event_log
185
- WHERE occurred_at > now() - ($1::int * interval '1 minute')
186
- AND occurred_at <= now()
187
- AND kind ~ $2::text
188
- GROUP BY kind`,
189
- windowMinutes,
190
- combined,
191
- );
192
- return rows.map((row) => ({
193
- kind: row.kind,
194
- count: Number(row.n),
195
- firstSeen: new Date(row.first_seen),
196
- lastSeen: new Date(row.last_seen),
197
- }));
198
- }
199
-
200
- /**
201
- * The message's evidence: the whitelisted sample fields AND the row identity
202
- * (id + occurred_at) of the newest payload for the kind. Field names come
203
- * from the valibot-validated config row and are constrained to bare
204
- * identifiers below, so the JSON path cannot break out of the string. The
205
- * `occurred_at <= now()` bound matches the aggregate's top edge: the cited
206
- * "last" row must be a real, non-future event whose id rides in the message
207
- * so the claim resolves against event_log.
208
- */
209
- async function fetchLatestEvidence(
210
- kind: string,
211
- fields: string[],
212
- ): Promise<{ sample: string; lastEvent: { occurredAt: string; eventId: string } | null }> {
213
- const prisma = getPrisma();
214
- if (fields.length === 0) {
215
- const rows = await prisma.$queryRawUnsafe<Array<{ id: unknown; occurred_at: Date }>>(
216
- `SELECT id::text, occurred_at
217
- FROM event_log
218
- WHERE kind = $1::text AND occurred_at <= now()
219
- ORDER BY occurred_at DESC
220
- LIMIT 1`,
221
- kind,
222
- );
223
- const top = rows[0];
224
- return { sample: "", lastEvent: top ? { occurredAt: isoSeconds(top.occurred_at), eventId: String(top.id) } : null };
225
- }
226
- for (const field of fields) {
227
- if (!/^[A-Za-z0-9_]+$/.test(field)) {
228
- throw new Error(`config/${CONFIG_SLUG}: sample_fields entry is not a bare field name: ${field}`);
229
- }
230
- }
231
- const selection = fields.map((field) => `payload_json->'${field}' AS "${field}"`).join(", ");
232
- const rows = await prisma.$queryRawUnsafe<Array<Record<string, unknown>>>(
233
- `SELECT id::text, occurred_at, ${selection}
234
- FROM event_log
235
- WHERE kind = $1::text AND occurred_at <= now()
236
- ORDER BY occurred_at DESC
237
- LIMIT 1`,
238
- kind,
239
- );
240
- const row = rows[0];
241
- if (!row) return { sample: "", lastEvent: null };
242
- const parts: string[] = [];
243
- for (const field of fields) {
244
- const value = row[field];
245
- if (value === null || value === undefined) continue;
246
- const formatted = formatEscalationSample(value);
247
- if (formatted) parts.push(`${field}=${formatted}`);
248
- }
249
- return {
250
- sample: parts.join("; "),
251
- lastEvent: {
252
- occurredAt: isoSeconds(row.occurred_at as string | Date),
253
- eventId: String(row.id),
254
- },
255
- };
256
- }
257
-
258
- /** ISO-8601 to whole seconds — the same precision event_log stores, so the
259
- * cited string round-trips a `WHERE occurred_at = '<cited>'` query. */
260
- function isoSeconds(value: Date | string): string {
261
- return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
262
- }
263
-
264
- async function resolveOperatorRelay(): Promise<OperatorRelayBot> {
265
- const bot = (await (await orpc()).messaging_bots.findFirst({
266
- where: {
267
- is_active: true,
268
- platform: "whatsapp",
269
- flow_config: { path: ["mode"], equals: "operator-relay" },
270
- },
271
- select: { id: true, organisation_id: true, external_id: true, flow_config: true },
272
- })) as unknown as OperatorRelayBot | null;
273
- if (!bot) throw new Error("platform-fault-escalation: no active operator-relay WhatsApp bot row");
274
- const phone = (bot.flow_config as { operator_phone?: string } | null)?.operator_phone;
275
- if (!phone) throw new Error("platform-fault-escalation: operator-relay bot has no operator_phone");
276
- return bot as OperatorRelayBot;
277
- }
278
-
279
- async function deliverEscalation(
280
- kind: string,
281
- aggregate: FaultAggregate,
282
- config: FaultEscalationConfig,
283
- ): Promise<{ messageId: string; workItemId: string }> {
284
- const log = getAppLogger();
285
- const now = new Date();
286
-
287
- // 1. Claim the episode BEFORE sending, so a send failure leaves a durable
288
- // claim the next sweep retries instead of a lost escalation.
289
- await (await orpc()).platform_fault_escalations.upsert({
290
- where: { kind },
291
- create: {
292
- kind,
293
- first_seen_at: aggregate.firstSeen.toISOString(),
294
- last_seen_at: aggregate.lastSeen.toISOString(),
295
- event_count: aggregate.count,
296
- },
297
- update: {
298
- first_seen_at: aggregate.firstSeen.toISOString(),
299
- last_seen_at: aggregate.lastSeen.toISOString(),
300
- event_count: aggregate.count,
301
- resolved_at: null,
302
- },
303
- });
304
-
305
- // 2. Resolve the operator relay and send. The send surface is the
306
- // `messaging_whatsapp_send` intrinsic (scala-mcp / messaging-mcp-boot),
307
- // which delegates to the Rust scala-messaging-service — the channel owner
308
- // that picks the device and records the send in messaging_logs. The retired
309
- // api_gowa_message tool (deactivated 2026-08-26) is no longer callable;
310
- // naming it here left every escalation send failing with "Unknown tool".
311
- const bot = await resolveOperatorRelay();
312
- const operatorPhone = (bot.flow_config as { operator_phone?: string }).operator_phone!;
313
- const copy = config.copy[kind] ?? config.default_copy;
314
- const { sample, lastEvent } = await fetchLatestEvidence(kind, config.sample_fields?.[kind] ?? []);
315
- const message = composeEscalationMessage({
316
- kind,
317
- aggregate,
318
- sample,
319
- lastEvent,
320
- impact: copy.impact,
321
- recommendation: copy.recommendation,
322
- });
323
-
324
- const { userId, organisationId } = await getSystemIdentity();
325
- const systemCtx: ExecutionContext = { userId, organisationId } as ExecutionContext;
326
- const { forwardToolToCentralMcp } = await import("@teamscala/chat-completion/central-tool-forwarder");
327
- const sendResult = await forwardToolToCentralMcp(
328
- "messaging_whatsapp_send",
329
- { phone: operatorPhone, message },
330
- systemCtx,
331
- );
332
-
333
- // 3. VERIFY delivery before recording it: the send must return a WhatsApp
334
- // message id. No id => throw, the claim stays undelivered, next sweep retries.
335
- const messageId = extractWhatsAppMessageId(sendResult);
336
- if (!messageId) {
337
- throw new Error(
338
- `platform-fault-escalation: WhatsApp send for '${kind}' returned no message id — not recording delivery. ` +
339
- `Response: ${JSON.stringify(sendResult).slice(0, 400)}`,
340
- );
341
- }
342
-
343
- // 4. Durable record: a COMMENT on the orchestrator's work item — an
344
- // escalation is a finding the orchestrator/operator triages, not an
345
- // assigned task (the fleet-task-placement constraint refuses task rows
346
- // without session identity by design).
347
- const comment = await (await orpc()).comments.create({
348
- data: {
349
- work_item_id: config.orchestrator_work_item_id,
350
- content: message,
351
- source_cli_session_id: null,
352
- },
353
- });
354
-
355
- // 5. Readback-correct the episode: delivered + linked in one update.
356
- await (await orpc()).platform_fault_escalations.update({
357
- where: { kind },
358
- data: {
359
- escalated_at: now.toISOString(),
360
- work_item_id: config.orchestrator_work_item_id,
361
- delivered_comment_id: comment.id,
362
- delivered_message_id: messageId,
363
- last_updated_at: now.toISOString(),
364
- updated_at: now.toISOString(),
365
- },
366
- });
367
-
368
- recordEvent({
369
- kind: "platform.fault.escalated",
370
- payload: {
371
- event_kind: kind,
372
- count: aggregate.count,
373
- sample,
374
- last_event_id: lastEvent?.eventId ?? null,
375
- last_event_occurred_at: lastEvent?.occurredAt ?? null,
376
- work_item_id: config.orchestrator_work_item_id,
377
- delivered_comment_id: comment.id,
378
- delivered_message_id: messageId,
379
- },
380
- });
381
- return { messageId, workItemId: config.orchestrator_work_item_id };
382
- }
383
-
384
- async function resolveEpisode(episode: EscalationEpisode, quietMinutes: number): Promise<void> {
385
- const now = new Date();
386
- await (await orpc()).platform_fault_escalations.update({
387
- where: { kind: episode.kind },
388
- data: { resolved_at: now.toISOString(), updated_at: now.toISOString() },
389
- });
390
- if (episode.workItemId) {
391
- await (await orpc()).comments.create({
392
- data: {
393
- work_item_id: episode.workItemId,
394
- content: `Fault stopped — no '${episode.kind}' events for ${Math.round(quietMinutes)} min. Episode closed; the fault will re-escalate if it recurs.`,
395
- sender_role: "system",
396
- sender_name: "platform-fault-escalation",
397
- },
398
- });
399
- }
400
- recordEvent({
401
- kind: "platform.fault.resolved",
402
- payload: { event_kind: episode.kind, quiet_minutes: Math.round(quietMinutes), work_item_id: episode.workItemId },
403
- });
404
- }
26
+ import { loadFaultEscalationConfig } from "./platform-fault-escalation/config.ts";
27
+ import { fetchAggregates } from "./platform-fault-escalation/event-log-queries.ts";
28
+ import { orpc } from "./platform-fault-escalation/orpc-client.ts";
29
+ import {
30
+ deliverEscalation,
31
+ resolveEpisode,
32
+ } from "./platform-fault-escalation/delivery.ts";
405
33
 
406
34
  async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig }): Promise<void> {
407
35
  const log = getAppLogger();
408
- const config = await loadConfig();
36
+ const config = await loadFaultEscalationConfig();
409
37
  const now = new Date();
410
38
 
411
39
  const aggregates = await fetchAggregates(config.watched_kind_patterns, config.window_minutes);
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Shared Puck-page-data helpers for the resolved-graph → pageData conversion:
7
+ * the local PuckData structural type, JSON extraction with an honest null on
8
+ * malformed input, layout-default application, region root-prop picking, the
9
+ * RegionEntry shape, and the unresolved-region-slot warn. Plus the brand-token
10
+ * prologue shared by all three render branches.
11
+ *
12
+ * Split out of resolve-page-data.ts (547 lines) — byte-for-byte moves; only
13
+ * closure variables became explicit parameters.
14
+ */
15
+
16
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
17
+
18
+ /**
19
+ * Local structural type for Puck page data. Avoids a type-only import of
20
+ * `@puckeditor/core`, which is NOT a runtime dependency — `deps-derived-from-source`
21
+ * excludes whole-statement type-only imports, so the bare specifier has no
22
+ * installed package to resolve against at publish typecheck time. The shape used
23
+ * here (root/content/zones) is all this module reads or constructs.
24
+ */
25
+ export type PuckData = {
26
+ root: { props?: Record<string, unknown> };
27
+ content: unknown[];
28
+ zones?: Record<string, unknown>;
29
+ };
30
+
31
+ export const EMPTY_PUCK_DATA: PuckData = { root: { props: {} }, content: [] };
32
+
33
+ // Returns null on malformed JSON (observed via warn log) instead of a silent
34
+ // masking empty fallback — protective-layers-fail-explicitly. Callers default
35
+ // to EMPTY_PUCK_DATA where an empty render is the intended degradation.
36
+ export function extractPuckData(source: Record<string, unknown>): PuckData | null {
37
+ const raw = source.puck_data ?? source.puckData;
38
+ if (!raw) return EMPTY_PUCK_DATA;
39
+ if (typeof raw === "string") {
40
+ try {
41
+ return JSON.parse(raw) as PuckData;
42
+ } catch (err) {
43
+ getAppLogger().warn(
44
+ "[extractPuckData] malformed puck_data JSON — rendering empty",
45
+ { err },
46
+ );
47
+ return null;
48
+ }
49
+ }
50
+ return raw as PuckData;
51
+ }
52
+
53
+ export function applyLayoutDefaults(
54
+ puckData: PuckData,
55
+ defaults: Record<string, string>,
56
+ ): void {
57
+ if (!defaults || Object.keys(defaults).length === 0) return;
58
+ const data = puckData as { root?: { props?: Record<string, unknown> } };
59
+ if (!data.root) data.root = { props: {} };
60
+ if (!data.root.props) data.root.props = {};
61
+ for (const [key, value] of Object.entries(defaults)) {
62
+ if (data.root.props[key] == null) data.root.props[key] = value;
63
+ }
64
+ }
65
+
66
+ export function pickRootProp<T>(regions: RegionEntry[], key: string): T | undefined {
67
+ for (const region of regions) {
68
+ const props = (
69
+ region.puckData as { root?: { props?: Record<string, unknown> } } | null
70
+ )?.root?.props;
71
+ const value = props?.[key];
72
+ if (value != null && value !== "") return value as T;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ export interface RegionEntry {
78
+ slot: string;
79
+ puckData: PuckData;
80
+ pageId: string;
81
+ website?: { slug: string; name: string; organisation_id?: string | null };
82
+ metaTitle?: string | null;
83
+ metaDescription?: string | null;
84
+ }
85
+
86
+ // A region slot the operator DECLARED (resource_loader.region_slots) whose
87
+ // source did not resolve used to vanish with no signal anywhere — the page
88
+ // rendered minus a region while the page, source, attributes, template and
89
+ // tree builder all looked innocent (measured 2026-08-18: a footer region
90
+ // added to a report produced no footer and no diagnostic; the cause was the
91
+ // boot-time config capture named in the message). One warn per slot per
92
+ // process, throttled like the block-coverage guard in the caller, so the hot
93
+ // path never spams it.
94
+ const unresolvedRegionSlotsLogged = new Set<string>();
95
+
96
+ export function warnUnresolvedRegionSlot(slot: string): void {
97
+ if (unresolvedRegionSlotsLogged.has(slot)) return;
98
+ unresolvedRegionSlotsLogged.add(slot);
99
+ getAppLogger().warn(
100
+ `[resolve-page-data] region slot "${slot}" is declared in the resource_loader config but no source resolved for it — the region is omitted from this render. A resource_loader edit reaches a booted service only after a restart.`,
101
+ );
102
+ }
103
+
104
+ /** The resolved brand tokens every branch reads (the old file's shared prologue). */
105
+ export function readBrandTokens(resolved: Record<string, unknown>): {
106
+ brand: Record<string, unknown>;
107
+ primaryColor: string;
108
+ headingFont: string | null;
109
+ bodyFont: string | null;
110
+ logo: { light: string | null; dark: string | null; brand: string | null };
111
+ brandTheme: string | undefined;
112
+ } {
113
+ const brand = (resolved.brand as Record<string, unknown>) ?? {};
114
+ const primaryColor = (brand.primaryColor as string) ?? "";
115
+ const headingFont = (brand.headingFont as string) ?? null;
116
+ const bodyFont = (brand.bodyFont as string) ?? null;
117
+ const logo = (brand.logo as {
118
+ light: string | null;
119
+ dark: string | null;
120
+ brand: string | null;
121
+ }) ?? { light: null, dark: null, brand: null };
122
+ const brandTheme = typeof brand.theme === "string" ? brand.theme : undefined;
123
+ return { brand, primaryColor, headingFont, bodyFont, logo, brandTheme };
124
+ }