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

1.7 KiB
Raw Permalink Blame History

kind slug title plan_slug epic_slug status priority tenant_id owner cursor_todo_id updated_at
task distribute-rate-limit-redis-backed Move rate-limit storage from in-memory to Redis multitenant-saas-hardening tenant-lifecycle draft P3 global unassigned null 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).
  • Parent: ./Task-rate-limit-and-abuse-guardrails.md
  • Epic: ./Epic-tenant-lifecycle.md