| fixed | high | src/app/_lib/agent/schedule.ts:15. Fixed in leadgrade 2026-09-19: scripts/build-crons.mjs derives src/generated/crons.json from wrangler.jsonc, which is what Cloudflare reads, and it is gitignored so it cannot go stale. npm run build exits 0 and npm test is 134 of 134. It does NOT restore the triggers: wrangler.jsonc declares none, so every surface prints 'not scheduled', which is true. | npm run build fails: src/app/_lib/agent/schedule.ts imports ../../../../vercel.json and the product tree has no vercel.json. Measured 2026-09-19 on a clean rsync of the repo: "Module not found: Can't resolve '../../../../vercel.json'" at schedule.ts:15:1. The product's own last successful build is stamped 2026-09-19 10:16 and .next/server/chunks/ssr/_1nj05cc._.js still carries the two expressions that file held, "10 3 * * *" and "*/5 * * * *", so it existed then and is gone now. The product currently ships nothing. |
| fixed | high | src/app/api/integrations/oauth/[provider]/callback/route.ts | The OAuth callback accepts an UNSIGNED, caller-supplied state as the account identity. `state` is plain base64url JSON with no HMAC and no nonce, and when there is no session cookie the route trusts `state.uid` and calls saveTokens(uid, ...). Measured against the local build 2026-09-19: with no session and no state the callback redirects with error=unauthorized; with no session and a forged state naming another account's uuid it gets past the identity check and redirects with error=not_configured, which is the NEXT check in the route. On a deployment where HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRET and HUBSPOT_REDIRECT_URI are set, as production's own .env.local sets them, the same request goes on to exchange the code and write the caller's HubSpot tokens onto the named account's integration row. The victim's lead data is then written into the attacker's CRM by the dispatcher, and readContact reads from it. FIXED 2026-09-19 in leadgrade b5a5df7: src/app/_lib/oauth-state.ts signs the state with HMAC-SHA256 and an expiry, the callback refuses anything that does not verify, and with no secret the start route answers 501 rather than minting an unsigned one. Measured: a forged state naming another account answers /dashboard?view=integrations&error=bad_state. |
| fixed | medium | src/app/_lib/billing/entitlement.ts seedAccount(), and the absence of any settings route | Autopilot cannot be turned on by a customer. leadgrade_settings.autonomy, autopilot_threshold, daily_enrichment_cap and icp are read by the scoring pass and rendered by the console, and no route, server action or UI control in the repo writes any of them: the only writers are seedAccount() (insert-only, ignoreDuplicates) and setWatermark(). /how-it-works and the terms page both state that autopilot is opt-in, and there is nothing to opt in with. |
| fixed | high | src/components/console/Console.tsx act(), and KillWindow.tsx's count | The console's "Take it back" control cancels ONE held write-back per lead, whichever `writesOf(lead.id).find(w => w.state === 'queued')` returns first, and the same panel says "N writes held". Measured 2026-09-19: approving Ardenhall's COO queued nine rows, the console rendered nine held, one press of Take it back left one cancelled and eight queued. A buyer who stops a write they did not mean to make stops one ninth of it. The API route is fine; it takes a write-back id, which is why the kill task in this environment is written against a lead holding exactly one held row. |
| fixed | medium | src/app/api/webhooks/forms/[token]/route.ts | The form endpoint's own comment says a retry is de-duplicated because "same email + same minute + same form is the same submission", and the implementation is sha256 of the ENTIRE raw body. Any difference at all, a re-ordered key, an added tracking field, a timestamp the tool stamps per delivery, produces a different source_id and a second lead. Measured: two byte-identical posts upsert onto one row, which is what the retry case needs, and the guarantee is narrower than the sentence describing it. |
| fixed | low | src/app/api/webhooks/forms/[token]/route.ts | A retried form delivery writes a SECOND `ingested` receipt against the same lead. upsertLead de-duplicates on (user_id, source, source_id) so the lead is one row, and insertEvents then runs unconditionally, so the ledger shows the same submission arriving twice. Measured 2026-09-19 by posting the fixture body twice: one lead, two receipts. |
| fixed | medium | src/app/_lib/agent/guardrails.ts, src/app/_lib/db/writes.ts | The per-lead enrichment attempt cap is published and unimplemented. LEAD_ENRICHMENT_ATTEMPT_CAP and leadEnrichmentExhausted() exist in _lib/agent/guardrails.ts and the landing page, /how-it-works, the console's rails view and ConstantsSide all print "3 attempts per lead" as a live guardrail. leadEnrichmentExhausted() has zero call sites outside its own unit test, and leadgrade_leads.enrichment_attempts is never incremented or read. Verified after driving a real pass: every enriched lead still reads enrichment_attempts = 0. |
| fixed | low | src/app/_lib/agent/pipeline.ts and src/app/_lib/db/writes.ts insertRun() | leadgrade_writebacks.run_id never matches a row in leadgrade_runs. The pass mints its run id with ports.newId("run"), a randomUUID, and stamps it on every write-back it queues; insertRun() then inserts the run row WITHOUT an id, so the database generates a different one. The link between a write-back and the pass that produced it is broken for every autopilot row. Only reachable in autopilot, which no customer can turn on, which is why it is low. |
| fixed | low | src/app/api/webhooks/forms/[token]/route.ts | The public form endpoint's hourly ceiling is the account's daily_enrichment_cap column. The route's own comment reasons that this gives 24x more headroom than the pass can process, which holds only at the default of 250. An account on a lower cap silently gets a lower hourly ceiling on inbound leads, and leads over it are answered 429 and never stored, which is the one failure the same file says the product cannot have. Reachable today only through a direct database edit, because nothing in the product writes that column. |