Ctrl K
ComunidadGuías…

INTEGRATION.md

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

Archivo original ↗Preguntar a KiBot
# Integration Guide

This guide shows the full flow for adding bollwark to a real application:

1. Run the CAPTCHA service.
2. Register your application as a site.
3. Embed the browser widget in your form.
4. Forward the opaque widget token from your backend.
5. Verify the token server-to-server before accepting the form.

## 1. Run the Service

For local development:

```bash
export ADMIN_TOKEN=$(openssl rand -hex 32)
export SITE_DB_PATH=tmp/sites.db
cargo run
```

The service listens on `http://localhost:3000` by default.

`ADMIN_TOKEN` protects site provisioning and admin APIs. `SITE_DB_PATH` persists registered sites so restarts do not invalidate your `secret_key`.

For production, also set a stable bind address and run behind TLS:

```bash
LISTEN_ADDR=127.0.0.1:3000
ADMIN_TOKEN=<long-random-secret>
SITE_DB_PATH=/var/lib/bollwark/sites.db
```

## 2. Register a Site

Call `POST /v1/sites` once for each application or environment.

```bash
curl -s -X POST http://localhost:3000/v1/sites \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"my-production-app"}' | jq
```

Response:

```json
{
  "site_key": "00000000-0000-0000-0000-000000000000",
  "secret_key": "hex-encoded-secret",
  "allowed_origins": [],
  "policy": {}
}
```

Use the keys this way:

| Key | Where it goes |
|---|---|
| `site_key` | Public frontend HTML, inside the widget |
| `secret_key` | Private backend config only |

Never expose `secret_key` to the browser.

### Optional: restrict browser origins

Pass an `allowed_origins` array to limit which origins can request puzzles for
this `site_key`:

```bash
curl -s -X POST http://localhost:3000/v1/sites \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"my-app","allowed_origins":["https://app.example.com"]}' | jq
```

Each entry is a full web origin — `http(s)://host[:port]`, no path or trailing
slash (up to 32 entries; values are lowercased and validated, a bad entry
returns `400`). When the list is non-empty, `GET /v1/puzzle` returns `403` to a
browser whose `Origin` header isn't on the list. Requests with **no** `Origin`
header (same-origin embeds, server-to-server calls) always pass.

This is **tenant hygiene, not bot defense**: it stops a third party from
embedding your public `site_key` on their own page and burning your quota or
polluting your stats. It is *not* a security control — a non-browser client can
forge the `Origin` header, so the real trust boundary stays the `secret_key` at
`/v1/verify`. Change the list later without rotating the secret via
`PUT /v1/admin/sites/{site_key}/origins` with the same `allowed_origins` body.

### Optional: tune the thresholds for this site

By default every site is scored with the server's env-var thresholds. Pass a
`policy` to override any of them for this `site_key` alone — useful when one
instance protects forms with different risk profiles (a contact form and a
login endpoint want very different bands):

```bash
curl -s -X POST http://localhost:3000/v1/sites \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name":"login",
        "policy":{"tier_hard_pow_min":25,"tier_block_min":60,"verify_block_min":30}
      }' | jq
```

Omitted fields inherit the server value (and keep tracking it if the operator
changes it later), so a policy only ever states what differs. Change it later
with `PUT /v1/admin/sites/{site_key}/policy` — the body replaces the stored
policy wholesale, so `{}` clears every override. See **Per-site policy** in
`CONFIGURATION.md` for the full field list and the validation rules.

### Optional: start in monitor mode

If you're putting this in front of an existing form and want to see what it
would do before it does it, register the site with `"mode": "monitor"`. Every
verdict is scored and logged as usual, but no visitor is ever refused — a
Block-tier request gets a max-difficulty puzzle instead of a `429`, and a
verify-time block returns `success: true`.

```bash
-d '{"name":"contact-form","policy":{"mode":"monitor"}}'
```

Watch `GET /v1/admin/stats` (`outcomes.verify_monitored`) for a week, then
`PUT .../policy` with `{"mode":"enforce"}`. The scoring is identical in both
modes, so nothing about the numbers changes when you flip it.

## 3. Embed the Widget

Add the widget container and one script tag to the form you want to protect.

```html
<form action="/signup" method="post">
  <label>
    Email
    <input type="email" name="email" required>
  </label>

  <div data-sitekey="<SITE_KEY>"></div>

  <button type="submit">Create account</button>
</form>

<script src="https://api.bollwark.eu/v1/widget.js"></script>
```

> Examples here use `https://api.bollwark.eu`, the hosted endpoint. Self-hosting? Substitute your own host throughout — nothing below is specific to ours.

`/v1/widget.js` is the entry point, and the only URL you should ever hardcode. It loads the stylesheet, the PoW worker and the Argon2 bundle itself, from a content-hashed directory pinned to that exact widget build — so a deploy can never leave you with a new widget talking to a worker your browser cached last month. The entry point is cached for 5 minutes; everything it pulls is cached for a year and never mutates.

Because the script is served from `https://api.bollwark.eu`, the widget uses that origin for `GET /v1/puzzle` and its assets automatically. Nothing else to configure.

If you bundle or proxy the script from your own app origin instead, set `data-server-url` explicitly:

```html
<div
  data-sitekey="<SITE_KEY>"
  data-server-url="https://api.bollwark.eu"
></div>
```

<details>
<summary>Older embeds using <code>/static/captcha-widget.js</code></summary>

The previous embed — a `<link>` for `/static/captcha-widget.css` plus a `<script>` for `/static/captcha-widget.js` — keeps working and is not going away. It just doesn't get the version pinning: those paths are unversioned, so the widget and worker are cached independently. Moving over is deleting the `<link>` and repointing the `<script>` at `/v1/widget.js`. If you keep your own `<link>`, the widget detects it and won't inject a second one.

**If you integrated before the `rust-captcha` → `bollwark` rename**, the JS names changed too. Both old names still work, so nothing breaks today, but they will be removed:

| Old | Current |
|---|---|
| `window.RustCaptcha` | `window.Bollwark` — aliased, warns once on first use |
| `rustcaptcha:puzzle` | `bollwark:puzzle` — both are dispatched |

The alias matters because the legacy `/static/` path serves the *current* widget: without it an old embed loads a working script that then does nothing, polling for a global that no longer exists, with nothing wrong in the network tab. Migrating is a find-and-replace of those two names.

</details>

### Widget Modes

The widget has two modes selected via `data-mode`:

- **`data-mode="default"`** (used when the attribute is omitted): the checkbox row and brand footer always render. The visible UX is uniform across pass tiers — visitors always see the same "I'm not a robot" → spinner → "Verified" sequence. On low-risk visitors (`invisible_pass`) the widget auto-runs the spinner without a click; on higher tiers it waits for a click. This is the simplest integration — no other wiring required.
- **`data-mode="invisible"`**: the widget renders no chrome at all for the `invisible_pass` tier — PoW runs in the background and the `captcha-token` field is injected silently when the visitor submits. If the server escalates the tier, the widget falls back to its visible UI on demand:
  - `invisible_pass` → no UI, silent PoW.
  - `checkbox` / `hard_pow` → checkbox appears, visitor clicks.
  - `block` → **the widget renders nothing**. Your page must listen for the `bollwark:puzzle` event to surface a failure UX (an inline message, a redirect, or anything else). Without a listener the block is silent and the visitor sees nothing happen. The widget logs a one-shot `console.warn` in this case to flag missed wiring during development.

```html
<div id="captcha" data-sitekey="<SITE_KEY>" data-mode="invisible"></div>
<script>
  document.getElementById("captcha").addEventListener("bollwark:puzzle", (e) => {
    // e.detail = { ok, tier, difficulty?, error? }
    if (!e.detail.ok) {
      // tier === "block" for HTTP 429; null on network/fetch errors.
      showSignupBlockedMessage(e.detail.tier);
    }
  });
</script>
```

The `bollwark:puzzle` event fires in default mode too if you want to drive your own UI alongside the widget — it bubbles, so a listener on a parent element works.

### Theme

The widget's appearance follows `data-theme`:

- **`data-theme="auto"`** (used when the attribute is omitted): follows the visitor's OS via `prefers-color-scheme` — light on a light OS, dark on a dark one.
- **`data-theme="light"`** / **`data-theme="dark"`**: force a fixed palette. Use this to match a host whose theme is fixed regardless of the OS (e.g. an always-dark dashboard).

```html
<div data-sitekey="<SITE_KEY>" data-theme="dark"></div>
```

### Accessibility

The widget is operable without a mouse. The checkbox is reachable by <kbd>Tab</kbd> and activated with <kbd>Space</kbd> or <kbd>Enter</kbd>, carries `role="checkbox"` with an `aria-checked` value that tracks verification, and shows a focus outline. State changes ("Solving challenge…", "Verified", "Verification failed") and the block/failover messages are announced through live regions rather than being visual-only, and the widget's container is tagged with the resolved `lang` so translated text is pronounced correctly.

Nothing is required of you to get this — but if you restyle the widget, keep a visible `:focus` indicator on `.rc-captcha-checkbox`. `e2e/tests/a11y.spec.ts` covers the whole keyboard path, including a full form submission with no mouse events at all.

> Keyboard and screen-reader use carries **no scoring penalty**. The behavioural signal's "no pointer movement" penalty only applies to submissions that also show at most one interaction — the isolated synthetic click it was written to catch. Anyone navigating by keyboard produces an interaction per keystroke, Tab and scroll, so they score zero on it.

### Language

The widget ships translated. Bundled locales: **en, de, fr, es, it, nl**. English is the fallback, applied per string — a locale that is missing one entry falls back for that entry alone.

The locale is picked from the first source that names a bundled language, most explicit first:

1. **`data-lang`** on the widget container — an explicit override, and the only way to pin a language different from the page's.
2. **`<html lang>`** — the page already declares what language it's in.
3. **`navigator.language`** — the visitor's browser preference, for pages that declare no language.

`<html lang>` deliberately outranks the browser: a widget sitting inside a German form should read German even for a visitor whose browser is set to English. Regional tags resolve to their base language (`de-AT` → `de`), and an unrecognised tag falls through to the next source rather than straight to English.

Most integrations need nothing — a page with `<html lang="de">` gets a German widget automatically. Override only when the widget's language should differ from the document's:

```html
<div data-sitekey="<SITE_KEY>" data-lang="fr"></div>
```

The widget also sets `lang` on its own container so screen readers pronounce the translated text correctly.

**Adding a language:** the strings live in one `TRANSLATIONS` table at the top of `static/captcha-widget.js` — eleven entries, no build step. `de` is maintained by the project; `fr`/`es`/`it`/`nl` follow the conventional phrasing other CAPTCHA widgets established but have not had a native review, so corrections are welcome. The `data-debug` panel is intentionally English — it's a developer tool.

The widget writes a hidden form field named `captcha-token` whose value is a single **opaque token** (a hex string). It already carries everything `/v1/verify` needs — the challenge id, the PoW nonce, the honeypot, and behavioural telemetry. Your backend treats it as a black box: read the field and forward it verbatim. There is nothing to parse.

```
a3f1c0...    # opaque; do not parse or depend on its contents
```

> Dwell time is **not** carried in the token. The server derives it from the challenge's issuance timestamp, so a bot can't claim a longer time-on-page than actually elapsed.

### Single-page apps: when nothing posts a form

The flow above assumes a real form submission. If your form is a React/Vue/Svelte/Dioxus component that calls an API instead, the widget still works and still writes `captcha-token` into the enclosing `<form>` — nothing posts it, so you read the value out and send it yourself.

Use `Bollwark.token()`. One `await`, and it handles the parts that are easy to get wrong:

```js
const result = await Bollwark.token(formEl);   // or the container element
if (!result.ok) return showError(result.reason);

await api.signup({ email, captchaToken: result.token });
```

It never rejects — every expected outcome is a value, so a submit handler reads as a branch rather than a try/catch, and the failure cases stay distinguishable in a way a bare token-or-null cannot be:

| `reason` | What happened | Reasonable response |
|---|---|---|
| `needs-interaction` | An escalated tier rendered a checkbox the visitor hasn't clicked. Returns immediately rather than holding a disabled button against a click that may never come. | "Please complete the verification." |
| `blocked` | Refused at puzzle time (HTTP 429). In invisible mode nothing was rendered, so this is the only way you learn of it. | Show a failure state, or route to your own moderation path. |
| `unreachable` | The script never loaded, the container never mounted, or the puzzle fetch failed. | Usually an ad/script blocker. Say so — it reads as an infra problem, not user error. |
| `no-form` | Solved, but the container has no `<form>` ancestor, so there was nowhere to write the token. | An integration bug; see point 2 below. |
| `timeout` | Mounted and solving, but not finished within `timeoutMs` (default 15s). | Ask them to try again. |

On success you get `{ ok: true, token, tier, failover }`. Pass `{ timeoutMs }` to override the deadline.

Two things still to get right, which no helper can do for you:

1. **Keep the `<div data-sitekey>` inside a `<form>` element.** The widget walks up to the nearest form to inject its hidden field; with no form ancestor there is nowhere to write the token (that's `reason: "no-form"`). The form never has to be *submitted* — it just has to exist.
2. **Call `Bollwark.token()` at submit time**, not at mount. It refreshes the token before returning so the behavioural counters reflect the visitor's actual interaction rather than the moment the worker happened to finish.

<details>
<summary>Doing it by hand</summary>

`Bollwark.token()` is a convenience, not a requirement. The manual equivalent is to call `window.Bollwark.scan()` after your component mounts (auto-init runs once at `DOMContentLoaded`, so a form in a modal or a late route was never on the page then; `scan()` tags what it mounts, so re-running is a no-op rather than a second widget), then read `document.querySelector('input[name="captcha-token"]').value` at submit time.

What you take on by doing that: the mount race in both directions, the asynchronous solve in invisible mode — where a fast submit beats the token and an empty field is indistinguishable from a failure — and telling `blocked` apart from `needs-interaction` apart from a script that never loaded, via the `bollwark:puzzle` event.

</details>

Your backend then calls `/v1/verify` with that string exactly as below — the token is opaque either way, and nothing about the server side changes.

> **Cross-origin**: the widget fetches its puzzle from wherever the script came from, and that endpoint answers every origin with wildcard CORS, so a different origin to your app (the usual case) needs no server setting. If you registered the site with `allowed_origins`, your app's origin must be on that list or `GET /v1/puzzle` answers `403`.

## 4. Verify on Your Backend

Your form handler should reject the submission if:

- `captcha-token` is missing or empty.
- `/v1/verify` does not return HTTP 200.
- `/v1/verify` returns `{ "success": false }`.

Forward the token verbatim:

```bash
curl -s -X POST https://api.bollwark.eu/v1/verify \
  -H "Authorization: Bearer <SECRET_KEY>" \
  -H "Content-Type: application/json" \
  -d '{ "token": "<captcha-token value>" }'
```

> **Server-to-server callers** that build the request without the widget can send the explicit fields instead of `token`: `challenge_id` plus `nonce`, with optional `honeypot` and `behavior`. Forward the widget's `behavior` blob verbatim or omit it — never synthesise a constant one: identical activity-claiming blobs repeating on a site are scored as scripted traffic (see *Verify-time signals* in `CONFIGURATION.md`), and an omitted blob is neutral by default.

Forward the visitor's IP too, if you can:

```bash
  -d '{ "token": "<captcha-token value>", "remote_ip": "<visitor IP as your backend saw it>" }'
```

`remote_ip` is optional. When present, the service compares it with the address the puzzle was issued to (IPv6 at /64); a mismatch is the shape of a **token farm** — puzzles solved on one machine, tokens handed to a fleet — and lands the submission in `risk: "elevated"`, never a refusal on its own. Pass the address you would rate-limit on: behind a proxy or CDN that is the resolved client IP, not the proxy's, or every submission reads as elevated. Only the top-level field counts; a `remote_ip` inside the opaque `token` is ignored, since the token is written by the visitor's browser. The `bollwark-verify` crate exposes this as `Client::verify_from(token, ip)`.

Successful response:

```json
{ "success": true, "failover": false, "risk": "low", "challenge_id": "…" }
```

Failed response:

```json
{ "success": false, "failover": false, "risk": "high", "challenge_id": "…", "signals": ["honeypot"] }
```

Challenges are single-use. A second submit with the same `challenge_id` will fail.

### The `risk` field

`risk` is the verify-time verdict as a band — `low`, `elevated` or `high` — so you can step up on a submission the service accepted but did not like. `success` stays the enforcement decision; `risk` is the reason behind it:

| `risk` | `success` | Meaning | Suggested handling |
|---|---|---|---|
| `low` | `true` | Solved, nothing suspicious at submit time | Accept |
| `elevated` | `true` | Solved, but verify-time signals landed in the shadow band (`VERIFY_SHADOW_MIN`) | Accept, and step up where the action warrants it: email confirmation, hold for review, tighter downstream limits |
| `high` | `false` | Solved, but refused on risk (`VERIFY_BLOCK_MIN`) | Reject or queue for manual review |
| `high` | `true` | Would have been refused, but the site is in monitor mode | Log it — this is what the site will reject once you flip to `enforce` |

`risk` is absent when there was no risk verdict to report: an invalid proof of work (`success: false`, no band). Servers from before the field existed omit it; treat a missing band as `low`. The `bollwark-verify` crate exposes it as `Verdict::Passed { risk, .. }`.

### The `signals` field

`signals` names the verify-time checks that contributed to `risk`: any of `honeypot`, `time_on_page`, `behavior` and `remote_ip`, in that order. The names are a stable contract — they are the same names the operator dashboard and decision log use — but the weights behind them are not, so no per-signal score is exposed. The field is omitted when nothing fired, which is every clean submission, and on the failover path and an invalid proof of work, where no verify-time scoring is reported. Treat a missing field as an empty list.

#### Enriching Stripe Radar

If the form leads to a payment, forward the verdict as PaymentIntent `metadata` so Radar rules can match on it, e.g. `Review if ::bollwark_risk:: = 'elevated'` or `Block if ::bollwark_signals:: INCLUDES 'honeypot'`:

```js
await stripe.paymentIntents.create({
  amount, currency: "eur",
  metadata: {
    bollwark_risk: verdict.risk ?? "low",
    bollwark_signals: (verdict.signals ?? []).join(","),
  },
});
```

### The `failover` field

`failover: true` means `success` was granted **without a solved puzzle**, because the captcha service was attestably unreachable when the visitor loaded your form. It's off unless the operator enabled client failover (see `CONFIGURATION.md`).

If you don't care, keep reading `success` alone — you get availability during outages by default. If you do, this is the hook for accept-but-flag:

```js
if (!result.success) return res.status(400).send("CAPTCHA failed");
if (result.failover) {
  // Verified only in the weak sense: the service was down, so no proof of
  // work was possible. The honeypot and behavioural signals were still
  // checked. Reasonable responses: accept but queue for review, tighten a
  // downstream rate limit, or require email confirmation before acting.
  logger.warn({ userId }, "captcha failover — accepted without proof of work");
}
```

## 4b. When the captcha service is down

Two failure modes the `failover` field does **not** cover, because in both of them there's no verify response to read.

**The widget script never loads.** A TLS, DNS, or CDN failure on `/v1/widget.js` means no widget code runs at all — so it can't fall back on your behalf. Only your page can detect this:

```html
<script src="https://api.bollwark.eu/v1/widget.js"
        onerror="captchaUnavailable()"></script>
<script>
  // Also guard the case where the script loads but never initialises.
  const t = setTimeout(captchaUnavailable, 8000);
  document.querySelector('.bollwark-widget')
    ?.addEventListener('bollwark:puzzle', () => clearTimeout(t));

  function captchaUnavailable() {
    // Your call: allow the submit (and flag it server-side), or disable it
    // with an explanation. Silently leaving the form unsubmittable is the
    // one option to avoid.
    document.querySelector('#signup button[type=submit]').disabled = false;
  }
</script>
```

**Your backend can't reach `/v1/verify`.** A connection error or timeout is not a failed verification — it's an unknown. Decide deliberately, and don't let it read as `success: false` by accident:

```js
let result;
try {
  result = await verifyCaptcha(token);
} catch (err) {
  // Fail open or closed — a real choice, not a default. Fail closed is safer
  // for account creation or payments; fail open is usually right for a
  // contact form, where blocking every visitor is the worse outcome.
  logger.error({ err }, "captcha verify unreachable");
  if (FAIL_CLOSED) return res.status(503).send("Try again shortly");
  result = { success: true, failover: true };
}
```

The widget also emits `bollwark:puzzle` with `detail.failover === true` when it enters failover mode, and `detail.recovered === true` if the service comes back and it upgrades to a real solved token in place.

## 4c. Tell the service what it got wrong

The verify-time score is only as good as the ground truth behind it, and the service never sees what happens to a submission after it answers. When you know — a `risk: "low"` submission that turned out to be spam, a `risk: "elevated"` one that was a real customer — say so:

```bash
curl -s -X POST https://api.bollwark.eu/v1/feedback \
  -H "Authorization: Bearer <SECRET_KEY>" \
  -H "Content-Type: application/json" \
  -d '{ "challenge_id": "<challenge_id from the verify response>", "verdict": "spam" }'
```

`verdict` is `spam` or `legit`; `204` on success. `/v1/verify` returns the `challenge_id` next to `success`, so keep it alongside the submission if you may want to label it later (the `bollwark-verify` crate carries it on `Verdict::Passed` and `Verdict::Blocked`). Only your own site's challenges can be labelled — anyone else's is a `404` — and only the id and the verdict are stored, never the submission. The label lands on an anonymised training record that carries no IP and no user-agent, and it still counts if it arrives days later.

## 5. Backend Example: Express

```js
app.post("/signup", express.urlencoded({ extended: false }), async (req, res) => {
  const token = req.body["captcha-token"];
  if (!token) {
    return res.status(400).send("CAPTCHA failed");
  }

  const verifyResp = await fetch("https://api.bollwark.eu/v1/verify", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.BOLLWARK_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ token }),
  });

  if (!verifyResp.ok) {
    return res.status(400).send("CAPTCHA failed");
  }

  const result = await verifyResp.json();
  if (!result.success) {
    return res.status(400).send("CAPTCHA failed");
  }

  // CAPTCHA passed. Continue with your real signup logic.
  res.send("ok");
});
```

## 6. Backend Example: Rust

Use [`bollwark-verify`](crates/bollwark-verify) rather than the hand-rolled version below:

```toml
bollwark-verify = { version = "0.1", features = ["axum"] }
```

```rust
use bollwark_verify::{Client, Error, Verdict};

let client = Client::new("https://api.bollwark.eu", std::env::var("BOLLWARK_SECRET_KEY")?);

match client.verify(token).await {
    Ok(Verdict::Passed { failover }) => accept(failover),
    // Recoverable — the visitor left the tab open, or double-submitted.
    // Ask for a resubmit rather than reporting a failure.
    Ok(Verdict::Expired | Verdict::Replayed) => ask_to_resubmit(),
    Ok(Verdict::Blocked) => refuse(),
    // Not a failed check: the service never got to decide. Fail closed for
    // signup or payments, open for a contact form — but decide.
    Err(Error::Unreachable(e)) => on_outage(e),
    Err(e) => return Err(e.into()),
}
```

With the `axum` feature, `Captcha<T>` does this as an extractor, so the handler only runs on a pass.

<details>
<summary>Without the crate</summary>

```rust
use serde::Deserialize;

#[derive(Deserialize)]
struct VerifyResponse {
    success: bool,
}

/// `token` is the opaque value of the hidden `captcha-token` field, forwarded
/// verbatim — no parsing.
async fn verify_captcha(token: &str) -> anyhow::Result<bool> {
    if token.is_empty() {
        return Ok(false);
    }

    let client = reqwest::Client::new();
    let resp = client
        .post("https://api.bollwark.eu/v1/verify")
        .bearer_auth(std::env::var("BOLLWARK_SECRET_KEY")?)
        .json(&serde_json::json!({ "token": token }))
        .send()
        .await?;

    if !resp.status().is_success() {
        return Ok(false);
    }

    let result: VerifyResponse = resp.json().await?;
    Ok(result.success)
}
```

Note what this collapses, and decide whether you mean it: an expired challenge, a replayed one and a risk block all become `false`, and so does an outage — every visitor silently rejected by a fail-closed policy nobody chose. See §9 for the status codes.

</details>

## 7. Cross-Origin Setup

None needed on the server. `GET /v1/puzzle` and the widget assets answer every origin with `Access-Control-Allow-Origin: *`, so an app at `https://app.example.com` embedding a widget served from `https://api.bollwark.eu` works as-is.

To restrict which origins may use your site key, register the site with `allowed_origins` (or set it later with `PUT /v1/admin/sites/{site_key}/origins`). Origins outside the list get a `403` the widget can read and explain. The retired `CORS_ALLOWED_ORIGINS` env is ignored and logs a warning at boot.

The service is cookie-free, so cross-origin embeds need no `SameSite` or credentials handling.

## 8. Production Checklist

Before using this in a real application:

- Set `ADMIN_TOKEN` to a long random value.
- Set `SITE_DB_PATH` so registered sites survive restarts.
- Store `secret_key` only in backend secrets/config.
- Put the service behind HTTPS.
- Register each site with `allowed_origins` so only your app's origin can spend its quota.
- Decide whether to enable IP reputation (`IP_REPUTATION_FILE`) and TLS fingerprinting (`TLS_FINGERPRINT_HEADER` + `TRUSTED_PROXIES`). Both self-gate on their own config and stay off until set; enabling either gives stronger scoring but adds a fingerprinting signal, so update your DPIA accordingly. The service is otherwise cookie-free and runs every signal under legitimate interest with data minimization.
- Configure `TRUSTED_PROXIES` if you rely on `X-Forwarded-For` or TLS fingerprint headers.
- Keep `/v1/verify`, `/v1/sites`, and `/v1/admin/*` server-to-server only.
- Monitor `puzzle_decision` and `verify_decision` logs before tightening thresholds.

Optional dashboard:

```bash
ADMIN_DB_PATH=/var/lib/bollwark/decisions.db
ADMIN_TOKEN=<same-admin-token>
```

Then open:

```text
https://api.bollwark.eu/static/admin.html
```

## 9. Error Handling

Common responses:

| Case | Response | What to do |
|---|---|---|
| Missing or bad `site_key` | `400` from `/v1/puzzle` | Check frontend config |
| Origin not on the site's allowlist | `403` from `/v1/puzzle` | Add the embedding origin via `allowed_origins`, or leave the allowlist empty to allow any origin. The widget shows the visitor "Verification isn't set up for this site" and logs the refused origin to the browser console. |
| High-risk puzzle request (`block` tier) | `429` from `/v1/puzzle` | Show a retry/error state or fall back to your own moderation path. |
| Missing verify auth | `401` from `/v1/verify` | Check backend `secret_key` |
| Challenge expired | `410` from `/v1/verify` | Ask user to retry the form |
| Replayed challenge | `404` from `/v1/verify` (single-use: the challenge is removed on the first successful verify) | Ask user to retry; do not accept the form |
| Valid PoW but blocked risk score | `{ "success": false, "risk": "high" }` | Reject or queue for manual review |
| Valid PoW, accepted with reservations | `{ "success": true, "risk": "elevated" }` | Accept; step up (confirmation, review) where the action warrants it |

## 10. Local Test Harness

Run the service, then open:

```text
http://localhost:3000/static/testsite.html
```

With `ADMIN_TOKEN` set, the test page prompts for it and registers a test site. In debug builds, you can skip the prompt for local/e2e testing:

```bash
DEV_DISABLE_ADMIN_AUTH=1 SITE_DB_PATH=tmp/sites.db cargo run
```

Do not use `DEV_DISABLE_ADMIN_AUTH=1` in production. Release builds ignore it.