@teamscala/service-boot 0.23.524 → 0.23.526

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