Skip to Content

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

LayerToolingScope
UnitVitestEvery utility, validator, normaliser, scope checker, rate-limit bucket, signed-URL helper, phone normaliser, Meta error mapper
IntegrationVitest + testcontainers  PostgresEvery 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 appCritical flows: webhook → message persisted → notification routed; send → Meta API (mocked at the network boundary with msw) → row written
Cross-tenant isolationVitest + testcontainersSpin up two clients with disjoint grants; assert client B cannot read/write client A’s rows via any tool, scope, or grant path
Webhook signatureVitestValid signature passes; tampered body → 404; missing header → 404; replay (same wamid) → idempotent no-op

Coverage gates (line coverage)

DirectoryMinimum
src/ (overall)80 %
src/auth/95 %
src/webhook/verify-signature.ts100 %
src/db/scoped.ts95 %
src/audit/95 %
src/media/signed-url.ts100 %

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 dev

Hooks and CI

  • Pre-commit (via lefthook): unit tests for changed files + eslint + tsc --noEmit.
  • Pre-push: full unit suite.
  • CI (GitHub Actions): pnpm test:ci on every PR + coverage gate + typedoc regeneration 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 (msw interceptor 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:

  1. The file’s responsibility (one sentence).
  2. Its trust level — untrusted-input (handles unauthenticated request bodies), authenticated-context (assumes req.auth is present), or internal (called only by other internal modules).
  3. 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 counter above counter++).
  • Do not write task-tracking comments (// added for ticket #123).
  • Do not write multi-paragraph essays; a @remarks block is at most ~5 lines.

3. TSDoc → markdown reference

  • typedoc + typedoc-plugin-markdown is wired up via pnpm docs:reference for 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/.

FolderContentsHand-written?
docs/plan/This plan setYes
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.mdYes

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 the docs/architecture/*.md / docs/components/*.md files 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 banning process.env outside src/config/.
  • Format: Prettier with a single shared config (no per-file overrides).
  • Package manager: pnpm.
  • Imports: absolute via tsconfig paths (@/auth, @/db, etc.) — no ../../../.
  • Logging: pino with a redaction list including authorization, *token*, *secret*, password, x-hub-signature-256.
  • HTTP client: native fetch with a thin retry wrapper. No axios.
  • Validation: zod at every trust boundary (env, MCP tool input, webhook payload, Inngest event data).
  • IDs: UUID v7 where available (sortable by time) via uuidv7, fallback to gen_random_uuid() (v4) in SQL defaults.