toga-ai 1.0.359 → 1.0.361

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.
@@ -6,6 +6,6 @@
6
6
  | [BDR Web Funnel — Full Implementation Plan](features/bdr-web-funnel-plan.md) | > **Execution status (2026-07-16):** PLAN **Phases 0–5 are BUILT and QA'd**, Phase 6 > was **mostly already built** (the `info` contract was implemented at comm | bdr/PLAN.md, bdr/mockup/app.jsx, bdr/mockup/screens.jsx, bdr/mockup/components.jsx |
7
7
  | [Call Orchestration — PHP Worker ↔ Vapi (the integration seam)](features/call-orchestration.md) | The **PHP worker** is the orchestrator; **Vapi** is the actor. | ai-bdr/docs/client-onboarding-sop.md, ai-bdr/docs/vapi-firstmessage-timing-fix.md, ai-bdr/docs/vapi-inbound-callback-assistant-request.md, ai-bdr/docs/vapi-voicemail-iphone-screening.md, ai-bdr/scripts/update_structured_output.py |
8
8
  | [Vapi Integration — Assistants, Tools, Structured Output](features/vapi-integration.md) | Everything inside Vapi: the three assistants, the three shared tools, the shared structured-output schema, the Liquid-templated system prompt, and the Python sc | ai-bdr/vapi/templates/assistant.template.json, ai-bdr/vapi/templates/system-prompt.template.md, ai-bdr/prompts/archive/prompt-may-15.txt, ai-bdr/prompts/campaigns/healthcare-2026-03-18.md, ai-bdr/prompts/campaigns/healthcare-v2-2026-03-25.md, ai-bdr/prompts/templates/campaign-template.md, ai-bdr/scripts/update_assistant.py, ai-bdr/scripts/update_system_prompt.py, ai-bdr/scripts/update_structured_output.py, ai-bdr/scripts/update_call_summary_context.py, ai-bdr/scripts/fix_booking_guardrails.py, ai-bdr/scripts/get_assistant.py, ai-bdr/docs/vapi-firstmessage-timing-fix.md, ai-bdr/docs/vapi-voicemail-iphone-screening.md |
9
- | [Web Funnel — Built Next.js App (structure, stack, how to run)](features/web-funnel-app.md) | The **built** state of the BDR web funnel — the Next.js UI / lead-capture front door of the existing AI-BDR product (see `../architecture.md`). | bdr/PLAN.md, bdr/.gitignore, bdr/eslint.config.mjs, bdr/amplify.yml, bdr/src/app, bdr/src/app/layout.tsx, bdr/src/app/page.tsx, bdr/src/app/studio.css, bdr/src/lib/resolveCampaignId.ts, bdr/src/lib/scheduleSlots.ts, bdr/src/lib/formatPhone.ts, bdr/src/lib/analytics.ts, bdr/src/lib/rateLimit.ts, bdr/src/proxy.ts, bdr/.env.example, bdr/src/components/GoogleAnalytics.tsx, bdr/src/components/RichText.tsx, bdr/src/content, bdr/src/components, bdr/src/flow, bdr/src/server, bdr/src/app/api, bdr/test, bdr/public, bdr/mockup/styles.css |
9
+ | [Web Funnel — Built Next.js App (structure, stack, how to run)](features/web-funnel-app.md) | The **built** state of the BDR web funnel — the Next.js UI / lead-capture front door of the existing AI-BDR product (see `../architecture.md`). | bdr/PLAN.md, bdr/.gitignore, bdr/eslint.config.mjs, bdr/amplify.yml, bdr/src/app, bdr/src/app/layout.tsx, bdr/src/app/page.tsx, bdr/src/app/studio.css, bdr/src/lib/resolveCampaignId.ts, bdr/src/lib/scheduleSlots.ts, bdr/src/lib/formatPhone.ts, bdr/src/lib/analytics.ts, bdr/src/lib/rateLimit.ts, bdr/src/lib/clientIp.ts, bdr/src/proxy.ts, bdr/.env.example, bdr/src/components/GoogleAnalytics.tsx, bdr/src/components/RichText.tsx, bdr/src/content, bdr/src/components, bdr/src/flow, bdr/src/server, bdr/src/app/api, bdr/test, bdr/public, bdr/mockup/styles.css |
10
10
  | [Web Funnel — Next.js UI + Config-Driven Campaign Content](features/web-funnel-content-model.md) | > **Status note (RESOLVED 2026-07-16):** the content **contract** below (schema, > provider seam, adapters) is BUILT and QA'd (PLAN Phases 0–5). | bdr/src/content/schema.ts, bdr/src/content/provider.ts, bdr/src/content/useCampaign.ts, bdr/src/content/default.ts, bdr/src/lib/resolveCampaignId.ts, bdr/src/server/leadSink.ts, bdr/src/server/callbackService.ts, bdr/src/app/api/call-now/route.ts, bdr/src/app/api/call-later/route.ts, bdr/src/app/api/contact/route.ts |
11
11
  | [New-Campaign Onboarding (6-phase runbook)](workflows/new-campaign-onboarding.md) | The end-to-end procedure for launching a new outbound BDR campaign (industry, offer, target persona, geography). | ai-bdr/docs/client-onboarding-sop.md, ai-bdr/docs/calcom-new-campaign-event-guide.md, ai-bdr/prompts/templates/campaign-template.md, ai-bdr/scripts/update_assistant.py, ai-bdr/scripts/update_system_prompt.py |
@@ -22,6 +22,7 @@ files:
22
22
  - bdr/src/lib/formatPhone.ts
23
23
  - bdr/src/lib/analytics.ts
24
24
  - bdr/src/lib/rateLimit.ts
25
+ - bdr/src/lib/clientIp.ts
25
26
  - bdr/src/proxy.ts
26
27
  - bdr/.env.example
27
28
  - bdr/src/components/GoogleAnalytics.tsx
@@ -56,13 +57,17 @@ fully implemented at commit `34a3910`: `page.tsx primeLead` reads `?hsContactId=
56
57
  campaign attribution → only `togaUuid` crosses to the client). The 2026-07-16 session
57
58
  added the deltas below (phone prefill + live input mask, `submitOk` on Success, GA4
58
59
  wiring) and resolved §9.10 (content mechanism — see `web-funnel-content-model.md`) and
59
- §9.11 (consent links). A later 2026-07-16 session added an **interim app-layer rate-limit
60
- mitigation** for the `?hsContactId=` enumeration risk (`src/proxy.ts` + `src/lib/rateLimit.ts`),
60
+ §9.11 (consent links). A later 2026-07-16 session added an **app-layer rate-limit
61
+ mitigation** for the `?hsContactId=` enumeration risk (`src/proxy.ts` + `src/lib/rateLimit.ts`
62
+ + `src/lib/clientIp.ts`),
61
63
  documented the **fully server-side request-a-call seam**, the **outbound dialer eligibility
62
64
  rules** (see `../../worker2/features/vapi-webhook-handler.md`), the **phone-less-contact
63
65
  dialer bug**, and the **local dev env wiring**. Developer QA verdict: the UI is "very close,
64
- minimal adjustments to be near exact" vs. the mockup. All checks green: tsc, eslint,
65
- 89/89 Vitest, `next build` (build output labels the proxy `ƒ Proxy (Middleware)`).
66
+ minimal adjustments to be near exact" vs. the mockup. The rate-limit mitigation then passed an
67
+ **independent `cso` security review — final verdict SHIP after two rounds** — and was HARDENED
68
+ to its ship state (trusted-IP position for X-Forwarded-For; O(1) LRU-capped limiter map; the
69
+ `?hsCampaignId=` page-prime param now `isTogaUuid`-validated). All checks green: tsc, eslint,
70
+ **104/104 Vitest**, `next build` (build output labels the proxy `ƒ Proxy (Middleware)`).
66
71
 
67
72
  - **Repo:** `agilantsolutions/BDR` on GitHub, working branch `BDR-Development`. The
68
73
  machine-local `.claude/` harness is **gitignored** — committing it would snapshot
@@ -83,15 +88,19 @@ minimal adjustments to be near exact" vs. the mockup. All checks green: tsc, esl
83
88
  - **ESLint flat config** carries a **no-em-dash rule** on shipped strings
84
89
  (`Literal` / `TemplateElement` / `JSXText` selectors) — enforces the mockup copy
85
90
  rule on in-repo strings (`eslint.config.mjs`).
86
- - **Vitest** test suite: **89 tests, all green** (13 added in the first 2026-07-16 session:
87
- phone format/prefill/mask, live-anchor consent links, `submitOk` race; 10 more in the
88
- later 2026-07-16 session for the rate limiter). Every phase was verified with `tsc` + lint
89
- + build + a live SSR `curl` before commit.
90
- - **Edge proxy (Next 16 `proxy.ts`).** `src/proxy.ts` is the Next 16 request proxy (the
91
- successor to the deprecated `middleware.ts` convention — see the Next 16 gotcha) carrying
92
- the interim rate-limit mitigation. It uses `src/lib/rateLimit.ts`, a pure clock-injected
93
- in-memory token-bucket limiter (no `server-only`, so it runs in the edge proxy and is
94
- unit-testable in Node).
91
+ - **Vitest** test suite: **104 tests, all green** (13 added in the first 2026-07-16 session:
92
+ phone format/prefill/mask, live-anchor consent links, `submitOk` race; the later 2026-07-16
93
+ session added the rate-limiter + trusted-IP + LRU-eviction + `hsCampaignId` validation tests
94
+ to reach 104). Every phase was verified with `tsc` + lint + build + a live SSR `curl` before
95
+ commit.
96
+ - **Edge proxy (Next 16 `proxy.ts`) — 3-file rate-limit mitigation.** `src/proxy.ts` is the
97
+ Next 16 request proxy (the successor to the deprecated `middleware.ts` convention — see the
98
+ Next 16 gotcha) carrying the rate-limit mitigation. It composes two libs: `src/lib/rateLimit.ts`,
99
+ a pure clock-injected in-memory token-bucket limiter with an O(1) LRU-capped map (no
100
+ `server-only`, so it runs in the edge proxy and is unit-testable in Node); and
101
+ `src/lib/clientIp.ts`, the pure client-IP resolution logic (no `next` import, so it is
102
+ independently testable) that parses `X-Forwarded-For` from a **trusted** position. See the two
103
+ rate-limiter security gotchas below.
95
104
  - **Deploy target:** AWS Amplify (SSR), `amplify.yml` present (Phase 8 not done).
96
105
 
97
106
  ## Directory map (what got built)
@@ -115,13 +124,14 @@ minimal adjustments to be near exact" vs. the mockup. All checks green: tsc, esl
115
124
  `web-funnel-content-model.md`).
116
125
  - `src/app/api/*` — route handlers `call-now`, `call-later`, `contact`. **These do NOT
117
126
  place a call themselves** — see "Request-a-call is fully server-side" below.
118
- - `src/proxy.ts` — Next 16 edge proxy (rate limiting; see gotcha). `src/lib/rateLimit.ts` —
119
- the token-bucket limiter it uses.
127
+ - `src/proxy.ts` — Next 16 edge proxy (rate limiting; see gotchas). `src/lib/rateLimit.ts` —
128
+ the LRU-capped token-bucket limiter it uses. `src/lib/clientIp.ts` — pure client-IP
129
+ resolution (trusted `X-Forwarded-For` position; no `next` import).
120
130
  - `public/*` — assets pruned to the **default campaign only**, **plus AutoBuild's
121
131
  FLOURISH characters** (Owl 1 `owl-02` + Robot 1 `robot-02`, art + talk videos) which
122
132
  the assembly cycle needs even though they are not the campaign agent. See the
123
133
  asset-pruning gotcha below.
124
- - `test/*` — the 67-test Vitest suite.
134
+ - `test/*` — the Vitest suite (104 tests).
125
135
 
126
136
  ## How the built app behaves (key runtime facts)
127
137
 
@@ -248,6 +258,16 @@ PRODUCTION credential set (`TOGA_API_BASE_URL = https://api.togahub.com/v2`).
248
258
  - **Remaining work is decision-gated, not code:** leadership's first-campaign copy;
249
259
  Phase 8 Amplify SSR (incl. a **per-IP WAF rate rule** — see the enumeration gotcha) +
250
260
  `togatech` integration. GA4 (Phase 7) is now wired; §9.10 and §9.11 are resolved.
261
+ - **Non-gating security residuals (recorded in `BDR/PLAN.md` §10 / Phase 8 checklist — do not
262
+ re-litigate; the `cso` review flagged these as follow-ups, not ship-blockers):** (a) **lock
263
+ the Amplify origin** so nothing reaches it without transiting CloudFront — any
264
+ direct-to-origin path lets an attacker own the whole XFF chain, the standing caveat behind
265
+ every header-based IP-trust decision above; (b) confirm the distribution **injects + strips**
266
+ `CloudFront-Viewer-Address` before setting `RATE_LIMIT_TRUST_CF_VIEWER_HEADER=true`;
267
+ (c) `readJsonObject` buffers the body **before** the size check and measures UTF-16 length,
268
+ not bytes (bounded in practice by the Lambda payload cap + the call limiter); (d) the proxy
269
+ matcher is an **exact-path allowlist** — re-verify no un-normalized path variant reaches a
270
+ handler off-matcher.
251
271
 
252
272
  ## Gotchas
253
273
 
@@ -263,21 +283,50 @@ PRODUCTION credential set (`TOGA_API_BASE_URL = https://api.togahub.com/v2`).
263
283
  server-rendered prefill, (b) scrape the **full** contact record via `GET /api/contact`
264
284
  (see next gotcha), and (c) drive the page-prime Toga upsert, polluting contacts/attribution.
265
285
  **Inherited from `info`'s CRM-link URL contract — cannot be changed unilaterally.**
266
- **Interim app-layer mitigation (BUILT, 2026-07-16):** `src/proxy.ts` + `src/lib/rateLimit.ts`
267
- rate-limit per client. Entry pages carrying `?hsContactId=` **soft-degrade** over the limit
268
- (strip `hsContactId`, `NextResponse.rewrite` to render anonymously — a real shared-NAT user
269
- keeps the funnel minus prefill; a scraper gets no PII and triggers no Toga upsert;
270
- `hsCampaignId` is preserved). `/api/contact`, `/api/call-now`, `/api/call-later` return a
271
- `429 {isSuccess:false,…}` envelope with `Retry-After` over the limit. **This is
272
- defense-in-depth ONLY** — the limiter is per-runtime-instance in-memory (Amplify SSR may run
273
- several instances), so it is not authoritative. The authoritative controls — a **per-IP edge
274
- WAF rate rule** (Phase 8) and **opaque/signed CRM link tokens** (raise with the CRM owner) —
275
- remain deferred. Tracked in `BDR/PLAN.md` §10/§11. (An independent `cso` review of this
276
- mitigation was still running at capture time; treat it as "in place, pending final review".)
286
+ **App-layer mitigation (BUILT + security-reviewed SHIP, 2026-07-16):** `src/proxy.ts` +
287
+ `src/lib/rateLimit.ts` + `src/lib/clientIp.ts` rate-limit per client (keyed off a **trusted**
288
+ client IP — see the two rate-limiter gotchas below). Entry pages carrying `?hsContactId=`
289
+ **soft-degrade** over the limit (strip `hsContactId`, `NextResponse.rewrite` to render
290
+ anonymously — a real shared-NAT user keeps the funnel minus prefill; a scraper gets no PII and
291
+ triggers no Toga upsert; `hsCampaignId` is preserved). `/api/contact`, `/api/call-now`,
292
+ `/api/call-later` return a `429 {isSuccess:false,…}` envelope with `Retry-After` over the
293
+ limit. **This is defense-in-depth ONLY** — the limiter is per-runtime-instance in-memory
294
+ (Amplify SSR may run several instances), so it is not authoritative. The authoritative
295
+ controls — a **per-IP edge WAF rate rule** (Phase 8) and **opaque/signed CRM link tokens**
296
+ (raise with the CRM owner) — remain deferred. Tracked in `BDR/PLAN.md` §10/§11. An independent
297
+ `cso` review of this mitigation returned a **final verdict of SHIP after two rounds**; the
298
+ hardening from that review (trusted-IP position, LRU-capped map, `hsCampaignId` validation) is
299
+ documented in the gotchas below.
277
300
  - **HIGH (security) — `GET /api/contact?hsContactId=` returns the FULL contact record.** Name,
278
301
  email, phone, company, and job title by id — a **more direct** PII scrape vector than the SSR
279
302
  phone prefill. Now covered by the same rate limiter; **any change to
280
303
  `src/app/api/contact/route.ts` must keep it rate-limited.**
304
+ - **HIGH (security) — rate limiting behind CloudFront MUST key off a TRUSTED IP position, never
305
+ the leftmost `X-Forwarded-For`.** The leftmost XFF entry is client-prependable, so keying the
306
+ limiter off it is trivially bypassable (a scraper spoofs a fresh left-most IP per request and
307
+ never hits the limit). Correct approach (in `src/lib/clientIp.ts`): parse XFF
308
+ **right-to-left** and take index `parts.length - 1 - RATE_LIMIT_TRUSTED_PROXY_HOPS` (default
309
+ `0` ⇒ the **rightmost**, CloudFront-appended, viewer IP). `x-real-ip` is **NOT** trusted.
310
+ `CloudFront-Viewer-Address` is trusted **only** when env
311
+ `RATE_LIMIT_TRUST_CF_VIEWER_HEADER=true` (default **OFF**) — because an unverified
312
+ distribution that forwards a client-supplied copy of that header reintroduces the bypass; ops
313
+ enables it only after confirming CloudFront both injects **and** strips it. `normalizeIp()`
314
+ handles port strip, `[ipv6]:port`, and `::ffff:` IPv4-mapped forms so one client = one key.
315
+ This whole approach is a **reusable pattern for any Next app behind CloudFront/Amplify** — do
316
+ not copy a naive leftmost-XFF limiter into another repo.
317
+ - **HIGH (security) — a naive in-memory limiter sweep is itself a DoS.** A sweep that only
318
+ reclaims fully-refilled buckets leaves an **unbounded map** plus an O(n) sweep per insert;
319
+ under a unique-key flood (e.g. rotated IPv6 sources) that is a self-inflicted DoS. Fix (in
320
+ `src/lib/rateLimit.ts`): a **HARD ceiling** `MAX_TRACKED_KEYS = 10_000` enforced by **O(1) LRU
321
+ eviction** (Map insertion order; every check re-inserts the key to move it to MRU), **plus**
322
+ the O(n) reclaim throttled to at most **once per window**. This guarantees the map never
323
+ exceeds the cap and per-insert work is O(1) amortized. Do not "simplify" the LRU re-insert or
324
+ the throttle away — both are load-bearing against the flood case.
325
+ - **`?hsCampaignId=` page-prime param must be UUID-validated before it crosses to the Toga
326
+ upsert.** It flows into the upsert as `togaCampaignUuid`; it previously had only a
327
+ `typeof === 'string'` guard. `page.tsx` now `isTogaUuid`-validates it before use, dropping a
328
+ malformed value to `undefined`. Never pass an unvalidated inbound query param into the Toga
329
+ upsert.
281
330
  - **Next 16 — `middleware.ts` is DEPRECATED; use `proxy.ts`.** Next 16.2.3 warns "The
282
331
  middleware file convention is deprecated. Please use proxy instead"
283
332
  (nextjs.org/docs/messages/middleware-to-proxy). Correct convention: `src/proxy.ts` exporting
@@ -322,6 +371,23 @@ PRODUCTION credential set (`TOGA_API_BASE_URL = https://api.togahub.com/v2`).
322
371
  harness; pull it with `npx toga-ai`.
323
372
 
324
373
  ## Change history
374
+ - 2026-07-16 — Rate-limit mitigation HARDENED to ship state after an independent `cso` security
375
+ review returned **SHIP after two rounds** (supersedes the mid-state recorded in the entry
376
+ below: test count is **104**, not 89, and the review is complete, not "still running"). Added
377
+ `src/lib/clientIp.ts` (pure, `next`-free, testable) → the mitigation is now **3 source files**
378
+ (`proxy.ts` + `rateLimit.ts` + `clientIp.ts`). Security hardening: (1) rate limiting behind
379
+ CloudFront keys off a **trusted** XFF position (parse right-to-left, index
380
+ `len-1-RATE_LIMIT_TRUSTED_PROXY_HOPS`, default rightmost/viewer IP; `x-real-ip` untrusted;
381
+ `CloudFront-Viewer-Address` trusted only when `RATE_LIMIT_TRUST_CF_VIEWER_HEADER=true`,
382
+ default OFF) — never the client-prependable leftmost IP; `normalizeIp` collapses port/
383
+ `[ipv6]:port`/`::ffff:` so one client = one key; (2) the limiter map is bounded by a HARD
384
+ `MAX_TRACKED_KEYS=10_000` via O(1) LRU eviction + reclaim throttled to once/window, closing a
385
+ unique-key-flood DoS in the naive sweep; (3) `?hsCampaignId=` is now `isTogaUuid`-validated in
386
+ `page.tsx` before it crosses to the Toga upsert (was a bare `typeof`-string guard). Recorded
387
+ the non-gating `cso` follow-ups (lock Amplify origin to CloudFront; verify CF-Viewer-Address
388
+ inject+strip before trusting it; `readJsonObject` buffers-then-measures UTF-16 not bytes;
389
+ proxy matcher exact-path allowlist). All green: tsc, eslint, **104/104** Vitest, `next build`.
390
+ (tcox)
325
391
  - 2026-07-16 — Later same-day session. BUILT an interim app-layer rate-limit mitigation for the
326
392
  `?hsContactId=` enumeration risk: `src/lib/rateLimit.ts` (pure clock-injected in-memory
327
393
  token-bucket, idle-key eviction, no `server-only`) + `src/proxy.ts` (Next 16 proxy). Policy:
@@ -0,0 +1,81 @@
1
+ ---
2
+ type: session
3
+ slug: bdr-hscontactid-rate-limit
4
+ title: BDR ?hsContactId= enumeration rate-limit mitigation (security-reviewed SHIP)
5
+ author: tcox
6
+ repos: [bdr, ai-bdr]
7
+ framework: "2.0"
8
+ client: shared
9
+ status: active
10
+ created: 2026-07-16
11
+ updated: 2026-07-16
12
+ ---
13
+
14
+ # Session: bdr-hscontactid-rate-limit
15
+ **Date:** 2026-07-16
16
+ **Project/Repo:** bdr (2.0) — local `C:\WWW\BDR`, GitHub `agilantsolutions/BDR`, branch `BDR-Phase-2`
17
+ **Task:** Build an interim app-layer rate-limit mitigation for the public funnel's `?hsContactId=` enumeration risk (PLAN §10, HIGH), driven to a security-reviewed SHIP; along the way, wire local dev env and diagnose why a live test call never fired.
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+ - **Interim rate-limit mitigation BUILT and SHIP-verified (two CSO review rounds).** New `src/proxy.ts` (Next 16 proxy), `src/lib/rateLimit.ts` (token bucket), `src/lib/clientIp.ts` (trusted IP resolution). Policy: entry pages with `?hsContactId=` **soft-degrade** over limit (strip id, `NextResponse.rewrite` to render anonymously — no PII, no Toga upsert; hsCampaignId preserved); `/api/contact`, `/api/call-now`, `/api/call-later` return a `429` `{isSuccess,status,error,messages,data}` envelope with `Retry-After`. Evidence: tsc clean, eslint clean, **104/104 vitest**, `next build` compiles it (logs `ƒ Proxy (Middleware)`).
23
+ - **C1 (Critical) FIXED — trusted client IP.** `resolveClientIp()` parses `X-Forwarded-For` **right-to-left** at `parts.length - 1 - RATE_LIMIT_TRUSTED_PROXY_HOPS` (default 0 => rightmost = CloudFront-appended viewer), so a forged/leftmost XFF can't shift the selected index. `x-real-ip` no longer trusted. `CloudFront-Viewer-Address` trusted ONLY when `RATE_LIMIT_TRUST_CF_VIEWER_HEADER=true` (env, default OFF). `normalizeIp` strips port, `[ipv6]:port`, `::ffff:` mapping. Verified by `test/clientIp.test.ts` incl. a 20-entry spoof-flood that still resolves to the trusted tail.
24
+ - **H1 (High) FIXED — limiter map hard-bounded.** `MAX_TRACKED_KEYS=10_000`; `check()` re-inserts existing keys (Map order = LRU); new-key insert at cap runs `makeRoom()` = throttled `sweepIdle` (≤ once/window) + O(1) LRU eviction loop. Verified: 50k unique keys → size ≤ 10k; a key touched every 50 iters survives a 40k flood and stays drained/denied.
25
+ - **M FIXED — `?hsCampaignId=` validated.** `page.tsx` now `isTogaUuid`-validates it before it flows into `togaLeadSink.upsertLead` as `togaCampaignUuid`; malformed drops to `undefined`.
26
+ - **CSO re-review verdict: SHIP** (SAFE TO SHIP) — all of C1/H1/M genuinely closed, no new defects.
27
+ - **Local dev env wired.** Created `C:\WWW\BDR\.env.local` (gitignored) by copying the 5 server-side vars (`HUBSPOT_ACCESS_TOKEN` + 4 `TOGA_*`) from `C:\WWW\info\.env.local` (PRODUCTION values, base `https://api.togahub.com/v2`). GA left unset locally (analytics.ts no-ops). Verified auth + contact writes hit prod.
28
+ - **Diagnosed the BDR call flow end to end.** A `200 POST /api/call-now` in the browser is success from BDR's side; Toga calls are server-side (never in the browser network tab). BDR only sets `dtNextContactRequested`/`isOkayToCall`/call-type on the contact; the worker2 dialer cron places the actual Vapi call. Confirmed the test contact `88800869-acc7-632b-2520-bbd601224ff5` got `dtNextContactRequested` written in prod.
29
+ - **Two `/capture` pushes to team KB (PUSHED).** `web-funnel-app.md` (rate-limit mitigation, server-side call seam, phone-less bug, `/api/contact` scrape vector, Next 16 proxy gotcha, dev-setup), `worker2/vapi-webhook-handler.md` (new "Outbound dialer eligibility" section), `ai-bdr/call-orchestration.md` (cross-ref). Second capture superseded the mid-state (test count → 104, clientIp.ts, SHIP verdict, 3 new security gotchas).
30
+
31
+ ## What did NOT work — DO NOT RETRY THESE
32
+ - **Live test call never fired despite a correct 200 submit — TWO production-data blockers, not a code bug.**
33
+ 1. The test contact's `Contacts.primaryContactPhoneNumberId` is **null**, so the dialer's eligibility SQL (`getEligibleContactsForVapi`, INNER JOIN `ContactPhoneNumbers ON Contacts.primaryContactPhoneNumberId`) can't see it. Root cause: the contact was created WITHOUT a phone (HubSpot record has none); the phone arrives via the later Call Now `PUT`, and **api2 creates the ContactPhoneNumber row but does not set the primary pointer on update**. Re-PUTting does NOT fix it (verified: primary stayed null while a phone row `(630) 803-7765` existed). This is a real product bug — any phone-less HubSpot contact can never be called; the funnel still shows success.
34
+ 2. The active campaign `26.05 - AI BDR - Ryan Nitti` (`15d0c02f-501e-e081-2ead-61b78c854223`) reads `campaignAssistantId = null`; the dialer INNER JOINs `CampaignAssistants`, so a null-assistant campaign is never dialed. Could not confirm whether truly null or hidden from the API credential.
35
+ - **`node check-contact.mjs` with a `with=ContactCallType,PrimaryContactPhoneNumber,CampaignsContacts` clause → HTTP 404.** The `with=` relation names were wrong for this endpoint; the plain `join=…&where=(…)` query returns 200. Use the join form, not `with=`.
36
+ - **Toga `/contact-attempts` and `/campaigns-contacts` (some join variants) → HTTP 403/500 for this API credential.** Can't read ContactAttempts or all campaign-contact links directly; infer from contact + campaign state instead.
37
+ - **Running `npx tsc`/`npx vitest`/`npx eslint`/`npx next build` without `cd /c/WWW/BDR &&` first.** The Bash tool cwd resets to `c:\WWW`, so tsc resolves the wrong binary and vitest runs sibling repos (saw 189 failing toga-blox-npm tests). ALWAYS prefix `cd /c/WWW/BDR &&`.
38
+ - **`middleware.ts` on Next 16 is DEPRECATED.** Next 16.2.3 warns "use proxy instead". Correct convention is `src/proxy.ts` exporting a `proxy` function + `export const config: ProxyConfig`; import from `next/server`. (Confirmed from `node_modules/next/dist/lib/constants.js` + build source; did not ship a middleware.ts.)
39
+ - **`info/.env.beta` is a DEAD file** — empty URLs, obsolete var names (`TOGA_CLIENT_SECRET` vs the current `TOGA_CLIENT_API_UUID`/`SECRET` pair). Production (`info/.env.local`) is the only working credential set `info` has. Do not source dev env from `.env.beta`.
40
+
41
+ ## Not tried yet (candidates for next session)
42
+ - **Commit the working-tree changes** on `BDR-Phase-2` (developer has not asked yet). Suggested split: `feat: rate-limit hsContactId enumeration surfaces` (proxy.ts, rateLimit.ts, clientIp.ts, tests, PLAN §10) + `fix: validate hsCampaignId before toga upsert` (page.tsx).
43
+ - **Unblock the live call test** (needs AI-BDR owner or a production write): set the test contact's `primaryContactPhoneNumberId`, and confirm/fix the campaign's `campaignAssistantId`.
44
+ - **Decide the phone-less-contact bug fix**: api2-side (set primary on phone-row create during update) vs. a BDR workaround (PUT linking the existing phone-row uuid as primary).
45
+ - **Non-gating security follow-ups (PLAN §10 / Phase 8):** lock the Amplify origin so nothing bypasses CloudFront; confirm the distribution injects+strips `CloudFront-Viewer-Address` before enabling `RATE_LIMIT_TRUST_CF_VIEWER_HEADER`; harden `readJsonObject` (body buffered before size check; measures UTF-16 length not bytes); re-verify no un-normalized path variant reaches a handler off the proxy `matcher`.
46
+ - **Phase 8 authoritative controls:** edge WAF per-IP rate rule (supersedes the interim in-app limiter); opaque/signed CRM link tokens instead of raw `hsContactId` (raise with HubSpot/CRM owner).
47
+ - Remaining PLAN §9 items (unchanged from prior sessions): §9.3 agent choice, §9.4 Landing service-picker scope, §9.5 campaign catalog, §9.6 togatech integration mechanism (gates deploy), §9.7 Tweaks panel in prod, §9.8 first-campaign copy, §9.12 Share button (still a mock), §9.13 connect-time copy. Phase 7 polish (pixel/motion/a11y; GA4 done).
48
+
49
+ ## Current file state
50
+ | File | Status | Notes |
51
+ |------|--------|-------|
52
+ | `src/proxy.ts` | ADDED, uncommitted | Next 16 proxy; per-IP limits, soft-degrade entry + 429 API routes; env `RATE_LIMIT_TRUSTED_PROXY_HOPS`, `RATE_LIMIT_TRUST_CF_VIEWER_HEADER` |
53
+ | `src/lib/rateLimit.ts` | ADDED, uncommitted | Pure token-bucket, clock-injected, hard-bounded 10k-key LRU map |
54
+ | `src/lib/clientIp.ts` | ADDED, uncommitted | Pure trusted-IP resolution (right-to-left XFF; CF header opt-in; normalizeIp) |
55
+ | `src/app/page.tsx` | MODIFIED, uncommitted | `isTogaUuid`-validate `hsCampaignId` before Toga upsert |
56
+ | `test/rateLimit.test.ts` | ADDED, uncommitted | limiter unit tests incl. hard-bound-under-flood + LRU survival |
57
+ | `test/clientIp.test.ts` | ADDED, uncommitted | IP-resolution tests incl. spoofed-leftmost-XFF bypass attempt |
58
+ | `PLAN.md` | MODIFIED, uncommitted | §10 mitigation + SHIP + residuals; §11 Phase-8 checklist items |
59
+ | `.env.local` | ADDED, uncommitted (gitignored) | 5 server-side vars from info prod; NEVER committed |
60
+ | Repo otherwise | clean at `bb40b15` on `BDR-Phase-2` | all above are working-tree only; no commit/PR yet |
61
+ | Team KB (`toga-tech`) | PUSHED | web-funnel-app.md, worker2/vapi-webhook-handler.md, ai-bdr/call-orchestration.md updated via 2× /capture |
62
+
63
+ ## Decisions made
64
+ - **App-layer rate limiting now, as defense-in-depth** — not the authoritative fix. Per-instance memory on Amplify SSR is honestly documented; the WAF per-IP rule + opaque/signed CRM tokens remain the real controls (Phase 8). Chosen because it's the only layer buildable pre-deploy.
65
+ - **Entry page soft-degrades; API routes hard-429.** Stripping `hsContactId` keeps a real (shared-NAT) user in the funnel minus prefill while denying a scraper any PII/upsert; the call/contact routes trigger real actions or leak full PII, so a hard stop is correct.
66
+ - **Trust IP from the rightmost XFF, not leftmost** (C1 fix) — the leftmost is client-prependable behind CloudFront; the rightmost is CloudFront-appended. `CloudFront-Viewer-Address` made **opt-in (default off)** so an unverified distribution can't be bypassed via a forged copy — safe-by-default, ops enables after verifying.
67
+ - **Hard-bound the limiter map with LRU eviction** (H1 fix) — a refill-only sweep is itself a unique-key-flood DoS; a hard ceiling + O(1) LRU + throttled reclaim guarantees bounded memory and O(1) amortized inserts.
68
+ - **Used Next 16 `proxy.ts`, not `middleware.ts`** — the latter is deprecated in 16.
69
+ - **Deferred `readJsonObject` + matcher-normalization fixes** as non-gating follow-ups — pre-existing, bounded by the Lambda payload cap + the call limiter; keeping the change focused and low-risk was the better call than widening scope into shared validated code.
70
+ - **Phone-less-contact bug recorded as a gotcha, not its own doc** — has an api2-side root cause but surfaces on the funnel + worker2 dialer docs per KB conventions.
71
+
72
+ ## Blockers
73
+ - **Live call test blocked on production data** (owner: AI-BDR owner, e.g. Rohan/Akash): test contact `primaryContactPhoneNumberId` is null, and campaign `campaignAssistantId` appears null. Both need a production write or owner action; developer's standing rule is read-only DB access for Claude.
74
+ - **Committing to `BDR-Phase-2`** awaits developer go-ahead (standing "ask before git/remote" policy).
75
+ - Pre-existing PLAN blockers unchanged: §9.6 togatech integration (gates Phase 8 deploy), §9.8 first-campaign copy from leadership.
76
+
77
+ ## Exact next step
78
+ > Decide whether to (a) commit the working-tree changes on `BDR-Phase-2` as the two commits noted above, or (b) tackle the phone-less-contact bug fix — whichever the developer wants first. The security work itself is complete (SHIP-verified) and fully captured; nothing there is pending.
79
+
80
+ ---
81
+ _Saved by /session-save on 2026-07-16_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.359",
3
+ "version": "1.0.361",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",