Architecture
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
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 |
|---|---|---|---|
| ESM library |
| The library entry point; not served to browsers directly |
| IIFE, minified | Every site's | The base embed served from the VPS/CDN. 32KB gzip budget, enforced in CI ( |
| IIFE, self-contained | Lazy-loaded by | 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.tsis the framework-agnostic core — every route handler is a function from a parsedApiRequestto anApiResponse.src/node-server.tsis the thinnode:httpadapter around it;src/http-server.tsadds rate limiting, CORS, and static embed serving on top;src/main.tsis 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 — seedev-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/v1can evolve on its own compatibility guarantee without being coupled to dashboard UI churn.Every
/v1request is authenticated by an API key and the org is derived from the key — callers never pass anorgId. Everycbidpath is checked for org ownership before use; a key from org A reading org B's site gets a plain404, not a403(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:
On
record(), the service readslastHash(cbid)— the previous record's hash — and includes it asprevHashon the new record before hashing (records.ts:buildRecord).verifyChain(cbid)walks the stored records in order and confirms each one'sprevHashmatches the prior record'shash; a single edited or deleted record breaks the chain from that point forward, andGET /v1/sites/:cbid/consent/verifysurfaces that as{ valid: false }.Optionally, every appended record is HMAC-signed over its hash (
ConsentLogServiceOpts.signingKey,CONSENT_SIGNING_KEYenv var). With signing active,verifyChainalso 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.Optionally,
userAgent/urlfields 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; seeconsent-crypto.ts/consent-keystore.ts, rotated viascripts/rotate-kek.mjs).POST /v1/sites/:cbid/erase-consentcrypto-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 breaksverifyChain.
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 |
|---|---|
| banner, prior-blocking engine, embed SDK, the three embed artifacts |
| IAB TCF (TC-string), GPP, US Privacy, CMP/GPP/USP API + locators |
| HTTP edge, config store, consent log, stats, CSV, Postgres, scanning, docs, DSAR wiring |
| multi-tenancy, auth/sessions, API keys + scopes, plans/billing |
| OAuth (Google/GitHub), SAML, SCIM, widget tokens |
| Google Consent Mode v2, Microsoft UET, Meta, TikTok |
| region resolution, GPC/DNT, jurisdictions |
| cookie scan + classification (Playwright Chromium crawl) |
| policy generation, preference center, DSAR, RoPA/vendor risk, signed receipts, cookie classification |
| the typed REST client and the MCP server for |
|
|
| Next.js landing page + dashboard |
| 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.