# Rate-limiting Readiness

> Audience: vendor implementation team designing the Azure rate-limit story.
> Purpose: pre-document the protected API surface, the storage decision, and a small adapter interface so the vendor inherits a deterministic plan rather than a blank slate.
> Priority: **High / Recommended before handover**.

This document is the design seam for rate-limiting on Azure. It does **not** introduce a production rate-limiter on the current Supabase + Vercel runtime. Adding a fragile in-memory limiter today would create false confidence and double-rewrite work at port time.

---

## 1. Current state

- **No rate limiting** on `/api/*`. Verified via grep — no Upstash / RateLimit / Limiter / throttle import in `app/`, `lib/`, `middleware.ts`, or `proxy.ts`.
- **Auth is JWT-cookie-based** with Supabase session refresh in `proxy.ts`. There is no per-IP, per-user, or per-tenant ceiling on requests.
- **Logger is structured.** `lib/logger.ts` emits JSON in production. A future rate-limiter has a structured place to log decisions (`logger.security.rateLimit.*`) without re-shaping the pipeline.
- **CSP report endpoint** (`/api/csp-report`) is the only currently-public endpoint that explicitly handles untrusted bursts. It logs and drops. No quota.
- **Owner-portal endpoints** (`/api/owner/*`) are gated by role. A leaked owner JWT plus no quota = unbounded multi-tenant reach until detection.

---

## 2. Target state (Azure)

- **Storage:** Azure Cache for Redis (managed, TLS, Managed-Identity auth). The vendor selects SKU based on call volume; the application never holds the connection string in code.
- **Algorithm:** sliding-window or token-bucket per key. Vendor picks. Both are supported by the `@upstash/ratelimit`-style API and by direct Redis Lua scripts.
- **Key shape:** `<scope>:<class>:<actor>` where:
  - `scope` is environment (`prod`, `staging`, `dev`).
  - `class` is the protected-API class below (`auth`, `mutation`, `demo`, `owner`, `csp`).
  - `actor` is the most-specific identity available — `tenant_id` + `user_id` if known, else `ip`.
- **Decision surface:** middleware (`proxy.ts`) for low-cost gating; per-route handler for class-specific quotas that need the resolved tenant.
- **Telemetry:** every deny logs `logger.security.rateLimit.deny` with key shape, quota, window, current count. Every allow logs sampling-rated.
- **Backstop:** Azure Front Door / Application Gateway WAF for L7 IP-volume protection. The application limiter handles per-actor semantics; the WAF handles brute volume.

---

## 3. Protected API classes

Vendor's Azure design must define quotas for each class. Defaults below are starting points — operator + vendor confirm before turning the limiter on.

| Class      | Routes                                                                                    | Default quota (per actor)            | Reasoning                                                                                          |
| ---------- | ----------------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `auth`     | `/api/auth/*`, `/api/admin/users/reset-password`, `/api/admin/users/update-email`         | 5 / minute, 50 / hour                | Brute-force surface. Tight ceiling.                                                                |
| `mutation` | All POST/PATCH/PUT/DELETE on `/api/v1/*`                                                  | 60 / minute, 600 / hour              | Normal authenticated user is well below this. Catches automation drift.                            |
| `demo`     | `/api/demo/*`, `/api/admin/demo`                                                          | 10 / minute, 100 / day               | Demo seed/reset is expensive. Protects DB.                                                         |
| `owner`    | `/api/owner/*` (control-plane endpoints)                                                  | 30 / minute, 300 / hour              | Compromised owner JWT damage cap.                                                                  |
| `csp`      | `/api/csp-report`                                                                         | 100 / minute / IP                    | Public endpoint. Token-bucket on IP. Drop quietly past the bucket.                                 |
| `read`     | All GET on `/api/v1/*`                                                                    | 600 / minute                         | Loose ceiling. Most pressure here is from runaway clients, not abuse.                              |

These quotas are **defaults**. Per-tenant overrides should land in `cp_tenants` (e.g. `rate_limit_profile TEXT NULL`), not hardcoded.

---

## 4. Storage decision (open)

| Option                              | Storage            | Production-ready? | Cross-instance correct? | Pick when                                                                            |
| ----------------------------------- | ------------------ | ----------------- | ----------------------- | ------------------------------------------------------------------------------------ |
| In-memory only                       | per-instance       | **NO**            | NO                      | Local dev. Disabled in production by config.                                         |
| Azure Cache for Redis               | managed            | YES               | YES                      | **Recommended target.** Vendor provisions; app reads via Managed Identity.            |
| Upstash Serverless Redis             | external SaaS      | YES               | YES                      | Transitional. Stops at Azure migration.                                              |
| PostgreSQL row-locked counter table  | existing PG        | YES               | YES                      | Last resort. High contention on hot keys.                                            |

**Recommendation:** Azure Cache for Redis. Pick decided by vendor; document the choice in the project's ADR set.

---

## 5. Adapter interface (proposal)

The application talks to a single adapter so the storage choice is swappable. Skeleton (TypeScript) for vendor reference — **not implemented** in this branch:

```ts
// Proposal — not part of the current runtime.
export interface RateLimitDecision {
  allowed: boolean;
  /** Remaining tokens / requests in the current window. */
  remaining: number;
  /** Unix-ms timestamp when the window resets. */
  resetAt: number;
  /** Human-readable rule that fired. */
  rule: string;
}

export interface RateLimitAdapter {
  check(input: {
    key: string;          // <scope>:<class>:<actor>
    quota: number;        // requests per window
    windowMs: number;     // window length in ms
    cost?: number;        // default 1
  }): Promise<RateLimitDecision>;
}

// Implementations to plug in:
//   - InMemoryAdapter    — dev only; warns on construction.
//   - RedisAdapter       — Azure Cache for Redis via Managed Identity.
//   - NoopAdapter        — production fail-open while limiter ramps up.
```

Notes:
- The adapter is the **only** rate-limit surface the rest of the app sees.
- Calling code never builds keys — a higher-level helper produces them from `(class, actor)` so the shape stays consistent.
- Telemetry is emitted by the helper, not the adapter, so the call site is captured.

---

## 6. Validation strategy

Before flipping a limiter on in production:

1. **Unit tests** against `InMemoryAdapter`: at-window-edge correctness, cost > 1, monotonic `remaining`.
2. **Integration tests** against a containerised Redis (or Testcontainers): cross-instance correctness, leader-election behaviour irrelevant since Redis is the single source of truth.
3. **Canary deploy** behind a `LIMITER_ENFORCE=0` flag — adapter runs and logs decisions, but never denies. Compare deny-rate against expected baseline for one week.
4. **Soft-launch** with `LIMITER_ENFORCE=1` for a single class (`csp` or `read`) where false-positives are cheapest.
5. **Full-launch** class-by-class. `auth` last — it is the highest-risk class for false-positive lockouts.
6. **Synthetic load test** post-launch: hammer `/api/auth/*` with stale credentials, expect deny telemetry, verify dashboards.

---

## 7. Out of scope for this branch

- No runtime adapter implementation in `lib/`.
- No middleware change in `proxy.ts`.
- No new dependency installed.
- No env vars added.
- No commercial / effort / cost estimation.

The next prompt (`03_final_small_features` or vendor handover) does not need to introduce a runtime limiter either. The Azure-side limiter is owned by the vendor at port time, against the Redis they provision.

---

## 8. Open decisions (vendor + operator)

| ID    | Question                                                                                | Default                                              |
| ----- | --------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| RL-1  | Storage: Azure Cache for Redis vs. PostgreSQL counter table?                              | Azure Cache for Redis.                               |
| RL-2  | Per-tenant override mechanism: column on `cp_tenants` vs. separate `cp_rate_limits` table? | Column for v1; separate table when override count > 5 distinct profiles. |
| RL-3  | Fail-open vs. fail-closed when storage unreachable?                                      | Fail-open with `logger.security.rateLimit.degraded` paging the on-call. |
| RL-4  | Account-lockout escalation policy for repeated `auth` denies?                            | 10 denies in 10 minutes → 1 h cooldown; logged.      |
| RL-5  | CSP report endpoint quota — drop vs. 429?                                                 | Drop silently past the bucket. Public endpoints should not echo quota state to actors. |
