@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 +1 -17
- package/src/content-gate/catalog.test.ts +68 -0
- package/src/content-gate/catalog.ts +81 -0
- package/src/content-gate/gate.ts +25 -4
- package/src/proposal-gate.ts +5 -4
- package/src/register-registry-page-route.ts +65 -10
- package/src/resource-chat-inject.test.ts +151 -22
- package/src/resource-chat-inject.ts +134 -13
- package/src/two-phase-boot.ts +10 -6
- package/src/proposal-chat-inject.ts +0 -158
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
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
|
+
}
|
package/src/content-gate/gate.ts
CHANGED
|
@@ -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<
|
|
41
|
+
): Promise<ContentGateOutcome> {
|
|
22
42
|
const resourceId = await config.getResourceId(resolved);
|
|
23
|
-
if (!resourceId) return
|
|
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
|
/**
|
package/src/proposal-gate.ts
CHANGED
|
@@ -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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
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 {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
|
15
|
-
*
|
|
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
|
-
|
|
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 = (
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 (
|
|
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
|
-
|
|
192
|
-
|
|
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(
|
|
328
|
+
const runtimeContext = await buildResourceContext(
|
|
329
|
+
resolved,
|
|
330
|
+
opts.resourceKey,
|
|
331
|
+
opts.regionSlots,
|
|
332
|
+
);
|
|
212
333
|
const presentation = await loadAgentPresentation(agentId);
|
|
213
|
-
(
|
|
334
|
+
(hostContent as Array<Record<string, unknown>>).push({
|
|
214
335
|
type: LAUNCHER_BLOCK,
|
|
215
336
|
props: {
|
|
216
337
|
id: `${opts.resourceKey}-chat-launcher`,
|
package/src/two-phase-boot.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
}
|