# Security & Abuse Protection

> How Divinci protects public Releases — built-in layered rate limiting and abuse defense, plus the controls you set: the Turnstile free-chat gate, embed-domain allowlist, spend caps, and landing-page HMAC.

Public Releases — especially **anonymous, no-login chat** — are the surface most exposed to abuse: bot floods, scraping, and wallet-burning. Divinci applies layered, defense-in-depth protection automatically, and gives you several controls to harden a public Release further.

## Built-in protection

Every anonymous-chat Release is protected by a defense-in-depth stack in the platform API. You don't configure these — they apply automatically, and they run **before any model call**, so blocked traffic never reaches (or bills) the LLM:

- **Per-IP velocity limits** — un-spoofable rate caps keyed on the Cloudflare client IP; the primary floor against bot floods. Over-limit requests get a `429` with `Retry-After`.
- **Per-device quotas** — monthly plus short-term burst caps per device, so one visitor can't churn unlimited messages across conversations.
- **Aggregate caps** — a workspace-wide ceiling tied to your subscription tier.
- **Abuse detection** — flags device/IP rotation patterns and blocks high-severity offenders.
- **Account lockout** — authentication endpoints add per-IP rate limiting plus a temporary lockout after repeated failures, to blunt credential stuffing.

The controls below are the knobs **you** set per Release to harden it further.

## Lock embeds to your domains

If you embed a Release, set the workspace's **`allowedDomains`** so only your origins can load it. **Always set it explicitly for a production embed** — an unset allowlist is not origin-restricted.

## Cap total spend

Set a workspace **subscription tier** to bound total monthly usage across all users of the workspace — the realistic operator knob for capping spend. Configure it on the WhiteLabel `subscription` document via the workspace/release APIs.

## Free-chat gate (Cloudflare Turnstile)

For public landing pages and homepages, route a Release's anonymous-chat surface through a **verification gate** that enforces a Turnstile CAPTCHA (and optionally email verification) plus per-email and per-device quotas before any message is served. This is the gate behind the Divinci.ai homepage and DrFuhrman.ai.

Configure it on the Release's `freeChatGate` config. It layers **on top of** the built-in protection above.

### Modes

| `mode` | Flow | Use when |
| ------ | ---- | -------- |
| `none` | No gate — plain anonymous chat | You want the built-in protection without an email step |
| `captcha-only` | Turnstile widget per session, no email | Bot deterrent only |
| `captcha+otp` | Turnstile + email + 6-digit OTP | Verified leads (the Divinci.ai homepage reference impl) |
| `captcha+magic-link` | Turnstile + email + click-through link | Lower friction; depends on inbox delivery |

Default (unset or `mode: "none"`) is back-compat: the Release's existing `allowAnonymousChat` / `maxAnonymousChatMessages` rules apply unchanged.

### Quota knobs

Per-email and per-device counters gate how much a visitor can send before and after verifying. Defaults shown:

| Field | Default | Meaning |
| ----- | ------- | ------- |
| `quota.perEmailLifetime` | 1 | Messages a single email may send **before** verifying |
| `quota.postVerifyWindowCount` | 5 | Messages per rolling window **after** verifying |
| `quota.postVerifyWindowHours` | 24 | Length of that rolling window |
| `quota.perDeviceLifetime` | 3 | Per-device lifetime cap — applied in **all** modes |

```typescript
await divinci.releases.update("rel_123", {
  freeChatGate: {
    mode: "captcha+otp",
    quota: {
      perEmailLifetime: 1,
      postVerifyWindowCount: 5,
      postVerifyWindowHours: 24,
      perDeviceLifetime: 3,
    },
  },
});
```

### Turnstile: platform vs BYO

The gate's Turnstile pairing decides whose Cloudflare account/sitekey verifies the challenge.

| `turnstile.mode` | Sitekey | Secret | Notes |
| ---------------- | ------- | ------ | ----- |
| `platform` (default) | Divinci's shared widget | Managed by Divinci | Domain binding handled by the embed-origin allowlist. |
| `byo` | `siteKey` on the config (public, served to the client) | `secretRef` → an env/secret-store key **name** holding the secret | Run your own widget in your own Cloudflare account/analytics. |

```typescript
// Bring your own Turnstile widget
await divinci.releases.update("rel_123", {
  freeChatGate: {
    mode: "captcha-only",
    turnstile: {
      mode: "byo",
      siteKey: "0x4AAA...",            // public sitekey, safe to serve
      secretRef: "MY_TURNSTILE_SECRET", // NAME of the secret-store key, never the raw secret
    },
  },
});
```

<Aside type="caution" title="BYO secretRef is by-reference">
`secretRef` is the **name** of a secret-store key — never the raw secret (same pattern as `idpConfig.clientSecretRef`). The server resolves only a name that is unambiguously a Turnstile key; anything else is refused and verification **fails closed**.
</Aside>

## Landing-page HMAC

Even with a Turnstile gate, the bare anonymous-chat endpoint can be called directly by anyone who knows the release id — burning your wallet/quota. Landing-page HMAC closes that hole: when enabled, anonymous-chat calls must carry a valid signature, so **only your signing proxy** (e.g. a Cloudflare Worker holding the shared HMAC key) can reach the endpoint.

This is the one security control with a CLI flag:

```bash
# Require signed anonymous chat for a release
divinci release update <releaseId> --require-signed-anonymous-chat

# Turn it back off
divinci release update <releaseId> --no-require-signed-anonymous-chat
```

Or via the SDK (`requireSignedAnonymousChat` only has an effect while `allowAnonymousChat` is `true`):

```typescript
await divinci.releases.update("rel_123", {
  allowAnonymousChat: true,
  requireSignedAnonymousChat: true,
  maxAnonymousChatMessages: 12,
});
```

Share the HMAC key only with your signing proxy. See the [Releases → Anonymous chat](/server/releases/) page for the full anonymous-chat field reference.

<Aside type="tip" title="Layer the controls">
For a public landing page: set `allowedDomains`, enable the free-chat gate (`captcha+otp` for lead capture, `captcha-only` for a pure bot deterrent), and turn on landing-page HMAC so only your proxy can reach the endpoint. The built-in per-IP and per-device protection applies underneath all of it.
</Aside>
