diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..15a2dbe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# The DisExcel Project — Context for Claude Code + +Backend: FastAPI + SQLAlchemy (async) + Pydantic v2 + PostgreSQL. JWT auth +(access + refresh tokens), RBAC permissions, Redis cache, RabbitMQ background +workers. Python >=3.13,<4.0, Poetry for dependency management. + +## Auth & Permissions + +- Users have `direct_permissions` (list of `Permissions`) and `group` + (list of `PermissionsGroups`, each with its own `permissions`) — + many-to-many both ways. +- Effective permissions = `direct_permissions ∪ (union of all groups' permissions)`. +- `require_permissions(*permissions)` in `src/web/protected_routes` is a + FastAPI dependency factory — wraps `CurrentUserService.get_current_user`. + Call with no args (`require_permissions()`) for "just authenticated, no + specific permission needed". +- Access tokens carry a `jti` claim. Logout writes `revoked_access_token:{jti}` + to Redis with TTL = remaining token lifetime — `get_current_user` checks + this key before anything else. +- `secure` flag on refresh_token cookie is driven by `env_settings.PROD_MODE` + (bool) — `False` locally/tests so cookies work over plain HTTP, `True` in + prod. + +## Redis (`src/cache/`) + +- `RedisClient(redis.Redis)` — module-level shared singleton, subclasses + `redis.Redis` directly (inherits all commands, no manual wrapping needed). +- Three uses: permissions is-cache was considered and rejected (no real DB + savings — `get_user_by_id` already eager-loads everything via `selectin` + in one call); rate limiting on login (`RateLimit.rate_limit(ip)` — + `INCR` + `EXPIRE` on first attempt, blocks >5/60s); access-token revoke + blacklist (see above). +- Rate limit is only triggered inside `except HTTPException` on `/protected/token` + — i.e. only on failed logins, not successful ones (otherwise legitimate + repeated logins would trip it). + +## RabbitMQ (`src/messaging/`) + +- `RabbitMQClient` — shared class, lazy `connect()` (can't be async `__init__`), + holds one `connection` + one `channel`, `get_channel()` ensures setup. +- Topic exchange named `"email"`. Each message type gets its own routing key + (`email.welcome`, `email.reset`) and its own durable queue + (`queue_welcome_email`, `queue_reset_email`), each queue explicitly bound + to the exchange with its own key. +- **Important**: both producer and consumer must declare/bind the queues — + if only the consumer does it and the consumer has never run, `publish` + on a not-yet-existing queue silently loses the message. `EmailProducer.setup()` + also declares+binds both queues defensively. +- `message.process()` async context manager = manual ack (auto-ack on + success, requeue/nack on exception) — this is the right choice for email, + not `no_ack=True`. +- Email templates: Jinja2, inline CSS (email clients don't support `