Security posture · honest version

The invariants we won't let break.

Six surfaces, each with named files, named guardrails, and a frank note on what's still ahead. We publish this because dealerships should be able to verify, not just trust.

Multi-tenancy

Strict per-owner_id keying. No fallbacks.

Every customer-facing data row belongs to one dealership, keyed by owner_id (uuid → auth.users(id)). Every query filters by it. Supabase RLS policies enforce server-side; service-role calls in webhooks and trigger tasks add explicit filters.

The lead-lookup helper keys by ONE of email or phone — never both. Two customers sharing a household landline do not get merged. This is documented as a non-obvious invariant; refactors that 'simplify' it to OR-matching are a regression.

AI prompts assemble dynamically from the dealership profile loaded via lead.owner_id. There are no hardcoded brand strings in prompt modules; a code reviewer rejects PRs that reintroduce them.

Files
supabase-actions/leads/getLeadByEmailorPhone.ts · db/rls-policies.sql · lib/prompts/*.ts
AI safety pipeline

Six guardrails. Each tied to an eval failure.

Every directive in lib/prompts/emailSystemPrompt.ts has a comment citing the eval-baseline failure that motivated it: no APR quotes (edge_financing_specifics, helpfulness=2), no trade-in dollar quotes (inv_trade_in_valuation, scored 3), brand deflection (inv_non_toyota_request, scored 1), only-facts-provided (groundedness was the weakest judge dimension).

Lead-field interpolations HTML-escape via lib/text/escapeLeadField before render — defense against template injection from user-controlled lead names.

Inventory mentions go through pickPrimaryItem with a confidence threshold; below threshold the smart-component is dropped, not invented.

Post-generation finalize chain: stripEmDashes → stripUngroundedClaims → HTML sanitizer. The persisted message body is the cleaned output, not the model's raw text.

SMS keyword compliance: STOP/START/HELP classified and acked via inline TwiML, bypassing the suppression list so a STOP recipient always receives confirmation.

Files
lib/prompts/{email,chat,sms,voice}SystemPrompt.ts · lib/text/* · lib/inventory/pickPrimaryItem.ts · lib/compliance/sms.ts
Webhook signing

Constant-time secret compare. Production rejects unsigned.

Twilio webhooks (SMS + voice): HMAC-SHA1 of (URL + sorted params) with TWILIO_AUTH_TOKEN. Production REQUIRES the signature; missing or invalid is 403. Was a P0 bypass in the audit; now enforced.

Widget chat: no HMAC — identity is (public_key, sessionToken). Per-widget allowed_origins[] is the abuse gate; check fails → request rejected.

personaplex-service WebSocket: when BRIDGE_SECRET is set, every /ws/twilio/* connection must present a token + exp signed by the Next.js TwiML route.

Files
app/api/webhooks/email/route.ts · utils/webhooks/verify.ts · personaplex-service/server.py · lib/voice/personaplexAuth.ts
Rate limiting

Two tiers. Every API gated.

Middleware-level (middleware.js): 5/min auth+admin, 20/min LLM, 15/min upload, 60/min standard. Excludes /api/webhooks, /api/twilio, /api/campaigns, /api/ai-agents/test-plugins (those bypass middleware and have their own per-route limit).

Per-route (lib/security/rate-limiter.ts withRateLimit): used by webhooks at 100/min, allows opt-in higher granularity.

Both limiters are in-memory today; the Redis backend interface is defined but not yet wired. Multi-instance hosting can multiply limits — tracked as a known production-readiness gap.

On exceed: 429 with Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers.

Files
middleware.js · lib/security/rate-limiter.ts
Headers + CSP

Strict by default. Per-path overrides for the widget.

Default: X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin, Strict-Transport-Security 2-year + preload, Permissions-Policy locking down camera / microphone / geolocation.

Default CSP: script-src self + Stripe + GTM, connect-src self + supabase + plenfi + openai + twilio + sendgrid, frame-ancestors none.

Widget paths (/widget.js, /widget/*, /api/widget/*) get a permissive override allowing frame-ancestors * and wide CORS so the loader can be embedded; per-widget Origin allow-list is the abuse gate.

Files
middleware.js → securityHeaders + isWidgetPath
Compliance scope

What is in scope and what isn't — honestly.

In scope: GDPR, CCPA, basic web security (OWASP), SMS keyword compliance (STOP/START/HELP via TwiML).

Not in scope today: SOC 2, HIPAA, PCI. The product handles name / contact / vehicle interest only — no financial details, no health, no card numbers.

Dealership-side compliance: A2P 10DLC for SMS (US carrier compliance) is the dealership's responsibility today; the product doesn't enforce or prompt for registration. CASL (Canada) and TCPA (US) consent capture is implicit.

Audit reports live in the repo: AI_HALLUCINATION_AUDIT_REPORT.md, SECURITY_AUDIT_REPORT.md, AUDIT_REPORT.md, SANDBOX_EVALUATION_REPORT.md.

Files
AI_HALLUCINATION_AUDIT_REPORT.md · SECURITY_AUDIT_REPORT.md
Want the full audit reports?

They're in the repo. We'll happily walk you through them.

Pilot dealerships get read-only access to the reports referenced here, plus the engineering-reference product.md with file-by-file detail.