Ctrl K
ComunidadGuías…

CLAUDE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build & Test Commands

```bash
cargo build                              # Build
cargo run                                # Run server (listens on 0.0.0.0:3000)
cargo test                               # Run all tests (unit + integration)
cargo test --lib                         # Unit tests only
cargo test --test api_integration        # Integration tests only
cargo test puzzle::challenge::tests      # Single test module
cargo test test_full_flow                # Single test by name
cargo clippy -- -D warnings              # Lint (warnings → errors)
cargo fmt                                # Format
```

A `justfile` wraps these (`just build`, `just test`, `just lint`, `just ci`). `just ci` runs `fmt-check + lint + test`.

### Observability & validation harnesses

- `LOG_FORMAT=json cargo run` emits structured JSONL on stderr. Decision events: `event=puzzle_decision` (per `/v1/puzzle`, with score / tier / signal breakdown / outcome=`issued|rejected`) and `event=verify_decision` (per `/v1/verify`, with score / decision-derived outcome=`pass|shadow_fail|block|pow_invalid` and the verify-side breakdown). Use `jq -c 'select(.event == "puzzle_decision")'` for clean parsing.
- `cargo run --release --example loadgen -- --base http://127.0.0.1:3000 --requests 200 --concurrency 16` drives 4 synthetic scenarios (`happy`, `no_ua`, `burst`, `full_solve`) and prints latency percentiles + tier distribution per scenario. Use `--only` to pick a subset. `full_solve` does real PoW on the client (SHA-256 only), so run the server with `PUZZLE_ALGORITHM=sha256 DEFAULT_DIFFICULTY=12 MAX_DIFFICULTY=16` to keep solve times sub-second (the default is now argon2id, which loadgen's client doesn't solve). Also set `IP_HARD_LIMIT=0` — loadgen drives everything from one IP, which otherwise trips the per-IP issuance cap (default 500/min) partway through and skews results with 429s.
- `cargo run --release --example argon2_cost` prints native ms/hash and expected solve seconds per `ARGON2_M_COST`; pair it with the widget's `data-debug` panel (`PoW hashes` / `PoW time`) for the WASM side. The measured table and the reasoning behind the 8 MiB default are in `CONFIGURATION.md` → *Choosing `ARGON2_M_COST`*.
- `cd e2e && bun install && bunx playwright install chromium && bun run test` runs Playwright against `static/testsite.html`. Auto-spawns `cargo run` itself; set `CAPTCHA_REUSE_SERVER=1` to reuse a server you already started (so you can capture its JSONL).

## Configuration

All runtime config is via environment variables; every setting has a default. **`CONFIGURATION.md` is the source of truth** — env var descriptions, score weights, file formats (CIDR reputation list, TLS fingerprint blocklist), and a production-leaning example are documented there. The most load-bearing knobs:

- `LISTEN_ADDR` (default `0.0.0.0:3000`), `RUST_LOG` (default `info`), `STATIC_DIR` (default `static`; resolved relative to the process CWD unless absolute — set an absolute path when not started from the repo root)
- `PUZZLE_ALGORITHM` (default `argon2id`; alternative `sha256`). Argon2id is the default because it's memory-hard — SHA-256 verifies fast but is trivially GPU-parallelised, so it taxes honest browsers more than attackers. `ARGON2_M_COST` / `ARGON2_T_COST` / `ARGON2_P_COST` (defaults `8192`/`2`/`1`) tune memory/iterations/lanes.
- `DEFAULT_DIFFICULTY` / `MAX_DIFFICULTY` — base PoW difficulty and upper clamp, in leading zero bits. Defaults are **algorithm-aware**: `5` / `10` for argon2id, `18` / `28` for sha256 (each Argon2id hash is orders of magnitude slower, so the same wall-clock cost lands at far fewer bits). An explicit env value overrides; selecting argon2id with a SHA-256-scale difficulty logs a loud WARN at boot (puzzles would be effectively unsolvable).
- `LOAD_LADDER` (default unset) — aggregate site-load difficulty floor: `threshold:difficulty` rungs (e.g. `200:20,500:22`) that raise PoW difficulty for *every* visitor once per-site request count crosses a threshold. Composes with the per-request tier via `max()`, never blocks. Implemented in `risk/load.rs` (`LoadLadder`), applied in the puzzle handler.
- `CHALLENGE_TTL_SECS` / `CLEANUP_INTERVAL_SECS` — challenge expiry + sweeper cadence
- `TIER_CHECKBOX_MIN` / `TIER_HARD_POW_MIN` / `TIER_BLOCK_MIN` — puzzle-time score → tier thresholds (defaults `20`/`40`/`85`)
- `IP_HARD_LIMIT` (default `500`, `0` disables) — hard per-IP issuance cap outside the scoring pipeline: past this many puzzle requests per 60s window from one IP (IPv6: /64 bucket), the handler throttles the request to a **max-difficulty PoW** (HardPow tier at `MAX_DIFFICULTY`) regardless of score — not a 429, so shared-IP populations (CGNAT/corporate NAT) aren't stranded. Exists because the rate signal maxes at +45 — a clean-header flood never reaches `TIER_BLOCK_MIN` on its own. Runaway memory is bounded separately by `MAX_ACTIVE_CHALLENGES`.
- `VERIFY_SHADOW_MIN` / `VERIFY_BLOCK_MIN` — verify-time score thresholds (defaults `30` / `60`)
- `VERIFY_REQUIRE_BEHAVIOR` (default `false`) — score an absent verify-time `behavior` blob as flatline (+30) instead of 0. For deployments where every legit client is the bundled widget; keeps direct-API bots from opting out of the behavioral layer. +30 alone is shadow-band; pair with `VERIFY_BLOCK_MIN=30` to hard-block.
- Privacy posture: the service is **cookie-free** (no client-side storage, no consent banner) and runs every signal under legitimate interest with data minimization. There is no global scoring toggle — each signal self-gates on its own input (header anomaly always computes; IP reputation is 0 without `IP_REPUTATION_FILE`; TLS fingerprint is 0 unless a trusted proxy supplies the header).
- Optional signal inputs, each disabled when its env var is unset: `IP_REPUTATION_FILE`, `TLS_FINGERPRINT_HEADER` (+ `TLS_FINGERPRINT_FILE`, `TRUSTED_PROXIES`).
- `SIGNAL_WEIGHTS_FILE` (default unset) — JSON object of `CONSTANT_NAME: integer` overriding any subset of the signal weights (`risk::weights::SignalWeights::NAMES`); unknown keys or bad values refuse to boot, overrides are logged at boot. Weights are process-wide (installed once via `risk::weights::install`, read through `current()`), never per-site: a `SitePolicy` moves the bands, not what a signal is worth.
- Provisioning + persistence: `ADMIN_TOKEN` gates `POST /v1/sites` (returns 404 when unset — no anonymous provisioning) and `/v1/admin/*`. `SITE_DB_PATH` enables SQLite-backed site persistence; without it sites are in-memory only. CORS on `GET /v1/puzzle` and the widget assets is always wildcard (cookie-free service, public payloads); the per-site `allowed_origins` list is the only origin control, enforced in `get_puzzle` with a 403 the widget can read. `CORS_ALLOWED_ORIGINS` is retired — setting it logs a boot warning and does nothing. Other routes never get CORS. `DEV_DISABLE_ADMIN_AUTH=1` (debug builds only) lets local dev/Playwright call `POST /v1/sites` without a bearer; never affects `/v1/admin/*`.
- Reverse-proxy aware client IP: `TRUSTED_PROXIES` (the same CIDR list the TLS fingerprint signal uses) also gates `X-Forwarded-For` walking. The handler resolves the client IP via `risk::client_ip`; per-IP signals score the resolved IP, the TLS fingerprint signal still keys off the immediate peer.
- Validation dashboard: `ADMIN_DB_PATH` enables SQLite-backed decision logging + the admin endpoints; `ADMIN_TOKEN` is the bearer token (shared with provisioning) and is required when `ADMIN_DB_PATH` is set. Browser UI is `static/admin.html`.
- `ANONYMIZE_LOG_IP` (default `true`) — truncate the client IP (IPv4 /24, IPv6 /48) before it's *emitted* anywhere, via `risk::anonymize_ip` applied in the puzzle handler. Live scoring always uses the full IP; both the durable decision log **and** the `puzzle_decision` tracing event (`ip=…`) use the truncated copy, so a log aggregator never sees a per-visitor address either.
- `LOG_RETENTION_HOURS` (default `72`, `0` disables) — retention window for the decision log. A background sweeper (spawned in `main.rs`, only when `ADMIN_DB_PATH` is set) prunes rows older than the window so the durable log obeys GDPR storage-limitation instead of growing unbounded. Cadence is 1/24 of the window, clamped to `[60s, 1h]`; the first sweep runs at boot, so stale rows from a prior run are cleaned immediately. Implemented as `DecisionLog::prune` (routed through the writer thread, like `clear`). Before deleting, the same transaction folds each row into `training_samples` (no IP, UA reduced to a browser family, hour-bucketed ts, plus any `labels` row from `POST /v1/feedback`) — the opt-in, retention-bounded pseudonymous set that `GET /v1/admin/training` pages out; see `CONFIGURATION.md` → *Feedback and training samples*. `DecisionLog::label` routes a verdict to the live row or the sample, whichever holds the challenge.
- `TRAINING_RETENTION_HOURS` (default `0`) — off and purges existing samples; opt-in must exceed nonzero log retention and be at most 2160h. `examples/tune_weights.rs` produces reviewed offline candidates; see `TUNING.md`. Challenge IDs are linkable, so never describe samples as anonymous or retention-free.
- `GEOIP_DB_PATH` (default unset) — path to a MaxMind GeoLite2/GeoIP2 **Country** `.mmdb`. When set (and `ADMIN_DB_PATH` is enabled), the decision-log writer thread stamps each puzzle row's `country` column with the visitor's ISO code via an **offline** lookup (`dashboard::GeoIp`, wrapping `maxminddb`), performed **at log-write time on the already-anonymized IP** — so it's GDPR-clean and adds zero hot-path cost. Unset → `country` stays NULL and the dashboard's Countries panel is empty. The `.mmdb` is operator-provisioned (not bundled); a missing/corrupt file logs a WARN and disables enrichment rather than blocking boot (loaded via `open_geoip` in `main.rs`).

## Architecture

Self-hostable proof-of-work CAPTCHA service. Clients solve a PoW puzzle (find a nonce whose hash — Argon2id by default, SHA-256 optionally — has N leading zero bits) to prove they are not bots. Rust edition 2024.

Two scoring passes bracket every successful solve:

1. **Puzzle-time** (`GET /v1/puzzle`): score the request, pick an `EscalationTier`, then either issue a PoW puzzle at a tier-adjusted difficulty, or short-circuit with `429` for the `Block` tier.
2. **Verify-time** (`POST /v1/verify`): after the PoW check passes, run a second scoring pass on signals only available at submit (time-on-page — derived server-side as `now - challenge.created_at`, not client-reported — honeypot, behavioural telemetry). Decision is `Pass` / `ShadowFail` (return `success: true` but emit a WARN log) / `Block` (return `success: false`). The decision is also returned as `risk: low|elevated|high` (`api::types::RiskBand`) so an integrator can step up on a shadow-failed submission; `success` stays the enforcement, which is why a monitored site answers `success: true` next to `risk: high`, and an invalid proof of work carries no band at all.

### Module Layout

- **`puzzle/`** — Core puzzle engine. `Challenge` carries **two** timestamps and they must not be conflated: `created_at` anchors `expires_at` and the decision log, while `dwell_since` anchors the verify-time time-on-page band and is *inherited* across the widget's pre-expiry refresh (`refresh_of` on `GET /v1/puzzle`, honoured only when the cited challenge exists and is same-site; a miss is ignored, never an error). Before that split, a refresh reset the dwell clock, so a five-minute dwell submitting just after a background refresh scored — and logged — as a 400ms submission. The citation is deliberately a *proof of possession* of a live same-site challenge rather than a client-reported dwell or an "I refreshed" flag: lying costs nothing, holding a real challenge for N seconds costs N seconds, which is the same capability as simply keeping the original to its TTL. The cited challenge is **not** consumed — a submit racing the refresh must not 404. `challenge.rs` generates and verifies PoW puzzles: `generate()` + `verify()` dispatch on `Algorithm` (SHA-256 via `compute_sha256` or Argon2id via `compute_argon2id`) and reuse `has_leading_zero_bits()` for both. `solve_challenge()` / `solve_argon2id_challenge()` are brute-force solvers used only in tests.
- **`risk/`** — Scoring + escalation. Each signal lives in its own module so it can be disabled by config:
  - `signals.rs` — always-on scorers: `score_rate` (per-IP over a 60s and a 15-min window — the IP component is the worse band of the two, never the sum, so the ceiling stays 45 and the second window only matters when the minute is quiet — plus per-site over 60s) and `score_header_anomaly` (UA / Accept-Language / Accept-Encoding, plus two browser-impersonation checks: a `Mozilla/` UA with no `Sec-Fetch-*` header and a `Chrome/` UA with no `Sec-CH-UA`, +15 each — either alone is below `TIER_CHECKBOX_MIN`, both together equal a missing UA; presence only, values never read). Test fixtures that mean "a clean browser" must carry the fetch-metadata triple, or they score +15.
  - `reputation.rs` — `CidrListReputation` loaded from `IP_REPUTATION_FILE`; categories `tor`/`datacenter`/`vpn`/`residential`.
  - `tls_fingerprint.rs` — `TrustedProxies` CIDR allowlist + `FingerprintBlocklist`. Header is only honored when the immediate peer is in the trusted-proxies list.
  - `client_ip.rs` — `client_ip` (reverse-proxy aware XFF resolution) + `anonymize_ip` (truncate to /24 or /48 for at-rest log storage) + `rate_key` (buckets IPv6 to /64 for the per-IP rate counter, so one host rotating through its delegated block shares a single counter; IPv4 and IPv4-mapped IPv6 count per-address).
  - `score.rs` — `RiskScorer` aggregates a `SignalContext` (ip, headers, counts, fingerprint) into a `RiskScore { total, breakdown, tier }` for puzzle-time. Single always-on path; each signal self-gates on its input. `TierThresholds` is a **per-call argument, not scorer state**: the signal weights are a property of this service, but the score → tier mapping is a property of the site being protected, so a `SitePolicy` can move the bands without changing what any signal is worth. `VerifyScorer::score` takes `VerifyThresholds` for the same reason.
  - `verify.rs` — `VerifyScorer` aggregates a `VerifyContext` into a `VerifyScore { total, breakdown, decision }`. Honeypot scores +100 (always blocks); time-on-page bands at <500ms (+50) and <2000ms (+25); the `behavior` component sums `score_behavior` + `score_impossible_timing` + `score_duplicate_blob`. Those three fold into **one** breakdown field on purpose — they are all behaviour-blob checks, and the decision-log schema stays put — but they *sum* rather than saturate, because they are separate facts (what the counters describe, whether this submission's timeline is internally possible, whether the blob is mass-produced), unlike the `webdriver`/`automation` pair inside `score_behavior`. `VerifyContext.behavior_duplicate_count` is supplied by the handler from the store, exactly as the puzzle handler supplies `ip_count`/`site_count`, so the scorer stays pure and every check is unit-testable without a database. `score_remote_ip` is a fourth breakdown field (`remote_ip`, +30, its own `sig_remote_ip` decision-log column): it fires when an integrator-forwarded `remote_ip` is not the challenge's `issued_to`, compared through `rate_key` so IPv6 is /64 — shadow band alone, because a phone changing networks, an egress pool like iCloud Private Relay and an integrator forwarding its own proxy all look identical to a token farm. `remote_ip` is only ever the outer body field of `/v1/verify`, never read from inside the client-authored token; `issued_to` lives on the in-memory challenge for its TTL and is never logged. `VerifyBreakdown::fired_signals` maps the non-zero fields to their names (field order) for the response's `signals` — names are the API contract, weights stay internal; puzzle-time names are not included because the challenge retains no puzzle-time breakdown.
  - `behavior.rs` — `BehaviorReport` (mouse moves, touches, interactions, first-interaction ms, plus three environment probes) collected by the widget and submitted in `/v1/verify`. Flatline (zero events) scores +30; an *isolated* click without pointer movement scores +15 — gated on `interactions <= BEHAVIOR_ISOLATED_INTERACTION_MAX`, because pointer absence on its own describes a keyboard or screen-reader visitor just as well as a synthetic-click driver, and penalising it contradicted the widget's deliberate keyboard operability. A driver dispatching `el.click()` produces exactly one interaction; a human navigating by keyboard produces one per keystroke, Tab and scroll. The gate is server-side and needs no new wire field, so it applies retroactively to every deployed widget. `risk::verify::tests::keyboard_only_visitor_passes_even_under_a_tight_policy` is the regression test; sub-50ms first interaction adds +20, gated on the *same* isolated-interaction count — `mousemove` doesn't set `first_interaction_ms` but `keydown`/`scroll` do, so anyone already typing or scrolling when a late-loading widget mounted was penalised for it. The `webdriver` and `automation` (ChromeDriver `cdc_` globals / legacy Selenium markers) probes are **one saturating dimension** worth +30 — they describe the same fact, so summing them would push every driven browser to the block threshold and reverse the deliberate "driven browser with organic interaction is ShadowFail, not Block" stance that `e2e/tests/browser-harness-simulator.spec.ts` guards. `headless` scores +20, below `VERIFY_SHADOW_MIN` on its own because its checks are the noisiest. Every probe is reduced to a boolean in the browser, so the blob carries no fingerprinting entropy. `BehaviorPresence::Absent` (legacy clients with no blob) scores 0, and probes absent from an older widget score 0 individually. Two further checks score the *blob* rather than the visitor, since everything above takes the client's counters at face value and a fabricated organic-looking blob scored 0: `score_impossible_timing` fires (+30) when `first_interaction_ms` exceeds the server-derived dwell by more than `BEHAVIOR_IMPOSSIBLE_TIMING_SLACK_MS` (30s) — the client's anchor is set before the puzzle fetch and the server's when it issues, so the fetch latency is the entire legitimate excess; the slack is generous so that anyone interacting within 30s of mount is immune *whatever* happens to the dwell anchor, which is what covers a pre-expiry refresh citing a challenge the server has already swept (an unmatched `refresh_of` re-anchors dwell to now). `score_duplicate_blob` fires (+30) at the `BEHAVIOR_DUPLICATE_MIN`th (5) byte-identical blob per site inside `BEHAVIOR_DUPLICATE_WINDOW_SECS` (600); `claims_activity` excludes flatlines, which every untouched form produces identically and which already score +30, and `behavior_fingerprint` hashes the *parsed fields* so re-ordered JSON can't mint a fresh identity. Both are +30 for the same reason `VERIFY_REQUIRE_BEHAVIOR` is: shadow band alone at the defaults, never a block by itself. Neither needs a new wire field, so both apply retroactively to every deployed widget.
  - `weights.rs` — `SignalWeights`, one field per "+N" constant with the constants as defaults, `parse`/`from_file` (strict: unknown names and non-integers are errors) and the `install`/`current` `OnceLock`. Every scorer has a `*_with(&SignalWeights, …)` form for tests and offline evaluation; the plain form is `*_with(current(), …)`, so existing call sites and tests never changed. Count-type thresholds (rate bands, dedup N, timing slack) are deliberately not in the table.
  - `tier.rs` — `EscalationTier` (`InvisiblePass` / `Checkbox` / `HardPow` / `Block`) and `difficulty_for(tier, default, max)` which returns `None` for `Block` (caller short-circuits with 429).
  - `load.rs` — `LoadLadder`: aggregate site-load difficulty floor (`threshold:difficulty` rungs from `LOAD_LADDER`). `floor_for(site_count)` returns the highest rung's difficulty the current per-site count meets; the handler composes it with the tier via `max()`. Orthogonal to per-request scoring and never blocks.
- **`dashboard/`** — Validation dashboard. `log.rs` (`DecisionLog`) owns a writer thread that drains an unbounded channel into SQLite. `query.rs` (`Sessions`) opens read-only connections in `spawn_blocking` for list/detail queries; database is in WAL mode so reads run concurrent with writes. `routes.rs` defines `GET /v1/admin/sessions` and `/sessions/:id` behind a bearer-token check. `types.rs` holds the per-decision records (`PuzzleRecord`, `VerifyRecord`) emitted from handlers and the JSON DTOs returned to the dashboard. `geo.rs` (`GeoIp`) wraps an optional `maxminddb` Country reader; the writer thread uses it to stamp the `country` column at insert time (see `GEOIP_DB_PATH`). The dashboard HTML is `static/admin.html` (vanilla JS, no build step).
- **`storage/`** — `Store` trait defines the async storage interface. `memory.rs` is the implementation: challenges live in a `RwLock<HashMap>` and rate-window counters in `ShardedRates` (hash-striped locks, with a per-instance window length: 60s for the per-IP/per-site counters, `RATE_SUSTAINED_WINDOW_SECS` (15 min) for the second per-IP map behind `increment_ip_count_sustained`, `BEHAVIOR_DUPLICATE_WINDOW_SECS` for the `(site_key, behavior_fingerprint)` map behind `increment_behavior_blob_count`, which `cleanup_expired` sweeps alongside them); sites are also in-memory but **write-through to a SQLite file** when `SITE_DB_PATH` is set, with rows reloaded on construction via `InMemoryStore::with_site_persistence`. The trait is designed for future Redis/MongoDB backends.
- **`api/`** — Axum router. `handlers.rs` contains the three handlers and is where the puzzle-time + verify-time scoring is orchestrated. The puzzle handler uses `risk::client_ip` to resolve the real client behind a trusted reverse proxy (XFF walked rightmost-untrusted) before scoring per-IP signals. `state.rs` defines `AppState` (shared via `Arc`) and `info_urls_from_config`; the threshold structs are no longer built there — both handlers derive them per request from the site's `SitePolicy` (see `site/`). `extract.rs` holds the `AppJson`/`AppQuery` wrappers (rejections rendered through the app's JSON error envelope) and `bearer_token`, the single `Authorization` parser — RFC 7235 makes the scheme case-insensitive, and one helper is what keeps that true at all three call sites (`/v1/verify`, `POST /v1/sites`, the dashboard router). The CORS layer is scoped: only `GET /v1/puzzle` and the widget assets are CORS-enabled, always wildcard — the per-site `allowed_origins` check in `get_puzzle` is the origin control, and open CORS is what lets the widget read its 403; `/v1/verify`, `/v1/sites`, and `/v1/admin/*` have no CORS layer (browsers can't reach them cross-origin).
- **`site/`** — Site registration types (`site_key` + `secret_key` + optional `allowed_origins` + optional `policy`). `SitePolicy` is a **sparse overlay** on `AppConfig`: every field is `Option`, and `None` means *inherit the env value* (including later changes to it), never *use the zero*. `SitePolicy::resolve(&AppConfig) -> EffectivePolicy` is the single place that overlay happens; both handlers work with the resolved struct and never read a threshold off `state.config` directly. `SitePolicy::validate` checks the **resolved** values rather than the supplied subset — a partial override like `{"tier_block_min": 10}` is only broken in combination with the globals, and validating the subset would let it through. Persisted as a nullable JSON `policy` column; an unparseable one refuses to boot rather than silently reverting a tightened site to the looser defaults. `SiteMode::Monitor` ("observe, don't refuse") is scoped to **risk verdicts about a visitor**: a scoring Block becomes a max-difficulty puzzle and a verify-time Block returns `success: true`, but the load sheds (`MAX_ACTIVE_CHALLENGES`, the flooder shed) are deliberately excluded — they protect the whole instance, and a monitored tenant able to exhaust the challenge map would take every other site with it. An invalid PoW and an unattested failover claim are likewise untouched: neither is a judgement about risk. Monitored rows keep their ordinary `block`/`rejected` outcome so the dashboard's aggregates still answer "what would I block?", with a separate `monitored` column marking them unenforced — which is why a monitored verify row has `outcome="block"` alongside `success=true`. `types.rs` also holds `normalize_origin`/`normalize_origins` (validate + lowercase `http(s)://host[:port]` entries, cap `MAX_ALLOWED_ORIGINS`), reused by `POST /v1/sites` and `PUT /v1/admin/sites/:id/origins`, and `normalize_site_name` (trim + `MAX_SITE_NAME_LEN`), reused by `POST /v1/sites` and `PUT /v1/admin/sites/:id/name` — a rename accepting what provisioning rejects would put a name in the store that could never have been created there. The per-site origin allowlist is tenant hygiene (quota/stats protection), enforced handler-side in `get_puzzle` with a 403 — not bot defense, since `Origin` is browser-set only.
- **`api/assets.rs`** — Content-addressed widget bundle. `WidgetBundle::load` hashes the four runtime assets (`captcha-widget.js`, `captcha-worker.js`, `captcha-widget.css`, `vendor/argon2.umd.min.js`) at router construction and mounts them immutably at `/assets/<hash>/` (`max-age=31536000, immutable`), behind one mutable entry point at **`GET /v1/widget.js`** (`max-age=300`) which is the URL integrators embed. Exists because those assets are independent cache entries that must agree with each other and with the server's wire format — unversioned, a visitor could hold a new widget against a worker cached from before the Argon2id default flip, which no server-side rollback can reach. The widget resolves its siblings from a `__BOLLWARK_ASSET_BASE__` placeholder the entry point substitutes; served raw (legacy `/static/`, or from inside `/assets/<hash>/`), it falls back to resolving relative to its own script URL, so one source file is correct on all three paths with no build step. The legacy `/static/` tree stays mounted permanently for pre-existing embeds and the operator pages, at the same short TTL. `/v1/widget.js` also injects the stylesheet, so the embed is a single tag; an integrator's existing `<link>` suppresses the injection.
- **`failover/`** — Client failover. `FailoverGuard` decides whether to honor a widget-minted "I couldn't reach you" claim at `/v1/verify`, so this service's downtime doesn't take every embedding form down with it. Off by default (`FAILOVER_ENABLED`). A claim is **unauthenticated by construction** — during an outage nothing could have been signed — so honoring one is a deliberate fail-open bounded by four things: an **attested outage window** must be open, acceptance keys on *now* (never the client's forgeable `issued_at`, so a closed window can't be reopened by backdating), a per-site rate cap, and the browser-local honeypot + behaviour blob still being scored (a flatline+`webdriver` claim is refused *inside* a window). Windows come from two sources: a **heartbeat gap** persisted to `FAILOVER_STATE_PATH` and detected at boot (crashes, OOM kills, deploys), and **operator-declared** windows via `POST /v1/admin/outages` — which covers the mode a heartbeat structurally cannot see, an intact process behind a broken edge (the Traefik self-signed-cert incident `scripts/check-public-endpoint.sh` exists for; that script declares the window itself when `MONITOR_ADMIN_TOKEN` is set). Every acceptance sets `failover: true` on the verify response and emits a WARN, so integrators can accept-but-flag. Two failure modes it deliberately can't cover — the widget script never loading, and the integrator's backend not reaching `/v1/verify` — are documented as integrator-side patterns in `INTEGRATION.md`.
- **`error.rs`** — `CaptchaError` enum implementing `IntoResponse` for Axum error mapping.
- **`config.rs`** — `AppConfig` loaded from environment variables with defaults.
- **`static/`** — Bundled browser widget. Served two ways: the **versioned** bundle (`api/assets.rs`, see below) and the legacy unversioned `/static/` `ServeDir`. `captcha-widget.js` + `captcha-widget.css` are the embeddable widget; `captcha-worker.js` is the Web Worker that solves the PoW off the main thread (dispatches on the wire `algorithm` field — SHA-256 via `crypto.subtle`, Argon2id via the vendored `static/vendor/argon2.umd.min.js` from hash-wasm); `testsite.html` is a local harness (its `?lang=` param retags `<html lang>` before the widget loads; `?forceclick=1` / `?forcepass=1` pin the new site's `SitePolicy` tier bands so a test gets the tier it is about — the per-IP rate signal is global and window-based, so past ~10 puzzle requests in 60s it adds +8, the whole of the e2e server's `TIER_CHECKBOX_MIN`, and an unrelated test added anywhere in the suite would otherwise change which tier another one sees). **Accessibility:** the checkbox is a styled `div`, so every affordance a real `<input type=checkbox>` gets for free is declared by hand — `role="checkbox"`, `tabindex="0"`, a Space/Enter keydown handler (with `preventDefault` so Space doesn't scroll the widget off screen), `aria-checked` kept in step with the paint in `_updateUI`, and `aria-busy` while solving. The accessible name is a fixed `aria-label`, deliberately **not** `aria-labelledby` pointing at the adjacent label: that label's text tracks state ("Verified"), and an ARIA name states what a control *asks* while its state belongs in `aria-checked`. `aria-disabled` is deliberately absent — busy is not disabled, and setting it would make the element non-actionable to anything reading ARIA (Playwright's actionability check included), which is a behaviour change rather than an accessibility fix. The label and the status line are `aria-live` regions, since they are the only channel carrying the block, failover and failure messages. `e2e/tests/a11y.spec.ts` guards the whole keyboard path and was verified to fail without the fix. **Localisation:** one `TRANSLATIONS` table at the top of `captcha-widget.js` (en/de/fr/es/it/nl) with per-*key* English fallback, so a locale may omit any string and adding a language is a dict entry with no other code change. `resolveLocale` prefers `data-lang` → `<html lang>` → `navigator.language`; document language deliberately outranks the browser, since a widget inside a German form should read German for an English-browser visitor. The locale is resolved once at construction (a late `<html lang>` swap must not re-label a widget mid-verification) and mirrored onto the container's `lang` attribute for screen readers. The `data-debug` panel stays English — it's a developer tool. The widget auto-refreshes its challenge shortly before the TTL expires (scheduled from the puzzle response's `expires_in_secs`, not the client clock) and quietly re-solves if already verified, so a visitor who dwells on a form longer than `CHALLENGE_TTL_SECS` never submits an expired token; the tier rendered at mount stays locked, and refresh is deferred while the tab is hidden.

### API Endpoints

| Endpoint | Auth | Purpose |
|---|---|---|
| `GET /v1/puzzle?site_key=<uuid>[&refresh_of=<uuid>]` | None | Score request and issue a PoW puzzle (with `algorithm`/`prefix`/`difficulty`) for any non-block tier, or `429` for `Block`. Cookie-free — never sets a cookie. |
| `POST /v1/verify` | Bearer (site secret) | Verify a solution server-to-server. Body is either the widget's opaque `token` (hex-encoded JSON, unpacked by `VerifyRequest::resolve`) or explicit fields: `challenge_id` plus `nonce`, optional `honeypot`, `behavior`. Optional top-level `remote_ip` (the visitor's address as the integrator saw it) is compared with `challenge.issued_to`; the response carries `risk: low|elevated|high` next to `success`, plus `signals` (names of the non-zero breakdown fields via `VerifyBreakdown::fired_signals`, omitted when empty, never weights). Time-on-page is derived server-side from `challenge.dwell_since`, never trusted from the client. `resolve()` returns a `ResolvedVerify::{Solved,Failover}` enum — the failover arm forks before any challenge lookup (see `failover/`) and answers `{success, failover: true}`. |
| `POST /v1/sites` | Bearer (`ADMIN_TOKEN`) | Register a site, returns site_key + secret + echoed `allowed_origins` + `policy`. Optional `allowed_origins` (`http(s)://host[:port]`, max 32) restricts which browser origins `GET /v1/puzzle` serves; optional `policy` overrides this site's scoring thresholds (validated post-merge, 400 on a set that would disable protection). Returns 404 when `ADMIN_TOKEN` is unset. |
| `POST /v1/feedback` | Bearer (site secret) | `{challenge_id, verdict: spam\|legit}` — the integrator's ground truth on a decision, scoped to its own site. Lands on the live decision row or, after the prune, on the pseudonymous training sample. 404 without `ADMIN_DB_PATH`. |
| `GET /v1/widget.js` | None | Widget entry point — the only asset URL integrators embed. Serves `captcha-widget.js` with its asset base rewritten to `/assets/<hash>/`, `Cache-Control: max-age=300`. `503` if `STATIC_DIR` has no readable bundle. |
| `GET /healthz` | None | Liveness probe. Returns `200 ok`. |
| `POST /v1/admin/outages` | Bearer (`ADMIN_TOKEN`) | Declare an outage window (`duration_secs`, or `start`+`end`; capped at 24h) so failover claims are honored for it. Mounted on the main router, not the dashboard's — failover must not require `ADMIN_DB_PATH`. 400 when failover is disabled. |
| `GET /v1/admin/outages` | Bearer (`ADMIN_TOKEN`) | Active outage windows plus accepted/refused failover counters. |
| `GET /v1/admin/sessions` | Bearer (`ADMIN_TOKEN`) | List recent puzzle/verify sessions for the dashboard. Only mounted when `ADMIN_DB_PATH` is set. |
| `GET /v1/admin/sessions/:id` | Bearer (`ADMIN_TOKEN`) | Detail for a single session. |
| `GET /v1/admin/stats` | Bearer (`ADMIN_TOKEN`) | Aggregate stats over the decision log (counts, tier/decision breakdowns) for the dashboard summary cards. |
| `GET /v1/admin/analytics?hours=&site_key=` | Bearer (`ADMIN_TOKEN`) | Windowed analytics for the dashboard's Analytics tab: time-bucketed traffic/outcome/tier series (dense, zero-filled), bot-probability histogram, browser-family breakdown, network-type + country breakdowns, per-signal fire counts. `hours` clamped to 1–720, optional `site_key` filter. |
| `GET /v1/admin/training?after_id=&limit=` | Bearer (`ADMIN_TOKEN`) | Keyset-paged pseudonymous training samples (`{samples, next_after_id}`), written by the retention prune. What bollwark-dx pulls. |
| `GET /v1/admin/sites` | Bearer (`ADMIN_TOKEN`) | List registered sites (no secrets) merged with per-site activity from the decision log. |
| `GET /v1/admin/sites/:id/activity?since=` | Bearer (`ADMIN_TOKEN`) | First and last solved challenge (`success = 1`) of one site, optionally at or after `since` (RFC 3339): `{first_verify_at, last_verify_at}`, both null until there is one. What bollwark-dx's `verify_setup` MCP tool polls. |
| `POST /v1/admin/sites/:id/rotate` | Bearer (`ADMIN_TOKEN`) | Issue a new `secret_key` for a site; old one invalidated immediately. |
| `PUT /v1/admin/sites/:id/name` | Bearer (`ADMIN_TOKEN`) | Rename a site (trimmed + length-checked like `POST /v1/sites`). A label only — no credential changes, no scoring changes. |
| `PUT /v1/admin/sites/:id/origins` | Bearer (`ADMIN_TOKEN`) | Replace a site's `allowed_origins` list (validated like `POST /v1/sites`) without rotating its secret. |
| `PUT /v1/admin/sites/:id/policy` | Bearer (`ADMIN_TOKEN`) | Replace a site's `SitePolicy` wholesale (`{}` clears every override) without rotating its secret. |
| `DELETE /v1/admin/sites/:id` | Bearer (`ADMIN_TOKEN`) | Delete a site. |

### Operational notes

- `main.rs` installs SIGINT + SIGTERM handlers and serves with `with_graceful_shutdown`, so in-flight requests drain on stop; the decision-log writer is then flushed (`DecisionLog::flush`, a FIFO drain barrier) so records from those requests land on disk instead of dying with the process. `/healthz` is the liveness probe.
- **Client failover** (`FAILOVER_ENABLED`, off by default) keeps embedding forms submittable through an outage. See the `failover/` module note above; the load-bearing operational fact is that `FAILOVER_STATE_PATH` must live on a volume that survives restarts, or nothing can ever be attested. `main.rs` loads the guard (and attests any boot-time heartbeat gap) **before** the listener binds, since visitors arrive the moment we're reachable again.
- **External TLS/uptime monitor.** `/healthz` only proves the app is up *inside* the container — it can't see a reverse-proxy failure. The public deployment is fronted by Coolify's Traefik, which manages the Let's Encrypt cert; when ACME issuance fails, Traefik silently falls back to its **default self-signed cert**, so browsers refuse `captcha-widget.js` and every embed breaks while the app still looks healthy. `scripts/check-public-endpoint.sh` checks the **public** URL with full TLS verification (assets return 2xx over a valid, non-self-signed, non-near-expiry chain). It is **manual only** — run it with `just monitor`; the scheduled GitHub Actions workflow that ran it every 15 min was removed, so nothing watches the public cert automatically. `MONITOR_PATHS` overrides which paths are fetched, which matters because the marketing apex has no `/healthz` and instances predating `/v1/widget.js` must be checked with the `/static/` paths only. `MONITOR_WEBHOOK` still posts a Slack/Discord alert on failure. **Fix when it fires:** the cert is Coolify/Traefik-managed, not in this repo — re-issue the certificate / re-trigger ACME for the captcha domain in Coolify.
- The decision-log channel is **bounded** (capacity 8192). The hot path uses `try_send`; on `Full` the record is dropped and a counter increments. Power-of-two thresholds emit a WARN so a sustained backlog is visible without log spam. The counter is also surfaced as `dropped_records` in `GET /v1/admin/stats` (merged in by the handler from the writer's live counter — the DB can't record rows that were dropped before insert). The writer thread drains the channel in batches (up to `MAX_INSERT_BATCH`) and commits each batch in a single transaction, so a burst costs a handful of fsyncs instead of thousands.
- Per-IP / per-site rate counters live in `ShardedRates` (16 hash-striped `RwLock<HashMap>` stripes each) rather than one global lock per map, so concurrent requests for different keys don't serialise on a single writer. `cleanup_expired` sweeps every stripe. The 15-min per-IP window and the verify-time behaviour-blob dedup map are further instances of the same structure; the latter's entries are only created *after* a valid proof of work, so growing it costs an attacker one solve per entry.
- The service is cookie-free: no `Set-Cookie` is ever issued and no request cookie is read, so cross-origin embeds need no `SameSite`/credentials handling.
- With the default `argon2id`, `DEFAULT_DIFFICULTY` defaults to `5` (algorithm-aware, see the config section); with `sha256` it's `18` to keep low-end mobile in the few-seconds range. Tests still use `8` (sha256) / `4` (argon2id) for speed.

### Key Design Decisions

- `Store` trait uses `impl Future` return types (RPITIT) instead of `async_trait` — requires Rust 1.75+.
- `AppState.store` is typed as `Arc<InMemoryStore>` (concrete), not `Arc<dyn Store>`. This will need to change when adding alternative backends.
- Challenges are deleted after successful PoW verification (single-use). Failed PoW leaves the challenge intact for retry, but `VERIFY_MAX_ATTEMPTS` (default 10) bounds retries: the store evicts a challenge once its failed-attempt counter hits the cap, so one challenge can't be used as an unlimited work amplifier. A verify-time `Block` decision still consumes the challenge — the PoW already succeeded.
- PoW verification runs inline for SHA-256 (a single hash) but is offloaded to `spawn_blocking` for Argon2id, since a memory-hard hash on an async worker thread would let a flood of `/v1/verify` attempts starve the runtime and stall every endpoint. Dispatch is on `challenge.algorithm` in the verify handler.
- `MAX_ACTIVE_CHALLENGES` (default 1,000,000; `0` disables) is a global memory backstop: when the challenge map hits the ceiling, the puzzle handler sheds new issuance via the `Block`/429 path regardless of per-request score. Distinct from `IP_HARD_LIMIT` (per-IP) — it bounds a distributed or IPv6 /64-spread flood that stays under the per-IP cap.
- `CLEANUP_INTERVAL_SECS` is coerced to ≥ 1s at config load: `tokio::time::interval` panics on a zero period, and that panic (swallowed inside the spawned task) would silently disable the only reclaimer for the challenge and rate-window maps.
- `ShadowFail` has no persistent quarantine store; the structured WARN log is the audit trail, and `risk: "elevated"` on the verify response is the integrator-facing signal.
- The TLS fingerprint signal deliberately doesn't do native TLS inspection — it reads a header set by a trusted reverse proxy (e.g. Cloudflare's `cf-ja4`). The `TRUSTED_PROXIES` CIDR check is what prevents direct clients from spoofing the header.
- Each signal self-gates on its own input: header anomaly always computes; IP reputation is 0 without `IP_REPUTATION_FILE`; TLS fingerprint is 0 unless a trusted proxy supplies the header. There is no global scoring toggle — the service is cookie-free and scores under legitimate interest with data minimization.
- Integration tests use `tower::ServiceExt::oneshot` with a manually-injected `ConnectInfo` extension to simulate client connections without binding a real port.
- Difficulty 8 is used in tests (instead of the production default 18) to keep solve times fast.