feat: better ratelimiting (#2520)

Replace hono-rate-limiter with a Prisma/PostgreSQL bucketed counter
approach that works correctly across multiple instances without sticky
sessions.

- Add RateLimit model with composite PK (key, action, bucket) and atomic
upsert
- Create rate limit factory with window parsing, bucket computation, and
fail-open
- Define auth-tier and API-tier rate limit instances
- Add Hono middleware, rateLimitResponse helper, and tRPC
assertRateLimit helper
- Wire rate limit headers through AppError constructor (was declared but
never assigned)
- Apply rate limits to auth routes (email-password, passkey), tRPC
routes
  (2FA email, link org account), API routes, and file upload endpoints
- Add cleanup cron job for expired rate limit rows (batched delete every
15 min)
- Remove hono-rate-limiter dependency
This commit is contained in:
Lucas Smith
2026-02-20 12:23:02 +11:00
committed by GitHub
parent 006b1d0a57
commit 653ab3678a
21 changed files with 1218 additions and 62 deletions
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID = 'internal.cleanup-rate-limits';
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA = z.object({});
export type TCleanupRateLimitsJobDefinition = z.infer<
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA
>;
export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = {
id: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
name: 'Cleanup Rate Limits',
version: '1.0.0',
trigger: {
name: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
schema: CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./cleanup-rate-limits.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
TCleanupRateLimitsJobDefinition
>;