ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-distribute-rate-limit-redis-backed.md
Randall Stillwell 336a5890a8 feat(audit): append-only audit_log, workspace archive cascade + restore, audit view
Soft-delete cascade was the missing half of archive: stamping
workspaces.archived_at alone left objects visible to anyone with a
direct id. The cascade runs in one transaction so the partial state
isn't reachable, and restore inverts it for any archived row in the
workspace — provenance-blind on purpose until we have a use case
that needs to distinguish per-workspace from per-object archives.

audit_log keeps the keyset index on (workspace_id, created_at) and
the actor_user_id FK with onDelete set null. recordAudit() refuses
to write a null actor without a metadata.system_actor label so the
audit view always has something to render. workspaces and invites
mutations call recordAudit on success; objects-router instrumentation
and the markdown importer's system-actor flow are filed as P2
follow-ups because each needs a thoughtful "what's audit-worthy?"
pass, not mechanical wiring.

Settings → Audit log lives at /<slug>/settings/audit, owner-gated,
keyset-paginated. ACTION_LABELS is small on purpose; new actions
fall back to their raw key so missing a label degrades gracefully.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:35:16 -05:00

37 lines
1.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
kind: task
slug: distribute-rate-limit-redis-backed
title: Move rate-limit storage from in-memory to Redis
plan_slug: multitenant-saas-hardening
epic_slug: tenant-lifecycle
status: draft
priority: P3
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-06-02"
---
# Task summary
`apps/web/server/lib/rate-limit.ts` uses an in-process `Map`. That works fine for a single pod but trips break the moment we scale horizontally — every pod has its own bucket, and an attacker rotating across them effectively gets `N × limit` attempts.
Swap the backing store to Redis (already deployed for Hocuspocus on CT 102) without changing the `rateLimit()` API surface.
## Subtasks
- [ ] Decide on the lib: `@upstash/ratelimit` works against any Redis URL despite the name, or hand-roll a sliding-window in `ioredis`. The token-bucket primitive in `packages/shared/src/utils/token-bucket.ts` is fine to keep as a pure-logic reference for tests.
- [ ] Add a `REDIS_URL` env to `apps/web` (it already exists for `apps/collab-server`; just plumb it).
- [ ] Implement the Redis-backed `rateLimit()` alongside the in-memory one. Feature-flag the swap behind `RATE_LIMIT_BACKEND=redis` so the rollback is one env edit.
- [ ] Add an integration test that brings up Redis in CI (or skip on absence) and verifies the bucket survives a process restart simulation.
## Acceptance criteria
- [ ] `pnpm --filter @tasks/web build` succeeds with the Redis backend selected.
- [ ] Two `apps/web` processes pointed at the same Redis share a single bucket per key.
- [ ] In-memory fallback still works when `RATE_LIMIT_BACKEND=memory` (default).
## Links
- Parent: `./Task-rate-limit-and-abuse-guardrails.md`
- Epic: `./Epic-tenant-lifecycle.md`