@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,197 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- */
5
-
6
- import type { GateSegmentation } from "./config-types.ts";
7
- import { resolveDbUrlOrNull } from "@teamscala/db/db-url";
8
-
9
- function secret(): string | null {
10
- // The last resort is the tenant DB address, read through the named-need
11
- // resolver (wrong-database guard, 2026-09-27) — never `process.env` here;
12
- // the resolver is the only env-aware surface and it names the plane. It is
13
- // used only as a stable per-deployment string (≥16 chars), never as a
14
- // connection target.
15
- for (const s of [
16
- process.env.AUTH_SECRET,
17
- process.env.ENCRYPTION_KEY,
18
- resolveDbUrlOrNull("tenant"),
19
- ]) {
20
- if (s && s.length >= 16) return s;
21
- }
22
- return null;
23
- }
24
-
25
- function hmac(payloadB64: string, key: string): string {
26
- return new Bun.CryptoHasher("sha256", key).update(payloadB64).digest("base64url");
27
- }
28
-
29
- /** Constant-time string compare (equal-length); avoids node:crypto. */
30
- function safeEqual(a: string, b: string): boolean {
31
- if (a.length !== b.length) return false;
32
- let diff = 0;
33
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
34
- return diff === 0;
35
- }
36
-
37
- /** Mint a token proving `email` is authorized for `resourceId` under `cookieName`. */
38
- export function signAccessToken(
39
- cookieName: string,
40
- resourceId: string,
41
- email: string,
42
- nowMs: number,
43
- ttlMs: number,
44
- /**
45
- * Optional display name to carry in the token (the public GPT lead-capture
46
- * gate uses this so a returning visitor's name is known at SSR — first paint
47
- * renders the full identity, not just the email). Allow-list gates
48
- * (proposals/agreements) omit it.
49
- */
50
- name?: string,
51
- /**
52
- * Optional captured phone, same reasoning as `name`. It exists so a capture
53
- * gate needs NO browser storage to remember it: the operator's ruling is
54
- * that identity lives in the database and the request, never in
55
- * localStorage.
56
- */
57
- phone?: string,
58
- /**
59
- * Optional prospect segmentation captured alongside the identity on the
60
- * quiz's composed gate screen (company / LinkedIn / website / team size).
61
- * Same reasoning as name/phone: it rides the signed request, never client
62
- * storage. OPTIONAL and absent from older tokens — a token without it
63
- * verifies exactly as before.
64
- */
65
- segmentation?: GateSegmentation,
66
- ): string | null {
67
- const key = secret();
68
- if (!key) return null;
69
- const payload: {
70
- c: string;
71
- r: string;
72
- e: string;
73
- x: number;
74
- n?: string;
75
- p?: string;
76
- s?: GateSegmentation;
77
- } = {
78
- c: cookieName,
79
- r: resourceId,
80
- e: email.trim().toLowerCase(),
81
- x: nowMs + ttlMs,
82
- };
83
- const trimmedName = name?.trim();
84
- if (trimmedName) payload.n = trimmedName;
85
- const trimmedPhone = phone?.trim();
86
- if (trimmedPhone) payload.p = trimmedPhone;
87
- const seg: GateSegmentation = {};
88
- const segCompany = segmentation?.company?.trim();
89
- if (segCompany) seg.company = segCompany;
90
- const segLinkedin = segmentation?.linkedinUrl?.trim();
91
- if (segLinkedin) seg.linkedinUrl = segLinkedin;
92
- const segWebsite = segmentation?.website?.trim();
93
- if (segWebsite) seg.website = segWebsite;
94
- const segTeamSize = segmentation?.teamSize?.trim();
95
- if (segTeamSize) seg.teamSize = segTeamSize;
96
- if (Object.keys(seg).length > 0) payload.s = seg;
97
- const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
98
- return `${payloadB64}.${hmac(payloadB64, key)}`;
99
- }
100
-
101
- /**
102
- * Validate a token for `cookieName` + `resourceId`. Returns the verified email
103
- * on success, null otherwise (bad signature, wrong gate, wrong resource,
104
- * expired, no secret).
105
- */
106
- export function verifyAccessToken(
107
- token: string | undefined | null,
108
- cookieName: string,
109
- resourceId: string,
110
- nowMs: number,
111
- ): string | null {
112
- if (!token) return null;
113
- const key = secret();
114
- if (!key) return null;
115
- const dot = token.indexOf(".");
116
- if (dot <= 0) return null;
117
- const payloadB64 = token.slice(0, dot);
118
- const sig = token.slice(dot + 1);
119
- if (!safeEqual(sig, hmac(payloadB64, key))) return null;
120
- try {
121
- const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
122
- c?: string;
123
- r?: string;
124
- e?: string;
125
- x?: number;
126
- };
127
- if (p.c !== cookieName) return null;
128
- if (p.r !== resourceId) return null;
129
- if (typeof p.x !== "number" || p.x < nowMs) return null;
130
- return typeof p.e === "string" ? p.e : null;
131
- } catch {
132
- return null;
133
- }
134
- }
135
-
136
- /**
137
- * Like `verifyAccessToken` but returns the full verified identity
138
- * ({email, name, phone, segmentation}) so a gate that carries a display name in
139
- * the token (the public GPT lead-capture gate) can seed the visitor's name — not
140
- * just the email — at SSR, and the quiz gate can seed the captured prospect
141
- * segmentation the same way. Same signature/expiry/gate-binding checks; null on
142
- * any failure. A token minted BEFORE segmentation existed carries no `s` key and
143
- * verifies identically (segmentation comes back undefined) — never an error.
144
- */
145
- export function verifyAccessTokenIdentity(
146
- token: string | undefined | null,
147
- cookieName: string,
148
- resourceId: string,
149
- nowMs: number,
150
- ): {
151
- email: string;
152
- name?: string;
153
- phone?: string;
154
- segmentation?: GateSegmentation;
155
- } | null {
156
- if (!token) return null;
157
- const key = secret();
158
- if (!key) return null;
159
- const dot = token.indexOf(".");
160
- if (dot <= 0) return null;
161
- const payloadB64 = token.slice(0, dot);
162
- const sig = token.slice(dot + 1);
163
- if (!safeEqual(sig, hmac(payloadB64, key))) return null;
164
- try {
165
- const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
166
- c?: string;
167
- r?: string;
168
- e?: string;
169
- x?: number;
170
- n?: string;
171
- p?: string;
172
- s?: GateSegmentation;
173
- };
174
- if (p.c !== cookieName) return null;
175
- if (p.r !== resourceId) return null;
176
- if (typeof p.x !== "number" || p.x < nowMs) return null;
177
- if (typeof p.e !== "string") return null;
178
- const segmentation =
179
- p.s && typeof p.s === "object"
180
- ? {
181
- company: typeof p.s.company === "string" ? p.s.company : undefined,
182
- linkedinUrl:
183
- typeof p.s.linkedinUrl === "string" ? p.s.linkedinUrl : undefined,
184
- website: typeof p.s.website === "string" ? p.s.website : undefined,
185
- teamSize: typeof p.s.teamSize === "string" ? p.s.teamSize : undefined,
186
- }
187
- : undefined;
188
- return {
189
- email: p.e,
190
- name: typeof p.n === "string" ? p.n : undefined,
191
- phone: typeof p.p === "string" ? p.p : undefined,
192
- segmentation,
193
- };
194
- } catch {
195
- return null;
196
- }
197
- }
@@ -1,45 +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 { expect, test } from "bun:test";
11
- import { forwardRef, lazy, memo } from "react";
12
- import { isReactElementType } from "./is-react-element-type.ts";
13
-
14
- function Plain() {
15
- return null;
16
- }
17
-
18
- test("accepts a plain function component", () => {
19
- expect(isReactElementType(Plain)).toBe(true);
20
- });
21
-
22
- test("accepts memo() — the PresenceAvatars shape that rendered blank", () => {
23
- expect(isReactElementType(memo(Plain))).toBe(true);
24
- });
25
-
26
- test("accepts forwardRef()", () => {
27
- expect(isReactElementType(forwardRef(() => null))).toBe(true);
28
- });
29
-
30
- test("accepts lazy()", () => {
31
- expect(isReactElementType(lazy(async () => ({ default: Plain })))).toBe(true);
32
- });
33
-
34
- test("still rejects what genuinely cannot render", () => {
35
- expect(isReactElementType(undefined)).toBe(false);
36
- expect(isReactElementType(null)).toBe(false);
37
- expect(isReactElementType({})).toBe(false);
38
- expect(isReactElementType("PresenceAvatarsBlock")).toBe(false);
39
- expect(isReactElementType(42)).toBe(false);
40
- });
41
-
42
- test("rejects a plain object wearing a non-symbol $$typeof", () => {
43
- // A data blob with the key but not the symbol must not pass as renderable.
44
- expect(isReactElementType({ $$typeof: "react.memo" })).toBe(false);
45
- });
@@ -1,10 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- */
5
-
6
- export function isReactElementType(value: unknown): boolean {
7
- if (typeof value === "function") return true;
8
- if (typeof value !== "object" || value === null) return false;
9
- return typeof (value as { $$typeof?: unknown }).$$typeof === "symbol";
10
- }
@@ -1,14 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- */
5
-
6
- let _hubMcpClient: unknown | null = null;
7
-
8
- export function setHubMcpClient(client: unknown): void {
9
- _hubMcpClient = client;
10
- }
11
-
12
- export function getHubMcpClient(): unknown | undefined {
13
- return _hubMcpClient ?? undefined;
14
- }
@@ -1,101 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The mount's poll-only contract: pg-boss v12 dispatches by polling, so the
7
- * mount dials the INJECTED transaction-pooler URL unchanged (no endpoint
8
- * derivation of its own — the session-port mapping died with the door,
9
- * 2026-09-14) and constructs with useListenNotify false spelled explicitly
10
- * rather than inherited from the supplier default, so a pg-boss bump cannot
11
- * silently re-introduce a LISTEN dependency. The mock captures the opts
12
- * PgBoss is constructed with — the claim is about what the mount DIALS and
13
- * how it dispatches, so the assertions are on the captured opts.
14
- */
15
-
16
- import { afterEach, describe, expect, it, mock } from "bun:test";
17
-
18
- interface CapturedOpts {
19
- connectionString?: string;
20
- max?: number;
21
- useListenNotify?: boolean;
22
- }
23
-
24
- const POOLED_URL =
25
- "postgresql://scala_container_gbg.gbg:s3cret@10.205.50.1:6543/scala_container_gbg?sslmode=require";
26
-
27
- function seedScheduledJob() {
28
- return {
29
- id: "test-job-1",
30
- type: "scheduled_job",
31
- slug: "scheduled_job:test-sync",
32
- label: "Test Sync Job",
33
- description: null,
34
- config: {
35
- handler_slug: "@teamscala/sync-worker:processSyncQueue",
36
- cron: "*/5 * * * *",
37
- queue_name: "sync-worker",
38
- },
39
- is_active: true,
40
- sort_order: 0,
41
- created_at: new Date(),
42
- updated_at: new Date(),
43
- noun: "scheduled_job",
44
- kind: "scheduled_job",
45
- };
46
- }
47
-
48
- describe("mountPgBoss poll-only contract", () => {
49
- afterEach(() => {
50
- mock.restore();
51
- });
52
-
53
- it("dials the injected transaction pooler unchanged and dispatches poll-only", async () => {
54
- let captured: CapturedOpts | null = null;
55
-
56
- mock.module("pg-boss", () => ({
57
- PgBoss: mock((opts: CapturedOpts) => {
58
- captured = opts;
59
- return {
60
- start: mock(() => Promise.resolve()),
61
- stop: mock(() => Promise.resolve()),
62
- on: mock(() => undefined),
63
- createQueue: mock(() => Promise.resolve()),
64
- work: mock(() => Promise.resolve("worker-id")),
65
- schedule: mock(() => Promise.resolve()),
66
- getSchedules: mock(() => Promise.resolve([])),
67
- unschedule: mock(() => Promise.resolve()),
68
- };
69
- }),
70
- }));
71
- mock.module("@teamscala/db/registry/accessors", () => ({
72
- getAllEntries: () => [seedScheduledJob()],
73
- }));
74
- mock.module("@teamscala/event-log/record-event", () => ({
75
- recordEvent: () => undefined,
76
- }));
77
-
78
- const { mountPgBoss } = await import("./pg-boss-mount.ts");
79
- await mountPgBoss({
80
- databaseUrl: POOLED_URL,
81
- serviceSlug: "scala-sync-worker",
82
- packages: ["@teamscala/sync-worker"],
83
- });
84
-
85
- const capturedOpts = captured as unknown as CapturedOpts | undefined;
86
- const connectionString = capturedOpts?.connectionString ?? "";
87
- // The transaction pooler, exactly as injected — the mount adds no
88
- // endpoint derivation.
89
- expect(connectionString).toContain(":6543");
90
- // NEVER the session port: the door is closing, not widening.
91
- expect(connectionString).not.toContain(":5999");
92
- // The tenant suffix the pooler routes by is kept.
93
- expect(connectionString).toContain("scala_container_gbg.gbg:");
94
- // The pg sslmode dialect rewrite still applies.
95
- expect(connectionString).toContain("sslmode=no-verify");
96
- // Poll-only, spelled: no LISTEN dependency, now and after a bump.
97
- expect(capturedOpts?.useListenNotify).toBe(false);
98
- // pg-boss stays a single sibling connection (pool sizing doctrine).
99
- expect(capturedOpts?.max).toBe(1);
100
- });
101
- });
@@ -1,81 +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, test } from "bun:test";
11
- import { createCache } from "@teamscala/cache/create-cache";
12
- import { readCachedGraph } from "./graph-cache.ts";
13
-
14
- const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
15
-
16
- describe("readCachedGraph", () => {
17
- test("fresh hit returns the cached value and fires NO refresh", async () => {
18
- const cache = createCache<unknown>("graph-cache-test:fresh", {
19
- maxSize: 4,
20
- ttlMs: 1000,
21
- staleTtlMs: 1000,
22
- });
23
- let refreshCalls = 0;
24
- cache.set("k", "v");
25
- expect(readCachedGraph(cache, "k", async () => ++refreshCalls)).toBe("v");
26
- await delay(5);
27
- expect(refreshCalls).toBe(0);
28
- });
29
-
30
- test("stale hit returns the cached value AND fires the background refresh", async () => {
31
- const cache = createCache<unknown>("graph-cache-test:stale", {
32
- maxSize: 4,
33
- ttlMs: 20,
34
- staleTtlMs: 5000,
35
- });
36
- let refreshCalls = 0;
37
- cache.set("k", "v");
38
- await delay(40); // past ttlMs (fresh) → within staleTtlMs → stale
39
- // Serves the stale value instantly...
40
- expect(readCachedGraph(cache, "k", async () => ++refreshCalls)).toBe("v");
41
- // ...and fires exactly one background refresh.
42
- await delay(5);
43
- expect(refreshCalls).toBe(1); // FAILS if the background refresh stops firing
44
- });
45
-
46
- test("miss returns undefined so the caller resolves synchronously", () => {
47
- const cache = createCache<unknown>("graph-cache-test:miss", {
48
- maxSize: 4,
49
- ttlMs: 1000,
50
- staleTtlMs: 1000,
51
- });
52
- let refreshCalls = 0;
53
- expect(
54
- readCachedGraph(cache, "absent", async () => ++refreshCalls),
55
- ).toBeUndefined();
56
- expect(refreshCalls).toBe(0);
57
- });
58
-
59
- test("stale refresh is single-flight (a second stale read while in-flight does not re-fire)", async () => {
60
- const cache = createCache<unknown>("graph-cache-test:dedup", {
61
- maxSize: 4,
62
- ttlMs: 20,
63
- staleTtlMs: 5000,
64
- });
65
- let refreshCalls = 0;
66
- let resolveRefresh: (value: number) => void = () => {};
67
- const slowRefresh = (): Promise<number> =>
68
- new Promise<number>((resolve) => {
69
- refreshCalls++;
70
- resolveRefresh = resolve;
71
- });
72
- cache.set("k", "v");
73
- await delay(40); // stale
74
- readCachedGraph(cache, "k", slowRefresh); // fires refresh → in-flight
75
- readCachedGraph(cache, "k", slowRefresh); // deduped against the same in-flight map
76
- await delay(5);
77
- expect(refreshCalls).toBe(1); // only ONE refresh in flight, not two
78
- resolveRefresh(refreshCalls);
79
- await delay(5);
80
- });
81
- });
@@ -1,71 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- */
5
- /**
6
- * Stale-while-revalidate read of a cached loader graph.
7
- *
8
- * - Fresh hit (within ttlMs): return the cached graph, no refresh.
9
- * - Stale hit (within ttlMs + staleTtlMs): return the cached graph AND fire ONE
10
- * background refresh via cache.refreshInBackground — single-flight (deduped
11
- * against the cache's in-flight map), never rejects, leaves the stale entry in
12
- * place on failure. The caller deep-clones the returned value before use.
13
- * - Miss (past staleTtlMs, or never cached): return undefined — the caller
14
- * resolves synchronously and cache.set()s the clone.
15
- *
16
- * The refresh callback must return a graph safe to store. To SKIP caching a
17
- * refresh result (e.g. a primary-missing / dead-link graph that must stay
18
- * per-request so a just-created record appears immediately), the callback THROWS
19
- * — refreshInBackground catches it, leaves the stale entry, and logs one line
20
- * (an observable dead-link signal, not a hard error).
21
- */
22
-
23
- import { createCache } from "@teamscala/cache/create-cache";
24
-
25
- // The structural surface readCachedGraph touches (getWithMeta + refreshInBackground).
26
- // ManagedCache<unknown> satisfies it; declared locally so the SWR decision does
27
- // not depend on the cache package's internal type export path.
28
- type GraphCache = {
29
- getWithMeta(key: string): { value: unknown; stale: boolean } | undefined;
30
- refreshInBackground(
31
- key: string,
32
- compute: () => Promise<unknown>,
33
- opts?: { ttlMs?: number },
34
- ): void;
35
- };
36
-
37
- // Static-shell routes (no resource_type, no :params, no wildcard — the fleet
38
- // dashboard's "/", any fixed page) resolve the SAME loader graph every request:
39
- // pure page/website structure that changes only on editor writes, yet every SSR
40
- // re-paid the full graph over the tunnel DB (load=1.8-2.2s measured 2026-06-06 —
41
- // the whole remaining load tier). 60s fresh (ttlMs) + 540s stale (staleTtlMs) =
42
- // 600s total; past the fresh window a hit still serves instantly and fires ONE
43
- // background re-resolve. Without SWR every cache-expiry request paid the full
44
- // resolve in-request — measured 3.8s on a cache-expired hit.
45
- export const staticShellGraphCache = createCache<unknown>(
46
- "registry-route:static-shell-graph",
47
- { maxSize: 64, ttlMs: 60_000, staleTtlMs: 540_000 },
48
- );
49
-
50
- // Parameterised routes (:param / wildcard — record pages, the portal catchall)
51
- // were permanently uncacheable: every hit re-paid the full graph resolve (~2.3s
52
- // from the dev host). The graphKey encodes routeRow.slug + host + path and the
53
- // graph is purely path-determined (searchParams, theme, agreement gating all
54
- // apply AFTER the clone), so a short fresh window is safe: a record edit is
55
- // visible within 15s on a trafficked page (background refresh). 15s fresh
56
- // (ttlMs) + 285s stale (staleTtlMs) = 300s total.
57
- export const paramRouteGraphCache = createCache<unknown>(
58
- "registry-route:param-route-graph",
59
- { maxSize: 256, ttlMs: 15_000, staleTtlMs: 285_000 },
60
- );
61
-
62
- export function readCachedGraph(
63
- cache: GraphCache,
64
- key: string,
65
- refresh: () => Promise<unknown>,
66
- ): unknown | undefined {
67
- const meta = cache.getWithMeta(key);
68
- if (meta === undefined) return undefined;
69
- if (meta.stale) cache.refreshInBackground(key, refresh);
70
- return meta.value;
71
- }
@@ -1,145 +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: "Results Assistant",
23
- welcome_message: "Hi! Ask me anything about your results.",
24
- conversation_starters: ["What are my strengths?"],
25
- primary_color: "#6366f1",
26
- }),
27
- },
28
- }),
29
- }));
30
- mock.module("@teamscala/logger/app-loggers", () => ({
31
- getAppLogger: () => ({ error: (...args: unknown[]) => void args }),
32
- }));
33
-
34
- const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
35
-
36
- const ORG = "org-1";
37
- const AGENT_OPTS = {
38
- agentConfigSlug: "assessment-assistant-agent" as never,
39
- launchLabel: "Ask about your results",
40
- placeholder: "Ask about your results…",
41
- regionSlots: ["header", "body", "footer"] as readonly string[],
42
- };
43
-
44
- /** A resolved response row as the loader's response source selects it. */
45
- const RESPONSE_ROW = {
46
- id: "resp-1",
47
- assessment_id: "asm-1",
48
- organisation_id: ORG,
49
- total_score: 64,
50
- first_name: "Anna",
51
- };
52
-
53
- /** The paired key as resolveReportHalves returns it — present on EVERY link
54
- * shape, carrying the anchor half's organisation. */
55
- const PAIRED = {
56
- response: RESPONSE_ROW,
57
- research: { id: "research-live", assessment_id: "asm-1", organisation_id: ORG },
58
- assessment_id: "asm-1",
59
- organisation_id: ORG,
60
- };
61
-
62
- function pageGraph(
63
- responseSource: unknown,
64
- pairSource: Record<string, unknown>,
65
- ): Record<string, unknown> {
66
- return {
67
- response: responseSource,
68
- pair: pairSource,
69
- quiz: { id: "asm-1", organisation_id: ORG, title: "Authority Index" },
70
- brand: { primaryColor: "#123456" },
71
- body: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
72
- header: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
73
- footer: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
74
- };
75
- }
76
-
77
- /** The launcher renders `position: fixed`, so it may land in whichever
78
- * region slot is declared first — count across EVERY slot, not one. */
79
- function launcherBlocks(
80
- resolved: Record<string, unknown>,
81
- ): Array<{ type: string; props?: Record<string, unknown> }> {
82
- const out: Array<{ type: string; props?: Record<string, unknown> }> = [];
83
- for (const slot of AGENT_OPTS.regionSlots) {
84
- const content = (
85
- resolved[slot] as {
86
- puck_data?: { content?: Array<{ type: string; props?: Record<string, unknown> }> };
87
- }
88
- ).puck_data?.content;
89
- for (const block of content ?? []) {
90
- if (block.type === REGISTERED_LAUNCHER_SLUG) out.push(block);
91
- }
92
- }
93
- return out;
94
- }
95
-
96
- async function launcherCount(
97
- responseSource: unknown,
98
- pairSource: Record<string, unknown>,
99
- resourceKey: string,
100
- ): Promise<number> {
101
- const resolved = pageGraph(responseSource, pairSource);
102
- await injectResourceChatLauncher(resolved, "", { ...AGENT_OPTS, resourceKey });
103
- return launcherBlocks(resolved).length;
104
- }
105
-
106
- describe("page assistant renders on every link shape", () => {
107
- test("response-id link: raw hit exists, launcher renders", async () => {
108
- expect(await launcherCount(RESPONSE_ROW, PAIRED, "pair")).toBe(1);
109
- });
110
-
111
- test("live research-anchor link: raw hit is null, launcher still renders", async () => {
112
- expect(await launcherCount(null, PAIRED, "pair")).toBe(1);
113
- });
114
-
115
- test("superseded research-anchor link: raw hit is null, launcher still renders", async () => {
116
- // identical graph shape to the live anchor — resolution to the live
117
- // pass happens upstream in the pair resolver, so the injection surface
118
- // is the same and is exercised identically
119
- expect(await launcherCount(null, PAIRED, "pair")).toBe(1);
120
- });
121
-
122
- test("the launcher block carries the agent, org scope and context", async () => {
123
- const resolved = pageGraph(null, PAIRED);
124
- await injectResourceChatLauncher(resolved, "viewer@example.com", {
125
- ...AGENT_OPTS,
126
- resourceKey: "pair",
127
- });
128
- const launchers = launcherBlocks(resolved);
129
- expect(launchers.length).toBe(1);
130
- const launcher = launchers[0];
131
- expect(launcher.props.agentId).toBe("agent-1");
132
- expect(launcher.props.organisationId).toBe(ORG);
133
- expect(typeof launcher.props.runtimeContext).toBe("string");
134
- expect((launcher.props.runtimeContext as string).length).toBeGreaterThan(0);
135
- });
136
-
137
- test("the DEFECT SHAPE: keying the raw response hit on an anchor link silently renders none", async () => {
138
- // resource_key "response" + a null raw hit is the 2026-09-08 regression.
139
- // The injector contract is fail-soft for the render, so the absence is
140
- // quiet — this pins the shape with the reason spelled out, so the row
141
- // can never go back to a key that is null on anchor links without the
142
- // next reader finding this.
143
- expect(await launcherCount(null, PAIRED, "response")).toBe(0);
144
- });
145
- });