# Proposal — Visitor Rate-Limit Visibility & Per-IP Throttle Policy

**Status:** draft (audit iter 14, 2026-05-16)
**Owner:** TBD
**Effort:** 4 days (2 backend, 1 frontend admin, 1 docs+QA)
**Plan gate:** Business+ tier

---

## Problem

Today rate limits exist (`routes/api.php` per-IP throttles + plan-limit
counters per workspace), but workspace admins have ZERO visibility
into:

- Which visitor IPs are hammering their LLM hardest.
- Whether they're approaching their plan's message quota.
- Suspicious traffic patterns: a single IP firing 200 chats in 10
  minutes.

When a workspace hits its monthly quota and the bot suddenly returns
"plan limit reached" to every visitor, the admin's only signal is a
support ticket from confused visitors.

Buyer feedback:
> "I got billed for 50K messages last month — half of which I think
>  were one bot scraping my widget. No way to see who." — Lucian,
>  batch v4
> "Visitors started seeing 'too many requests' errors. Took me 2
>  hours to figure out the rate limit kicked in because I had no
>  dashboard for it." — whispbar, batch v3
> "I want to block specific IPs without writing a firewall rule." —
>  ttsoft, batch v4

Competitors:
- **Intercom** — visitor-level conversation throttling + IP-block UI.
- **Drift** — per-IP rate limiter with admin override list.
- **HubSpot Chat** — bot-detection dashboard with visitor session
  flagging.

Pitchbar's rate limits are invisible until they fire. Admins want a
dashboard + control surface.

---

## Goals

1. **Live usage dashboard** (workspace settings → "Traffic"):
   - Last-24h conversation count + last-30d trend.
   - Last-24h message count + plan quota % used.
   - Top 10 visitor IPs by conversation count (privacy: salted +
     truncated to /24, never the raw IP).
   - Trigger threshold: 100 conversations from one /24 in 24h gets a
     yellow badge, 500 gets red.

2. **Per-IP block list** (workspace settings → "Traffic" → "Block
   IPs"):
   - Admin pastes IP or /24 CIDR → added to `workspaces.ip_blocklist`
     JSON array.
   - Widget init endpoint rejects with 403 + a clean "this site is
     not accepting new conversations from your network" error.
   - Audit log captures each add/remove with operator + reason.

3. **Soft visitor-rate-limit override per workspace**:
   - Today: hardcoded 60 conversations / IP / day at the api.php
     throttle.
   - New: workspace-tunable from "strict (30/day)" / "default
     (60/day)" / "loose (180/day)" / "off" via a settings dropdown.
   - Server still enforces a hard ceiling (500/day per IP) at the
     framework level to protect Pitchbar's bill.

4. **Anomaly alert**:
   - Daily digest email already exists. Extend it with an "unusual
     traffic" section when a workspace's last-24h conversation count
     exceeds 3× the 7-day rolling average.

---

## Non-goals

- **No real-time IP geo lookup.** Out of scope. Buyer can layer
  Cloudflare in front if they need geo blocks; we surface IPs only.
- **No CAPTCHA challenge.** Adds friction to legitimate visitors;
  the block-list + rate-limit covers the abuse vector.
- **No bot-fingerprinting / signed-script verification.** Separate
  initiative.
- **No "ban this visitor across workspaces"** super-admin tool.
  Per-workspace lists only.
- **No real-time block-list propagation to the CDN.** Block is
  enforced at the application layer; that's enough for the 99%
  attacker who uses one IP.

---

## Data model

Existing tables to extend:

```php
Schema::table('workspaces', function (Blueprint $table) {
    $table->json('ip_blocklist')->nullable()->after('attribution');
    $table->string('rate_limit_policy', 16)->default('default')->after('ip_blocklist');
});

Schema::table('conversations', function (Blueprint $table) {
    // Already exists? Let me check — visitor_ip_sha is the
    // privacy-friendly grouping key. Add if missing.
    $table->string('visitor_ip_sha', 64)->nullable()->after('claimed_by_user_id')->index();
});
```

`visitor_ip_sha`: `hash('sha256', $env['VISITOR_IP_SALT'] . $remoteAddr . '/24')`.
Salt rotates monthly so a leaked DB row can't be used to identify
visitors. /24 grouping prevents enumeration while still letting us
rank top abusers.

---

## Read path — dashboard

`GET /app/settings/traffic` (Inertia page, Wayfinder route):
- Workspace 24h / 30d / quota counters from `usage_logs` aggregation
  (already indexed by `(workspace_id, created_at)`).
- Top-10 IP grouping from `conversations.visitor_ip_sha` GROUP BY +
  count.
- Threshold badge computed server-side (yellow ≥ 100, red ≥ 500
  conversations from one /24).
- Cached 60s per workspace (acceptable freshness for an admin view).

---

## Write path — block list

`POST /app/settings/traffic/blocklist`:
- Validates IPv4 / IPv6 / CIDR syntax.
- Appends to `workspaces.ip_blocklist` JSON array.
- Writes an `audit_logs` row (`event: 'ip_blocked'`, payload includes
  the new entry + actor user).

`DELETE /app/settings/traffic/blocklist/{entry}`:
- Removes from the array.
- Writes an `audit_logs` row.

`PATCH /app/settings/traffic/policy`:
- Updates `workspaces.rate_limit_policy` (`strict|default|loose|off`).
- Bumps the `RateLimiter::for('widget_init')` named limiter to read
  the per-workspace value at runtime.

---

## Enforcement points

1. **Widget `init` endpoint** — `InitController::__invoke`:
   - Reject early if `$visitorIpSha` matches any `workspaces.ip_blocklist`
     entry → 403 with a clean error code.
2. **Per-workspace named limiter** — `App\Providers\RouteServiceProvider::boot()`:
   ```php
   RateLimiter::for('widget_init', function (Request $request) {
       $workspace = $request->attributes->get('resolved_workspace');
       $limit = match ($workspace?->rate_limit_policy ?? 'default') {
           'strict' => 30,
           'loose' => 180,
           'off' => 500,
           default => 60,
       };
       return Limit::perDay($limit)->by($request->ip());
   });
   ```
   Framework hard ceiling 500/day applies regardless (protects Pitchbar's
   bill; even an "off" workspace can't authorize infinite traffic).

---

## Hot-path safety

- IP hash computation: ~5μs.
- Blocklist check: in-memory array intersect, <1μs per init.
- Dashboard query is admin-side, cached, NOT on visitor path.
- No new sync HTTP. Zero new DB writes before first token.

---

## Test plan

Pest feature tests:
- Blocked IP returns 403 from `widget/init`.
- Unblocked IP from same /24 still passes (CIDR scope).
- Rate-limit policy `strict` cuts limit to 30/day.
- Rate-limit policy `off` still hard-caps at 500/day.
- Block-list write logs an `audit_logs` row.
- Block-list cannot be edited by Viewer role.
- Dashboard query excludes other workspaces' IPs (tenancy).
- Top-10 IP query returns hashed values, never the raw IP.
- `visitor_ip_sha` salt rotation keeps existing rows hashed under
  the OLD salt (rolling key — re-hash batch deferred to cleanup
  job).

UI test plan:
1. Sign in as Business workspace admin.
2. /settings/traffic → see 24h counters + Top-10 IP list.
3. Click "Block this IP" on a hashed row → confirm row disappears +
   audit log entry visible in platform admin.
4. Set policy to "strict (30/day)" → spam-test from same IP (use
   curl loop) → confirm 429 at request 31.
5. Set policy to "off" → confirm 500/day hard ceiling still
   enforced.

---

## Rollout

1. Phase 1 (1.5 days): schema migration, `visitor_ip_sha` write at
   init, rate-limit policy plumbing. No UI yet.
2. Phase 2 (1.5 days): admin Inertia page + Wayfinder routes + block
   list endpoints.
3. Phase 3 (1 day): docs + Mintlify nav + troubleshooting page +
   audit-log entries.
4. Canary: enable on Pitchbar's demo workspace first; ship to
   Business tier after 7 days.

---

## Risks / open questions

- **GDPR / privacy**: hashing the IP makes us safe under most
  interpretations (IP is "online identifier"). Salt rotation +
  truncation to /24 further reduces re-identification risk. Document
  the design in `docs/PRIVACY.md` (already exists).
- **False positives in /24 blocks**: corporate NAT-ed networks. A
  block of `203.0.113.0/24` could hit a legitimate office that shares
  one egress IP. Workspace admin owns the consequence; surface a
  warning when blocking a /24.
- **Block-list size**: realistic max ~100 entries per workspace.
  In-memory array scan is fine. If we ever see 10K+ entries, swap to
  bloom filter.
- **What about CAPTCHA?** Out of scope, but the data we'd need
  (per-IP fail count) is collected by this feature, so future-easy
  to layer on.

---

## Why now

- 3 buyer asks in the past 60 days, all from Business-tier customers
  who are paying for the higher quota and want to protect it.
- Engineering scope clean: 2 endpoints, 1 admin page, 1 named
  limiter. No hot-path risk.
- Closes a real privacy + abuse-protection gap that loses larger
  workspaces.
- 10th proposal in Q3 roadmap (~51 days total engineering across
  10 proposals).
