# Public broker intake — security model

Implemented 2026-09-22 from Nash's specification. Rule: **easy for a real
dispatcher, expensive for a bot.** The page is public; completing a
submission needs a verified email.

## The experience

1. Carrier opens `/intake/<broker-slug>` (or a secure invitation link `?invite=<token>`).
2. Fills the form, uploads the rate confirmation and permits.
3. Clicks Submit. Signed in → submitted. Not signed in → an inline panel asks
   for their email, sends a 6-digit code, they type it, the account is
   created (or recognised) and **the submission continues by itself** with
   everything they typed. Status lines: "Verify your email to submit this
   trip." → "Account verified — continuing submission."
4. Next time they are signed in; no repeat.

## Trust levels → limits (`intake_settings`, admin-editable)

| Level | Who | Per day |
|---|---|---|
| 0 | anonymous | view only, never completes |
| 1 | account, email unverified | 1 |
| 2 | email verified | 10 |
| 3 | company verified (Company Info) | 50 |
| 4 | invited by the broker / marked trusted / admin | 100 |

Plus: 20 per IP per hour, 200 per page per hour (broker can lower it), 3
codes per address per 15 minutes, 10 files / 20 MB each / 75 MB total.

## Server-side guard — `src/lib/data/intake-guard.ts` (§36)

Order, for every POST to `/api/intake`: honeypot → block list (ip / email /
user) → sign-in and verified email → broker policy (enabled, who can submit)
→ protection mode → daily quota by level → IP velocity → page velocity →
Turnstile (when configured) → file count / sizes / **real type by magic
bytes** (PDF, JPG, PNG) → SHA-256 of every file, duplicate submission in
24 h → risk score (§26: unverified, new account, velocity, accounts per IP,
disposable email, repeated files, bot failures, failed codes) → allow /
review / block. Every attempt is an `intake_submissions` row with status,
reason, risk and hashes; every denial is a `security_events` row
(`detail.kind = intake_denied`). Medium risk is stored as
`review_required` and skips OCR/AI until a moderator releases it.

Protection mode (§25): a page crossing 500 submissions/hour flips to
invited-only for 6 hours automatically; admins can toggle it.

## Accounts

A verified email is an account: `auth_accounts.source = 'self_signup'`, no
password, sign-in by code (`/api/intake/auth`). Admin-provisioned logins
keep working unchanged. Self-service accounts default to the carrier
dispatch role, so the trip they submitted opens in their Carrier Dashboard.

## Invitations (§15)

Broker Dashboard → **Invite a Carrier**: emails a link tied to the address
(14 days). Following it sets trust level 4 once the email is confirmed.

## Admin — `/admin/intake-security` (§34)

Counts for the day, pages (enable / disable, protection mode, who can
submit), blocks (ip / email / user, temporary or permanent), top IPs,
recent submissions with release / trust actions, editable limits, intake
events.

## Not in this pass

Malware scanning (files are type-checked by content and stored privately in
S3; `scan_status` is recorded as `not_scanned` for a future scanner), a
background job queue (processing still runs inline for allowed submissions;
`review_required` ones wait), disposable-email list beyond a starter set.
