Docs

Architecture

AdminUpdated Sep 15, 2026

Architecture

Cookie Munch is a pnpm monorepo of small, framework-agnostic TypeScript packages (@cookiemunch/*, ESM, strict TypeScript). This page covers the three pieces that matter most to an integrator: how the embed is built and shipped, how the API server is structured, and how the consent ledger is made tamper-evident.

The embed: three build artifacts, one source

@cookiemunch/core compiles to three distinct outputs (packages/core/tsup.config.ts), and the split between them is load-bearing, not incidental:

Artifact

Format

Consumers

Notes

index.js

ESM library

@cookiemunch/react, the server, tests

The library entry point; not served to browsers directly

consent.js

IIFE, minified

Every site's <script> tag

The base embed served from the VPS/CDN. 32KB gzip budget, enforced in CI (pnpm --filter @cookiemunch/core size)

consent-tcf.js

IIFE, self-contained

Lazy-loaded by consent.js

The heavy IAB TCF/GPP/US-Privacy bundle — TC-string encode/decode, GVL handling

consent.js contains the prior-blocking engine, the banner UI, and thin queueing stubs for __tcfapi/__gpp/__uspapi plus a lazy-loader. It never imports consent-tcf.js — that dependency edge is exactly what the size budget guards against. When a site has IAB TCF/GPP/USP enabled, consent.js installs the synchronous API stubs immediately (so __tcfapi calls queued before the banner finishes loading aren't lost) and fetches consent-tcf.js in the background only then. Sites that don't use TCF/GPP/USP never pay for that bundle at all.

Prior-blocking itself lives in packages/core/src/blocker/: auto.ts rewrites <script> tags to type="text/plain" before the browser executes them and restores them post-consent; manual.ts honors explicit data-cookieconsent markup for scripts you tag yourself. The blocker also reads the legacy Cookiebot CookieConsent cookie, so a migrating site doesn't re-prompt visitors who already consented under the old tool.

The API server: a runnable app, not a library

packages/server is structured so its HTTP logic is testable without a socket:

  • src/http.ts is the framework-agnostic core — every route handler is a function from a parsed ApiRequest to an ApiResponse. src/node-server.ts is the thin node:http adapter around it; src/http-server.ts adds rate limiting, CORS, and static embed serving on top; src/main.ts is the entry point that wires dependencies (tenancy, config store, consent log, etc.) and starts listening.

  • Two distinct authenticated surfaces are dispatched from the same edge: the dashboard/control-plane API (/api/v1/*, session-cookie auth, everything the web app's own UI calls) and the Developer API (/v1/*, API-key auth, the stable external-integration surface — see dev-api.ts, dev-api-auth.ts, dev-api-shared.ts, dev-api-sites.ts). They're deliberately separate implementations even where they overlap (e.g. banner-library CRUD exists on both), so /v1 can evolve on its own compatibility guarantee without being coupled to dashboard UI churn.

  • Every /v1 request is authenticated by an API key and the org is derived from the key — callers never pass an orgId. Every cbid path is checked for org ownership before use; a key from org A reading org B's site gets a plain 404, not a 403 (the API never confirms another org's resource exists).

Dual persistence

The server runs against Postgres when DATABASE_URL is set, or entirely in-memory otherwise (seeded with demo data so a fresh checkout has something to look at). This isn't a fallback bolted on top — every store is implemented twice behind a shared interface: InMemoryConsentRepository/PgConsentRepository, InMemoryDsarStore/PgDsarStore, InMemoryRopaStore/PgRopaStore, InMemoryVendorStore/PgVendorStore, and so on (see repositories.ts, pg.ts). createDevApi's dependency injection reflects this directly: DSAR/RoPA/vendor services are constructed per-org, backed by Postgres when a pool is supplied to the server and by in-memory stores otherwise — a self-host without a database still has a fully functional (if non-durable) Developer API. When you add a new kind of persisted resource to the server, both implementations have to exist for this pattern to hold.

The hash-chained consent ledger

Consent records are anonymised (IP truncated) and appended to a hash chain, making the log tamper-evident by default and tamper-proof when a signing key is configured:

  1. On record(), the service reads lastHash(cbid) — the previous record's hash — and includes it as prevHash on the new record before hashing (records.ts: buildRecord). verifyChain(cbid) walks the stored records in order and confirms each one's prevHash matches the prior record's hash; a single edited or deleted record breaks the chain from that point forward, and GET /v1/sites/:cbid/consent/verify surfaces that as { valid: false }.

  2. Optionally, every appended record is HMAC-signed over its hash (ConsentLogServiceOpts.signingKey, CONSENT_SIGNING_KEY env var). With signing active, verifyChain also rejects any record whose signature doesn't match — an attacker with direct database access can no longer even forge a consistent-looking chain, not just get caught extending a broken one.

  3. Optionally, userAgent/url fields are stored as AES-GCM ciphertext under a per-subject data-encryption key (DEK), itself wrapped by a key-encryption key (KEK — COOKIE_CONSENT_KEK; see consent-crypto.ts/consent-keystore.ts, rotated via scripts/rotate-kek.mjs). POST /v1/sites/:cbid/erase-consent crypto-erases a subject by destroying their DEK — the ciphertext becomes permanently unreadable, but the hash chain (computed over the ciphertext, not the plaintext) stays intact, so erasure never breaks verifyChain.

This is why receipts, subject export, and erasure are gated behind the consent:* scope rather than sites:* in the Developer API — they touch consent PII directly, unlike config or site metadata. See Authentication & scopes.

Package map

Package

Role

core

banner, prior-blocking engine, embed SDK, the three embed artifacts

tcf

IAB TCF (TC-string), GPP, US Privacy, CMP/GPP/USP API + locators

server

HTTP edge, config store, consent log, stats, CSV, Postgres, scanning, docs, DSAR wiring

saas

multi-tenancy, auth/sessions, API keys + scopes, plans/billing

sso

OAuth (Google/GitHub), SAML, SCIM, widget tokens

integrations

Google Consent Mode v2, Microsoft UET, Meta, TikTok

geo

region resolution, GPC/DNT, jurisdictions

scanner

cookie scan + classification (Playwright Chromium crawl)

policy / preferences / dsar / governance / receipts / cookie-db

policy generation, preference center, DSAR, RoPA/vendor risk, signed receipts, cookie classification

sdk / mcp

the typed REST client and the MCP server for /v1

react / react-native

<ConsentProvider>, useConsent, <ConsentGate>

apps/web

Next.js landing page + dashboard

native/

Swift, Kotlin/Gradle, Flutter SDKs (built in CI, outside the pnpm workspace)

Next: Authentication & scopes to start calling /v1, or Conventions & errors for the shared request/response shape.

Was this page helpful?