mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
db11f76226
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
db11f76226
|
test: foundational Vitest suite for security + critical pure logic
Adds the first test layer to the repo. Vitest is wired into the existing CI workflow between typecheck and build so every PR gets the same gating before merge. No source code under test was modified; this PR only adds the test framework + tests. Coverage focuses on the highest-impact pure modules — security-critical crypto/HMAC, the phone-number pipeline every outbound message touches, the rate-limiter that protects send/broadcast, automation activation validation, and dashboard date math. 89 tests across 7 files; full run is ~300ms. Modules covered: - lib/whatsapp/encryption — GCM round-trip, tamper detection (auth tag, ciphertext, IV length, auth-tag length), legacy CBC compat, format detection, malformed input - lib/whatsapp/webhook-signature — valid/invalid HMAC, missing header, wrong prefix, length-mismatch guard, fail-closed when secret unset - lib/whatsapp/phone-utils — sanitize, normalize, phonesMatch with trunk-prefix tail fallback, isValidE164 boundaries (7–15 digits, non-zero start), phoneVariants insert/remove with dedupe, isRecipientNotAllowedError detection - lib/rate-limit — first-request math, exact-limit boundary, per-key isolation, window expiry with fake timers, 429 response shape, Retry-After clamp - lib/automations/validate — every step type's required fields, wait amount/unit boundaries, webhook URL validation (missing / non-http / malformed), assign_conversation mode branching, recursive condition walking with stable dot-paths, unknown step types - lib/broadcast-status — known status lookups, pulse flag, dark-theme class shape, fallback for unknown strings - lib/dashboard/date-utils — startOfLocalDay, daysAgoStart, localDayKey zero-padding, lastNDayKeys (1, N, month crossing), Monday-first DOW Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e570e1a8f2
|
security: switch WhatsApp token encryption from AES-CBC to AES-GCM
CBC without a MAC is unauthenticated — anyone who can write rows to
whatsapp_config can flip bits in the stored access_token or
verify_token without the decrypt throwing. You'd silently get garbled
bytes; worst case, if the mutation happens to produce a valid token,
messages go out under a spoofed account. GCM appends a 16-byte
authentication tag; any tampering fails the decrypt.
## What changed
src/lib/whatsapp/encryption.ts
- encrypt() now emits AES-256-GCM ciphertexts with a 12-byte IV,
formatted as `<iv>:<ciphertext>:<authTag>` (three colons).
- decrypt() auto-detects format by splitting on `:`:
3 parts → GCM, validated via auth tag.
2 parts → legacy CBC, read-only compatibility path.
Callers are unchanged; same signature, same return type.
- isLegacyFormat() exposes the format check so hot paths can
opportunistically upgrade old rows.
- IV length is validated on decrypt (12 for GCM, 16 for CBC) to
fail fast on corrupted input.
## Self-healing existing rows
Two call sites opportunistically upgrade a legacy row to GCM after a
successful decrypt — fire-and-forget, no latency impact on the request
path, idempotent under concurrent writes.
- src/app/api/whatsapp/send/route.ts (hot path)
After decrypting access_token, if it was CBC, we schedule an
UPDATE that writes a fresh GCM ciphertext back to the same row.
The first send after deploy upgrades the token; subsequent
sends are no-ops because isLegacyFormat returns false.
- src/app/api/whatsapp/webhook/route.ts (subscribe handshake)
When a config's verify_token matches the inbound
hub.verify_token, we upgrade it in place if it was legacy.
Runs during Meta's webhook re-subscription events, which
happen on config changes and periodic refreshes.
No schema migration required — the DB column holds whatever string
encrypt() produces. Operators who want to force an upgrade for a
particular user can clear and re-save the WhatsApp settings form.
## Verified (tsx smoke test)
- GCM roundtrip works (plaintext in → same plaintext out).
- Legacy CBC decrypt works on 2-part format.
- isLegacyFormat distinguishes new/legacy correctly.
- Bit-flipped GCM ciphertext throws on decrypt.
- Forged auth tag throws on decrypt.
- Same plaintext produces different ciphertexts across calls
(IV randomness).
## Rollback
Revert this file. Existing GCM rows would then fail to decrypt
because the previous encrypt.ts has no branch for the 3-part format.
Safer rollback: keep the decrypt branches, revert only the encrypt()
body to CBC — but since GCM roundtrips cleanly and the PR's bug
surface is small (pure crypto utility), a straight revert should only
matter if a forker had time between merging this PR and reverting to
accumulate GCM rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
be7edfd180
|
fix(webhook): require META_APP_SECRET, fail closed
The webhook signature verifier used to fall open with a warning log
when META_APP_SECRET was unset — a deliberate soft-rollout choice that
is unsafe for a public template. A forker who forgets the env var
would be running a fully spoofable webhook: anyone knowing the URL
could POST fabricated status updates and drift broadcast counts.
Now:
- `verifyMetaWebhookSignature` returns false when the secret is missing
and logs at error level, so the route responds 401 to every POST
until the operator configures it.
- Docs mark META_APP_SECRET as **required**:
- docs/environment-variables.md lists it under Required, not
Recommended.
- docs/whatsapp-setup.md §6 is now "Signature verification (required)".
- docs/troubleshooting.md updated accordingly.
- .env.local.example lists the var with guidance on where to find it.
Also fixes a pre-existing gitignore bug: `.env*` was swallowing
`.env.local.example`, which meant forkers running
`cp .env.local.example .env.local` from the getting-started guide
hit "no such file". Added a negation for `*.example`.
Upgrade note for existing deploys: set META_APP_SECRET (Meta → App
Settings → Basic → App Secret) before updating, or inbound webhooks
will 401 until it's configured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
66dd4ef97e
|
fix(broadcasts): senior-review hardening pass
Fixes 8 real issues found during a thorough review of the broadcast
feature (PRs #21-#29).
Security
* Webhook POSTs had zero spoof protection. Anyone could forge a
status update and drift broadcast counts arbitrarily. Added
HMAC-SHA256 signature verification via the X-Hub-Signature-256
header (Meta's standard). New helper src/lib/whatsapp/webhook-
signature.ts with timing-safe comparison. Rollout fails open
with a warning when META_APP_SECRET is unset so existing
deploys don't break at rollout; set the env var to enforce.
Correctness
* CSV audience was completely broken — synthesized "csv-N" strings
as contact_id, which failed the UUID cast on insert. Every CSV
broadcast silently created zero recipients. Replaced with a
real find-or-create against the contacts table (batched lookup
by phone, single INSERT for missing rows, de-duplicates CSV
inputs). CSV contacts now end up as real contacts.rows with
real UUIDs flowing through the rest of the pipeline.
* Recipient insert errors were logged with console.error and the
loop marched on — broadcasts then ran with incomplete recipient
sets, webhook updates couldn't find rows, and aggregate counts
drifted. Now: on batch insert failure, flip the broadcast to
failed + failed_count=total + throw so handleSend's toast fires.
* Webhook status guard ordered "failed" above "replied", which
meant a webhook replay delivering "failed" for an already-
replied recipient would upgrade replied → failed. Split the
ladder: forward-only ordering across pending → sent →
delivered → read → replied; failed is a terminal side branch
that can only be reached from pending or sent (never from any
success state). Net effect: a message that was read can no
longer regress to failed on a replay.
* Template sync only pulled page 1 (ignored paging.next). Users
with >100 templates silently missed some, and broadcasts with
those names failed with Meta's "template not found". Now
follows paging.next with a 20-page safety cap, reports
truncated=true in the response if hit.
* Template sync reported success:true even when individual
upserts failed. Now accumulates per-template errors and
surfaces them via a separate toast.error in the UI — green
toast no longer hides silent drift.
Performance
* Migration 005: replace the broadcast_recipients aggregate
trigger installed in migration 003. Old version ran
COUNT(*) FILTER on every row change — O(n²) across a send
loop (10k recipients × 10k index scans). New version
increments / decrements by ±1 per status transition: O(1)
per change. Kept the old recompute_broadcast_counts() as a
callable safety-net in case of drift.
* List page poll kept running while the tab was hidden. Added
visibilitychange handler — polling pauses when hidden,
resumes (and does an immediate fetch) on refocus.
UX
* Step 3 (Personalize) let users skip past unmapped placeholders
→ broadcasts then sent to Meta with empty-string params.
Next is now disabled until every {{N}} has a value; an amber
warning lists the missing placeholders.
Documented accepted risks in the PR body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
f3c68967f1
|
refactor: Meta API helpers use named params (prevents swapped-args bugs)
Over the course of the project the same swapped-args bug was found four separate times against the Meta API helpers (config in PR #8, send in PR #12, broadcast in PR #13, webhook media in PR #14). Each time the compiler accepted the call because all the args were strings, so the bug only surfaced at runtime as an opaque Meta error. This refactor eliminates the entire bug class by converting every helper to a single options object. A typo like sendTextMessage({ accessToken: id, phoneNumberId: tok, ... }) is now a TypeScript error at the call site rather than a 400 from Meta two layers deep. Helpers updated in src/lib/whatsapp/meta-api.ts: - verifyPhoneNumber({ phoneNumberId, accessToken }) - sendTextMessage({ phoneNumberId, accessToken, to, text }) - sendTemplateMessage({ phoneNumberId, accessToken, to, templateName, language?, params? }) - getMediaUrl({ mediaId, accessToken }) - downloadMedia({ downloadUrl, accessToken }) Each exported Args interface so routes can type local variables holding partial argument sets (useful for the broadcast retry loop). Also factored the duplicated "parse Meta error JSON" block into a shared throwMetaError() helper — was repeated in 5 places. Call sites updated (all were already correct after prior fixes, but now the types enforce it): - src/app/api/whatsapp/config/route.ts (GET + POST) - src/app/api/whatsapp/send/route.ts (attempt() closure) - src/app/api/whatsapp/broadcast/route.ts (retry loop) - src/app/api/whatsapp/webhook/route.ts (verifyAndBuildUrl) - src/app/api/whatsapp/media/[mediaId]/route.ts (proxy pipeline) No behavior change. Build passes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
e97d5b5064
|
feat: auto-retry with phone variants on Meta "not in allowed list"
Meta WhatsApp returns error #131030 ("Recipient phone number not in allowed list") in two situations: 1. Sandbox: the recipient isn't registered as a test number. 2. The registered number format differs from what we sent, usually because of trunk-prefix 0 quirks (e.g. Lithuanian numbers stored as +370063949836 vs international +37063949836). Today that error bubbles up to the UI and the user has to manually edit the contact. This PR makes the send pipeline self-heal: on 131030 it retries with alternate phone-number formats, and if an alternate works it persists the corrected format back to the contact so future sends are a single API call. src/lib/whatsapp/phone-utils.ts - Added phoneVariants(sanitized) — yields original, then variants with a trunk 0 inserted/removed after each plausible country-code length (1-3 digits). Deduplicated. Original is always first so the fast path for correctly-formatted numbers is one attempt. - Added isRecipientNotAllowedError(message) helper — matches code 131030 or the English error text, so we only retry when the failure is actually about the recipient (other errors fail fast). src/app/api/whatsapp/send/route.ts - Reply send now iterates phoneVariants() and stops at the first that succeeds. If a non-original variant works, contacts.phone is updated to the working format. src/app/api/whatsapp/broadcast/route.ts - Same retry applied per recipient in the broadcast loop. - Also fixed a separate bug: broadcast was calling sendTemplateMessage(accessToken, phone_number_id, ...) — wrong order. Correct signature is (phoneNumberId, accessToken, to, templateName, language?, params?). This was silently rejecting every broadcast. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
658d070811
|
Initial scaffold: WhatsApp CRM with Next.js + Supabase
Complete WhatsApp CRM application built with Next.js 14 (App Router), Supabase (PostgreSQL + Auth + Realtime), and shadcn/ui. Features: - Auth: Login, signup, forgot password with Supabase Auth - Dashboard: Analytics overview with stats, recent activity - Inbox: Real-time WhatsApp conversations with message threading - Contacts: CRUD with tags, custom fields, notes, CSV import - Broadcasts: 4-step wizard with audience selection, template sending - Pipelines: Kanban board with deal management - Settings: WhatsApp API config, templates, tags - WhatsApp API: Webhook (verify + receive), send, broadcast, media proxy - Database: 16 tables with RLS policies, indexes, realtime subscriptions - Dark theme throughout with emerald accents Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |