# Keksdose

[![Hex.pm](https://img.shields.io/hexpm/v/keksdose.svg)](https://hex.pm/packages/keksdose)
[![HexDocs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/keksdose)

Drop-in cookie-consent ingestion and audit dashboard for Phoenix/Plug applications.

## About the name

*Keks* is German for cookie; a *Keksdose* is a cookie jar — the kind that
sits on a kitchen counter holding biscuits. The library is the jar your
browser-cookie consents go into, one biscuit per visitor decision.

## Browser side

Keksdose is the backend half — it has no opinion about which JavaScript
library generates the consent events, only about the JSON shape they POST.
The wire format documented below was designed against
**[vanilla-cookieconsent](https://github.com/orestbida/cookieconsent/)** by
Orest Bida (see [his blog post](https://orestbida.com/blog/cookie-consent/)
for the design context), and that's what we test against. Any other consent
library that can be configured to emit the same payload shape will work too.

The package stores anonymous, audit-ready consent records into your existing
Ecto repo as an **append-only log** (one row per consent event). It ships:

- POST ingestion plug — mount wherever you `forward` to it
- Paginated, country-filtered, server-rendered analytics dashboard
- `mix keksdose.install` migration generator
- Opt-in retention `GenServer`
- `:telemetry` events for observability
- `<script>` helper to wire the JavaScript client

## Installation

Add the dependency to `mix.exs`:

```elixir
def deps do
  [
    {:keksdose, "~> 0.4"}
  ]
end
```

Fetch and compile:

```bash
mix deps.get
```

Generate the migration:

```bash
mix keksdose.install
mix ecto.migrate
```

Configure your repo in `config/config.exs`:

```elixir
config :keksdose, repo: MyApp.Repo
```

Mount the ingestion plug in your Phoenix router at whatever path you like:

```elixir
pipeline :api do
  plug :accepts, ["json"]
end

scope "/" do
  pipe_through :api
  forward "/api/cookie-consents", Keksdose.PlugHandler
end
```

The plug accepts any POST that reaches it and responds 405 to other methods.
The URL is your decision — `/api/cookie-consents`, `/privacy`, anything that
matches your app's conventions. Keep that path in mind for the client wiring
and the rate-limit rule below.

## Data model

Each POST creates a new row. Consent changes from the same browser produce
additional rows sharing the same `consent_id`. The full history is preserved.

| Column | Required | Type | Notes |
|---|---|---|---|
| `id` | yes | `binary_id` | Server-generated event ID (PK) |
| `consent_id` | yes | `binary_id` | UUIDv4 the browser generates once and persists |
| `categories` | yes | JSON list of strings | e.g. `["necessary", "analytics"]`. Column type: jsonb (Postgres) / json (MySQL) / text (SQLite). |
| `changed_categories` | no | JSON list of strings | Delta on consent change |
| `revision` | no | `integer` | Policy version at consent time (host opts in by sending one) |
| `language` | no | `string(10)` | BCP-47-ish tag, e.g. `"en"` |
| `country_iso` | no | `string(3)` | Resolved from CDN headers, else `"XX"` |
| `masked_ip` | no | `string(45)` | IPv4 last octet zeroed; IPv6 truncated to `/48` |
| `inserted_at` | yes | `utc_datetime` | Server-stamped |

## Client integration

In your layout (passing the same mount path you put in the router):

```html
<%= raw Keksdose.FrontendConfig.inject_script("/api/cookie-consents") %>
```

That script defines `window.keksdoseEndpoint`. In your consent JS, POST a
camelCase JSON body:

```javascript
CookieConsent.run({
  onFirstConsent: ({ cookie }) => transmit(cookie),
  onChange:       ({ cookie }) => transmit(cookie)
});

function transmit(data) {
  fetch(window.keksdoseEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      consentId: data.id,                       // UUIDv4 persisted by the browser
      categories: data.categories,              // ["necessary", "analytics", ...]
      changedCategories: data.changedCategories,// optional delta on change events
      revision: data.revision,                  // optional policy version
      language: data.language                   // optional, e.g. "en"
    })
  });
}
```

> **`consentId` must be a UUIDv4 that your browser-side library generates
> once and persists** (localStorage / first-party cookie). It's what makes
> the audit log auditable — every consent event from the same browser
> shares it. If your library doesn't supply one out of the box, generate
> and persist a UUID yourself.

The server stamps `inserted_at` and ignores any client-supplied timestamp.

## Security

### Gate the dashboard

The dashboard plug performs no authentication. **Mount it inside your admin
auth pipeline.** Example with `plug` checks:

```elixir
pipeline :admin do
  plug :browser
  plug MyApp.RequireAdmin
end

scope "/" do
  pipe_through :admin
  forward "/admin/keksdose/records", Keksdose.DashboardPlug
end
```

Anyone reaching the plug can read every consent record, so do not skip this.

### Rate-limit ingestion

The ingestion endpoint is intentionally unauthenticated (the visitor is
anonymous). Put a rate limit in front of it — these examples assume you
mounted it at `/api/cookie-consents`; adjust to your path.

**Nginx**

```nginx
http {
    limit_req_zone $binary_remote_addr zone=consent_ingest:10m rate=5r/s;

    server {
        location /api/cookie-consents {
            limit_req zone=consent_ingest burst=10 nodelay;
            proxy_pass http://phoenix_upstream;
        }
    }
}
```

**Caddy**

```caddy
your.domain.com {
    @consent path /api/cookie-consents
    rate_limit @consent {
        zone consent_ingest
        events 5
        window 1s
        burst 10
    }
    reverse_proxy phoenix_upstream:4000
}
```

(Caddy's `rate_limit` directive is available via the
[caddy-ratelimit](https://github.com/mholt/caddy-ratelimit) module.)

Application-layer alternatives: [Hammer](https://hex.pm/packages/hammer),
[PlugAttack](https://hex.pm/packages/plug_attack), or a Cloudflare rule.

## Retention

The package can periodically purge rows older than a configurable threshold.
Opt in by adding `Keksdose.Retention` to your supervision tree:

```elixir
# config/config.exs
config :keksdose,
  repo: MyApp.Repo,
  retention_days: 395,                       # nil disables purging
  retention_check_interval_ms: :timer.hours(24)

# lib/my_app/application.ex
children = [
  MyApp.Repo,
  Keksdose.Retention
]
```

The GenServer waits ~60 seconds after boot before its first run, then ticks on
the configured interval. To run a purge synchronously (tests, ops):

```elixir
Keksdose.Retention.purge_now()
```

## Telemetry

Attach to these events:

| Event | Measurements | Metadata |
|---|---|---|
| `[:keksdose, :record, :inserted]` | `count`, `duration_native` | `country_iso`, `revision` (nullable) |
| `[:keksdose, :record, :rejected]` | `count` | `errors` |
| `[:keksdose, :retention, :purged]` | `count`, `duration_native` | `cutoff`, `retention_days` |

```elixir
:telemetry.attach_many(
  "my-app-consent",
  [
    [:keksdose, :record, :inserted],
    [:keksdose, :record, :rejected],
    [:keksdose, :retention, :purged]
  ],
  &MyApp.Telemetry.handle_event/4,
  nil
)
```

## Database adapters

Supported out of the box. The install task detects your configured repo's
adapter and emits the right column type for `categories`:

| Adapter | `categories` column | Native? |
|---|---|---|
| PostgreSQL (`Ecto.Adapters.Postgres`) | `:jsonb` | Yes |
| MySQL 5.7+ / 8.x (`Ecto.Adapters.MyXQL`) | `:json` | Yes |
| SQLite (`Ecto.Adapters.SQLite3`) | `:text` | Yes (with JSON1) |
| Other / unknown | `:text` | Fallback |

Override with `--adapter`:

```bash
mix keksdose.install --adapter mysql
mix keksdose.install --adapter sqlite
mix keksdose.install --adapter postgres
```

The Ecto schema field uses `Keksdose.Types.StringList`, a custom type
that JSON-encodes on write and decodes on read — so the application surface
stays the same regardless of adapter.

## IP anonymization

* **IPv4**: the final octet is zeroed (`203.0.113.55` → `203.0.113.0`).
* **IPv6**: truncated to the `/48` prefix — the first three 16-bit groups
  are preserved and the remaining 80 bits are zeroed
  (`2001:db8:abcd:1:2:3:4:5` → `2001:db8:abcd::`). This matches the
  anonymization granularity recommended by the German DPAs and is finer than
  the `/64` typically used by ISPs to identify a single subscriber.

The server always derives `masked_ip` from `conn.remote_ip`; any
client-supplied value is discarded.

In production behind a reverse proxy or CDN, install [`remote_ip`](https://hex.pm/packages/remote_ip) (or equivalent `X-Forwarded-For` parsing) upstream of this plug so `conn.remote_ip` carries the real client address rather than the proxy's. `country_iso` is read from `cf-ipcountry` / `x-vercel-ip-country` request headers — making sure one of those is set (by fronting with Cloudflare/Vercel, or by emitting it from your own plug) is left as an exercise to the host.

## License

MIT
