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>
The Pro-badged Schedule for Later tile in step 4 was a non-interactive
div — no handler, no scheduling backend, and no Pro tier in the product.
Send Immediately was the only path that did anything, so the picker
itself was decorative. Remove the section (and the now-unused timing
state / Clock / Lock imports). The wizard still completes Send
Immediately end-to-end via the existing Send Broadcast button below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleOpenTemplates flipped templateModalOpen but nothing rendered when
true, so both composer template buttons were dead. Add a TemplatePicker
dialog that lists Approved templates, prompts for any {{N}} variables,
then sends through the existing /api/whatsapp/send route with
message_type=template. The rendered body is stored as content_text so
the thread bubble and conversation preview show the actual message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the no-op Assign button in the thread header with a dropdown that
reads/writes conversations.assigned_agent_id. Lists profiles visible
under RLS (single-tenant today, ready for shared workspaces) and offers
Unassign when the conversation is already claimed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Forks of this repo no longer inherit the wacrm.tech landing page
and docs — those move to the dedicated marketing-site repo at
https://github.com/ArnasDon/wacrm-site (deployed to wacrm.tech).
Changes here:
- Replace src/app/page.tsx with a redirect to /dashboard.
Middleware already handles the unauth → /login bounce, so this
works for both auth states.
- Drop src/app/{docs,opengraph-image.tsx,robots.ts,sitemap.ts}.
- Drop src/components/{landing,docs,seo} and src/lib/{seo,docs}.
- Drop the top-level docs/ markdown — moved to wacrm-site/docs.
- Slim src/app/layout.tsx metadata down to product chrome
(no public OG cards, robots: noindex).
- Drop `marked` from dependencies; refresh package-lock.
- Refocus README on self-hosting; point readers at wacrm.tech/docs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Meta's brand-use guidance for third-party products, sites that
reference WhatsApp must include a clear non-affiliation disclaimer in
the footer. Adds the standard line:
"WhatsApp® is a registered trademark of Meta Platforms, Inc.
Hostinger is not affiliated with, endorsed by, or sponsored by
Meta Platforms, Inc."
Placed in:
- src/components/landing/footer.tsx — marketing footer (every public
landing page).
- src/components/docs/shell.tsx — public docs pages had no footer at
all; added a slim one carrying the same disclaimer.
Out of scope:
- Authed dashboard pages don't have a footer (full-screen workspace
layout). The trademark-fair-use risk is concentrated on public
surfaces — crawlers, visitors who haven't signed up, social shares.
Skipping the dashboard keeps the workspace layout intact and the
disclaimer where it does the most work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Meta's brand-use guidance for third-party products, the registered
trademark symbol (®) should appear on the FIRST mention of "WhatsApp"
on each user-facing page. Subsequent mentions on the same page are
left plain.
Surfaces touched (one-per-page first mention):
- Landing hero badge ("Built on the official WhatsApp® Business API").
- src/lib/seo/site-config.ts: SITE_TAGLINE and SITE_DESCRIPTION
(rendered into <title> and <meta description> on every page).
- src/app/opengraph-image.tsx: hero text in social-share image.
- Settings, Inbox connection banner, Automations page subtitle.
- README.md tagline.
- All public docs pages (docs/README.md, docs/whatsapp-setup.md,
docs/getting-started.md, docs/supabase-setup.md,
docs/environment-variables.md, docs/deployment-hostinger.md,
docs/automations-and-cron.md, docs/architecture.md,
docs/troubleshooting.md).
Out of scope: SITE_KEYWORDS (the meta-keywords array isn't
user-rendered text — putting ® on every keyword would look broken in
the meta tag).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legal brief on third-party trademark use says: avoid the WhatsApp
teal/green palette and any visual treatment that could read as a
WhatsApp/Meta product. Emerald-500 was used as the primary brand colour
across hero, CTAs, sidebar, charts, badges, and the favicon — close
enough in hue to WhatsApp's signature green to muddy the line.
Swapping to Tailwind violet (which matches the Hostinger brand purple
#7c3aed) makes the visual identity unambiguously ours.
Scope:
- Mechanical Tailwind class swap: emerald-* → violet-* across src/.
- src/app/globals.css: --primary, --ring, --sidebar-primary, --sidebar-ring,
--chart-1 oklch tokens swapped from emerald (oklch 0.696 0.17 162) to
Hostinger-aligned violet (oklch 0.526 0.247 293).
- src/app/icon.tsx: favicon background #10b981 → #7c3aed.
- src/app/opengraph-image.tsx: badge bg, accent text, gradient.
- src/components/landing/hero.tsx + cta-banner.tsx: radial-gradient hex.
- src/components/landing/mock/analytics-mock.tsx: marketing chart line.
- src/components/dashboard/conversations-chart.tsx + response-time-chart.tsx:
"Outgoing" series colour. Distinguishable from "Incoming" blue at a
glance; we can revisit the chart pair later for stronger colourblind
contrast (separate concern).
- README.md: license badge colour parameter.
Out of scope:
- src/components/settings/tag-manager.tsx still offers "Emerald" as one
of the user-pickable contact-tag colours alongside Blue / Yellow / Red
/ etc. — that's a user data choice, not a brand mark.
Contrast check: white text on #7c3aed measures ~4.78:1 (WCAG AA passing
for normal body text). Violet-400 (#a78bfa) on slate-950 measures ~10:1
for accent text, well clear of AA.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the "WaCRM" branded name across user-facing surfaces in favour
of a descriptive name. Branded names that imply WhatsApp ownership or
sponsorship are a trademark fair-use risk; a relationship-based name
("CRM Template for WhatsApp") sidesteps that.
Scope:
- Brand text in nav, sidebar, footer, docs shell, mock window chrome.
- SITE_NAME / SITE_DESCRIPTION / legalName in src/lib/seo/site-config.ts.
- README, CONTRIBUTING, docs/, .github/ templates and policies.
- package.json description.
- Auth pages, settings copy, FAQ, code comments.
Out of scope (intentionally — these are infra renames the project
owner should drive separately so links and the package lockfile stay
consistent):
- The github.com/ArnasDon/wacrm repo URL.
- The wacrm.tech domain / homepage field.
- The package.json "name" field.
- The Hostinger application name and Meta system-user name in deploy
docs (existing-deployment instructions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the 14 pre-existing lint errors surfaced by React 19's new
hook rules so CI can enforce lint on every PR instead of tolerating
failures. The errors split roughly into three camps:
1) react-hooks/refs — "Cannot access refs during render"
4 occurrences of the "latest ref" pattern that assigned
`ref.current = x` during render. Moved each assignment into a
`useEffect(() => { ref.current = x })` — the canonical React-19
pattern. Consumers only read `.current` inside async callbacks
that run after commit, so the very slight delay to the update is
fine.
Touched: src/hooks/use-realtime.ts (onMessageRef,
onConversationRef), src/components/inbox/message-thread.tsx
(onMessagesLoadedRef), src/components/inbox/conversation-list.tsx
(onConversationsLoadedRef).
2) react-hooks/set-state-in-effect — genuine issues
- contacts/page.tsx: the `setPage(0)` on search change moved from
a useEffect into the `onChange` handler of the search input,
which is the cleaner pattern anyway.
- settings/page.tsx: the local `tab` state + sync effect were
removed entirely; the URL query param is now the single source
of truth, and the `onChange` handler just calls
`router.replace(...)`. Simpler and fixes the lint error.
3) react-hooks/set-state-in-effect — false positives for
load-on-mount and prop-driven sync
Suppressed per-call (or per-block for effects with many setters)
with explanatory comments. These effects use setters inside
async Supabase-query callbacks, not synchronously in the effect
body, so the cascade the rule warns about doesn't apply. Files:
contacts/page.tsx, pipelines/page.tsx, pipelines/deal-form.tsx,
pipelines/pipeline-settings.tsx,
components/inbox/contact-sidebar.tsx.
4) react-hooks/preserve-manual-memoization — one useCallback
Rule complained that the inferred dep was `contact` but the
source dep list was `[contact?.phone]`. Broadened the manual dep
to `[contact]` to match — callback behaviour is unchanged (it
still only reads `contact?.phone`), and the React Compiler can
now preserve the memoization.
And:
- .github/workflows/ci.yml: dropped `continue-on-error: true` on
the lint step. Lint now blocks merge on any new error.
Verified: `npm run lint` → 0 errors (24 warnings remain; all
pre-existing unused-import / missing-dep / <img> warnings that don't
fail CI). `npm run typecheck` clean. Landing, /docs, /login, and
/settings redirects all still respond correctly from the dev server.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The template is about shipping on Hostinger. Offering Vercel / Railway
/ raw VPS / Docker alongside it blurs that story and creates support
surface for hosts we don't actually test against.
- Delete docs/deployment-alternatives.md.
- README Deploy list drops the "Alternative hosts" bullet.
- docs/README.md reading-order drops step 6 and renumbers.
- docs/architecture.md drops its trailing "Next step" pointer to
the removed page.
- src/lib/docs/content.ts drops the deployment-alternatives entry;
remaining pages renumbered to 5..8.
Verified: /docs sidebar now lists 8 pages,
/docs/deployment-alternatives → 404.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Finishes the template-readiness sequence. All docs — nothing runtime.
README.md
Rewrites the landing page of the repo to actually sell what it is
to someone finding the repo cold on GitHub search.
- Shields.io badges: MIT, CI status, Next.js 16, Supabase, GitHub
stars.
- "What you get out of the box" bullet list — inbox, contacts,
pipelines, broadcasts, automations, dashboard, account
management — so the feature surface is visible before the reader
scrolls.
- "Why fork this?" section: full ownership, full customisation,
zero ops to start, real security primitives. Frames WaCRM as a
template, not an SDK.
- Docs section reorganised into Setup / Deploy / Reference to
match the in-app /docs viewer and docs/README.md.
- Contributing section rewrites the stale "Pull requests welcome"
line to match the fork-first framing from PR #73.
docs/architecture.md (new)
One-page tour for forkers: stack table, full folder layout,
two ASCII request-lifecycle diagrams (inbound webhook + outbound
send), security primitives rundown, and a "where to change things"
index that maps common customisation tasks to source files. This
is the thing I wish I'd had the first time I opened a forked
template.
docs/deployment-alternatives.md (new)
Short blocks for Vercel, Railway, raw VPS, and Docker. Each covers
env-var setup, cron strategy (Vercel cron without custom headers
is a footgun worth calling out), and trade-offs. Keeps Hostinger
as the primary recommendation — this page is for people whose
constraints rule it out.
docs/README.md
- Reading order grouped as Setup / Deploy / Reference, adding the
two new pages.
- Stack-at-a-glance updated: encryption is AES-256-GCM, not CBC
(the PR #71 change that earlier docs hadn't caught up to).
src/lib/docs/content.ts
Registers the two new markdown files in the ORDER map so they
appear in the in-app /docs sidebar under the Reference section,
with prev/next pagination wiring.
Verified: /docs, /docs/architecture, /docs/deployment-alternatives,
and /docs/getting-started all return 200 from the dev server.
Sidebar shows 9 pages grouped Setup / Deploy / Reference with the
new entries. Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WaCRM is a template — users are expected to fork and customise their
own deployment, not to send feature work back upstream. The previous
CONTRIBUTING.md read like a standard collaborative-project onboarding
guide and set the wrong expectation.
Reframed docs:
- CONTRIBUTING.md is now "Using WaCRM". Opens with "fork it and make
it yours", covers forking + keeping-in-sync commands, explains
which PRs do tend to land (security, correctness, docs, small
nits) vs. which belong in a fork (new features, stack swaps,
opinionated refactors). Keeps the filename so GitHub's built-in
"Contributing guidelines" link still surfaces it.
- .github/ISSUE_TEMPLATE/feature_request.yml: the intro block now
tells the forker up front that feature requests usually become
"build it in your fork", and describes the narrow cases where an
upstream request is useful.
- .github/ISSUE_TEMPLATE/config.yml: adds a third contact link
pointing at CONTRIBUTING.md so people see the fork-first framing
before filing.
- .github/pull_request_template.md: prepends a heads-up that most
changes belong in the forker's own repo, with a pointer to
CONTRIBUTING.md.
Nothing else in the PR changes — CoC, Dependabot, EditorConfig,
Prettier configs, issue bug_report.yml, and the package.json changes
are orthogonal to the fork-first framing and stay as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nine paperwork files + one devDep. All support work for a repo that's
about to be public; zero runtime behaviour change.
- CONTRIBUTING.md at root. Dev loop, script reference, PR style, code
style, review process. Short on prose, long on specifics.
- .github/CODE_OF_CONDUCT.md adopts Contributor Covenant 2.1 by
reference (no inlined text). Points reports to the maintainer's
email and GitHub's private flow.
- .github/ISSUE_TEMPLATE/ — structured YAML forms for bug and feature
reports, plus config.yml that disables blank issues and surfaces
the security-disclosure + docs links. The bug form asks for
version, runtime, and logs up front so triage doesn't start with
a round of "where is it running?".
- .github/pull_request_template.md — short checklist; nudges toward
typecheck / lint / build before submit without being long enough
to skip.
- .github/dependabot.yml — weekly npm + github-actions scans,
grouped so Supabase / @types / dev-only patches batch into single
PRs instead of six per week. Next / React / Tailwind / eslint-
config-next are ignored so majors stay manual.
- .editorconfig — LF, UTF-8, 2 spaces, final newline, strip trailing
whitespace (except in Markdown where trailing spaces are line
breaks).
- .prettierrc — single quotes + trailing-comma es5, matches the
plurality quote style in the codebase (336 single vs 236 double
in imports). Tailwind plugin sorts class names.
- .prettierignore — node_modules, build output, lockfile, migrations.
- package.json adds "format" / "format:check" scripts and pins
prettier + prettier-plugin-tailwindcss as devDeps.
- package-lock.json refreshed for the new devDeps.
Important: existing source files are NOT reformatted in this PR. 153
files would change if we ran `npm run format`, which would balloon
the diff and make review impossible. The reformat pass is a separate,
deliberate PR.
Typecheck clean; `npx prettier --check` on the new config files
passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>