@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.
- package/package.json +1 -11
- package/src/agreement-gate/authorized-emails.ts +0 -7
- package/src/agreement-gate/placeholder-strip.ts +0 -2
- package/src/agreement-gate/settings.ts +0 -15
- package/src/agreement-gate.ts +0 -34
- package/src/attach-proposal-accept.ts +1 -1
- package/src/attach-proposal-magic.ts +1 -1
- package/src/auth/sign-out.ts +0 -2
- package/src/auth-gate/public-paths.ts +0 -86
- package/src/auth-gate.ts +0 -19
- package/src/bind-page-handler.ts +0 -76
- package/src/build-client-assets/hydrate-bundle.ts +0 -126
- package/src/build-client-assets/manifest.ts +0 -18
- package/src/build-client-assets/source-candidates.ts +0 -34
- package/src/build-client-assets/sourcemaps-upload.ts +0 -42
- package/src/build-client-assets/styles-build.ts +0 -50
- package/src/capability-slug.ts +0 -10
- package/src/compose-page-title.ts +0 -9
- package/src/content-gate/access.ts +0 -26
- package/src/content-gate/catalog.ts +0 -29
- package/src/content-gate/config-types.ts +3 -84
- package/src/content-gate/gate.ts +0 -20
- package/src/content-page-floor.ts +0 -11
- package/src/error-page-logo.ts +2 -12
- package/src/expand-roster.ts +0 -9
- package/src/gpt-gate.ts +1 -1
- package/src/hono-error-handler.ts +0 -4
- package/src/install-logging.ts +0 -32
- package/src/load-page-manifest.ts +0 -6
- package/src/load-service-install-row.ts +41 -4
- package/src/module-not-found.ts +0 -11
- package/src/mount-orpc-router.ts +1 -88
- package/src/mount-server-packages.ts +0 -29
- package/src/mutable-app-ref.ts +0 -11
- package/src/orpc-contract-rows.json +4102 -61
- package/src/orpc-openapi-document.ts +0 -6
- package/src/orpc-openapi-gate.ts +0 -10
- package/src/pg-boss-handler-registry.ts +0 -12
- package/src/pg-boss-mount.ts +0 -101
- package/src/proposal-gate.ts +1 -41
- package/src/quiz-gate.ts +1 -9
- package/src/register-page-server-client.ts +0 -53
- package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
- package/src/register-registry-page-route/apply-page-branding.ts +0 -90
- package/src/register-registry-page-route/captured-loader-config.ts +0 -1
- package/src/register-registry-page-route/live-loader-config.ts +0 -16
- package/src/register-registry-page-route/meta-title.ts +0 -24
- package/src/register-registry-page-route/page-data-shared.ts +0 -6
- package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
- package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
- package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
- package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
- package/src/register-registry-page-route/resolve-request-graph.ts +2 -155
- package/src/register-registry-page-route/run-content-gates.ts +2 -54
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
- package/src/register-registry-page-route.ts +0 -85
- package/src/register-static-assets.ts +0 -45
- package/src/resolve-brand-favicon.ts +0 -9
- package/src/resolve-service-port.ts +0 -11
- package/src/resolve-theme-mode.ts +0 -8
- package/src/resolvers/app-header.ts +0 -1
- package/src/resolvers/fetch-org-brand.ts +0 -1
- package/src/resolvers/portal-route-paths.ts +0 -17
- package/src/resource-not-found.ts +0 -24
- package/src/select-service-routes.ts +0 -14
- package/src/theme-fonts-link.ts +0 -5
- package/src/two-phase-full-app.ts +0 -76
- package/src/two-phase-minimal-app.ts +1 -105
- package/src/types.ts +0 -14
- package/src/ui-service.ts +1 -42
- package/src/content-gate/gate-entry-marker.test.ts +0 -111
- package/src/content-gate/token.segmentation.test.ts +0 -76
- package/src/content-gate/token.test.ts +0 -65
- package/src/content-gate/token.ts +0 -197
- package/src/is-react-element-type.test.ts +0 -45
- package/src/is-react-element-type.ts +0 -10
- package/src/page-server-orpc/hub-mcp-client.ts +0 -14
- package/src/pg-boss-mount.test.ts +0 -101
- package/src/register-registry-page-route/graph-cache.test.ts +0 -81
- package/src/register-registry-page-route/graph-cache.ts +0 -71
- package/src/resource-chat-inject.page-assistant.test.ts +0 -145
- package/src/resource-chat-inject.test.ts +0 -240
- 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
|
-
});
|