@teamscala/service-boot 0.23.506 → 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 CHANGED
@@ -1131,5 +1131,5 @@
1131
1131
  "repository": "https://github.com/teamgbg/service-boot",
1132
1132
  "sideEffects": false,
1133
1133
  "type": "module",
1134
- "version": "0.23.506"
1134
+ "version": "0.23.508"
1135
1135
  }
@@ -22,10 +22,7 @@
22
22
  // wave-parallel loader, brotli) is the sanctioned path; re-adding shared-cache headers needs a fresh operator ruling.
23
23
 
24
24
  import { getAppLogger } from "@teamscala/logger/app-loggers";
25
- import {
26
- withGateEntryClears,
27
- takeGateEntryClears,
28
- } from "./content-gate/gate-entry-clears.ts";
25
+ import { withGateEntryClears } from "./content-gate/gate-entry-clears.ts";
29
26
  import type { Context } from "hono";
30
27
  import { readHtmlTemplate } from "./read-html-template.ts";
31
28
  import {
@@ -127,7 +124,7 @@ export async function bindPageHandler(
127
124
 
128
125
  const nonce = c.get("nonce") as string | undefined;
129
126
 
130
- const resp = await withGateEntryClears(async () =>
127
+ const { result: resp, clears: gateEntryClears } = await withGateEntryClears(async () =>
131
128
  handler(c.req.raw, {
132
129
  userId,
133
130
  orgId,
@@ -142,7 +139,7 @@ export async function bindPageHandler(
142
139
  // clears it here, so the next load (a refresh) is unauthorized and shows
143
140
  // the gate again. Without this the marker would outlive the view that
144
141
  // used it and every load would skip the gate.
145
- for (const name of takeGateEntryClears()) {
142
+ for (const name of gateEntryClears) {
146
143
  headers.append(
147
144
  "Set-Cookie",
148
145
  `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
@@ -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 (open-capture gates
115
- * carry them in the token; allow-list gates ignore them). They exist so a
116
- * capture gate needs no browser storage to remember who this is.
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
  }
@@ -10,6 +10,12 @@
10
10
  * gate. The gate writes the name here; bindPageHandler — which owns the page
11
11
  * Response — drains it into Set-Cookie, so the marker lives exactly one view.
12
12
  *
13
+ * THE CLEARS COME BACK AS THE CALL'S RETURN VALUE, never from a post-hoc
14
+ * read: the store only exists inside the run() scope, so a reader called after
15
+ * the awaited call has already left it and would see an empty list — which is
16
+ * exactly how the first version of this file shipped a marker that was never
17
+ * cleared (measured live 2026-09-14: the cookie survived the render).
18
+ *
13
19
  * AsyncLocalStorage rather than a module-level map: request-scoped by
14
20
  * construction, no accumulated process state that a restart would silently
15
21
  * reset (in-memory-state-is-not-durable).
@@ -18,8 +24,12 @@ import { AsyncLocalStorage } from "node:async_hooks";
18
24
 
19
25
  const clears = new AsyncLocalStorage<string[]>();
20
26
 
21
- export async function withGateEntryClears<T>(fn: () => Promise<T>): Promise<T> {
22
- return clears.run([], fn);
27
+ export async function withGateEntryClears<T>(
28
+ fn: () => Promise<T>,
29
+ ): Promise<{ result: T; clears: string[] }> {
30
+ const list: string[] = [];
31
+ const result = await clears.run(list, fn);
32
+ return { result, clears: [...list] };
23
33
  }
24
34
 
25
35
  export function recordGateEntryClear(cookieName: string): void {
@@ -29,10 +39,3 @@ export function recordGateEntryClear(cookieName: string): void {
29
39
  // throwing would make an otherwise valid direct invocation a failure.
30
40
  if (list && !list.includes(cookieName)) list.push(cookieName);
31
41
  }
32
-
33
- export function takeGateEntryClears(): string[] {
34
- const list = clears.getStore() ?? [];
35
- const out = [...list];
36
- list.length = 0;
37
- return out;
38
- }
@@ -7,8 +7,12 @@
7
7
  * entryCookieName must behave exactly as before — a valid token authorizes on
8
8
  * any load and nothing is recorded for clearing (condition 1: every non-opting
9
9
  * service unchanged). An opting gate authorizes ONLY the request that carries
10
- * the marker (the identity submit's own redirect) and records the marker so the
11
- * page Response clears it — one view, then the gate is presented again.
10
+ * the marker (the identity submit's own redirect) and returns the marker name
11
+ * so the page Response clears it — one view, then the gate is presented again.
12
+ *
13
+ * The clears are asserted from the CALL'S RETURN VALUE: the store lives only
14
+ * inside the run() scope, and the first shipped version read it afterwards,
15
+ * saw nothing, and left the marker in the browser (measured live 2026-09-14).
12
16
  *
13
17
  * Operator ruling 2026-09-14 (refresh must return to the identity gate),
14
18
  * mechanism measured live: the quiz_token cookie alone was the carrier that
@@ -17,10 +21,7 @@
17
21
  import { describe, expect, test } from "bun:test";
18
22
  import type { ContentGateConfig } from "./config-types.ts";
19
23
  import { runContentGate } from "./gate.ts";
20
- import {
21
- takeGateEntryClears,
22
- withGateEntryClears,
23
- } from "./gate-entry-clears.ts";
24
+ import { withGateEntryClears } from "./gate-entry-clears.ts";
24
25
 
25
26
  function makeConfig(entryCookieName?: string) {
26
27
  const calls = { authorized: 0, unauthorized: 0 };
@@ -41,30 +42,30 @@ function makeConfig(entryCookieName?: string) {
41
42
  }
42
43
 
43
44
  describe("gate entry marker is opt-in and one-view", () => {
44
- test("non-opting gate: a valid token authorizes on any load, nothing recorded — behaviour unchanged", async () => {
45
+ test("non-opting gate: a valid token authorizes on any load, nothing cleared — behaviour unchanged", async () => {
45
46
  const { cfg, calls } = makeConfig();
46
- await withGateEntryClears(async () => {
47
+ const { clears } = await withGateEntryClears(async () => {
47
48
  const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
48
49
  expect(out.authorized).toBe(true);
49
50
  expect(calls.authorized).toBe(1);
50
- expect(takeGateEntryClears()).toEqual([]);
51
51
  });
52
+ expect(clears).toEqual([]);
52
53
  });
53
54
 
54
55
  test("opting gate: token WITHOUT the marker is unauthorized — a refresh shows the gate", async () => {
55
56
  const { cfg, calls } = makeConfig("quiz_token_entered");
56
- await withGateEntryClears(async () => {
57
+ const { clears } = await withGateEntryClears(async () => {
57
58
  const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
58
59
  expect(out.authorized).toBe(false);
59
60
  expect(calls.unauthorized).toBe(1);
60
61
  expect(calls.authorized).toBe(0);
61
- expect(takeGateEntryClears()).toEqual([]);
62
62
  });
63
+ expect(clears).toEqual([]);
63
64
  });
64
65
 
65
- test("opting gate: token + marker authorizes and records the marker for clearing, exactly once", async () => {
66
+ test("opting gate: token + marker authorizes and RETURNS the marker for clearing — one view", async () => {
66
67
  const { cfg, calls } = makeConfig("quiz_token_entered");
67
- await withGateEntryClears(async () => {
68
+ const { clears } = await withGateEntryClears(async () => {
68
69
  const out = await runContentGate(
69
70
  {},
70
71
  "quiz_token=good; quiz_token_entered=1",
@@ -73,15 +74,13 @@ describe("gate entry marker is opt-in and one-view", () => {
73
74
  );
74
75
  expect(out.authorized).toBe(true);
75
76
  expect(calls.authorized).toBe(1);
76
- expect(takeGateEntryClears()).toEqual(["quiz_token_entered"]);
77
- // drained: the next load must not re-clear
78
- expect(takeGateEntryClears()).toEqual([]);
79
77
  });
78
+ expect(clears).toEqual(["quiz_token_entered"]);
80
79
  });
81
80
 
82
- test("opting gate: marker present but token invalid stays unauthorized and records nothing", async () => {
81
+ test("opting gate: marker present but token invalid stays unauthorized and clears nothing", async () => {
83
82
  const { cfg, calls } = makeConfig("quiz_token_entered");
84
- await withGateEntryClears(async () => {
83
+ const { clears } = await withGateEntryClears(async () => {
85
84
  const out = await runContentGate(
86
85
  {},
87
86
  "quiz_token=bad; quiz_token_entered=1",
@@ -90,8 +89,18 @@ describe("gate entry marker is opt-in and one-view", () => {
90
89
  );
91
90
  expect(out.authorized).toBe(false);
92
91
  expect(calls.unauthorized).toBe(1);
93
- expect(takeGateEntryClears()).toEqual([]);
94
92
  });
93
+ expect(clears).toEqual([]);
94
+ });
95
+
96
+ test("two gates in one request each report their own marker, drained once", async () => {
97
+ const first = makeConfig("gate_a_entered");
98
+ const second = makeConfig("gate_b_entered");
99
+ const { clears } = await withGateEntryClears(async () => {
100
+ await runContentGate({}, "quiz_token=good; gate_a_entered=1", Date.now(), first.cfg);
101
+ await runContentGate({}, "quiz_token=good; gate_b_entered=1", Date.now(), second.cfg);
102
+ });
103
+ expect(clears).toEqual(["gate_a_entered", "gate_b_entered"]);
95
104
  });
96
105
 
97
106
  test("the quiz gate and its access endpoint both opt in on the same marker name", async () => {
@@ -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: { c: string; r: string; e: string; x: number; n?: string; p?: string } = {
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 ({email, name, phone})
125
- * so a gate that carries a display name in the token (the public GPT lead-capture
126
- * gate) can seed the visitor's name — not just the email — at SSR. Same
127
- * signature/expiry/gate-binding checks; null on any failure.
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
- ): { email: string; name?: string; phone?: string } | null {
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
- flow_config: { path: ["mode"], equals: "operator-relay" },
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
+ });
@@ -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 + LISTEN 1 = 2).
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?: { name?: string; email: string; phone?: string };
64
- }).initialVisitor = { name, email, phone };
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
  };