Prep work so the repo is ready to be public. None of this is runtime
code; it's the governance a forker expects on day one.
- LICENSE: MIT at repo root. README already claimed MIT; this makes
GitHub actually recognise it and show the license badge.
- .github/SECURITY.md: private disclosure path via GitHub Security
Advisories or email. Spells out scope, safe harbour, and what the
reporter should expect — so a researcher finds it before they try
to file a public issue.
- .env.local.example: fleshed out with the three vars it was missing
— META_APP_SECRET (required, from PR 1), NEXT_PUBLIC_SITE_URL
(recommended), and AUTOMATION_CRON_SECRET (optional). Grouped as
Required / Recommended / Optional with a one-line comment per
entry. Includes the openssl/node generators for the secrets.
- package.json: description, repository, bugs, homepage, keywords,
license, author, engines.node >=20. No runtime impact; feeds npm
registry metadata, GitHub's repo sidebar, and prevents forkers from
silently installing on Node <20 (Next 16's floor).
Typecheck clean; npm install run to refresh the lockfile for the
package.json metadata additions (no dependency changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Reports: clicking a conversation sometimes left the message thread
stuck on "No messages yet" until the user hit browser refresh.
Root cause — a three-way race kicked off by any click on a conversation
in the list:
1. handleSelectConversation runs:
setActiveConversation(B)
setMessages([])
router.replace("/inbox?c=B")
2. The URL change flips `deepLinkConvId` (from useSearchParams).
handleConversationsLoaded is a useCallback with
[deepLinkConvId], so its identity changes. That cascades into
ConversationList, whose fetchConversations useCallback has
[onConversationsLoaded], whose useEffect has [fetchConversations] —
so the list refetches conversations purely because the URL changed.
3. When the refetch lands, handleConversationsLoaded runs its deep-link
auto-select branch. autoSelectedForDeepLinkRef still points at its
previous value (null, or the previous deep-link), so the guard
`ref !== deepLinkConvId` passes and the branch re-applies B:
setActiveConversation(B) // no-op, same id
setMessages([]) // BLOWS AWAY the messages
// MessageThread just fetched
4. MessageThread's fetch effect only re-runs when conversationId
changes. B is still B. It does not refetch. Messages stuck at [].
A hard reload unsticks things because state starts from scratch and
the initial deep-link flow is the only path that runs the auto-select.
Three defense-in-depth fixes:
- handleSelectConversation now writes the clicked id into
autoSelectedForDeepLinkRef BEFORE the router.replace, so by the
time any downstream refetch lands, the auto-select branch sees
ref === deepLinkConvId and skips.
- handleConversationsLoaded early-returns if the deep-linked
conversation is already the active one. This is redundant with
the ref update above but protects against any other code path
we don't foresee triggering a refetch.
- ConversationList's fetch is now driven by a plain useEffect with
[] deps, using a ref for the latest onConversationsLoaded
callback. URL changes no longer cause a conversations refetch.
Matches the same stabilisation pattern used in
src/hooks/use-auth.tsx and src/components/inbox/message-thread.tsx.
Verified: typecheck passes; lint count unchanged (37 pre-existing
problems, zero added). Couldn't exercise the rendered /inbox from the
preview sandbox — it's auth-gated and no test credentials are
available. Please confirm in the authenticated inbox:
- Click back and forth between two conversations with different
message histories; messages should populate every time.
- Open /inbox?c=<id> directly; thread opens with messages.
- Switch conversations after a deep-link landing; still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A fork running WaCRM publicly had no server-side guard on the
WhatsApp send endpoints — a compromised session or an aggressive UI
loop could call /api/whatsapp/send or /broadcast as fast as the
network allowed and blow through Meta's per-number quota (or worse,
get the number suspended).
This PR adds a minimal in-memory fixed-window rate limiter keyed by
user id.
Budgets (tuned for a template default — forkers can edit):
- /api/whatsapp/send 60 per minute per user (1/s sustained)
- /api/whatsapp/broadcast 5 per minute per user (campaign dispatch
rate, not fan-out)
Behaviour:
- Check runs right after the Supabase auth check; unauthenticated
calls 401 as before and never consume a bucket entry.
- When a user exceeds the budget the route returns 429 with
Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Reset headers.
- Send and broadcast buckets are independent; two users are
independent.
Design trade-off:
The limiter lives in a process-local Map. On a single Hostinger
Managed Node.js instance — the template's default deployment —
that's the whole limit. On a horizontally-scaled deploy (multiple
regions, serverless fan-out) each instance has its own Map and the
effective rate is N * limit. Swap src/lib/rate-limit.ts for a
Redis/Upstash/Durable-Objects backend if you scale past one
instance; keep the checkRateLimit return shape and the call sites
don't move.
Memory is self-cleaning: a 1-in-1000 opportunistic sweep drops
expired keys without a background timer, so the Map stays bounded
without running anything outside the request path.
Smoke test (tsx):
- 7 broadcasts → 5 ok, 2 denied.
- Different user → independent bucket.
- Same user, different route → independent bucket.
- Retry-after = 60s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The \"Type '/' for quick replies\" hint used to live inside the
textarea's flex-1 column. With items-end on the parent row, the
column's height grew by the hint's line box and the buttons aligned
to *that* bottom — sitting one row below the textarea on screen.
Move the hint out to a sibling of the flex row. The row now only
measures the textarea, items-end aligns the buttons level with the
textarea's bottom, and the hint drops neatly below with a pl-11
indent so it stays aligned under the textarea's left edge
(w-9 button + gap-2 = 44px).
Verified structurally via source; the /inbox route itself requires
auth so the preview couldn't exercise the rendered UI. Typecheck
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a catch-all rule in next.config.ts that merges a set of baseline
security headers into every response — served alongside the existing
Cache-Control rules, not instead of them.
Enforced today:
- Strict-Transport-Security: 2yr, includeSubDomains, preload-eligible.
No-op on http://localhost.
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY (same defense as frame-ancestors below, but
older browsers only read this one)
- Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy: deny camera/mic/geolocation/payment/usb. We
don't use any of those APIs; closing them off prevents a supply-
chain compromise or a forgotten plugin from silently enabling them.
Report-only:
- Content-Security-Policy-Report-Only with a pragmatic allowlist:
default-src 'self'
script-src 'self' 'unsafe-inline' 'unsafe-eval'
style-src 'self' 'unsafe-inline'
img-src 'self' data: blob: https:
font-src 'self' data:
connect-src 'self' https://*.supabase.co wss://*.supabase.co
frame-ancestors 'none'
base-uri 'self'
form-action 'self'
'unsafe-inline' / 'unsafe-eval' are there because Next's inline
hydration script + dev-mode require them; nonce-based CSP is a
later project. Violations appear in the browser console but
nothing is blocked until we flip the header name to
`Content-Security-Policy`.
Verified against the running dev server:
- /, /docs, /login, /api/whatsapp/webhook, /_next/static/* all
return the six headers.
- No CSP violations on / or /docs in the browser console.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds .github/workflows/ci.yml running lint, typecheck, and build on
pull_request + push to main. Node 20 (matches the engines we'll pin
in the metadata PR), npm cache keyed on package-lock.json,
concurrency cancel so force-pushes don't queue runs.
Also adds a `typecheck` script to package.json — `tsc --noEmit`. Next
runs its own typecheck during `build`, but exposing it as a script
means editors and pre-commit hooks can invoke it cheaply, and CI can
surface type errors separately from build errors.
Build passes with dummy env vars (tested locally with .env.local
moved aside); NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,
and ENCRYPTION_KEY are set to placeholder values inside the workflow.
Lint is wired up but marked continue-on-error for now: the repo has
14 pre-existing errors from React 19's new react-hooks/set-state-in-
effect rule, across 8 files, that are out of scope for this PR. They
still show up in the CI logs so regressions are visible; a follow-up
PR will fix them and drop the non-blocking flag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Adds a profile tab to /settings with the five things every B2B SaaS
user expects:
- Display name — edits profiles.full_name.
- Avatar — upload / replace / remove, stored in a new `avatars`
Supabase Storage bucket with RLS scoped to the owner's folder.
Migration 008_profile_avatars_storage.sql creates the bucket (public
read, 2 MB cap, image mimes only) and the four storage.objects
policies.
- Email — goes through supabase.auth.updateUser({ email }), which
confirms to both the old and new addresses. UI surfaces a "check
both inboxes" banner after save.
- Password — three-field form with a current-password gate implemented
via a throwaway signInWithPassword() before calling updateUser.
- Active sessions — one-click "sign out of all devices" using
supabase.auth.signOut({ scope: 'global' }), with a confirm dialog.
Plus read-only role, joined date, and user id for operator debugging.
The header avatar and sidebar user row are now both dropdown menus
linking to /settings?tab=profile and /settings?tab=whatsapp; the
settings page reads ?tab= to land on the right tab. useAuth gets a
refreshProfile() callback so the header/sidebar update immediately
after a save without a full reload.
Deferred (separate work): account deletion, 2FA, phone/timezone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
layout.tsx still had `icons.shortcut: ["/favicon.ico"]`, which kept
emitting the default Next.js favicon link in <head> even after the
file was deleted. icon.tsx is the single source now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
We already have src/app/icon.tsx generating the WaCRM emerald mark,
but the default create-next-app favicon.ico was still in src/app/
so Next.js kept emitting <link rel="shortcut icon" href="/favicon.ico">
in <head>. Browsers prefer the ICO, so tabs still showed the default
Vercel triangle on the landing page.
Removing favicon.ico leaves icon.tsx as the sole source, which is
what the comment in that file already claimed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a CODEOWNERS file mapping every path to @ArnasDon so GitHub
auto-requests his review on every PR. Pair with a branch-protection
rule on main that requires Code Owner approval to make review
mandatory before merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders every file in docs/*.md as a proper documentation site styled
like Stripe / Vercel / Linear docs: sticky header, grouped left sidebar
(Setup / Deploy / Operate), dark prose with inline code pills, syntax
-ready code blocks, responsive table styling, and prev/next pagination.
- src/lib/docs/content.ts — parses md with marked and decorates the
HTML with Tailwind utility classes via post-processing regex. Also
rewrites inter-doc `./other.md` links to `/docs/other` and extracts
an h2/h3 TOC for future use.
- src/app/docs/layout.tsx + src/components/docs/shell.tsx — shared
frame with docs-scoped header (GitHub, Home, Get started), collapsing
sidebar on mobile.
- src/app/docs/page.tsx — grouped index cards per section.
- src/app/docs/[slug]/page.tsx — individual doc + prev/next nav.
- Adds "Docs" to landing nav, footer, and a "Read the full
documentation" CTA under the open-source cards.
- Adds `marked` dependency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swaps the Hostinger recommendation on the landing page and throughout the
docs from VPS to Managed Node.js Hosting
(https://www.hostinger.com/web-apps-hosting).
The deploy walkthrough is rewritten around hPanel's Node.js app flow
(Git-based deploy, env vars panel, AutoSSL, cron via Advanced → Cron Jobs)
instead of a raw Ubuntu VPS with nginx + PM2 + certbot. VPS stays
mentioned at the bottom of the deploy page as an alternative when a
user actually needs one (background workers, custom binaries, etc.).
Also touched:
- docs/README.md, environment-variables.md, automations-and-cron.md,
troubleshooting.md to drop VPS-specific assumptions.
- footer link URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds docs/ with a reading-order flow that covers:
- getting started (fork, install, run locally)
- supabase setup + migrations
- whatsapp business api + webhook wiring
- environment variable reference
- hostinger vps deploy walkthrough
- automations cron scheduling
- troubleshooting for the common issues
Rewrites the top-level README as a pointer into docs/ instead of the
create-next-app boilerplate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a #self-host landing section with two cards — one linking to
the GitHub repo (fork / clone) and one recommending Hostinger VPS
for deploying the template. Also surfaces the GitHub link from the
nav (desktop icon + mobile drawer) and the footer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every column rendered at every width, relying on horizontal scroll.
A 375px phone had to swipe sideways just to read a name. Matches
the tiering contacts already uses:
- Mobile (<sm): Name + Status only.
- sm+: adds Recipients, Date.
- md+: adds Template.
- lg+: adds Delivery + Read rate bars.
Desktop (≥lg) unchanged — all seven columns visible as before.
Salvaged from the closed PR #52 — the OG-image portion of that PR
is already on main via PR #55, so only the broadcasts-table change
is shipped here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of a series to make the app mobile-friendly. The sidebar used
to be a fixed 240px column always visible, eating most of a 375px
phone screen and leaving ~135px for content. Now:
- Sidebar becomes a slide-in drawer on <lg, static on ≥lg. Controlled
via a drawer-open prop wired from the shell; transform + duration
feels native on touch devices.
- Backdrop overlay closes it on tap; Escape closes it; body scroll
locks while open; route changes auto-close it.
- Header gains a 44×44 hamburger button on <lg that opens the drawer,
hidden on ≥lg. Title shrinks slightly on narrow widths so the
hamburger + avatar + sign-out row never wraps.
- Nav rows are taller (py-2.5) on mobile for comfortable touch
targets, slim (py-2) back on desktop.
- Main content padding is p-4 on mobile, p-6 on desktop so cards
have room to breathe without shouldering into the viewport edge.
No desktop changes visible beyond tighter padding on very narrow
lg breakpoints (main content width is unchanged).
Next PRs in this series will adapt:
- Inbox (3-pane → single-pane with back)
- Pipelines (kanban snap-scroll)
- Automation builder (card widths + bottom-sheet edit)
- Tables + forms polish
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-applies the original SEO work from PR #47 that was reverted in
PR #54 because its opengraph-image.tsx crashed `next build` during
static prerender of /opengraph-image. Satori (next/og's renderer)
errored with:
Error: Expected <div> to have explicit "display: flex",
"display: contents", or "display: none" if it has more
than one child node.
Failed to load dynamic font for ✓ . Status: 400
Root cause in the OG image JSX:
- Several <div>s held text + a sibling element (e.g. "Run your
WhatsApp business from <span>one inbox.</span>") without an
explicit display value. Satori requires it whenever there is
more than one child.
- The ✓ Unicode glyph isn't covered by the default font Satori
loads, so next/og tried to download a dynamic font and failed.
Fix:
- Every <div> with more than one child now has display: flex.
- Multi-span headline sits in a flex-wrap row of two <span>s.
- ✓ replaced with plain text dot separators in the feature row.
Verified end-to-end: `npm run build` now completes with 27/27
pages prerendered, /opengraph-image among them.
All the other SEO-pass content is identical to the originally
merged-and-reverted work: metadataBase + title template, OpenGraph
+ Twitter Cards defaults, JSON-LD (WebSite + Organization +
SoftwareApplication + FAQPage), sitemap.ts, robots.ts, noindex on
auth + dashboard route-group layouts, shared faq-data + site-config
modules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The automation flow builder had fixed-width cards (320px for steps,
400px for conditions) and a crammed top bar. On a 375px phone the
condition cards overflowed horizontally and the top bar's Active
switch label left the name input with almost no room.
- Step cards: w-full max-w-[320px] sm:w-80 — full-width on mobile,
original 320px on sm+.
- Condition cards: w-full max-w-[400px] sm:w-[400px].
- Trigger card matches the step cards.
- Condition branches (Yes/No) stack vertically on mobile — two
columns at 375 would cram each branch to ~170px. Two columns
return on sm+.
- Top bar tightens on mobile: "Active" label hidden under sm so
the switch + save button + name input all fit; back-arrow grows
from h-8 w-8 → h-9 w-9 for a comfortable touch target; name input
is text-sm on mobile, text-base on sm+.
Desktop unchanged — every mobile-specific class is overridden by
its sm: counterpart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pipelines board was already horizontally scrollable, but on a
phone it felt imprecise — swipes landed mid-column, there was no
visual hint that more columns existed past the viewport, and 260px
columns felt cramped at 375px width.
- Each StageColumn is now w-[85vw] min/max-clamped on mobile, so the
next column's edge peeks in as a "more here" affordance. On lg+
the columns revert to flex-1 basis-[260px] (share the row).
- Scroll container gets snap-x + snap-mandatory on mobile, snap-none
on lg+. Each column has snap-start so swipes always land cleanly
at the left edge of the next column.
- shrink-0 on mobile prevents flex from compressing the columns when
space is tight.
No desktop visual change — all the mobile-specific classes are
overridden back by their lg: counterparts, and the original
flex-1 basis-[260px] behavior is preserved above lg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The inbox was a 3-pane flex row (list + thread + contact sidebar).
On a 375px phone both list and thread tried to share ~187px each
and the whole UI was unusable. Now:
- On <lg the inbox shows ONE pane at a time: list when nothing's
selected, thread when a conversation is open.
- Thread gains a back-arrow in its header (mobile only) that pops
back to the list and clears the ?c= URL param so refreshing lands
on the list, not on the thread the user just backed out of.
- Conversation list grows to w-full on mobile (was fixed w-80)
so each row uses the whole viewport width.
- Thread header tightens: name/phone truncate with min-w-0, session
timer badge is hidden under sm to save room for back arrow + name.
- Dashboard shell's p-6 is already p-4 on mobile (PR #48); matched
the -m-6 offset in the inbox to -m-4 sm:-m-6.
Desktop layout (lg+) is unchanged: list + thread + contact sidebar
side-by-side exactly as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of a series to make the app mobile-friendly. The sidebar used
to be a fixed 240px column always visible, eating most of a 375px
phone screen and leaving ~135px for content. Now:
- Sidebar becomes a slide-in drawer on <lg, static on ≥lg. Controlled
via a drawer-open prop wired from the shell; transform + duration
feels native on touch devices.
- Backdrop overlay closes it on tap; Escape closes it; body scroll
locks while open; route changes auto-close it.
- Header gains a 44×44 hamburger button on <lg that opens the drawer,
hidden on ≥lg. Title shrinks slightly on narrow widths so the
hamburger + avatar + sign-out row never wraps.
- Nav rows are taller (py-2.5) on mobile for comfortable touch
targets, slim (py-2) back on desktop.
- Main content padding is p-4 on mobile, p-6 on desktop so cards
have room to breathe without shouldering into the viewport edge.
No desktop changes visible beyond tighter padding on very narrow
lg breakpoints (main content width is unchanged).
Next PRs in this series will adapt:
- Inbox (3-pane → single-pane with back)
- Pipelines (kanban snap-scroll)
- Automation builder (card widths + bottom-sheet edit)
- Tables + forms polish
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>