@teamscala/service-boot 0.23.381 → 0.23.382

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.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.381",
9
+ "version": "0.23.382",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -490,14 +490,6 @@
490
490
  "types": "./src/handlers/upstream-issue-sync.d.ts",
491
491
  "default": "./src/handlers/upstream-issue-sync.ts"
492
492
  },
493
- "./handlers/whatsapp-liveness": {
494
- "types": "./src/handlers/whatsapp-liveness.d.ts",
495
- "default": "./src/handlers/whatsapp-liveness.ts"
496
- },
497
- "./handlers/whatsapp-liveness.ts": {
498
- "types": "./src/handlers/whatsapp-liveness.d.ts",
499
- "default": "./src/handlers/whatsapp-liveness.ts"
500
- },
501
493
  "./handlers/whatsapp-liveness-logic": {
502
494
  "types": "./src/handlers/whatsapp-liveness-logic.d.ts",
503
495
  "default": "./src/handlers/whatsapp-liveness-logic.ts"
@@ -650,14 +642,6 @@
650
642
  "types": "./src/proposal-authorized-emails.d.ts",
651
643
  "default": "./src/proposal-authorized-emails.ts"
652
644
  },
653
- "./proposal-chat-inject": {
654
- "types": "./src/proposal-chat-inject.d.ts",
655
- "default": "./src/proposal-chat-inject.ts"
656
- },
657
- "./proposal-chat-inject.ts": {
658
- "types": "./src/proposal-chat-inject.d.ts",
659
- "default": "./src/proposal-chat-inject.ts"
660
- },
661
645
  "./proposal-gate": {
662
646
  "types": "./src/proposal-gate.d.ts",
663
647
  "default": "./src/proposal-gate.ts"
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The catalog's invariants — the ones that were unenforceable while the gate
7
+ * list lived as two hand-written arrays in two files.
8
+ */
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import {
12
+ CONTENT_GATES,
13
+ contentWithholdingGates,
14
+ withholdsContent,
15
+ } from "./catalog.ts";
16
+
17
+ describe("content gate catalog", () => {
18
+ test("registers each gate exactly once", () => {
19
+ const slugs = CONTENT_GATES.map((entry) => entry.slug);
20
+ expect(slugs.length).toBe(new Set(slugs).size);
21
+ });
22
+
23
+ test("mints and verifies through the same cookie", () => {
24
+ // THE INVARIANT THE TWO SEPARATE LISTS COULD NOT ENFORCE. The access
25
+ // endpoint SETS a cookie and the SSR gate READS one; if a registration
26
+ // pairs a gate with another gate's access config, the endpoint mints a
27
+ // token the gate never looks for and the page becomes permanently
28
+ // unlockable-by-nobody — with no error anywhere, because each half is
29
+ // individually valid.
30
+ for (const entry of CONTENT_GATES) {
31
+ expect(entry.access.cookieName).toBe(entry.gate.cookieName);
32
+ }
33
+ });
34
+
35
+ test("evaluates proposals before agreements", () => {
36
+ // Order is behaviour, not presentation: an authorized proposal viewer's
37
+ // gate block is removed from the resolved graph, and that removal is what
38
+ // makes the agreement gate no-op on a page carrying both.
39
+ const order = CONTENT_GATES.map((entry) => entry.slug);
40
+ expect(order.indexOf("proposal")).toBeLessThan(order.indexOf("agreement"));
41
+ });
42
+
43
+ test("treats an open-capture gate as withholding nothing", () => {
44
+ // The GPT gate captures a visitor rather than authorizing one, so an
45
+ // anonymous viewer of a public GPT page is not an unauthorized viewer.
46
+ // Getting this backwards silently removes the assistant from a page the
47
+ // visitor is fully entitled to see.
48
+ const gpt = CONTENT_GATES.find((entry) => entry.slug === "gpt");
49
+ expect(gpt).toBeDefined();
50
+ expect(gpt?.access.openCapture).toBe(true);
51
+ expect(withholdsContent(gpt as NonNullable<typeof gpt>)).toBe(false);
52
+ });
53
+
54
+ test("treats an allow-list gate as withholding content", () => {
55
+ for (const slug of ["proposal", "agreement"]) {
56
+ const entry = CONTENT_GATES.find((candidate) => candidate.slug === slug);
57
+ expect(entry).toBeDefined();
58
+ expect(withholdsContent(entry as NonNullable<typeof entry>)).toBe(true);
59
+ }
60
+ });
61
+
62
+ test("exposes only the withholding gates as the assistant's authority", () => {
63
+ expect(contentWithholdingGates().map((entry) => entry.slug)).toEqual([
64
+ "proposal",
65
+ "agreement",
66
+ ]);
67
+ });
68
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * THE CATALOG OF GATED CONTENT TYPES — the one place a gate is registered.
7
+ *
8
+ * The gate ENGINE (gate.ts) and the access MOUNTER (access.ts) were already
9
+ * generic and config-driven, but the SEQUENCE was not: `register-registry-page-
10
+ * route.ts` named proposalGateConfig + agreementGateConfig + gptGateConfig in a
11
+ * hand-written array, and `two-phase-boot.ts` named the same three AccessConfigs
12
+ * in a second hand-written array. Two lists, one fact, and adding a fourth gated
13
+ * resource meant editing both — the same drift as the assistant, one layer down
14
+ * (`information-exists-in-one-location`).
15
+ *
16
+ * Now both call sites iterate THIS. A gate is registered once, as a pair, and
17
+ * the SSR gate and its public access endpoint cannot disagree about which gates
18
+ * exist — the shape doctrine asks for when behaviour genuinely is code:
19
+ * `business-logic-is-rows-plus-executors`, "extend the executor catalog".
20
+ *
21
+ * WHY A CATALOG AND NOT A REGISTRY ROW. A gate is not data — `verifyToken`,
22
+ * `resolveAuthorizedEmails` and the withholding rules are executable behaviour,
23
+ * which the rule places in a typed executor, never in a row. What belongs in a
24
+ * row is the SELECTION: which gate governs a given page. That is a `page_route`
25
+ * field, and a row can only select from a catalog, so this file is the thing it
26
+ * will select from — not a step on the way to it.
27
+ */
28
+
29
+ import type { AccessConfig, ContentGateConfig } from "./config-types.ts";
30
+ import { agreementAccessConfig, agreementGateConfig } from "../agreement-gate.ts";
31
+ import { gptAccessConfig, gptGateConfig } from "../gpt-gate.ts";
32
+ import { proposalAccessConfig, proposalGateConfig } from "../proposal-gate.ts";
33
+
34
+ /** One gated content type: its SSR gate and its public access endpoint. */
35
+ export interface ContentGateEntry {
36
+ /** Stable identity for this gate — what a `page_route` will name to select it. */
37
+ slug: string;
38
+ gate: ContentGateConfig;
39
+ access: AccessConfig;
40
+ }
41
+
42
+ /**
43
+ * Registered gates, in evaluation order.
44
+ *
45
+ * ORDER IS LOAD-BEARING AND SO IT IS DECLARED, NOT INCIDENTAL. Proposals run
46
+ * before agreements: an authorized proposal viewer has their gate block removed
47
+ * from the resolved graph, after which the agreement gate finds no resource and
48
+ * no-ops. Reversing these two changes behaviour on a page carrying both.
49
+ *
50
+ * Each gate still self-selects — `getResourceId` returns "" when the page is not
51
+ * its kind — so listing a gate here never makes it apply to a page it does not
52
+ * govern.
53
+ */
54
+ export const CONTENT_GATES: readonly ContentGateEntry[] = [
55
+ { slug: "proposal", gate: proposalGateConfig, access: proposalAccessConfig },
56
+ { slug: "agreement", gate: agreementGateConfig, access: agreementAccessConfig },
57
+ { slug: "gpt", gate: gptGateConfig, access: gptAccessConfig },
58
+ ];
59
+
60
+ /**
61
+ * Does this gate WITHHOLD content, or only identify the visitor?
62
+ *
63
+ * DERIVED FROM THE ACCESS CONFIG, NEVER DECLARED TWICE. `openCapture` already
64
+ * states that any email is a valid visitor, which is precisely the case where
65
+ * nothing is being withheld — the GPT page body is public and only its identity
66
+ * is gated (see gptGateConfig.onUnauthorized, a documented no-op). A separate
67
+ * `withholdsContent` flag would be a second copy of that fact, free to drift
68
+ * from the one the access endpoint actually enforces (`offered-is-derived`).
69
+ *
70
+ * The distinction matters to the assistant: a seed-only gate must not count as
71
+ * "governing" this page, or an anonymous visitor to a public GPT page would be
72
+ * read as an unauthorized viewer and lose an assistant they are entitled to.
73
+ */
74
+ export function withholdsContent(entry: ContentGateEntry): boolean {
75
+ return entry.access.openCapture !== true;
76
+ }
77
+
78
+ /** The gates that can withhold content, in order — the assistant's authority. */
79
+ export function contentWithholdingGates(): ContentGateEntry[] {
80
+ return CONTENT_GATES.filter(withholdsContent);
81
+ }
@@ -13,21 +13,41 @@
13
13
  import type { ContentGateConfig } from "./config-types.ts";
14
14
  import { parseCookie } from "./parse-cookie.ts";
15
15
 
16
+ /**
17
+ * What a gate decided, so a caller can act on it without re-deriving it.
18
+ *
19
+ * THE ASSISTANT MUST NOT OUTRUN THE GATE. Its runtime context is the whole
20
+ * document serialised, so injecting it for an unauthorized viewer would hand
21
+ * the gated bytes to the chat -- the exact thing the gate exists to withhold.
22
+ * The gate already knows the answer; returning it means the page-route
23
+ * assistant setting reads ONE decision instead of inventing a second notion of
24
+ * "is this viewer allowed" that could drift from this one.
25
+ *
26
+ * `applies: false` means the gate did not govern this page at all (a public
27
+ * page), which is different from an unauthorized viewer and must not be
28
+ * confused with one.
29
+ */
30
+ export interface ContentGateOutcome {
31
+ applies: boolean;
32
+ authorized: boolean;
33
+ email?: string;
34
+ }
35
+
16
36
  export async function runContentGate(
17
37
  resolved: Record<string, unknown>,
18
38
  cookieHeader: string | null,
19
39
  nowMs: number,
20
40
  config: ContentGateConfig,
21
- ): Promise<void> {
41
+ ): Promise<ContentGateOutcome> {
22
42
  const resourceId = await config.getResourceId(resolved);
23
- if (!resourceId) return; // gate does not apply to this page.
43
+ if (!resourceId) return { applies: false, authorized: false };
24
44
 
25
45
  const token = parseCookie(cookieHeader, config.cookieName);
26
46
  const identity = config.verifyToken(token, resourceId, nowMs);
27
47
 
28
48
  if (identity) {
29
49
  await config.onAuthorized(resolved, resourceId, identity);
30
- return;
50
+ return { applies: true, authorized: true, email: identity.email };
31
51
  }
32
52
 
33
53
  // No valid access token — BAKED-IN unified auth: release a logged-in viewer
@@ -44,10 +64,11 @@ export async function runContentGate(
44
64
  );
45
65
  if (sessionIdentity) {
46
66
  await config.onAuthorized(resolved, resourceId, sessionIdentity);
47
- return;
67
+ return { applies: true, authorized: true, email: sessionIdentity.email };
48
68
  }
49
69
 
50
70
  await config.onUnauthorized(resolved, resourceId);
71
+ return { applies: true, authorized: false };
51
72
  }
52
73
 
53
74
  /**
@@ -17,7 +17,6 @@
17
17
  import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
18
18
  import { signAccessToken, verifyAccessToken } from "./content-gate/token.ts";
19
19
  import { resolveProposalAuthorizedEmails } from "./proposal-authorized-emails.ts";
20
- import { injectProposalChatLauncher } from "./proposal-chat-inject.ts";
21
20
  import { propTtlMs } from "./gate-ttls.ts";
22
21
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
23
22
  import * as v from "valibot";
@@ -117,9 +116,11 @@ export const proposalGateConfig: ContentGateConfig = {
117
116
  (resolved as { initialVisitor?: { email: string } }).initialVisitor = {
118
117
  email,
119
118
  };
120
- // Inject the floating Proposal Assistant chat bubble — strictly behind the
121
- // gate, so only an authorised viewer ever sees it. Content otherwise intact.
122
- await injectProposalChatLauncher(resolved, email);
119
+ // THE ASSISTANT IS NO LONGER INJECTED HERE. It is declared on the
120
+ // page_route row (`assistant`) and applied once, centrally, after every
121
+ // gate has run — so the bubble is a setting rather than a line of code
122
+ // inside one resource type's gate. This call was the reason a third
123
+ // resource type could not have an assistant without editing service-boot.
123
124
  },
124
125
  async onUnauthorized(resolved, resourceId) {
125
126
  const settings = await loadProposalGateSettings();
@@ -29,9 +29,9 @@ import { bindPageHandler } from "./bind-page-handler.ts";
29
29
  import { getServerClient } from "./page-server-orpc/server-client.ts";
30
30
  import { resolveFromLoaderConfig } from "./resolve-from-loader-config.ts";
31
31
  import { runContentGate } from "./content-gate/gate.ts";
32
- import { agreementGateConfig } from "./agreement-gate.ts";
33
- import { proposalGateConfig } from "./proposal-gate.ts";
34
- import { gptGateConfig } from "./gpt-gate.ts";
32
+ import type { ContentGateOutcome } from "./content-gate/gate.ts";
33
+ import { CONTENT_GATES, withholdsContent } from "./content-gate/catalog.ts";
34
+ import { injectResourceChatLauncher } from "./resource-chat-inject.ts";
35
35
  import { extractOrgIdFromCallbackPath } from "./resolvers/portal-route-paths.ts";
36
36
  import { PrimaryResourceMissingError } from "./resource-not-found.ts";
37
37
  import { decideContentPageFloor } from "./content-page-floor.ts";
@@ -60,6 +60,13 @@ interface PageRouteConfig {
60
60
  loader_slug: string;
61
61
  meta_context?: Record<string, Record<string, string>>;
62
62
  resource_type?: string;
63
+ /** The floating AI assistant this page offers (page_route.assistant). */
64
+ assistant?: {
65
+ agent_config_slug: string;
66
+ resource_key: string;
67
+ launch_label: string;
68
+ placeholder?: string;
69
+ };
63
70
  providers?: string[];
64
71
  }
65
72
 
@@ -409,13 +416,61 @@ export async function registerRegistryPageRoute(
409
416
  } catch {
410
417
  // No auth factory / no session → anonymous; gates fall back to token.
411
418
  }
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
+ // EVERY REGISTERED GATE, IN CATALOG ORDER (content-gate/catalog.ts).
420
+ // This used to name proposalGateConfig, agreementGateConfig and
421
+ // gptGateConfig by hand, so a fourth gated resource type meant editing
422
+ // this file AND the second hand-written list in two-phase-boot.ts.
423
+ //
424
+ // SEQUENTIAL, NEVER PARALLEL. An authorized proposal viewer has their
425
+ // gate block removed from the resolved graph, and that removal is what
426
+ // makes the agreement gate find no resource and no-op on a page that
427
+ // carries both. Awaiting these together would race that dependency.
428
+ const gateOutcomes: ContentGateOutcome[] = [];
429
+ for (const entry of CONTENT_GATES) {
430
+ const outcome = await runContentGate(
431
+ resolved,
432
+ gateCookie,
433
+ gateNow,
434
+ entry.gate,
435
+ );
436
+ // A seed-only gate (open capture — the public GPT identity gate)
437
+ // withholds nothing, so its verdict is not evidence about whether
438
+ // this viewer may see the page and must not reach the assistant
439
+ // decision below. See withholdsContent, which derives that from the
440
+ // access config rather than restating it here.
441
+ if (withholdsContent(entry)) gateOutcomes.push(outcome);
442
+ }
443
+
444
+ // THE ASSISTANT IS A ROW, AND THIS IS THE ONE PLACE IT IS APPLIED.
445
+ // It used to be a call inside each gate's onAuthorized plus an entry
446
+ // in the hardcoded gate sequence above, so a third resource type
447
+ // meant editing this file. Now any page declaring `assistant` gets
448
+ // one and no page without the key does.
449
+ //
450
+ // GATED PAGES ONLY WHEN AUTHORIZED. The launcher's runtime context is
451
+ // the whole document serialised, so injecting past a closed gate
452
+ // would hand withheld bytes to the chat. A page no gate governs is
453
+ // public and gets the assistant unconditionally -- that is a
454
+ // different state from an unauthorized viewer, which is why the gate
455
+ // reports `applies` separately from `authorized`.
456
+ const assistant = routeConfig.assistant;
457
+ if (assistant) {
458
+ const governing = gateOutcomes.filter((outcome) => outcome.applies);
459
+ const authorized = governing.find((outcome) => outcome.authorized);
460
+ if (governing.length === 0 || authorized) {
461
+ await injectResourceChatLauncher(resolved, authorized?.email ?? "", {
462
+ resourceKey: assistant.resource_key,
463
+ agentConfigSlug: assistant.agent_config_slug as never,
464
+ launchLabel: assistant.launch_label,
465
+ placeholder: assistant.placeholder,
466
+ // From the loader row, so the injector never has to guess
467
+ // which resolved keys hold rendered content.
468
+ regionSlots: (
469
+ loaderConfig as { region_slots?: string[] }
470
+ ).region_slots,
471
+ });
472
+ }
473
+ }
419
474
  const meta = buildMetaFromContext(routeConfig.meta_context, resolved);
420
475
  // /sign-in brand override: a shared org-scoped link 302→/sign-in for the
421
476
  // unauthed crawler (WhatsApp), which saw the service's default website
@@ -11,8 +11,9 @@
11
11
  * (`components[row.slug] = …`), so a pushed block `type` must EQUAL an active
12
12
  * slug. The launcher block was consolidated to the single `ResourceLauncher`
13
13
  * row, but the injectors still pushed the pre-consolidation names —
14
- * `ResourceChatLauncher` here and `ChatLauncherBlock` in proposal-chat-inject.
15
- * Neither resolves, and because injection is deliberately fail-soft the page
14
+ * `ResourceChatLauncher` here, and `ChatLauncherBlock` in the bespoke proposal
15
+ * injector that has since been deleted in favour of this one. Neither name
16
+ * resolves, and because injection is deliberately fail-soft the page
16
17
  * rendered unchanged with no error: the classic shape of
17
18
  * `a-deleted-producer-leaves-its-consumers-executing`.
18
19
  *
@@ -32,15 +33,9 @@ mock.module("@teamscala/db/registry/load-config", () => ({
32
33
  loadRegistryConfig: async () => ({ agentId: "agent-1" }),
33
34
  }));
34
35
  mock.module("./page-server-orpc/server-client.ts", () => ({
35
- getServerClient: () => ({}),
36
- }));
37
- mock.module("@teamscala/logger/app-loggers", () => ({
38
- getAppLogger: () => ({ error: () => {} }),
39
- }));
40
- mock.module("@teamscala/db/prisma-registry", () => ({
41
- getPrisma: () => ({
36
+ getServerClient: () => ({
42
37
  ai_agents: {
43
- findUnique: async () => ({
38
+ findFirst: async () => ({
44
39
  name: "Agreement Assistant",
45
40
  welcome_message: "Hi! Ask me anything about this agreement.",
46
41
  conversation_starters: ["What are the key terms?", "How do I sign this?"],
@@ -49,9 +44,11 @@ mock.module("@teamscala/db/prisma-registry", () => ({
49
44
  },
50
45
  }),
51
46
  }));
47
+ mock.module("@teamscala/logger/app-loggers", () => ({
48
+ getAppLogger: () => ({ error: () => {} }),
49
+ }));
52
50
 
53
51
  const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
54
- const { injectProposalChatLauncher } = await import("./proposal-chat-inject.ts");
55
52
 
56
53
  function makeResolved(resourceKey: string): Record<string, unknown> {
57
54
  return {
@@ -62,7 +59,11 @@ function makeResolved(resourceKey: string): Record<string, unknown> {
62
59
  }
63
60
 
64
61
  function pushedBlocks(resolved: Record<string, unknown>) {
65
- const pd = (resolved.page as { puck_data: { content: Array<{ type: string }> } }).puck_data;
62
+ const pd = (
63
+ resolved.page as {
64
+ puck_data: { content: Array<{ type: string; props?: Record<string, unknown> }> };
65
+ }
66
+ ).puck_data;
66
67
  return pd.content;
67
68
  }
68
69
 
@@ -79,14 +80,6 @@ describe("chat injectors — pushed block name resolves to a registered block",
79
80
  expect(blocks[0].type).toBe(REGISTERED_LAUNCHER_SLUG);
80
81
  });
81
82
 
82
- test("the proposal injector pushes the registered launcher slug", async () => {
83
- const resolved = makeResolved("proposal");
84
- await injectProposalChatLauncher(resolved, "viewer@example.com");
85
- const blocks = pushedBlocks(resolved);
86
- expect(blocks).toHaveLength(1);
87
- expect(blocks[0].type).toBe(REGISTERED_LAUNCHER_SLUG);
88
- });
89
-
90
83
  test("neither injector pushes a pre-consolidation launcher name", async () => {
91
84
  const a = makeResolved("agreement");
92
85
  await injectResourceChatLauncher(a, "viewer@example.com", {
@@ -95,7 +88,11 @@ describe("chat injectors — pushed block name resolves to a registered block",
95
88
  launchLabel: "Ask about this agreement",
96
89
  });
97
90
  const p = makeResolved("proposal");
98
- await injectProposalChatLauncher(p, "viewer@example.com");
91
+ await injectResourceChatLauncher(p, "viewer@example.com", {
92
+ resourceKey: "proposal",
93
+ agentConfigSlug: "proposal-assistant-agent",
94
+ launchLabel: "Ask about this proposal",
95
+ });
99
96
 
100
97
  const dead = ["ResourceChatLauncher", "ChatLauncherBlock"];
101
98
  for (const blocks of [pushedBlocks(a), pushedBlocks(p)]) {
@@ -115,7 +112,7 @@ describe("chat injectors — pushed block name resolves to a registered block",
115
112
  agentConfigSlug: "agreement-assistant-agent",
116
113
  launchLabel: "Ask about this agreement",
117
114
  });
118
- const props = (pushedBlocks(resolved)[0] as { props: Record<string, unknown> }).props;
115
+ const props = pushedBlocks(resolved)[0].props ?? {};
119
116
  expect(props.title).toBe("Agreement Assistant");
120
117
  expect(props.welcomeMessage).toBe("Hi! Ask me anything about this agreement.");
121
118
  expect(props.conversationStarters).toEqual([
@@ -126,3 +123,135 @@ describe("chat injectors — pushed block name resolves to a registered block",
126
123
  expect(props.launchLabel).toBe("Ask about this agreement");
127
124
  });
128
125
  });
126
+
127
+ /** A region-slot page — the shape a quiz report resolves (no `page` source). */
128
+ function makeAssessmentResolved(): Record<string, unknown> {
129
+ return {
130
+ response: {
131
+ organisation_id: "org-1",
132
+ email: "reader@example.com",
133
+ total_score: 95,
134
+ category_scores: { "Be Seen": 100, "Be Chosen": 83 },
135
+ },
136
+ quiz: { id: "quiz-1", report_title: "The Launcha Authority Index" },
137
+ reportData: { narrative: "You are strongest at being seen." },
138
+ brand: { primaryColor: "#123456" },
139
+ header: { puck_data: { content: [{ type: "SiteHeader" }] } },
140
+ body: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
141
+ footer: { puck_data: { content: [{ type: "SiteFooter" }] } },
142
+ };
143
+ }
144
+
145
+ /** The launcher's props, wherever in the graph it was attached. */
146
+ function launcherProps(resolved: Record<string, unknown>): Record<string, unknown> {
147
+ for (const value of Object.values(resolved)) {
148
+ const content = (value as { puck_data?: { content?: unknown } } | null)?.puck_data
149
+ ?.content;
150
+ if (!Array.isArray(content)) continue;
151
+ const launcher = (content as Array<{ type: string; props?: Record<string, unknown> }>).find(
152
+ (block) => block.type === REGISTERED_LAUNCHER_SLUG,
153
+ );
154
+ if (launcher) return launcher.props ?? {};
155
+ }
156
+ throw new Error("no launcher block was attached to any region");
157
+ }
158
+
159
+ function bodyBlocks(resolved: Record<string, unknown>) {
160
+ return (
161
+ resolved.body as {
162
+ puck_data: { content: Array<{ type: string; props?: Record<string, unknown> }> };
163
+ }
164
+ ).puck_data.content;
165
+ }
166
+
167
+ /**
168
+ * REGION SLOTS COME FROM THE LOADER ROW, exactly as the caller reads them —
169
+ * the injector is never allowed to guess which resolved keys hold content.
170
+ */
171
+ const assessmentOpts = {
172
+ resourceKey: "response",
173
+ agentConfigSlug: "assessment-assistant-agent" as never,
174
+ launchLabel: "Ask about your results",
175
+ regionSlots: ["header", "body", "footer"],
176
+ };
177
+
178
+ describe("region-slot pages (a generated report, not an authored document)", () => {
179
+ test("attaches to a declared region when there is no `page` source", async () => {
180
+ // THE DEFECT THIS PINS: the injector read resolved.page.puck_data.content
181
+ // and returned early when absent. A generated report resolves region
182
+ // slots instead, so the early return fired and the assistant was
183
+ // silently missing — fail-soft, so nothing was logged.
184
+ const resolved = makeAssessmentResolved();
185
+ await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
186
+ // The first declared slot holding a content tree. The launcher is
187
+ // position:fixed, so any rendered region shows it identically.
188
+ const header = (
189
+ resolved.header as { puck_data: { content: Array<{ type: string }> } }
190
+ ).puck_data.content;
191
+ expect(header.map((block) => block.type)).toContain(REGISTERED_LAUNCHER_SLUG);
192
+ });
193
+
194
+ test("follows the loader's slot order rather than a hardcoded key", async () => {
195
+ // Reordering the row must move the launcher. If this passes while the
196
+ // code names "body", the derivation is a literal wearing a costume.
197
+ const resolved = makeAssessmentResolved();
198
+ await injectResourceChatLauncher(resolved, "reader@example.com", {
199
+ ...assessmentOpts,
200
+ regionSlots: ["body", "header", "footer"],
201
+ });
202
+ expect(bodyBlocks(resolved).map((block) => block.type)).toContain(
203
+ REGISTERED_LAUNCHER_SLUG,
204
+ );
205
+ });
206
+
207
+ test("sends the assessment RESULT, not just the rendered page", async () => {
208
+ // A report's body page is a shared template — one builda_pages row serves
209
+ // every respondent — so the content tree holds zero personal facts. An
210
+ // assistant given only the page would know nothing about this reader.
211
+ const resolved = makeAssessmentResolved();
212
+ await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
213
+ const context = String(launcherProps(resolved).runtimeContext ?? "");
214
+ expect(context).toContain("total_score");
215
+ expect(context).toContain("95");
216
+ expect(context).toContain("Be Chosen");
217
+ expect(context).toContain("You are strongest at being seen.");
218
+ expect(context).toContain("The Launcha Authority Index");
219
+ });
220
+
221
+ test("sends the rendered document too, not just the result", async () => {
222
+ // The mirror of the test above: the reader is asking about what is on
223
+ // screen, so the assistant needs the document as well as the data.
224
+ const resolved = makeAssessmentResolved();
225
+ (
226
+ resolved.body as { puck_data: { content: unknown[] } }
227
+ ).puck_data.content.push({ type: "ScoreDial", props: { label: "Authority" } });
228
+ await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
229
+ const context = String(launcherProps(resolved).runtimeContext ?? "");
230
+ expect(context).toContain("ScoreDial");
231
+ expect(context).toContain("Authority");
232
+ });
233
+
234
+ test("names no resolved key — it sends whatever the loader produced", async () => {
235
+ // THE REGRESSION THIS PINS: a draft excluded brand/website/header/footer
236
+ // by name, which is a loader row copied into TypeScript. Those keys are
237
+ // database facts, so the serialiser must not have opinions about them —
238
+ // it walks the graph and a loader edit propagates with no code change.
239
+ const resolved = makeAssessmentResolved();
240
+ await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
241
+ const context = String(launcherProps(resolved).runtimeContext ?? "");
242
+ for (const key of Object.keys(resolved)) {
243
+ expect(context).toContain(key);
244
+ }
245
+ });
246
+
247
+ test("carries a source a future loader adds, with no edit here", async () => {
248
+ // The serialiser walks the graph rather than naming keys, so a loader
249
+ // resolving a new source reaches the assistant for free.
250
+ const resolved = makeAssessmentResolved();
251
+ resolved.benchmarks = { industryMedian: 62 };
252
+ await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
253
+ const context = String(launcherProps(resolved).runtimeContext ?? "");
254
+ expect(context).toContain("industryMedian");
255
+ expect(context).toContain("62");
256
+ });
257
+ });
@@ -22,7 +22,6 @@
22
22
 
23
23
  import { getAppLogger } from "@teamscala/logger/app-loggers";
24
24
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
25
- import { getPrisma } from "@teamscala/db/prisma-registry";
26
25
  import { getServerClient } from "./page-server-orpc/server-client.ts";
27
26
  import * as v from "valibot";
28
27
  import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
@@ -54,6 +53,12 @@ export interface ResourceChatOptions {
54
53
  launchLabel: string;
55
54
  /** Input placeholder. */
56
55
  placeholder?: string;
56
+ /**
57
+ * The loader row's `region_slots`, when this page has them — passed in rather
58
+ * than guessed, so which keys hold rendered content stays a database fact.
59
+ * Absent means a flat page whose content is under the `page` source.
60
+ */
61
+ regionSlots?: readonly string[];
57
62
  }
58
63
 
59
64
  /**
@@ -79,8 +84,30 @@ export interface AgentPresentation {
79
84
  }
80
85
 
81
86
  export async function loadAgentPresentation(agentId: string): Promise<AgentPresentation> {
87
+ // THROUGH ORPC, NEVER PRISMA DIRECTLY (`no-direct-prisma-outside-orpc`).
88
+ // This is the same service-scoped client the ProductTiers read above uses;
89
+ // `ai_agents` is already in the declared models of both services that mount
90
+ // a launcher, so no schema change is needed to read it here.
91
+ const client = getServerClient() as {
92
+ ai_agents?: {
93
+ findFirst: (args: {
94
+ where: { id: string };
95
+ select: {
96
+ name: true;
97
+ welcome_message: true;
98
+ conversation_starters: true;
99
+ primary_color: true;
100
+ };
101
+ }) => Promise<{
102
+ name: string | null;
103
+ welcome_message: string | null;
104
+ conversation_starters: unknown;
105
+ primary_color: string | null;
106
+ } | null>;
107
+ };
108
+ };
82
109
  try {
83
- const agent = await getPrisma().ai_agents.findUnique({
110
+ const agent = await client.ai_agents?.findFirst({
84
111
  where: { id: agentId },
85
112
  select: {
86
113
  name: true,
@@ -113,10 +140,90 @@ export async function loadAgentPresentation(agentId: string): Promise<AgentPrese
113
140
  }
114
141
  }
115
142
 
116
- /** Serialise the resource record + entire page content tree as text for the GPT. */
143
+ /** A resolved source that carries a Puck content tree. */
144
+ function puckContentOf(value: unknown): unknown[] | null {
145
+ const content = (value as { puck_data?: { content?: unknown } } | null | undefined)
146
+ ?.puck_data?.content;
147
+ return Array.isArray(content) ? content : null;
148
+ }
149
+
150
+ /**
151
+ * The rendered content tree to append the launcher to.
152
+ *
153
+ * WHICH KEYS EXIST IS A ROW FACT, SO IT IS NOT SPELLED HERE. A page's resolved
154
+ * sources and its region slots are declared on the `resource_loader` row, and
155
+ * the caller has already read that row — so it passes `regionSlots` in. An
156
+ * earlier draft of this function named "page", "body", "header" and "footer" as
157
+ * literals, which is a loader row copied into TypeScript, free to drift from it
158
+ * the moment a loader is edited (`identity-is-imported-never-spelled`).
159
+ *
160
+ * A FLAT PAGE KEEPS ITS CONTENT UNDER `page`; A REGION-SLOT PAGE DOES NOT.
161
+ * Proposals and agreements resolve one `page` source, so the original injector
162
+ * read `resolved.page.puck_data.content` and returned early when it was absent.
163
+ * A generated report resolves region slots instead, so that early return fired
164
+ * and the assistant was silently missing — the same failure shape as the dead
165
+ * block name, since fail-soft injection logs nothing and the page looks fine.
166
+ *
167
+ * ANY RENDERED REGION WILL DO, SO THE FIRST DECLARED ONE IS TAKEN. The launcher
168
+ * renders `position: fixed` at `zIndex: 50`, anchored to the viewport and not
169
+ * to its container, so it appears identically whichever region carries it. That
170
+ * is what makes "first declared region holding a content tree" a derivation
171
+ * rather than a guess dressed up as one: there is no better region to prefer.
172
+ *
173
+ * `PAGE_SOURCE_KEY` is the platform's existing name for a flat page's body
174
+ * source — the same convention `decideContentPageFloor` already keys on in the
175
+ * caller. One shared convention, not a per-resource list.
176
+ */
177
+ const PAGE_SOURCE_KEY = "page";
178
+
179
+ function findContentHost(
180
+ resolved: Record<string, unknown>,
181
+ regionSlots?: readonly string[],
182
+ ): unknown[] | null {
183
+ if (regionSlots?.length) {
184
+ for (const slot of regionSlots) {
185
+ const content = puckContentOf(resolved[slot]);
186
+ if (content) return content;
187
+ }
188
+ return null;
189
+ }
190
+ return puckContentOf(resolved[PAGE_SOURCE_KEY]);
191
+ }
192
+
193
+ /**
194
+ * Serialise EVERYTHING THIS VIEWER MAY SEE as text for the assistant.
195
+ *
196
+ * BOTH THE RESULT AND THE RENDERED DOCUMENT, BECAUSE NEITHER IS SUFFICIENT.
197
+ * The original serialiser sent the resource record plus the page's content
198
+ * tree, which is exactly right for a proposal — the proposal's substance IS in
199
+ * its blocks. It is wrong for anything the platform GENERATES a document from.
200
+ * A report's body page is a shared template (one `builda_pages` row serves
201
+ * every respondent) so the content tree holds headings and layout and not one
202
+ * personal fact, while the substance — the answers, the category scores, the
203
+ * computed narrative and bands — sits in sibling resolved keys the serialiser
204
+ * never looked at. Sending only the page would give the assistant a form with
205
+ * no answers; sending only the record would leave it unable to discuss what the
206
+ * reader is actually looking at. An assessment result GENERATES a report, so
207
+ * the assistant needs both halves of that.
208
+ *
209
+ * NO KEY IS NAMED, INCLUDED OR EXCLUDED. The loader row decides what a page
210
+ * resolves; this walks whatever that produced. A draft carried a `CHROME_KEYS`
211
+ * exclusion list dropping brand, website, header and footer — every one of them
212
+ * a loader source key, which is to say database facts hardcoded here to save a
213
+ * few hundred tokens of colours and navigation markup. Not a trade worth a
214
+ * literal that goes stale in silence. A loader resolving a new source reaches
215
+ * the assistant with no edit here (`offered-is-derived`).
216
+ *
217
+ * IT CANNOT OUTRUN THE GATE, BY CONSTRUCTION. This runs AFTER every content
218
+ * gate, and a gate withholds by REMOVING content from this same graph. So
219
+ * "everything in the graph" and "everything this viewer may see" are the same
220
+ * set, and they cannot drift — which is what makes serialising the whole graph
221
+ * safe rather than reckless.
222
+ */
117
223
  async function buildResourceContext(
118
224
  resolved: Record<string, unknown>,
119
225
  resourceKey: string,
226
+ regionSlots?: readonly string[],
120
227
  ): Promise<string> {
121
228
  const record = resolved[resourceKey] as Record<string, unknown> | undefined;
122
229
  const parts: string[] = [];
@@ -131,11 +238,17 @@ async function buildResourceContext(
131
238
  parts.push(`Description: ${record.description}`);
132
239
  }
133
240
 
134
- // Dump the entire page content tree — every block, every prop, verbatim.
135
- const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
136
- if (page && Array.isArray(page.content)) {
137
- parts.push("\n--- Document content ---");
138
- parts.push(JSON.stringify(page.content, null, 2));
241
+ for (const [key, value] of Object.entries(resolved)) {
242
+ if (value == null || typeof value === "function") continue;
243
+ const content = puckContentOf(value);
244
+ if (content) {
245
+ // A rendered document: every block, every prop, verbatim.
246
+ parts.push(`\n--- Document content (${key}) ---`);
247
+ parts.push(JSON.stringify(content, null, 2));
248
+ continue;
249
+ }
250
+ parts.push(`\n--- ${key} ---`);
251
+ parts.push(JSON.stringify(value, null, 2));
139
252
  }
140
253
 
141
254
  // Resolve DB-backed product tiers when present (proposals) so the GPT sees
@@ -148,7 +261,9 @@ async function buildResourceContext(
148
261
  }) => Promise<{ id: string; billing_type: string; tiers: unknown; components: unknown } | null>;
149
262
  };
150
263
  };
151
- for (const block of (page?.content ?? []) as Array<Record<string, unknown>>) {
264
+ for (const block of (findContentHost(resolved, regionSlots) ?? []) as Array<
265
+ Record<string, unknown>
266
+ >) {
152
267
  if (block?.type !== "ProductTiers") continue;
153
268
  const props = block.props as Record<string, unknown> | undefined;
154
269
  if (!props) continue;
@@ -188,8 +303,10 @@ export async function injectResourceChatLauncher(
188
303
  viewerEmail: string,
189
304
  opts: ResourceChatOptions,
190
305
  ): Promise<void> {
191
- const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
192
- if (!page || !Array.isArray(page.content)) return;
306
+ // Whichever region actually renders — `page` on a flat document, `body` on a
307
+ // region-slot one. No rendered content at all → nothing to attach to.
308
+ const hostContent = findContentHost(resolved, opts.regionSlots);
309
+ if (!hostContent) return;
193
310
 
194
311
  const record = resolved[opts.resourceKey] as Record<string, unknown> | undefined;
195
312
  const organisationId =
@@ -208,9 +325,13 @@ export async function injectResourceChatLauncher(
208
325
  if (!agentId) return;
209
326
 
210
327
  try {
211
- const runtimeContext = await buildResourceContext(resolved, opts.resourceKey);
328
+ const runtimeContext = await buildResourceContext(
329
+ resolved,
330
+ opts.resourceKey,
331
+ opts.regionSlots,
332
+ );
212
333
  const presentation = await loadAgentPresentation(agentId);
213
- (page.content as Array<Record<string, unknown>>).push({
334
+ (hostContent as Array<Record<string, unknown>>).push({
214
335
  type: LAUNCHER_BLOCK,
215
336
  props: {
216
337
  id: `${opts.resourceKey}-chat-launcher`,
@@ -24,9 +24,7 @@ import { cors } from "hono/cors";
24
24
  import { timing } from "hono/timing";
25
25
  import { attachContentGateAccess } from "./content-gate/access.ts";
26
26
  import { initGateTtls } from "./gate-ttls.ts";
27
- import { agreementAccessConfig } from "./agreement-gate.ts";
28
- import { proposalAccessConfig } from "./proposal-gate.ts";
29
- import { gptAccessConfig } from "./gpt-gate.ts";
27
+ import { CONTENT_GATES } from "./content-gate/catalog.ts";
30
28
  import { attachProposalAccept } from "./attach-proposal-accept.ts";
31
29
  import { attachAgreementMagic } from "./attach-agreement-magic.ts";
32
30
  import { getCookieDomain } from "@teamscala/db/registry/cookie-domain";
@@ -327,9 +325,15 @@ export async function buildMinimalApp(
327
325
  // beyond the Hono app's synchronous route array — so they parallelise.
328
326
  await runStep("public-routes", () =>
329
327
  Promise.all([
330
- attachContentGateAccess(app, serviceSlug, agreementAccessConfig),
331
- attachContentGateAccess(app, serviceSlug, proposalAccessConfig),
332
- attachContentGateAccess(app, serviceSlug, gptAccessConfig),
328
+ // EVERY REGISTERED GATE'S ACCESS ENDPOINT (content-gate/catalog.ts).
329
+ // The same catalog the SSR gate sequence iterates, so the endpoint that
330
+ // MINTS a gate's token and the gate that VERIFIES it cannot disagree
331
+ // about which gates exist — this list and that one were two copies of
332
+ // one fact, and a gate added to one and missed in the other is a page
333
+ // that gates content with no way to unlock it.
334
+ ...CONTENT_GATES.map((entry) =>
335
+ attachContentGateAccess(app, serviceSlug, entry.access),
336
+ ),
333
337
  attachProposalAccept(app, serviceSlug),
334
338
  attachAgreementMagic(app, serviceSlug),
335
339
  attachProposalMagic(app, serviceSlug),
@@ -1,158 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Injects the ProposalChatLauncher block into an AUTHORISED proposal page (called
7
- * from proposalGateConfig.onAuthorized — so the bubble lives strictly behind the
8
- * email gate, and every proposal gets it with no per-page Puck edit). It builds
9
- * the proposal's full page content into a runtime-context string and passes it to
10
- * the launcher, which forwards it to the chat as <runtime_context> — so ONE shared
11
- * "Proposal Assistant" agent answers about whichever proposal is being viewed.
12
- *
13
- * The agent id is config, not code (registries-are-source-of-truth): the
14
- * `config / proposal-assistant-agent` row carries { agentId }. No row (or no
15
- * agent) → no injection, the page renders exactly as before (fail-soft).
16
- */
17
-
18
- import { getAppLogger } from "@teamscala/logger/app-loggers";
19
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
20
- import { getServerClient } from "./page-server-orpc/server-client.ts";
21
- import { loadAgentPresentation } from "./resource-chat-inject.ts";
22
- import * as v from "valibot";
23
- import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
24
-
25
- const AgentConfigSchema = v.object({ agentId: v.string() });
26
-
27
- // THE PUSHED BLOCK NAME IS AN IMPORTED IDENTITY, NEVER A SPELLED STRING.
28
- // discover-blocks keys the Puck registry off block_schema.slug alone
29
- // (`components[row.slug] = …`), so a type that is not a live slug resolves to
30
- // nothing — and because injection is fail-soft, the page renders unchanged with
31
- // no error. That is exactly how the bubble went missing from every gated
32
- // proposal and agreement: the block was consolidated to ResourceLauncher and
33
- // these injectors kept pushing their pre-consolidation names. BlockSchemaKey is
34
- // the generated union of live slugs, so annotating it here makes a stale name a
35
- // COMPILE error instead of a silent absence (identity-is-imported-never-spelled).
36
- const LAUNCHER_BLOCK: BlockSchemaKey = "ResourceLauncher";
37
-
38
- /** Serialise the entire page content tree as text for the GPT context. */
39
- async function buildProposalContext(resolved: Record<string, unknown>): Promise<string> {
40
- const proposal = resolved.proposal as Record<string, unknown> | undefined;
41
- const parts: string[] = [];
42
-
43
- if (typeof proposal?.name === "string" && proposal.name) {
44
- parts.push(`Proposal title: ${proposal.name}`);
45
- }
46
- if (typeof proposal?.client_name === "string" && proposal.client_name) {
47
- parts.push(`Prepared for: ${proposal.client_name}`);
48
- }
49
- if (typeof proposal?.description === "string" && proposal.description) {
50
- parts.push(`Description: ${proposal.description}`);
51
- }
52
-
53
- // Dump the entire page content tree — every block, every prop, verbatim.
54
- const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
55
- if (page && Array.isArray(page.content)) {
56
- parts.push("\n--- Page content ---");
57
- parts.push(JSON.stringify(page.content, null, 2));
58
- }
59
-
60
- // Resolve DB-backed product tiers so the GPT sees actual pricing data.
61
- const client = getServerClient() as {
62
- products?: {
63
- findFirst: (args: {
64
- where: { id: string };
65
- select: { id: true; billing_type: true; tiers: true; components: true };
66
- }) => Promise<{
67
- id: string;
68
- billing_type: string;
69
- tiers: unknown;
70
- components: unknown;
71
- } | null>;
72
- };
73
- };
74
-
75
- for (const block of (page?.content ?? []) as Array<Record<string, unknown>>) {
76
- if (block?.type !== "ProductTiers") continue;
77
- const props = block.props as Record<string, unknown> | undefined;
78
- if (!props) continue;
79
-
80
- const productIds = [
81
- props.productId,
82
- props.productId2,
83
- props.productId3,
84
- ].filter((id): id is string => typeof id === "string" && id.trim().length > 0);
85
-
86
- for (const pid of productIds) {
87
- try {
88
- const product = await client.products?.findFirst({
89
- where: { id: pid },
90
- select: { id: true, billing_type: true, tiers: true, components: true },
91
- });
92
- if (product) {
93
- parts.push(`\n--- Product tiers (productId: ${pid}) ---`);
94
- parts.push(JSON.stringify(product, null, 2));
95
- }
96
- } catch {
97
- // best-effort
98
- }
99
- }
100
- }
101
-
102
- return parts.join("\n");
103
- }
104
-
105
- function brandPrimaryColor(resolved: Record<string, unknown>): string {
106
- const brand = resolved.brand as { primaryColor?: unknown } | undefined;
107
- return typeof brand?.primaryColor === "string" ? brand.primaryColor : "";
108
- }
109
-
110
- export async function injectProposalChatLauncher(
111
- resolved: Record<string, unknown>,
112
- viewerEmail: string,
113
- ): Promise<void> {
114
- const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
115
- if (!page || !Array.isArray(page.content)) return;
116
-
117
- const proposal = resolved.proposal as Record<string, unknown> | undefined;
118
- const organisationId =
119
- typeof proposal?.organisation_id === "string" ? proposal.organisation_id : "";
120
- if (!organisationId) return;
121
-
122
- let agentId = "";
123
- try {
124
- agentId = (
125
- await loadRegistryConfig("config", "proposal-assistant-agent", AgentConfigSchema)
126
- ).agentId;
127
- } catch {
128
- return; // no agent configured yet → no bubble (fail-soft, page unchanged)
129
- }
130
- if (!agentId) return;
131
-
132
- try {
133
- const runtimeContext = await buildProposalContext(resolved);
134
- const presentation = await loadAgentPresentation(agentId);
135
- (page.content as Array<Record<string, unknown>>).push({
136
- type: LAUNCHER_BLOCK,
137
- props: {
138
- id: "proposal-chat-launcher",
139
- agentId,
140
- organisationId,
141
- viewerEmail,
142
- runtimeContext,
143
- // Copy comes from the agent row (see loadAgentPresentation): these
144
- // three were spelled here while ai_agents held blank columns for
145
- // each of them.
146
- primaryColor: brandPrimaryColor(resolved) || presentation.primaryColor,
147
- title: presentation.title,
148
- welcomeMessage: presentation.welcomeMessage,
149
- conversationStarters: presentation.conversationStarters,
150
- launchLabel: "Ask about this proposal",
151
- },
152
- });
153
- } catch (err) {
154
- getAppLogger().error(
155
- `[proposal-chat] launcher injection failed: ${err instanceof Error ? err.message : String(err)}`,
156
- );
157
- }
158
- }