Plugins

Rate Limiting

Add a Postgres-backed fixed-window rate limiter with zero external dependencies.

This template does not ship with rate limiting. If you are deploying as a public-facing SaaS (or any environment where abuse is a concern), you can add a Postgres-backed fixed-window rate limiter with zero external dependencies.

To install this plugin, copy the prompt below, paste it into Claude Code (or Codex, or any AI coding tool), and let it run.

Rate Limiter

Add a Postgres-backed fixed-window rate limiter to this project. Here is the
full specification:

### 1. Database migration

Create a Supabase migration that:

- Creates a `rate_limits` table:
  - `key` (text, not null) — arbitrary string identifying the resource being
    limited (e.g. "invite-create:<team_id>")
  - `window_start` (timestamptz, not null) — the start of the current time
    window
  - `count` (int, not null, default 0) — number of requests in this window
  - Primary key: `(key, window_start)`
- Adds an index on `window_start` for cleanup queries
- Enables RLS with zero policies (table is only accessed by the service role)
- Creates an `enforce_rate_limit(p_key text, p_window_seconds int)` function
  (SECURITY DEFINER, search_path = public, pg_temp) that:
  - Snaps to the current fixed window: `to_timestamp(floor(extract(epoch from
    now()) / p_window_seconds) * p_window_seconds)`
  - Upserts into `rate_limits` with `ON CONFLICT DO UPDATE SET count =
    rate_limits.count + 1`
  - Returns the new count
- Creates a `cleanup_rate_limits()` function that deletes rows older than 1 day

### 2. Server utility

Create `server/utils/rateLimit.ts` with an `enforceRateLimit` function:

```ts
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "~~/shared/types/database.types";
import { createError } from "h3";

type RateLimitOptions = {
  key: string;
  limit: number;
  windowSeconds: number;
};

export async function enforceRateLimit(
  client: SupabaseClient<Database>,
  { key, limit, windowSeconds }: RateLimitOptions,
) {
  const { data, error } = await client.rpc("enforce_rate_limit", {
    p_key: key,
    p_window_seconds: windowSeconds,
  });

  if (error || data == null) {
    console.error("[rateLimit] enforce_rate_limit failed:", error);
    return; // fail open
  }

  if (data > limit) {
    throw createError({
      statusCode: 429,
      statusMessage: "Too many requests. Please slow down and try again shortly.",
    });
  }
}
```

### 3. Add rate limits to these server routes

Each `enforceRateLimit` call goes right after the auth check, before any
business logic:

- `server/api/teams/index.post.ts` — 10 per hour per user:
  `{ key: \`team-create:${user.sub}\`, limit: 10, windowSeconds: 60 * 60 }`

- `server/api/teams/[teamId]/invitations/index.post.ts` — two limits:
  - 10 per minute per team:
    `{ key: \`invite-create:${teamId}:m\`, limit: 10, windowSeconds: 60 }`
  - 100 per hour per team:
    `{ key: \`invite-create:${teamId}:h\`, limit: 100, windowSeconds: 60 * 60 }`

- `server/api/invitations/[token].get.ts` — 30 per minute per IP (this is an
  unauthenticated endpoint, so key on IP instead of user):
  ```ts
  const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
  await enforceRateLimit(client, { key: \`invite-lookup:${ip}\`, limit: 30, windowSeconds: 60 });
  ```

- `server/api/invitations/[token]/accept.post.ts` — 20 per minute per user:
  `{ key: \`invite-accept:${user.sub}\`, limit: 20, windowSeconds: 60 }`

- `server/api/teams/[teamId]/avatar.post.ts` — 10 per minute per team:
  `{ key: \`team-avatar-upload:${teamMember.teamId}\`, limit: 10, windowSeconds: 60 }`

- `server/api/auth/profile/avatar.post.ts` — 10 per minute per user:
  `{ key: \`avatar-upload:${user.sub}\`, limit: 10, windowSeconds: 60 }`

### 4. Regenerate database types

Run `npx supabase gen types typescript` to add the `rate_limits` table and
the `enforce_rate_limit` / `cleanup_rate_limits` functions to
`shared/types/database.types.ts`.