# Rate Limit - Per-Operation Request Caps

Per-operation request caps for every CRUD endpoint. Backed by Cloudflare's native [Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) — no extra storage, no third-party libraries.

## Defaults

Every resource gets these limits automatically (per authenticated user, per minute):

| Operation | Default |
|-----------|---------|
| `read`    | 1000 / 60s |
| `create`  | 200 / 60s  |
| `update`  | 200 / 60s  |
| `delete`  | 200 / 60s  |

Numbers are loose enough that no legitimate user-driven traffic hits them, but tight enough to catch a runaway script before it burns the day's D1 budget. Override per-op or opt out wherever the defaults don't fit.

## Override one operation

```typescript
// features/claims/claims.ts
import { q, defineTable } from '@quickback/compiler';

export const claims = q.table('claims', {
  id:             q.id(),
  reference:      q.text().required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(claims, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  rateLimit: {
    delete: { limit: 5, period: 60 },  // tighter than the 200/60s default
  },
  // read / create / update keep shipped defaults
});
```

## Disable rate-limiting on a resource

```typescript
// features/telemetry/events.ts
import { q, defineTable } from '@quickback/compiler';

export const telemetryEvents = q.table('telemetryEvents', {
  id:             q.id(),
  kind:           q.text().required(),
  payload:        q.text().optional(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(telemetryEvents, {
  rateLimit: false,   // high-volume sink, skip enforcement
});
```

## Project-wide overrides

```typescript
// quickback.config.ts
export default defineConfig({
  // ...
  rateLimit: {
    read: { limit: 500, period: 60 },   // tighter project-wide read default
  },
});
```

## Disable globally

```typescript
// quickback.config.ts
export default defineConfig({
  // ...
  rateLimit: false,  // e.g. when behind a WAF that handles request shaping
});
```

## Configuration shape

```typescript
rateLimit?: {
  read?:   { limit: number, period: 10 | 60 };
  create?: { limit: number, period: 10 | 60 };
  update?: { limit: number, period: 10 | 60 };
  delete?: { limit: number, period: 10 | 60 };
} | false
```

- `period` must be `10` or `60` (Cloudflare binding constraint — anything else is a compile-time error).
- `limit` is a positive integer.
- Omit an op to inherit the project default (or shipped default).
- Set `false` at resource or project level to opt out.

## Resolution order

1. Project `rateLimit: false` → no enforcement anywhere
2. Resource `rateLimit: false` → no enforcement on this resource
3. Resource per-op override
4. Project per-op default
5. Quickback shipped defaults (1000 / 200 above)

Default-using resources collapse onto two shared bindings (`RL_1000_60`, `RL_200_60`), so a project that never declares `rateLimit` still ships exactly two rate-limit bindings — not one per resource.

## How requests are keyed

Each handler composes a key as `<resource>:<op>:<userId>`. For routes that opt into PUBLIC access (no `userId`), the key falls back to the client's `cf-connecting-ip` header, then to the literal `'anon'` if that header is absent too.

This means:

- A single user is rate-limited per resource per op — heavy `read` traffic on `claims` doesn't slow their `update` traffic on `applications`.
- One noisy user can't starve another — keys are scoped by `userId`.
- Anonymous traffic hitting PUBLIC routes is keyed by IP, which works behind a CDN but is trivially bypassed by a botnet — pair PUBLIC routes with auth-required mutations or a Cloudflare WAF rule for hostile traffic.

## How limits compile

The compiler walks every resource, collects unique `(limit, period)` tuples, and emits one Cloudflare binding per tuple to `wrangler.toml`:

```toml
[[ratelimits]]
name = "RL_1000_60"
namespace_id = "60002000"
simple = { limit = 1000, period = 60 }

[[ratelimits]]
name = "RL_200_60"
namespace_id = "60001200"
simple = { limit = 200, period = 60 }
```

`namespace_id` is a stable per-tuple integer (`1000 + period * 1_000_000 + limit`), so it survives resource reordering and never drifts across recompiles.

Each generated CRUD handler invokes `c.env.RL_<L>_<P>.limit({ key })` between the auth gate and the access check. Hitting the limit returns:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json

{ "error": "Rate limit exceeded", "code": "RATE_LIMITED", "retryAfter": 60 }
```

## Batch endpoints

Batch routes (`POST /batch`, `PATCH /batch`, `DELETE /batch`, `PUT /batch`) count as **a single request** against their op's limit, not N. A batch of 100 records consumes one token, not one hundred. This matches how most rate-limit configurations are actually written (caps on request volume, not row volume).

## Actions

Custom actions (record-based and standalone) inherit rate limits without configuration:

- **Record-based actions** (e.g. `POST /api/v1/claims/:id/approve`) inherit the resource's `update` (write) bucket. A user who hits the write limit on `claims` is also blocked from `approve`.
- **Standalone actions** (e.g. `POST /api/v1/twilioIncoming`) inherit the project-default `update` bucket — they don't belong to a resource.

Override either with the action's `rateLimit` field:

```typescript
// features/claims/actions/approve.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: 'Approve a claim',
  input: z.object({ claimId: z.string() }),
  access: { roles: ['admin'] },
  rateLimit: { limit: 5, period: 60 },   // tighter than resource update bucket
  execute: async ({ db, input }) => { /* ... */ },
});
```

```typescript
// features/integrations/actions/twilioIncoming.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: 'Webhook from Twilio',
  path: '/twilioIncoming',
  method: 'POST',
  input: z.object({ /* ... */ }),
  access: { roles: ['PUBLIC'] },
  rateLimit: { limit: 1000, period: 10 },   // tolerate burst from Twilio
  execute: async ({ input }) => { /* ... */ },
});
```

Set `rateLimit: false` to opt the action out of enforcement entirely.

**Key composition** — actions get their own keyspace so a generic resource bucket and an action's bucket never share counters:

| Action kind | Key shape |
|-------------|-----------|
| Record-based | `<resource>:action:<actionName>:<userId\|cf-connecting-ip\|anon>` |
| Standalone   | `standalone:<path>:<userId\|cf-connecting-ip\|anon>` |

The `:action:` infix is what keeps `update` (resource bucket) and `approve` (action bucket) from sharing counters when they happen to land on the same binding name.

A record action's bulk variant (`POST /<resource>/batch/<action>`) shares its single-record sibling's key exactly, so a batch of N records consumes **one** token from the action's bucket per request — not N.

## Cardinality — how many bindings ship

Cloudflare bindings are sized at compile time (limit + period are baked in), so the compiler emits one binding per **unique `(limit, period)` tuple**, not one per resource. A project of 50 resources that all use the defaults still ships exactly two rate-limit bindings.

| Configuration | Bindings emitted |
|---------------|------------------|
| All defaults | 2 (`RL_1000_60` for reads, `RL_200_60` for writes) |
| One project-wide read override (`read: { limit: 500, period: 60 }`) | 3 (`RL_500_60`, `RL_1000_60` ← only if some resource opts back into the default, `RL_200_60`) |
| Five resources tightening `delete` to `{ limit: 5, period: 60 }` | 3 (defaults + `RL_5_60` shared) |
| Ten distinct per-action overrides at unique tuples | 12 (defaults + ten action buckets) |

**Practical caps.** Cloudflare workers have generous binding limits (hundreds), so this is unlikely to be a real ceiling — but it's the reason every distinct `(limit, period)` you write on a `rateLimit` config widens the binding set, even when the same resource declares the same tuple multiple times. Resource-level configs that share a tuple share a binding and simply produce different runtime keys.

**Key cardinality is separate.** Each binding holds an independent counter per key. A binding called via `claims:read:user_123` and `applications:read:user_123` consumes two separate counters in the same binding — fan-out cost lives in Cloudflare's distributed counter store, not in your binding count. The user-perceivable limit is "per (resource, op, user)", regardless of how many resources share the binding.

## Runtime support

| Runtime | Rate limiting |
|---------|---------------|
| `cloudflare` | Backed by Cloudflare's native [Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) |
| non-Cloudflare (`bun`, `node`) | Degrades safely — the check no-ops when the binding is absent from `c.env` |

Every generated rate-limit call site — CRUD middleware and custom actions alike — first looks up `c.env.RL_<limit>_<period>` and only calls `.limit({ key })` when the binding is present. On the Cloudflare runtime it always is. On other runtimes it isn't, so the check no-ops and the request proceeds — generated code never hard-depends on the binding.

**Roadmap — Bun in-process shim.** An in-memory fixed-window shim (`src/lib/rate-limit-memory.ts`) that backs the same `c.env.RL_*.limit({ key })` call site with a process-memory token bucket is planned but **not yet emitted**. Until it lands, non-Cloudflare deployments that need enforcement should sit behind a Cloudflare WAF rule or an upstream rate limiter. A multi-instance Bun shim would additionally need sticky sessions (same user → same instance → same bucket), since a process-memory counter can't coordinate across processes.

## Compile-time validation

The compiler errors on:

1. `period` other than `10` or `60`
2. Non-integer or non-positive `limit`
3. Unknown ops (e.g. `list` instead of `read`)

## Disabling vs. tightening — when to use which

| Goal | Configuration |
|------|---------------|
| One op needs a tighter cap (e.g. `delete`) | Resource override on that op only |
| One op needs a looser cap (high-volume reads) | Resource override raising the limit |
| One resource is a high-volume sink | `rateLimit: false` on the resource |
| Whole project is behind a WAF | `rateLimit: false` at project level |
| App-wide tighter default (e.g. expensive reads) | Project-level override of that op |
