Engineering Standards
This document defines the non-negotiable engineering rules for the WhatsApp MCP server. No phase is “done” without satisfying every section here. CI enforces what can be enforced; code review enforces the rest.
1. Automated testing
Layers
| Layer | Tooling | Scope |
|---|---|---|
| Unit | Vitest | Every utility, validator, normaliser, scope checker, rate-limit bucket, signed-URL helper, phone normaliser, Meta error mapper |
| Integration | Vitest + testcontainers Postgres | Every tool handler, Inngest function, webhook route, auth middleware. Tests run against a real ephemeral Postgres, not mocks of the DB |
| End-to-end (selective) | Vitest + supertest against a running app | Critical flows: webhook → message persisted → notification routed; send → Meta API (mocked at the network boundary with msw) → row written |
| Cross-tenant isolation | Vitest + testcontainers | Spin up two clients with disjoint grants; assert client B cannot read/write client A’s rows via any tool, scope, or grant path |
| Webhook signature | Vitest | Valid signature passes; tampered body → 404; missing header → 404; replay (same wamid) → idempotent no-op |
Coverage gates (line coverage)
| Directory | Minimum |
|---|---|
src/ (overall) | 80 % |
src/auth/ | 95 % |
src/webhook/verify-signature.ts | 100 % |
src/db/scoped.ts | 95 % |
src/audit/ | 95 % |
src/media/signed-url.ts | 100 % |
CI fails if coverage drops below threshold. Coverage is reported per PR.
Test commands
pnpm test # unit tests, fast, no Docker
pnpm test:integration # integration tests; spins up Postgres testcontainer
pnpm test:e2e # end-to-end against a running app
pnpm test:ci # all of the above + coverage report
pnpm test:watch # unit tests in watch mode for local devHooks and CI
- Pre-commit (via
lefthook): unit tests for changed files +eslint+tsc --noEmit. - Pre-push: full unit suite.
- CI (GitHub Actions):
pnpm test:cion every PR + coverage gate +typedocregeneration check.
When to write what
- A new function → at least one unit test before the function ships.
- A new tool → integration test against testcontainers Postgres, asserting auth, scope, grant, rate-limit interaction, and the happy path.
- A new Inngest function → integration test using the Inngest test helper, asserting idempotency and retry semantics.
- A bug fix → a failing test that reproduces the bug, then the fix that turns it green.
What we never mock
- Postgres (use testcontainers).
- HMAC / crypto primitives.
- Meta error code → typed error mapping.
What we always mock
- The Meta Cloud API HTTP boundary (
mswinterceptor at the fetch layer). - Filesystem writes outside
MEDIA_ROOT(memfs). - Time (use
vi.useFakeTimers()for sliding-window tests).
2. Code commenting
Every exported function, class, type, and module carries a TSDoc block.
/**
* Verifies a Meta webhook signature header against the raw request body.
*
* @param rawBody — exact bytes of the request body, before JSON parsing.
* @param header — the value of the `X-Hub-Signature-256` header (must include the `sha256=` prefix).
* @param appSecret — the WhatsApp App Secret bound to this webhook endpoint.
* @returns `true` if the signature is valid, `false` otherwise.
*
* @remarks
* Security boundary: this is the gatekeeper for all inbound webhook traffic.
* - Uses `crypto.timingSafeEqual` to prevent timing-based oracle attacks.
* - Returns `false` (not throws) on malformed headers so the caller decides the HTTP response.
* - Caller MUST reject with HTTP 404 on `false` — do not leak endpoint existence to scanners.
*/File-level header
The first comment block in every src/**/*.ts file states:
- The file’s responsibility (one sentence).
- Its trust level —
untrusted-input(handles unauthenticated request bodies),authenticated-context(assumesreq.authis present), orinternal(called only by other internal modules). - Data it must not log (e.g. message bodies, tokens).
// src/webhook/verify-signature.ts
/**
* @file Webhook signature verification for Meta inbound deliveries.
* @trust untrusted-input — runs before any authentication.
* @no-log Request body, signature header (both are forensic-only).
*/@remarks usage
Add a @remarks block whenever the behaviour involves:
- A non-obvious invariant (“idempotent on
wamid”). - A security boundary (“constant-time compare”).
- An idempotency contract (“safe to retry; insert with ON CONFLICT DO NOTHING”).
- A rate-limit interaction (“counts toward daily cap after dequeue”).
- A Meta-API quirk (“error 131047 means 24h window expired”).
Migrations
Every drizzle/*.sql file starts with a -- WHY: comment explaining the change, not what it does (which is the SQL):
-- WHY: introduce client_phone_grants so that revoking a number from a client
-- is a single row update that takes effect on the next request, without
-- having to revoke every API key the client holds.
ALTER TABLE ...Anti-patterns
- Do not write comments that restate the code (
// increment counterabovecounter++). - Do not write task-tracking comments (
// added for ticket #123). - Do not write multi-paragraph essays; a
@remarksblock is at most ~5 lines.
3. TSDoc → markdown reference
typedoc+typedoc-plugin-markdownis wired up viapnpm docs:referencefor local use.- Output lands in
docs/reference/(one file per module), which is gitignored. - Generated docs are not committed; a dedicated docs project will consume the TSDoc output later.
docs/reference/is not hand-edited under any circumstance.
4. Documentation structure under docs/
Created incrementally per phase, not all up-front. The plan files are themselves under docs/plan/.
| Folder | Contents | Hand-written? |
|---|---|---|
docs/plan/ | This plan set | Yes |
docs/architecture/ | One file per subsystem: auth.md, inngest.md, webhook.md, mcp-transport.md, media.md, rate-limiting.md, audit.md, database.md. Each ≤ 2 pages, explains the why. Authoritative human-readable design. | Yes |
docs/reference/ | Auto-generated TSDoc → markdown for every exported symbol. Gitignored; produced locally via pnpm docs:reference. | No (generated, not committed) |
docs/components/ | One file per top-level component: mcp-server.md, webhook-server.md, inngest-runner.md, admin-cli.md. Each lists responsibilities, public interfaces, dependencies, and key files. | Yes |
docs/api/ | External-facing: mcp-tools.md (every tool’s name, scope, input/output schema with examples for client devs), webhook-payloads.md (Meta payload shapes we accept), errors.md (error codes the MCP surface emits). | Yes |
docs/operations/ | Runbooks. See docs/plan/ops/ for stubs that will be promoted here. | Yes |
docs/testing/ | test-strategy.md, running-tests-locally.md, coverage-policy.md | Yes |
5. Per-phase deliverable addendum
Every phase file (phase-0 through phase-8) carries three mandatory subsections in addition to its work items:
- Tests — the suites that must pass for this phase to be done. Names of test files where reasonable; what they cover at minimum.
- Code documentation — the TSDoc blocks (modules to document with
@remarks) and thedocs/architecture/*.md/docs/components/*.mdfiles this phase produces or extends. - Acceptance — the concrete test-runs and doc-existence proofs that verify the phase is complete.
A phase is closed by a single PR (or series, all merged) that satisfies all three.
6. Style and tooling
- Language: TypeScript with
strict: true, ESM, Node 20+. - Lint: ESLint with
@typescript-eslint, plus a custom rule banningprocess.envoutsidesrc/config/. - Format: Prettier with a single shared config (no per-file overrides).
- Package manager:
pnpm. - Imports: absolute via
tsconfigpaths(@/auth,@/db, etc.) — no../../../. - Logging:
pinowith a redaction list includingauthorization,*token*,*secret*,password,x-hub-signature-256. - HTTP client: native
fetchwith a thin retry wrapper. No axios. - Validation:
zodat every trust boundary (env, MCP tool input, webhook payload, Inngest event data). - IDs: UUID v7 where available (sortable by time) via
uuidv7, fallback togen_random_uuid()(v4) in SQL defaults.