P/09 · Personal project · in developmentformerly Galletas4All

Consentinel: A cookie consent widget I built to own my data.

Self-hosted cookie consent on Cloudflare Workers + D1.

  • In development
  • Personal project
  • 54 tests
  • CI on Node 20 + 22
  • Cloudflare free tier

Consentinel is a self-hosted cookie consent widget that runs on Cloudflare Workers, in development as a personal project. It blocks non-essential scripts until a visitor consents, logs every decision to a Cloudflare D1 database in your own account, and covers the opt-in requirements of GDPR, Chile's Ley 21.719, and Brazil's LGPD.

Many consent tools charge monthly, share a tracking layer across customer sites, and keep your consent records on servers you don't control. I wanted something small, self-hosted, and auditable — so I built one. It runs on your own Cloudflare account — the free tier covers around 100,000 consent events a day — and the consent log lives in a database you own.

Built around Chile's Ley 21.719 (in force December 1, 2026), but the opt-in model it uses is the same one GDPR and most Latin American laws require.

~9 KBWidget on the wire, gzipped (≈22 KB raw)
54Tests — unit + integration via vitest-pool-workers
~100k/dayFree-tier ceiling for consent events on Cloudflare
What it does

One script tag. Your database.

You drop one script tag in your site's <head>. The widget shows a consent banner in Spanish or English, blocks any script you've tagged with data-consent-category until the visitor decides, then logs the decision to a Cloudflare D1 table you own.

That last part is the point. The consent log lives in your own Cloudflare account — not mine, not a shared SaaS database. When a regulator asks for an audit trail, you pull it yourself.

At runtime it makes no external calls except the audit POST to your own Worker.

Where it applies

Opt-in laws. One mechanic.

Consentinel implements the opt-in model: block non-essential scripts until the visitor agrees. That's what GDPR and the ePrivacy Directive require across the EU and UK (with fines reaching up to €20M or 4% of global turnover), what Chile's Ley 21.719 requires from December 1, 2026, and what most Latin American laws — including Brazil's LGPD — are built around. The same code covers all of them, because the mechanic is the same.

What it does not do is the US opt-out model — CCPA/CPRA, "Do Not Sell," and the Global Privacy Control signal work the opposite way. That's a different mechanic and it's out of scope.

Compatible with opt-in laws by design — not legal advice, and not a compliance certification.

GDPR / ePrivacy

EU & UK

Ley 21.719

Chile (from Dec 2026)

LGPD

Brazil

Installation

By hand or by prompt.

Installation is one script tag. It's small enough and declarative enough that you don't need a dashboard, an account, or an SDK to learn.

You can paste the tag, or hand the README to your AI coding agent — Claude Code, Cursor, bolt.new, Lovable, Replit — and have it wired up in a single prompt. Shipping it as a package instead of a service is the whole point: it drops straight into whatever you're already building.

An early version is published on npm under the project's former name, galletas4all.

  • Claude Code
  • Cursor
  • bolt.new
  • Lovable
  • Replit
Architecture

Under the hood

Browser

Preact 10 renders the banner — ~3 KB of runtime; MutationObserver rewrites tagged scripts to type="text/plain" until consent is given, then swaps them back; navigator.sendBeacon sends the audit payload — survives tab close, unlike a plain fetch

The Worker

Origin allow-list validation on every request; 4 KB body cap measured from actual bytes, not the Content-Length header; Parameterized D1 insert — no SQL injection surface; GET /stats is default-deny until you set STATS_TOKEN

Audit log

Every decision writes one row: visitor ID, accepted boolean, category arrays, URL, referrer, user-agent, timestamp; Table lives in your Cloudflare account — not mine; D1 errors return 503; unexpected errors return 500

Design decisions

A few calls worth explaining

navigator.sendBeacon first, not fetch

The most common failure mode for consent loggers is the user accepting and immediately closing the tab. A plain fetch without keepalive gets cancelled on unload. sendBeacon survives it. I found this bug during a pre-launch audit and fixed it before shipping 1.0.2.

Content-Type: text/plain on the POST

This skips the CORS preflight round-trip — text/plain is a CORS-safelisted content type. The server still parses the body as JSON. It saves a round-trip on every consent decision — not a huge optimization, but a free one.

Default-deny on /stats

A consent widget handling privacy data shouldn't expose analytics by accident. The stats endpoint returns 503 unless you've explicitly set a bearer token. Most operators won't set one, and that's fine — the safe default is off.

Preact, not React

The whole widget is ~9 KB gzipped. Preact's runtime is around 3 KB of that. I didn't want to ship ~45 KB of React to a site that otherwise doesn't use it.

teardownCookieEnforcer()

The MutationObserver runs as long as the page exists. In an SPA that mounts the widget on every route change, you'd stack observers indefinitely. The mount() return function now tears the observer down. This was a real memory leak — the kind that only shows up in long-running SPA sessions.

Tech stack

What it's built with

Core

TypeScript (strict), Preact 10, esbuild 0.28

Testing

Vitest 2, jsdom, @cloudflare/vitest-pool-workers

Infrastructure

Cloudflare Workers, Cloudflare D1, Cloudflare Pages

Delivery

npm, jsDelivr CDN, CI on Node 20 + 22

Roadmap

What's next

A personal side project, not a product I'm scaling — so the roadmap is "what I'll probably get to."

Nothing here is committed. Compliance gaps come before polish: the /export endpoint for data-subject requests matters more than a theme editor.

  • Google Consent Mode v2
  • GTM template
  • React wrapper
  • Vue wrapper
  • Svelte wrapper
  • Astro wrapper
  • WordPress recipe
  • Shopify recipe
  • Webflow recipe
  • Ghost recipe
  • CSS-variable theming
  • HMAC-signed consent ledger

v1.0.3

Accessibility — focus trap, Esc to close, reduced-motion

v1.0.4

Worker /health endpoint · custom domain for the demo

v1.0.5

/export endpoint for DSAR audit trails (NDJSON, token-gated)

Deep dives

More on the architecture

Why Cloudflare Workers + D1, not a traditional backend?

Cloudflare's free tier covers roughly 100,000 consent events per day — more than enough for most personal or small-business sites. D1 is a SQLite-compatible database that lives in your Cloudflare account, not mine. There's no server to provision, no connection pool to manage, and the cold-start time for a Worker is measured in milliseconds. The self-hosting angle is the real reason. Any hosted consent-log backend means your audit data is on someone else's infrastructure. With D1, the data is yours by default.

How does script blocking work?

Before consent, the widget rewrites any data-consent-category-tagged scripts to type="text/plain", which the browser ignores. A MutationObserver watches for new scripts added after the widget mounts — they get the same treatment until consent is recorded. On consent, the type attribute is restored, and the browser re-evaluates the scripts. Reject follows the same flow in reverse — scripts are neutralized and any cookies that were already set are not re-set on subsequent loads.

What does 'self-hosted' mean in practice?

You deploy one Cloudflare Worker (a single TypeScript file) and one D1 table to your own Cloudflare account. Setup takes about 10 minutes following the quickstart. After that, the widget's audit POSTs go to your Worker URL — no traffic passes through anything I own.

stack

  • TypeScript (strict)
  • Preact 10
  • esbuild 0.28
  • Vitest 2
  • jsdom
  • @cloudflare/vitest-pool-workers
  • Cloudflare Workers
  • Cloudflare D1
  • Cloudflare Pages
  • npm
  • jsDelivr CDN
  • CI on Node 20 + 22

Want to talk it through? Email hello@fmillan.com or see the contact page.