@teamscala/service-boot 0.23.507 → 0.23.508
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 -1
- package/src/content-gate/access.ts +18 -0
- package/src/content-gate/config-types.ts +20 -5
- package/src/content-gate/token.segmentation.test.ts +76 -0
- package/src/content-gate/token.ts +54 -6
- package/src/handlers/platform-fault-escalation/delivery.ts +9 -1
- package/src/pg-boss-mount.test.ts +100 -0
- package/src/pg-boss-mount.ts +16 -1
- package/src/quiz-gate.ts +16 -5
package/package.json
CHANGED
|
@@ -38,6 +38,17 @@ export async function attachContentGateAccess(
|
|
|
38
38
|
let email = "";
|
|
39
39
|
let name = "";
|
|
40
40
|
let phone = "";
|
|
41
|
+
// Optional prospect segmentation a capture gate's composed form may post
|
|
42
|
+
// (the quiz identity screen: company, LinkedIn, website, team size). Every
|
|
43
|
+
// field is optional and every reader treats absence as "" — a gate that
|
|
44
|
+
// never collects them (the GPT) is unaffected, and a form that collects
|
|
45
|
+
// only some carries the rest as absent. Field names follow the response
|
|
46
|
+
// columns the segmentation lands in (linkedin_url / team_size, snake case)
|
|
47
|
+
// because the composed form posts exactly what its FormField rows name.
|
|
48
|
+
let company = "";
|
|
49
|
+
let linkedinUrl = "";
|
|
50
|
+
let website = "";
|
|
51
|
+
let teamSize = "";
|
|
41
52
|
let bodyFailed = false;
|
|
42
53
|
// The resource id may ride on the URL instead of the body. A gate whose
|
|
43
54
|
// form is COMPOSED FROM BLOCKS has no place to put a hidden field — the
|
|
@@ -58,6 +69,10 @@ export async function attachContentGateAccess(
|
|
|
58
69
|
// Captured alongside the email by an open-capture gate's composed
|
|
59
70
|
// form. It rides in the signed token rather than any client store.
|
|
60
71
|
phone = String(form.phone ?? "").trim();
|
|
72
|
+
company = String(form.company ?? "").trim();
|
|
73
|
+
linkedinUrl = String(form.linkedin_url ?? "").trim();
|
|
74
|
+
website = String(form.website ?? "").trim();
|
|
75
|
+
teamSize = String(form.team_size ?? "").trim();
|
|
61
76
|
} catch (error) {
|
|
62
77
|
log.warn(`[content-gate:${config.cookieName}] body parse failed — redirecting back: ${String(error)}`);
|
|
63
78
|
bodyFailed = true;
|
|
@@ -94,6 +109,9 @@ export async function attachContentGateAccess(
|
|
|
94
109
|
Date.now(),
|
|
95
110
|
name || undefined,
|
|
96
111
|
phone || undefined,
|
|
112
|
+
company || linkedinUrl || website || teamSize
|
|
113
|
+
? { company, linkedinUrl, website, teamSize }
|
|
114
|
+
: undefined,
|
|
97
115
|
);
|
|
98
116
|
if (token) {
|
|
99
117
|
const maxAge = Math.floor(config.ttlMs / 1000);
|
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
|
|
12
12
|
type Resolved = Record<string, unknown>;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Optional prospect-segmentation a lead-capture gate may capture alongside the
|
|
16
|
+
* identity. The quiz gate collects these on its composed identity screen; the
|
|
17
|
+
* token carries them so the next render and the final submit know the visitor
|
|
18
|
+
* fully, with NO client storage (same reasoning as the captured name/phone).
|
|
19
|
+
*/
|
|
20
|
+
export interface GateSegmentation {
|
|
21
|
+
company?: string;
|
|
22
|
+
linkedinUrl?: string;
|
|
23
|
+
website?: string;
|
|
24
|
+
teamSize?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
/** Drives the SSR-time gate (gate.ts / runContentGate). */
|
|
15
28
|
export interface ContentGateConfig {
|
|
16
29
|
/** Cookie that carries this gate's access token (e.g. "ag_token"). */
|
|
@@ -35,7 +48,7 @@ export interface ContentGateConfig {
|
|
|
35
48
|
token: string | undefined,
|
|
36
49
|
resourceId: string,
|
|
37
50
|
nowMs: number,
|
|
38
|
-
): { email: string; name?: string; phone?: string } | null;
|
|
51
|
+
): { email: string; name?: string; phone?: string; segmentation?: GateSegmentation } | null;
|
|
39
52
|
/**
|
|
40
53
|
* BAKED-IN unified auth. When a gate provides this resolver, the engine
|
|
41
54
|
* AUTOMATICALLY releases a logged-in viewer whose session email is in the
|
|
@@ -64,7 +77,7 @@ export interface ContentGateConfig {
|
|
|
64
77
|
onAuthorized(
|
|
65
78
|
resolved: Resolved,
|
|
66
79
|
resourceId: string,
|
|
67
|
-
identity: { email: string; name?: string; phone?: string },
|
|
80
|
+
identity: { email: string; name?: string; phone?: string; segmentation?: GateSegmentation },
|
|
68
81
|
): void | Promise<void>;
|
|
69
82
|
/** Withhold content (strip/inject the gate) — token absent/invalid. Async-
|
|
70
83
|
* capable so a gate can load its display/behaviour settings from a registry
|
|
@@ -111,9 +124,10 @@ export interface AccessConfig {
|
|
|
111
124
|
resolveAuthorizedEmails(resourceId: string): Promise<Set<string>>;
|
|
112
125
|
/**
|
|
113
126
|
* Mint the access token for a matched email (token-agnostic per gate).
|
|
114
|
-
* `name` and `phone` are the optional captured identity
|
|
115
|
-
*
|
|
116
|
-
*
|
|
127
|
+
* `name` and `phone` are the optional captured identity, `segmentation` the
|
|
128
|
+
* optional captured prospect fields (open-capture gates carry them in the
|
|
129
|
+
* token; allow-list gates ignore them). They exist so a capture gate needs
|
|
130
|
+
* no browser storage to remember who this is.
|
|
117
131
|
*/
|
|
118
132
|
signToken(
|
|
119
133
|
resourceId: string,
|
|
@@ -121,5 +135,6 @@ export interface AccessConfig {
|
|
|
121
135
|
nowMs: number,
|
|
122
136
|
name?: string,
|
|
123
137
|
phone?: string,
|
|
138
|
+
segmentation?: GateSegmentation,
|
|
124
139
|
): string | null;
|
|
125
140
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Segmentation carriage in the content-gate token. The quiz's composed gate
|
|
7
|
+
* screen captures company/linkedin_url/website/team_size alongside the
|
|
8
|
+
* identity; the token carries them to the next render so the submit can put
|
|
9
|
+
* all seven fields on the ONE response row with no client storage. The
|
|
10
|
+
* generated `token.test.ts` owns the identity/binding suite; these are the
|
|
11
|
+
* segmentation-specific cases (including the backward-compatibility contract:
|
|
12
|
+
* a token minted before `s` existed verifies identically).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
process.env.AUTH_SECRET = "local-test-fixture-entropy-0001";
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import { signAccessToken, verifyAccessTokenIdentity } from "./token.ts";
|
|
19
|
+
|
|
20
|
+
const NOW = 1_000_000_000_000;
|
|
21
|
+
const TTL = 60_000;
|
|
22
|
+
|
|
23
|
+
function decodePayload(token: string): Record<string, unknown> {
|
|
24
|
+
return JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("content-gate token — segmentation carriage", () => {
|
|
28
|
+
test("round-trips the four captured fields", () => {
|
|
29
|
+
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614", {
|
|
30
|
+
company: "Acme",
|
|
31
|
+
linkedinUrl: "https://www.linkedin.com/in/ann",
|
|
32
|
+
website: "https://acme.test",
|
|
33
|
+
teamSize: "Me + team of 2–5",
|
|
34
|
+
});
|
|
35
|
+
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
36
|
+
expect(id?.email).toBe("a@b.com");
|
|
37
|
+
expect(id?.name).toBe("Ann");
|
|
38
|
+
expect(id?.phone).toBe("+614");
|
|
39
|
+
expect(id?.segmentation).toEqual({
|
|
40
|
+
company: "Acme",
|
|
41
|
+
linkedinUrl: "https://www.linkedin.com/in/ann",
|
|
42
|
+
website: "https://acme.test",
|
|
43
|
+
teamSize: "Me + team of 2–5",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("BACKWARD COMPATIBILITY: a pre-widening token (no s key) verifies with segmentation undefined — never an error", () => {
|
|
48
|
+
// Mint with the old shape by omitting segmentation entirely, then assert
|
|
49
|
+
// the exact contract a deploy-window survivor must satisfy.
|
|
50
|
+
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614");
|
|
51
|
+
expect(decodePayload(t as string).s).toBeUndefined();
|
|
52
|
+
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
53
|
+
expect(id).toEqual({ email: "a@b.com", name: "Ann", phone: "+614", segmentation: undefined });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("empty/whitespace segmentation values produce no s key at all", () => {
|
|
57
|
+
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
|
|
58
|
+
company: " ",
|
|
59
|
+
linkedinUrl: "",
|
|
60
|
+
website: undefined,
|
|
61
|
+
teamSize: undefined,
|
|
62
|
+
});
|
|
63
|
+
expect(decodePayload(t as string).s).toBeUndefined();
|
|
64
|
+
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
65
|
+
expect(id?.segmentation).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("values are trimmed on sign", () => {
|
|
69
|
+
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
|
|
70
|
+
company: " Acme ",
|
|
71
|
+
teamSize: "Just me",
|
|
72
|
+
});
|
|
73
|
+
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
74
|
+
expect(id?.segmentation).toEqual({ company: "Acme", teamSize: "Just me" });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* Fail-closed: no usable secret yields null (no token, no access).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import type { GateSegmentation } from "./config-types.ts";
|
|
28
|
+
|
|
27
29
|
function secret(): string | null {
|
|
28
30
|
for (const s of [
|
|
29
31
|
process.env.AUTH_SECRET,
|
|
@@ -68,10 +70,26 @@ export function signAccessToken(
|
|
|
68
70
|
* localStorage.
|
|
69
71
|
*/
|
|
70
72
|
phone?: string,
|
|
73
|
+
/**
|
|
74
|
+
* Optional prospect segmentation captured alongside the identity on the
|
|
75
|
+
* quiz's composed gate screen (company / LinkedIn / website / team size).
|
|
76
|
+
* Same reasoning as name/phone: it rides the signed request, never client
|
|
77
|
+
* storage. OPTIONAL and absent from older tokens — a token without it
|
|
78
|
+
* verifies exactly as before.
|
|
79
|
+
*/
|
|
80
|
+
segmentation?: GateSegmentation,
|
|
71
81
|
): string | null {
|
|
72
82
|
const key = secret();
|
|
73
83
|
if (!key) return null;
|
|
74
|
-
const payload: {
|
|
84
|
+
const payload: {
|
|
85
|
+
c: string;
|
|
86
|
+
r: string;
|
|
87
|
+
e: string;
|
|
88
|
+
x: number;
|
|
89
|
+
n?: string;
|
|
90
|
+
p?: string;
|
|
91
|
+
s?: GateSegmentation;
|
|
92
|
+
} = {
|
|
75
93
|
c: cookieName,
|
|
76
94
|
r: resourceId,
|
|
77
95
|
e: email.trim().toLowerCase(),
|
|
@@ -81,6 +99,16 @@ export function signAccessToken(
|
|
|
81
99
|
if (trimmedName) payload.n = trimmedName;
|
|
82
100
|
const trimmedPhone = phone?.trim();
|
|
83
101
|
if (trimmedPhone) payload.p = trimmedPhone;
|
|
102
|
+
const seg: GateSegmentation = {};
|
|
103
|
+
const segCompany = segmentation?.company?.trim();
|
|
104
|
+
if (segCompany) seg.company = segCompany;
|
|
105
|
+
const segLinkedin = segmentation?.linkedinUrl?.trim();
|
|
106
|
+
if (segLinkedin) seg.linkedinUrl = segLinkedin;
|
|
107
|
+
const segWebsite = segmentation?.website?.trim();
|
|
108
|
+
if (segWebsite) seg.website = segWebsite;
|
|
109
|
+
const segTeamSize = segmentation?.teamSize?.trim();
|
|
110
|
+
if (segTeamSize) seg.teamSize = segTeamSize;
|
|
111
|
+
if (Object.keys(seg).length > 0) payload.s = seg;
|
|
84
112
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
85
113
|
return `${payloadB64}.${hmac(payloadB64, key)}`;
|
|
86
114
|
}
|
|
@@ -121,17 +149,25 @@ export function verifyAccessToken(
|
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
/**
|
|
124
|
-
* Like `verifyAccessToken` but returns the full verified identity
|
|
125
|
-
* so a gate that carries a display name in
|
|
126
|
-
* gate) can seed the visitor's name — not
|
|
127
|
-
*
|
|
152
|
+
* Like `verifyAccessToken` but returns the full verified identity
|
|
153
|
+
* ({email, name, phone, segmentation}) so a gate that carries a display name in
|
|
154
|
+
* the token (the public GPT lead-capture gate) can seed the visitor's name — not
|
|
155
|
+
* just the email — at SSR, and the quiz gate can seed the captured prospect
|
|
156
|
+
* segmentation the same way. Same signature/expiry/gate-binding checks; null on
|
|
157
|
+
* any failure. A token minted BEFORE segmentation existed carries no `s` key and
|
|
158
|
+
* verifies identically (segmentation comes back undefined) — never an error.
|
|
128
159
|
*/
|
|
129
160
|
export function verifyAccessTokenIdentity(
|
|
130
161
|
token: string | undefined | null,
|
|
131
162
|
cookieName: string,
|
|
132
163
|
resourceId: string,
|
|
133
164
|
nowMs: number,
|
|
134
|
-
): {
|
|
165
|
+
): {
|
|
166
|
+
email: string;
|
|
167
|
+
name?: string;
|
|
168
|
+
phone?: string;
|
|
169
|
+
segmentation?: GateSegmentation;
|
|
170
|
+
} | null {
|
|
135
171
|
if (!token) return null;
|
|
136
172
|
const key = secret();
|
|
137
173
|
if (!key) return null;
|
|
@@ -148,15 +184,27 @@ export function verifyAccessTokenIdentity(
|
|
|
148
184
|
x?: number;
|
|
149
185
|
n?: string;
|
|
150
186
|
p?: string;
|
|
187
|
+
s?: GateSegmentation;
|
|
151
188
|
};
|
|
152
189
|
if (p.c !== cookieName) return null;
|
|
153
190
|
if (p.r !== resourceId) return null;
|
|
154
191
|
if (typeof p.x !== "number" || p.x < nowMs) return null;
|
|
155
192
|
if (typeof p.e !== "string") return null;
|
|
193
|
+
const segmentation =
|
|
194
|
+
p.s && typeof p.s === "object"
|
|
195
|
+
? {
|
|
196
|
+
company: typeof p.s.company === "string" ? p.s.company : undefined,
|
|
197
|
+
linkedinUrl:
|
|
198
|
+
typeof p.s.linkedinUrl === "string" ? p.s.linkedinUrl : undefined,
|
|
199
|
+
website: typeof p.s.website === "string" ? p.s.website : undefined,
|
|
200
|
+
teamSize: typeof p.s.teamSize === "string" ? p.s.teamSize : undefined,
|
|
201
|
+
}
|
|
202
|
+
: undefined;
|
|
156
203
|
return {
|
|
157
204
|
email: p.e,
|
|
158
205
|
name: typeof p.n === "string" ? p.n : undefined,
|
|
159
206
|
phone: typeof p.p === "string" ? p.p : undefined,
|
|
207
|
+
segmentation,
|
|
160
208
|
};
|
|
161
209
|
} catch {
|
|
162
210
|
return null;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ExecutionContext } from "@teamscala/os/contracts/mcp";
|
|
16
|
+
import { idsWhereJsonKey } from "@teamscala/db/json-key-filter";
|
|
16
17
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
17
18
|
import {
|
|
18
19
|
composeEscalationMessage,
|
|
@@ -36,11 +37,18 @@ interface OperatorRelayBot {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async function resolveOperatorRelay(): Promise<OperatorRelayBot> {
|
|
40
|
+
// flow_config.mode lives inside json; the v8 builder cannot address it.
|
|
41
|
+
const relayBotIds = await idsWhereJsonKey({
|
|
42
|
+
table: "messaging_bots",
|
|
43
|
+
column: "flow_config",
|
|
44
|
+
key: "mode",
|
|
45
|
+
equals: "operator-relay",
|
|
46
|
+
});
|
|
39
47
|
const bot = (await (await orpc()).messaging_bots.findFirst({
|
|
40
48
|
where: {
|
|
41
49
|
is_active: true,
|
|
42
50
|
platform: "whatsapp",
|
|
43
|
-
|
|
51
|
+
id: { in: relayBotIds },
|
|
44
52
|
},
|
|
45
53
|
select: { id: true, organisation_id: true, external_id: true, flow_config: true },
|
|
46
54
|
})) as unknown as OperatorRelayBot | null;
|
|
@@ -0,0 +1,100 @@
|
|
|
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 connectionString = captured?.connectionString ?? "";
|
|
86
|
+
// The transaction pooler, exactly as injected — the mount adds no
|
|
87
|
+
// endpoint derivation.
|
|
88
|
+
expect(connectionString).toContain(":6543");
|
|
89
|
+
// NEVER the session port: the door is closing, not widening.
|
|
90
|
+
expect(connectionString).not.toContain(":5999");
|
|
91
|
+
// The tenant suffix the pooler routes by is kept.
|
|
92
|
+
expect(connectionString).toContain("scala_container_gbg.gbg:");
|
|
93
|
+
// The pg sslmode dialect rewrite still applies.
|
|
94
|
+
expect(connectionString).toContain("sslmode=no-verify");
|
|
95
|
+
// Poll-only, spelled: no LISTEN dependency, now and after a bump.
|
|
96
|
+
expect(captured?.useListenNotify).toBe(false);
|
|
97
|
+
// pg-boss stays a single sibling connection (pool sizing doctrine).
|
|
98
|
+
expect(captured?.max).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -135,7 +135,22 @@ export async function mountPgBoss(
|
|
|
135
135
|
// shared URL's libpq spelling — route through the canonical rewrite.
|
|
136
136
|
// Config object (not a bare connectionString) so the internal pool is
|
|
137
137
|
// bounded + tagged for the ~220ms cross-internet dev→Railway RTT.
|
|
138
|
+
// POLL-ONLY, EXPLICITLY. pg-boss v12 dispatches by polling (SELECT .. FOR
|
|
139
|
+
// UPDATE SKIP LOCKED inside a transaction; its locks are
|
|
140
|
+
// pg_advisory_xact_lock — transaction-scoped, so a transaction pooler
|
|
141
|
+
// cannot leak them); LISTEN/NOTIFY is a supplier-documented latency hint
|
|
142
|
+
// whose registration dies on a transaction-pooler backend reassignment
|
|
143
|
+
// (measured 2026-09-14: the opted-in sync worker boss logged
|
|
144
|
+
// `LISTEN/NOTIFY channel registration was lost` every log window).
|
|
145
|
+
// attorney.js defaults useListenNotify to false in 12.29.0 (pinned),
|
|
146
|
+
// 12.30.0 (deployed) and 12.31.1 (latest) — all three read directly —
|
|
147
|
+
// and spelling it here keeps that contract explicit so a pg-boss bump
|
|
148
|
+
// cannot silently re-introduce a LISTEN dependency
|
|
149
|
+
// (close-the-session-mode-door, 2026-09-14). The connection dials the
|
|
150
|
+
// injected DATABASE_URL — the transaction pooler — reconciling the pg
|
|
151
|
+
// sslmode dialect; the mount adds no endpoint derivation of its own.
|
|
138
152
|
const boss = new PgBoss({
|
|
153
|
+
useListenNotify: false,
|
|
139
154
|
connectionString: pgSslCompatUrl(ctx.databaseUrl),
|
|
140
155
|
application_name: `pgboss:${ctx.serviceSlug}`,
|
|
141
156
|
// ONE connection, not three: pg-boss is a sibling pool that sits OUTSIDE
|
|
@@ -145,7 +160,7 @@ export async function mountPgBoss(
|
|
|
145
160
|
// background degradation (`a-per-consumer-pool-max-is-not-a-bound-on-the-server`:
|
|
146
161
|
// background work degrades to a queue, interactive work degrades to an
|
|
147
162
|
// outage). The sibling total is declared in
|
|
148
|
-
// config/db-pool-sizing.perServiceOverhead (pg-boss 1
|
|
163
|
+
// config/db-pool-sizing.perServiceOverhead (pg-boss 1; the LISTEN allowance is gone with poll-only).
|
|
149
164
|
max: 1,
|
|
150
165
|
// Generous connection-acquisition budget for the cross-internet link so
|
|
151
166
|
// a slow TLS handshake under sustained load doesn't terminate the conn.
|
package/src/quiz-gate.ts
CHANGED
|
@@ -56,12 +56,23 @@ export const quizGateConfig: ContentGateConfig = {
|
|
|
56
56
|
verifyToken(token, resourceId, nowMs) {
|
|
57
57
|
return verifyAccessTokenIdentity(token, QUIZ_COOKIE, resourceId, nowMs);
|
|
58
58
|
},
|
|
59
|
-
onAuthorized(resolved, _resourceId, { email, name, phone }) {
|
|
59
|
+
onAuthorized(resolved, _resourceId, { email, name, phone, segmentation }) {
|
|
60
60
|
// The one fact the page's block conditions read. Present → the identity
|
|
61
61
|
// container hides and the quiz container shows; absent → the reverse.
|
|
62
|
+
// The segmentation (captured on the same composed screen) rides along so
|
|
63
|
+
// the quiz flow starts already knowing the prospect — its submit then
|
|
64
|
+
// persists all seven identity fields on the ONE response row.
|
|
62
65
|
(resolved as {
|
|
63
|
-
initialVisitor?: {
|
|
64
|
-
|
|
66
|
+
initialVisitor?: {
|
|
67
|
+
name?: string;
|
|
68
|
+
email: string;
|
|
69
|
+
phone?: string;
|
|
70
|
+
company?: string;
|
|
71
|
+
linkedinUrl?: string;
|
|
72
|
+
website?: string;
|
|
73
|
+
teamSize?: string;
|
|
74
|
+
};
|
|
75
|
+
}).initialVisitor = { name, email, phone, ...segmentation };
|
|
65
76
|
},
|
|
66
77
|
onUnauthorized(resolved, resourceId) {
|
|
67
78
|
// The endpoint the identity form posts to, PUBLISHED INTO THE PAGE DATA
|
|
@@ -91,7 +102,7 @@ export const quizAccessConfig: AccessConfig = {
|
|
|
91
102
|
async resolveAuthorizedEmails() {
|
|
92
103
|
return new Set<string>();
|
|
93
104
|
},
|
|
94
|
-
signToken(resourceId, email, nowMs, name, phone) {
|
|
95
|
-
return signAccessToken(QUIZ_COOKIE, resourceId, email, nowMs, quizTtlMs(), name, phone);
|
|
105
|
+
signToken(resourceId, email, nowMs, name, phone, segmentation) {
|
|
106
|
+
return signAccessToken(QUIZ_COOKIE, resourceId, email, nowMs, quizTtlMs(), name, phone, segmentation);
|
|
96
107
|
},
|
|
97
108
|
};
|