@teamscala/service-boot 0.23.523 → 0.23.525

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 (127) hide show
  1. package/package.json +1 -9
  2. package/src/agreement-gate/agreement-lookup.ts +0 -7
  3. package/src/agreement-gate/authorized-emails.ts +0 -14
  4. package/src/agreement-gate/placeholder-strip.ts +0 -10
  5. package/src/agreement-gate/resolution-caches.ts +0 -5
  6. package/src/agreement-gate/settings.ts +0 -22
  7. package/src/agreement-gate/signing-session.ts +0 -8
  8. package/src/agreement-gate/signing-shell.ts +0 -9
  9. package/src/agreement-gate.ts +0 -52
  10. package/src/apply-logger-config.ts +1 -9
  11. package/src/asset-cache-key.ts +1 -5
  12. package/src/asset-optimize.ts +1 -7
  13. package/src/asset-proxy.ts +1 -10
  14. package/src/asset-social-card.ts +1 -9
  15. package/src/attach-agreement-magic.ts +0 -14
  16. package/src/attach-portal-members.ts +0 -17
  17. package/src/attach-proposal-accept.ts +1 -21
  18. package/src/attach-proposal-magic.ts +1 -13
  19. package/src/attach-transfers-upload.ts +0 -21
  20. package/src/auth/sign-out.ts +1 -44
  21. package/src/auth-gate/public-paths.ts +1 -96
  22. package/src/auth-gate.ts +1 -41
  23. package/src/bind-page-handler.ts +1 -83
  24. package/src/build-client-assets/build-lock.ts +1 -11
  25. package/src/build-client-assets/hashed-output.ts +1 -11
  26. package/src/build-client-assets/hydrate-bundle.ts +1 -137
  27. package/src/build-client-assets/manifest.ts +1 -30
  28. package/src/build-client-assets/source-candidates.ts +0 -51
  29. package/src/build-client-assets/sourcemaps-upload.ts +1 -52
  30. package/src/build-client-assets/styles-build.ts +1 -71
  31. package/src/build-client-assets.ts +1 -30
  32. package/src/capability-slug.ts +1 -45
  33. package/src/compose-page-title.ts +1 -34
  34. package/src/content-gate/access.ts +0 -34
  35. package/src/content-gate/catalog.ts +0 -52
  36. package/src/content-gate/config-types.ts +3 -90
  37. package/src/content-gate/gate-entry-clears.ts +0 -18
  38. package/src/content-gate/gate.ts +0 -27
  39. package/src/content-gate/parse-cookie.ts +0 -4
  40. package/src/content-page-floor.ts +0 -28
  41. package/src/csrf-protection.ts +1 -28
  42. package/src/error-page-logo.ts +2 -35
  43. package/src/expand-roster.ts +1 -25
  44. package/src/gate-ttls.ts +0 -12
  45. package/src/gpt-gate.ts +1 -19
  46. package/src/hono-error-handler.ts +0 -19
  47. package/src/install-harness.ts +1 -12
  48. package/src/install-logging.ts +1 -47
  49. package/src/load-page-manifest.ts +1 -29
  50. package/src/load-service-install-row.ts +42 -10
  51. package/src/module-not-found.ts +0 -17
  52. package/src/mount-orpc-router.ts +1 -94
  53. package/src/mount-server-packages.ts +1 -37
  54. package/src/mount.ts +1 -4
  55. package/src/mutable-app-ref.ts +0 -21
  56. package/src/orpc-contract-rows.json +289 -0
  57. package/src/orpc-openapi-document.ts +1 -19
  58. package/src/orpc-openapi-gate.ts +1 -23
  59. package/src/page-route-shell-types.ts +1 -8
  60. package/src/page-server-loader-graph.ts +0 -10
  61. package/src/page-server-orpc/server-client.ts +1 -23
  62. package/src/pg-boss-handler-registry.ts +1 -24
  63. package/src/pg-boss-mount.ts +1 -109
  64. package/src/proposal-authorized-emails.ts +0 -32
  65. package/src/proposal-gate.ts +1 -52
  66. package/src/quiz-gate.ts +1 -31
  67. package/src/read-html-template.ts +1 -49
  68. package/src/readiness-gate.ts +0 -8
  69. package/src/register-api-routes.ts +1 -11
  70. package/src/register-crash-handlers.ts +1 -6
  71. package/src/register-page-routes.ts +1 -12
  72. package/src/register-page-server-client.ts +1 -66
  73. package/src/register-registry-page-route/ai-chat-prefetch.ts +1 -24
  74. package/src/register-registry-page-route/apply-page-branding.ts +1 -100
  75. package/src/register-registry-page-route/captured-loader-config.ts +1 -12
  76. package/src/register-registry-page-route/graph-cache.ts +1 -38
  77. package/src/register-registry-page-route/live-loader-config.ts +1 -22
  78. package/src/register-registry-page-route/meta-title.ts +1 -36
  79. package/src/register-registry-page-route/page-data-shared.ts +1 -18
  80. package/src/register-registry-page-route/prefetch-org-id.ts +1 -24
  81. package/src/register-registry-page-route/proposal-approval-email-token.ts +1 -22
  82. package/src/register-registry-page-route/proposal-brand-stamping.ts +1 -31
  83. package/src/register-registry-page-route/public-shell-page-data.ts +1 -34
  84. package/src/register-registry-page-route/region-slots-page-data.ts +1 -8
  85. package/src/register-registry-page-route/resolve-page-data.ts +1 -10
  86. package/src/register-registry-page-route/resolve-panel-page.ts +1 -10
  87. package/src/register-registry-page-route/resolve-request-graph.ts +1 -68
  88. package/src/register-registry-page-route/run-content-gates.ts +3 -64
  89. package/src/register-registry-page-route/single-page-data.ts +1 -9
  90. package/src/register-registry-page-route/stamp-session-context.ts +1 -10
  91. package/src/register-registry-page-route/warn-uncovered-blocks.ts +1 -18
  92. package/src/register-registry-page-route.ts +1 -93
  93. package/src/register-static-assets.ts +1 -53
  94. package/src/register-ws-routes.ts +1 -21
  95. package/src/resolve-brand-favicon.ts +0 -22
  96. package/src/resolve-service-port.ts +1 -61
  97. package/src/resolve-theme-mode.ts +1 -30
  98. package/src/resolvers/app-header.ts +1 -20
  99. package/src/resolvers/fetch-org-brand.ts +1 -11
  100. package/src/resolvers/portal-route-paths.ts +1 -29
  101. package/src/resolvers/website-by-host-map.ts +1 -7
  102. package/src/resource-not-found.ts +0 -48
  103. package/src/run-boot-hook-list.ts +1 -13
  104. package/src/run-boot-hooks.ts +1 -19
  105. package/src/scala-tools-currency-watchdog.ts +1 -7
  106. package/src/security-headers.ts +1 -7
  107. package/src/select-service-routes.ts +1 -31
  108. package/src/server-error-page.ts +0 -18
  109. package/src/theme-fonts-link.ts +1 -21
  110. package/src/transfers/presigned-upload.ts +1 -27
  111. package/src/two-phase-full-app.ts +0 -82
  112. package/src/two-phase-minimal-app.ts +1 -111
  113. package/src/two-phase-readiness.ts +0 -6
  114. package/src/types.ts +0 -17
  115. package/src/ui-service.ts +2 -48
  116. package/src/ws-boot-self-test.ts +1 -14
  117. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  118. package/src/content-gate/token.segmentation.test.ts +0 -76
  119. package/src/content-gate/token.test.ts +0 -65
  120. package/src/content-gate/token.ts +0 -219
  121. package/src/is-react-element-type.test.ts +0 -45
  122. package/src/is-react-element-type.ts +0 -20
  123. package/src/page-server-orpc/hub-mcp-client.ts +0 -20
  124. package/src/pg-boss-mount.test.ts +0 -101
  125. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  126. package/src/resource-chat-inject.test.ts +0 -240
  127. package/src/resource-chat-inject.ts +0 -374
@@ -1,374 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Injects the generic ResourceChatLauncher block into an AUTHORISED resource
7
- * page (proposal, agreement, or any gated document) — called from a gate's
8
- * onAuthorized, so the floating GPT bubble lives strictly behind the email gate
9
- * and every resource of that type gets it with no per-page Puck edit. It builds
10
- * the resource's page content into a runtime-context string and passes it to the
11
- * launcher, which forwards it to the chat as <runtime_context> — so ONE shared
12
- * assistant agent answers about whichever resource is being viewed.
13
- *
14
- * RESOURCE-AGNOSTIC by design (one-ai-chat-system / chat-ui-is-one-canonical-block):
15
- * the launcher, the chat engine, and this injector are generic; only the
16
- * per-resource CONFIG differs (which agent, the copy, which resolved key holds
17
- * the record). The agent id is config, not code (registries-are-source-of-truth):
18
- * each resource type names a `config / <slug>-assistant-agent` row carrying
19
- * { agentId }. No row (or no agent) → no injection, page renders unchanged
20
- * (fail-soft).
21
- */
22
-
23
- /**
24
- * THE ASSISTANT'S WORDS BELONG TO THE AGENT ROW, NOT TO THIS FILE.
25
- *
26
- * `ai_agents` already carries a column for every one of them -- name,
27
- * welcome_message, conversation_starters, primary_color -- and on 2026-08-19
28
- * both live agents had welcome_message NULL and conversation_starters empty
29
- * while the copy sat spelled in TypeScript here and in proposal-chat-inject.
30
- * The intended home existed, sat blank, and the hardcoded copy won, so nobody
31
- * could change a word the assistant says without a code change and a deploy.
32
- * Two copies of one fact with the stale one winning silently is exactly
33
- * `information-exists-in-one-location`.
34
- *
35
- * `launchLabel` and `placeholder` remain caller arguments only because no
36
- * column holds them yet.
37
- */
38
-
39
- /**
40
- * The rendered content tree to append the launcher to.
41
- *
42
- * WHICH KEYS EXIST IS A ROW FACT, SO IT IS NOT SPELLED HERE. A page's resolved
43
- * sources and its region slots are declared on the `resource_loader` row, and
44
- * the caller has already read that row — so it passes `regionSlots` in. An
45
- * earlier draft of this function named "page", "body", "header" and "footer" as
46
- * literals, which is a loader row copied into TypeScript, free to drift from it
47
- * the moment a loader is edited (`identity-is-imported-never-spelled`).
48
- *
49
- * A FLAT PAGE KEEPS ITS CONTENT UNDER `page`; A REGION-SLOT PAGE DOES NOT.
50
- * Proposals and agreements resolve one `page` source, so the original injector
51
- * read `resolved.page.puck_data.content` and returned early when it was absent.
52
- * A generated report resolves region slots instead, so that early return fired
53
- * and the assistant was silently missing — the same failure shape as the dead
54
- * block name, since fail-soft injection logs nothing and the page looks fine.
55
- *
56
- * ANY RENDERED REGION WILL DO, SO THE FIRST DECLARED ONE IS TAKEN. The launcher
57
- * renders `position: fixed` at `zIndex: 50`, anchored to the viewport and not
58
- * to its container, so it appears identically whichever region carries it. That
59
- * is what makes "first declared region holding a content tree" a derivation
60
- * rather than a guess dressed up as one: there is no better region to prefer.
61
- *
62
- * `PAGE_SOURCE_KEY` is the platform's existing name for a flat page's body
63
- * source — the same convention `decideContentPageFloor` already keys on in the
64
- * caller. One shared convention, not a per-resource list.
65
- */
66
-
67
- /**
68
- * Serialise EVERYTHING THIS VIEWER MAY SEE as text for the assistant.
69
- *
70
- * BOTH THE RESULT AND THE RENDERED DOCUMENT, BECAUSE NEITHER IS SUFFICIENT.
71
- * The original serialiser sent the resource record plus the page's content
72
- * tree, which is exactly right for a proposal — the proposal's substance IS in
73
- * its blocks. It is wrong for anything the platform GENERATES a document from.
74
- * A report's body page is a shared template (one `builda_pages` row serves
75
- * every respondent) so the content tree holds headings and layout and not one
76
- * personal fact, while the substance — the answers, the category scores, the
77
- * computed narrative and bands — sits in sibling resolved keys the serialiser
78
- * never looked at. Sending only the page would give the assistant a form with
79
- * no answers; sending only the record would leave it unable to discuss what the
80
- * reader is actually looking at. An assessment result GENERATES a report, so
81
- * the assistant needs both halves of that.
82
- *
83
- * NO KEY IS NAMED, INCLUDED OR EXCLUDED. The loader row decides what a page
84
- * resolves; this walks whatever that produced. A draft carried a `CHROME_KEYS`
85
- * exclusion list dropping brand, website, header and footer — every one of them
86
- * a loader source key, which is to say database facts hardcoded here to save a
87
- * few hundred tokens of colours and navigation markup. Not a trade worth a
88
- * literal that goes stale in silence. A loader resolving a new source reaches
89
- * the assistant with no edit here (`offered-is-derived`).
90
- *
91
- * IT CANNOT OUTRUN THE GATE, BY CONSTRUCTION. This runs AFTER every content
92
- * gate, and a gate withholds by REMOVING content from this same graph. So
93
- * "everything in the graph" and "everything this viewer may see" are the same
94
- * set, and they cannot drift — which is what makes serialising the whole graph
95
- * safe rather than reckless.
96
- */
97
-
98
- import { getAppLogger } from "@teamscala/logger/app-loggers";
99
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
100
- import { getServerClient } from "./page-server-orpc/server-client.ts";
101
- import * as v from "valibot";
102
- import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
103
- import type { ConfigKey } from "@teamscala/db/registry/generated/config";
104
-
105
- const AgentConfigSchema = v.object({ agentId: v.string() });
106
-
107
- // THE PUSHED BLOCK NAME IS AN IMPORTED IDENTITY, NEVER A SPELLED STRING.
108
- // discover-blocks keys the Puck registry off block_schema.slug alone
109
- // (`components[row.slug] = …`), so a type that is not a live slug resolves to
110
- // nothing — and because injection is fail-soft, the page renders unchanged with
111
- // no error. That is exactly how the bubble went missing from every gated
112
- // proposal and agreement: the block was consolidated to ResourceLauncher and
113
- // these injectors kept pushing their pre-consolidation names. BlockSchemaKey is
114
- // the generated union of live slugs, so annotating it here makes a stale name a
115
- // COMPILE error instead of a silent absence (identity-is-imported-never-spelled).
116
- const LAUNCHER_BLOCK: BlockSchemaKey = "ResourceLauncher";
117
-
118
- /** Per-resource-type injection config — the only thing that varies. */
119
- export interface ResourceChatOptions {
120
- /** Key in `resolved` holding the record (e.g. "proposal", "agreement"). */
121
- resourceKey: string;
122
- /** `config` row slug carrying { agentId } (e.g. "agreement-assistant-agent"). */
123
- agentConfigSlug: ConfigKey;
124
- /** Org id to scope the chat. If omitted, read from resolved[resourceKey].organisation_id
125
- * (proposals); agreements pass it explicitly (org isn't on the resolved record). */
126
- organisationId?: string;
127
- /** Floating button label / speech bubble (e.g. "Ask about this agreement"). */
128
- launchLabel: string;
129
- /** Input placeholder. */
130
- placeholder?: string;
131
- /**
132
- * The loader row's `region_slots`, when this page has them — passed in rather
133
- * than guessed, so which keys hold rendered content stays a database fact.
134
- * Absent means a flat page whose content is under the `page` source.
135
- */
136
- regionSlots?: readonly string[];
137
- }
138
-
139
- export interface AgentPresentation {
140
- title?: string;
141
- welcomeMessage?: string;
142
- conversationStarters?: string[];
143
- primaryColor?: string;
144
- }
145
-
146
- export async function loadAgentPresentation(agentId: string): Promise<AgentPresentation> {
147
- // THROUGH ORPC, NEVER PRISMA DIRECTLY (`no-direct-prisma-outside-orpc`).
148
- // This is the same service-scoped client the ProductTiers read above uses;
149
- // `ai_agents` is already in the declared models of both services that mount
150
- // a launcher, so no schema change is needed to read it here.
151
- const client = getServerClient() as {
152
- ai_agents?: {
153
- findFirst: (args: {
154
- where: { id: string };
155
- select: {
156
- name: true;
157
- welcome_message: true;
158
- conversation_starters: true;
159
- primary_color: true;
160
- };
161
- }) => Promise<{
162
- name: string | null;
163
- welcome_message: string | null;
164
- conversation_starters: unknown;
165
- primary_color: string | null;
166
- } | null>;
167
- };
168
- };
169
- try {
170
- const agent = await client.ai_agents?.findFirst({
171
- where: { id: agentId },
172
- select: {
173
- name: true,
174
- welcome_message: true,
175
- conversation_starters: true,
176
- primary_color: true,
177
- },
178
- });
179
- if (!agent) return {};
180
- const starters = Array.isArray(agent.conversation_starters)
181
- ? (agent.conversation_starters as unknown[]).filter(
182
- (entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
183
- )
184
- : [];
185
- return {
186
- title: agent.name ?? undefined,
187
- welcomeMessage: agent.welcome_message ?? undefined,
188
- conversationStarters: starters.length > 0 ? starters : undefined,
189
- primaryColor: agent.primary_color ?? undefined,
190
- };
191
- } catch (err) {
192
- // FAIL SOFT, LIKE THE REST OF THE INJECTION. A presentation read that
193
- // fails must not remove the bubble: the launcher carries its own
194
- // default for every one of these fields, so an unreadable row costs
195
- // wording, never the assistant itself.
196
- getAppLogger().error(
197
- `[resource-chat] agent presentation read failed (${agentId}): ${err instanceof Error ? err.message : String(err)}`,
198
- );
199
- return {};
200
- }
201
- }
202
-
203
- /** A resolved source that carries a Puck content tree. */
204
- function puckContentOf(value: unknown): unknown[] | null {
205
- const content = (value as { puck_data?: { content?: unknown } } | null | undefined)
206
- ?.puck_data?.content;
207
- return Array.isArray(content) ? content : null;
208
- }
209
-
210
- const PAGE_SOURCE_KEY = "page";
211
-
212
- function findContentHost(
213
- resolved: Record<string, unknown>,
214
- regionSlots?: readonly string[],
215
- ): unknown[] | null {
216
- if (regionSlots?.length) {
217
- for (const slot of regionSlots) {
218
- const content = puckContentOf(resolved[slot]);
219
- if (content) return content;
220
- }
221
- return null;
222
- }
223
- return puckContentOf(resolved[PAGE_SOURCE_KEY]);
224
- }
225
-
226
- async function buildResourceContext(
227
- resolved: Record<string, unknown>,
228
- resourceKey: string,
229
- regionSlots?: readonly string[],
230
- ): Promise<string> {
231
- const record = resolved[resourceKey] as Record<string, unknown> | undefined;
232
- const parts: string[] = [];
233
-
234
- if (typeof record?.name === "string" && record.name) {
235
- parts.push(`Title: ${record.name}`);
236
- }
237
- if (typeof record?.client_name === "string" && record.client_name) {
238
- parts.push(`For: ${record.client_name}`);
239
- }
240
- if (typeof record?.description === "string" && record.description) {
241
- parts.push(`Description: ${record.description}`);
242
- }
243
-
244
- for (const [key, value] of Object.entries(resolved)) {
245
- if (value == null || typeof value === "function") continue;
246
- const content = puckContentOf(value);
247
- if (content) {
248
- // A rendered document: every block, every prop, verbatim.
249
- parts.push(`\n--- Document content (${key}) ---`);
250
- parts.push(JSON.stringify(content, null, 2));
251
- continue;
252
- }
253
- parts.push(`\n--- ${key} ---`);
254
- parts.push(JSON.stringify(value, null, 2));
255
- }
256
-
257
- // Resolve DB-backed product tiers when present (proposals) so the GPT sees
258
- // real pricing. Harmless for resources with no ProductTiers blocks.
259
- const client = getServerClient() as {
260
- products?: {
261
- findFirst: (args: {
262
- where: { id: string };
263
- select: { id: true; billing_type: true; tiers: true; components: true };
264
- }) => Promise<{ id: string; billing_type: string; tiers: unknown; components: unknown } | null>;
265
- };
266
- };
267
- for (const block of (findContentHost(resolved, regionSlots) ?? []) as Array<
268
- Record<string, unknown>
269
- >) {
270
- if (block?.type !== "ProductTiers") continue;
271
- const props = block.props as Record<string, unknown> | undefined;
272
- if (!props) continue;
273
- const productIds = [props.productId, props.productId2, props.productId3].filter(
274
- (id): id is string => typeof id === "string" && id.trim().length > 0,
275
- );
276
- for (const pid of productIds) {
277
- try {
278
- const product = await client.products?.findFirst({
279
- where: { id: pid },
280
- select: { id: true, billing_type: true, tiers: true, components: true },
281
- });
282
- if (product) {
283
- parts.push(`\n--- Product tiers (productId: ${pid}) ---`);
284
- parts.push(JSON.stringify(product, null, 2));
285
- }
286
- } catch {
287
- // best-effort
288
- }
289
- }
290
- }
291
-
292
- return parts.join("\n");
293
- }
294
-
295
- function brandPrimaryColor(resolved: Record<string, unknown>): string {
296
- const brand = resolved.brand as { primaryColor?: unknown } | undefined;
297
- return typeof brand?.primaryColor === "string" ? brand.primaryColor : "";
298
- }
299
-
300
- /**
301
- * Inject the generic ResourceChatLauncher behind the gate. `opts` is the only
302
- * per-resource-type variation; everything else is shared.
303
- */
304
- export async function injectResourceChatLauncher(
305
- resolved: Record<string, unknown>,
306
- viewerEmail: string,
307
- opts: ResourceChatOptions,
308
- ): Promise<void> {
309
- // Whichever region actually renders — `page` on a flat document, `body` on a
310
- // region-slot one. No rendered content at all → nothing to attach to.
311
- const hostContent = findContentHost(resolved, opts.regionSlots);
312
- if (!hostContent) return;
313
-
314
- const record = resolved[opts.resourceKey] as Record<string, unknown> | undefined;
315
- const organisationId =
316
- opts.organisationId ||
317
- (typeof record?.organisation_id === "string" ? record.organisation_id : "");
318
- if (!organisationId) {
319
- // THIS BAIL USED TO BE SILENT, AND A SILENT DROP IS THE WORST FAILURE
320
- // SHAPE A BLOCK CAN HAVE (measured 2026-09-08 on the quiz report): the
321
- // page returns 200 and looks complete while the assistant is simply
322
- // absent — resource_key named a resolved key that is null on some link
323
- // shapes (a research-anchor link has no raw response match), and the
324
- // page rendered a full report with no bubble and no error anywhere.
325
- // The page must stay usable (fail-soft for the RENDER is the contract),
326
- // but a declared assistant that did not inject is never "fine": name
327
- // the key and the reason so the absence is observable.
328
- getAppLogger().error(
329
- `[resource-chat] assistant declared but NOT injected: resolved["${opts.resourceKey}"] is ${record === undefined ? "absent" : "null"} or carries no organisation_id — the launcher is silently missing from this page`,
330
- );
331
- return;
332
- }
333
-
334
- let agentId = "";
335
- try {
336
- agentId = (
337
- await loadRegistryConfig("config", opts.agentConfigSlug, AgentConfigSchema)
338
- ).agentId;
339
- } catch {
340
- return; // no agent configured → no bubble (fail-soft, page unchanged)
341
- }
342
- if (!agentId) return;
343
-
344
- try {
345
- const runtimeContext = await buildResourceContext(
346
- resolved,
347
- opts.resourceKey,
348
- opts.regionSlots,
349
- );
350
- const presentation = await loadAgentPresentation(agentId);
351
- (hostContent as Array<Record<string, unknown>>).push({
352
- type: LAUNCHER_BLOCK,
353
- props: {
354
- id: `${opts.resourceKey}-chat-launcher`,
355
- agentId,
356
- organisationId,
357
- viewerEmail,
358
- runtimeContext,
359
- // The page's own brand still wins over the agent's stored colour:
360
- // the bubble sits ON this document and must match it.
361
- primaryColor: brandPrimaryColor(resolved) || presentation.primaryColor,
362
- title: presentation.title,
363
- welcomeMessage: presentation.welcomeMessage,
364
- launchLabel: opts.launchLabel,
365
- placeholder: opts.placeholder,
366
- conversationStarters: presentation.conversationStarters,
367
- },
368
- });
369
- } catch (err) {
370
- getAppLogger().error(
371
- `[resource-chat] launcher injection failed (${opts.resourceKey}): ${err instanceof Error ? err.message : String(err)}`,
372
- );
373
- }
374
- }