feat(rate-limit): per-user mutation-rate-limiting (v1-readiness #3)

lib/rate-limit.ts: 11 nieuwe scope-configs + enforceUserRateLimit(scope, userId)
helper. Returnt { error, code: 429 } shape voor consistent foutbeleid.

Toegepast op de high-value mutation-paths:
- actions/pbis.ts createPbiAction
- actions/stories.ts createStoryAction
- actions/tasks.ts saveTask (alleen create-path) + createTaskAction
- actions/todos.ts createTodoAction
- actions/sprints.ts createSprintAction
- actions/products.ts createProductAction + createProductFormAction
- actions/api-tokens.ts createApiTokenAction
- actions/questions.ts answerQuestion
- actions/claude-jobs.ts enqueueClaudeJobAction + enqueueClaudeJobsBatchAction
- app/api/profile/avatar/route.ts POST
- app/api/stories/[id]/log/route.ts POST

Limits zijn ruim genoeg voor normaal gebruik, eng genoeg voor abuse-loops:
create-task 100/min, create-todo 60/min, create-pbi 30/min, create-product
5/min, create-token 10/uur, etc. Per-user scope (geen globale block).

Niet aangeraakt: reorder/status-toggle (intra-session frequent, lage abuse),
update/delete (laag-volume), cron-routes (CRON_SECRET-gated).

Consumer-tweaks: 'success' in result narrowing waar TS de bredere union niet
meer accepteerde. Tests: 9 nieuwe op rate-limit-helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 13:48:59 +02:00
parent 43778e3bcb
commit a0a10001d5
16 changed files with 175 additions and 13 deletions

View file

@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { checkRateLimit, enforceUserRateLimit, _resetRateLimit } from '@/lib/rate-limit'
beforeEach(() => {
_resetRateLimit()
})
describe('checkRateLimit (legacy auth-keys)', () => {
it('staat de eerste request toe', () => {
expect(checkRateLimit('login:1.2.3.4')).toBe(true)
})
it('blokkeert na exceeding max (login: 10/min)', () => {
for (let i = 0; i < 10; i++) checkRateLimit('login:1.2.3.4')
expect(checkRateLimit('login:1.2.3.4')).toBe(false)
})
it('register heeft eigen lagere limiet (5/uur)', () => {
for (let i = 0; i < 5; i++) checkRateLimit('register:9.9.9.9')
expect(checkRateLimit('register:9.9.9.9')).toBe(false)
})
it('verschillende keys hebben hun eigen counter', () => {
for (let i = 0; i < 10; i++) checkRateLimit('login:1.1.1.1')
expect(checkRateLimit('login:1.1.1.1')).toBe(false)
expect(checkRateLimit('login:2.2.2.2')).toBe(true)
})
})
describe('enforceUserRateLimit (v1-readiness #3 mutation-scopes)', () => {
it('returnt null bij eerste call', () => {
expect(enforceUserRateLimit('create-pbi', 'user-1')).toBeNull()
})
it('returnt 429-shape na exceeding limiet', () => {
// create-product limiet = 5/min
for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1')
const result = enforceUserRateLimit('create-product', 'user-1')
expect(result).not.toBeNull()
expect(result?.code).toBe(429)
expect(result?.error).toContain('Te veel acties')
})
it('scope is per (action, user) — andere user heeft eigen quota', () => {
for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-A')
expect(enforceUserRateLimit('create-product', 'user-A')).not.toBeNull()
expect(enforceUserRateLimit('create-product', 'user-B')).toBeNull()
})
it('verschillende scopes voor dezelfde user vullen apart', () => {
for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1')
expect(enforceUserRateLimit('create-product', 'user-1')).not.toBeNull()
// create-task heeft eigen counter
expect(enforceUserRateLimit('create-task', 'user-1')).toBeNull()
})
it('create-task limiet (100) is hoger dan create-pbi (30)', () => {
for (let i = 0; i < 30; i++) enforceUserRateLimit('create-pbi', 'u')
expect(enforceUserRateLimit('create-pbi', 'u')).not.toBeNull()
// create-task is nog niet hit
for (let i = 0; i < 30; i++) enforceUserRateLimit('create-task', 'u')
expect(enforceUserRateLimit('create-task', 'u')).toBeNull()
})
})