diff --git a/.env.local.example b/.env.local.example index 55093289..690217f2 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,29 +1,107 @@ -# App -SECRET_KEY='secret-key' +# App Configuration +SECRET_KEY='your-secret-key-here' LITESTAR_DEBUG=true LITESTAR_HOST=0.0.0.0 -LITESTAR_PORT=8089 -APP_URL=http://localhost:${LITESTAR_PORT} +LITESTAR_PORT=8000 +APP_URL=http://localhost:8000 +LOG_LEVEL=20 -LOG_LEVEL=10 -# Database -DATABASE_ECHO=true -DATABASE_ECHO_POOL=true +# Database Configuration +DATABASE_ECHO=false +DATABASE_ECHO_POOL=false DATABASE_POOL_DISABLE=false -DATABASE_POOL_MAX_OVERFLOW=5 +DATABASE_POOL_MAX_OVERFLOW=10 DATABASE_POOL_SIZE=5 DATABASE_POOL_TIMEOUT=30 -DATABASE_URL=postgresql+asyncpg://app:app@localhost:15432/app +DATABASE_USER=app +DATABASE_PASSWORD=app +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_DB=app +DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_DB} -REDIS_URL=redis://localhost:16379/0 +# Redis Configuration +REDIS_URL=redis://localhost:6379/0 -# Worker -SAQ_USE_SERVER_LIFESPAN=True +# Worker Configuration +SAQ_USE_SERVER_LIFESPAN=False SAQ_WEB_ENABLED=True SAQ_BACKGROUND_WORKERS=1 SAQ_CONCURRENCY=1 -VITE_HOST=localhost -VITE_PORT=5174 +# Frontend Configuration +VITE_USE_SERVER_LIFESPAN=True VITE_HOT_RELOAD=True VITE_DEV_MODE=True +VITE_HOST=localhost +VITE_PORT=3006 +ALLOWED_CORS_ORIGINS=["localhost:3006","localhost:8080","localhost:8000"] + +# Storage Configuration +APP_SCRATCH_PATH=/tmp/app + +# Email Configuration (SMTP) +EMAIL_ENABLED=true # Set to true to enable email sending +EMAIL_SMTP_HOST=localhost # For MailHog: localhost, for production: your SMTP host +EMAIL_SMTP_PORT=11025 # For MailHog: 11025, for production: 587 (TLS) or 465 (SSL) +EMAIL_SMTP_USER= # MailHog doesn't require auth, leave empty for dev +EMAIL_SMTP_PASSWORD= # MailHog doesn't require auth, leave empty for dev +EMAIL_USE_TLS=false # MailHog doesn't use TLS, set true for production +EMAIL_USE_SSL=false # MailHog doesn't use SSL, set true for production +EMAIL_FROM_ADDRESS=noreply@localhost # Default from email +EMAIL_FROM_NAME="Litestar Dev App" # Default from name +EMAIL_TIMEOUT=30 # SMTP connection timeout in seconds + +# OAuth Configuration + +# Keycloak (Local Development OAuth Server) +# ========================================== +# Access Keycloak admin at: http://localhost:18080 (admin/admin) +# Create a client with these settings for development OAuth testing +OAUTH_ENABLED=true +GOOGLE_CLIENT_ID=litestar-app # Your Keycloak client ID +GOOGLE_CLIENT_SECRET=your-client-secret # Generated in Keycloak client settings +GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback + +# Production Google OAuth (replace Keycloak values above when deploying) +# GOOGLE_CLIENT_ID=your-production-google-client-id +# GOOGLE_CLIENT_SECRET=your-production-google-client-secret +# GOOGLE_REDIRECT_URI=https://yourdomain.com/auth/google/callback + +# MailHog (Development Email Testing) +# ==================================== +# Access MailHog web UI at: http://localhost:8025 +# MailHog SMTP server runs on: localhost:1025 +# MailHog catches all emails sent to it during development + +# Example Production Email Provider Configurations: +# ================================================= + +# Gmail (requires app password): +# EMAIL_SMTP_HOST=smtp.gmail.com +# EMAIL_SMTP_PORT=587 +# EMAIL_SMTP_USER=your-email@gmail.com +# EMAIL_SMTP_PASSWORD=your-app-password +# EMAIL_USE_TLS=true +# EMAIL_USE_SSL=false + +# SendGrid: +# EMAIL_SMTP_HOST=smtp.sendgrid.net +# EMAIL_SMTP_PORT=587 +# EMAIL_SMTP_USER=apikey +# EMAIL_SMTP_PASSWORD=your-sendgrid-api-key +# EMAIL_USE_TLS=true + +# AWS SES: +# EMAIL_SMTP_HOST=email-smtp.us-east-1.amazonaws.com +# EMAIL_SMTP_PORT=587 +# EMAIL_SMTP_USER=your-ses-smtp-username +# EMAIL_SMTP_PASSWORD=your-ses-smtp-password +# EMAIL_USE_TLS=true + +# Mailgun: +# EMAIL_SMTP_HOST=smtp.mailgun.org +# EMAIL_SMTP_PORT=587 +# EMAIL_SMTP_USER=postmaster@yourdomain.mailgun.org +# EMAIL_SMTP_PASSWORD=your-mailgun-password +# EMAIL_USE_TLS=true diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 99ce592c..00000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,54 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended", - "prettier", - "plugin:prettier/recommended", - "plugin:import/recommended", - "plugin:react-hooks/recommended", - ], - ignorePatterns: ["dist", ".eslintrc.cjs"], - parser: "@typescript-eslint/parser", - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - ecmaVersion: 12, - sourceType: "module", - }, - plugins: ["react-refresh", "react", "@typescript-eslint", "react-hooks"], - rules: { - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], - "no-use-before-define": "off", - "@typescript-eslint/no-use-before-define": ["error"], - "react/jsx-filename-extension": ["warn", { extensions: [".tsx"] }], - "import/extensions": [ - "error", - "ignorePackages", - { ts: "never", tsx: "never" }, - ], - "no-shadow": "off", - "@typescript-eslint/no-shadow": ["error"], - "@typescript-eslint/explicit-function-return-type": [ - "error", - { allowExpressions: true }, - ], - "@typescript-eslint/no-explicit-any": "off", - "max-len": ["warn", { code: 120, ignoreComments: true, ignoreUrls: true }], - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", - "import/prefer-default-export": "off", - "react/prop-types": "off", - "prettier/prettier": ["error", { endOfLine: "auto" }], - }, - settings: { - "import/resolver": { - typescript: {}, - }, - }, -} diff --git a/.gitignore b/.gitignore index f53feb24..699f0032 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ celerybeat.pid .env* !.env.*.example !.env.testing +!tools/deploy/docker/.env.docker .venv env/ venv/ @@ -131,11 +132,12 @@ dmypy.json # Pyre type checker .pyre/ - +.claude/ +.cursor/ # vscode # .vscode .venv - +TODO.md # Logs logs *.log @@ -167,15 +169,17 @@ tmp/ temp/ # built files from the web UI -src/app/domain/web/public -src/app/domain/web/public/hot +src/py/app/server/web/public/* +src/py/app/server/web/static/* +src/py/app/server/public/* .vite -src/app/domain/web/static public/hot public/bundle -pdm-pythn +pdm-python db.duckdb local.duckdb requirements.txt +dist-ssr +*.local diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 65b53a44..03df9f11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,10 +2,17 @@ default_language_version: python: "3" repos: - repo: https://github.com/compilerla/conventional-pre-commit - rev: v4.0.0 + rev: v4.2.0 hooks: - id: conventional-pre-commit stages: [commit-msg] + - repo: local + hooks: + - id: local-biome-check + name: biome check + entry: npx @biomejs/biome check --write --files-ignore-unknown=true --no-errors-on-unmatched --config-path src/js/biome.json + language: system + types: [text] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: @@ -17,15 +24,15 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.9.10" + rev: "v0.12.0" hooks: # Run the linter. - - id: ruff - types_or: [ python, pyi ] - args: [ --fix ] - # Run the formatter. - - id: ruff-format - types_or: [ python, pyi ] + - id: ruff + types_or: [python, pyi] + args: [--fix] + # Run the formatter. + - id: ruff-format + types_or: [python, pyi] - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index cae1a354..00000000 --- a/.prettierignore +++ /dev/null @@ -1,14 +0,0 @@ -templates -scripts -artwork -deploy -docs -*.json -.eslintrc.cjs -postcss.config.cjs -.github -.venv -media -public -dist -.git diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index c08d3078..00000000 --- a/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "trailingComma": "es5", - "tabWidth": 2, - "semi": false, - "singleQuote": false, - "endOfLine": "auto" -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 80a56511..bf4c2d06 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,10 +1,3 @@ { - "recommendations": [ - "mikestead.dotenv", - "christian-kohler.path-intellisense", - "ms-python.vscode-pylance", - "ms-python.python", - "charliermarsh.ruff", - "ms-python.mypy-type-checker" - ] + "recommendations": ["mikestead.dotenv", "christian-kohler.path-intellisense", "ms-python.vscode-pylance", "ms-python.python", "charliermarsh.ruff", "ms-python.mypy-type-checker"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index cff8f155..e663c3c3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,50 +6,83 @@ }, ".mypy_cache": true, "**/__pycache__": true, - ".venv": false, + ".venv": true, ".idea": true, ".run": true, ".pytest_cache": true, - ".hypothesis": true, - ".nova": true, ".cache": true, ".dist": true, "**/.pytest_cache": true, "site": true, ".angular": true, ".ruff_cache": true, + ".unasyncd_cache": true, ".coverage": true, - "node_modules": false + "coverage": true, + "**/node_modules": true, + ".terraform": true, + "tmp": false, + "scratch": true, + ".static": true, + "dist": false, + ".dmypy.json": true, + ".claude": true, }, - "ruff.format.args": ["--config=${workspaceFolder}/pyproject.toml"], - "ruff.lint.run": "onType", - "ruff.lint.args": ["--config=${workspaceFolder}/pyproject.toml"], "mypy-type-checker.importStrategy": "fromEnvironment", - "black-formatter.importStrategy": "fromEnvironment", - "pylint.importStrategy": "fromEnvironment", - "pylint.args": [ "--rcfile=pylintrc"], - "python.autoComplete.extraPaths": ["${workspaceFolder}/src"], - "python.terminal.activateEnvInCurrentTerminal": true, - "python.terminal.executeInFileDir": true, - "python.testing.pytestEnabled": true, - "autoDocstring.guessTypes": false, - "python.analysis.autoImportCompletions": true, - "python.analysis.autoFormatStrings": true, - "python.analysis.extraPaths": ["${workspaceFolder}/src"], "editor.formatOnSave": true, + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[html]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + }, "notebook.formatOnSave.enabled": true, - "black-formatter.args": ["--line-length=120"], "evenBetterToml.formatter.reorderKeys": true, "evenBetterToml.formatter.trailingNewline": true, "evenBetterToml.formatter.columnWidth": 120, "evenBetterToml.formatter.arrayAutoCollapse": true, - "python.globalModuleInstallation": false, - "python.testing.unittestEnabled": false, - "python.testing.autoTestDiscoverOnSaveEnabled": true, "editor.codeActionsOnSave": { "source.fixAll.ruff": "explicit", - "source.organizeImports.ruff": "explicit" + "source.organizeImports.ruff": "explicit", + "source.organizeImports.biome": "explicit", + "source.fixAll.biome": "explicit" }, + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "pyproject.toml": "pyproject.toml,uv.lock,poetry.lock,poetry.toml,mkdocs.yaml,.gcloudignore,.gitignore,.editorconfig,.bumpversion.cfg,.pylintrc,.pre-commit-config.yaml,LICENSE,mkdocs.yml,service_account.json,nixpacks.toml,railway.json,sonar-project.properties,sonar-project.properties.example,sonar-project.properties.example.example,biome.json", + ".env": ".env*,.*.env", + "docker-compose.yml": "docker-compose.yml, docker-compose.*.yml, .dockerignore", + "package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, tsconfig.*.json, tsconfig.json, .stylelintrc.json,angular.json,proxy.conf.json,cypress.*.ts,extra-webpack*.cjs,.eslintrc.json, biome.json, components.json, vite.config.js, tsconfig.json, vite.config.js, .cta.json, postcss.config.js, tailwind.config.js, vite.config.ts, openapi-api.config.ts", + "README.md": "CONTRIBUTING.rst" + }, + "terminal.integrated.allowChords": false, "[python]": { "editor.formatOnSave": true, "editor.formatOnSaveMode": "file", @@ -62,20 +95,34 @@ "source.organizeImports": "explicit" } }, - "python.analysis.fixAll": [ - "source.unusedImports", - "source.convertImportFormat" - ], - "sqltools.disableReleaseNotifications": true, - "sqltools.disableNodeDetectNotifications": true, - "python.testing.unittestArgs": [ - "-v", - "-s", - "./tests", - "-p", - "test_*.py" - ], - "python.testing.pytestArgs": [ - "tests" + "python.analysis.fixAll": ["source.unusedImports", "source.convertImportFormat"], + "python.testing.unittestEnabled": false, + "python.analysis.supportDocstringTemplate": true, + "python.analysis.supportRestructuredText": true, + "python.analysis.regenerateStdLibIndices": true, + "python.analysis.showOnlyDirectDependenciesInAutoImport": false, + "python.analysis.includeAliasesFromUserFiles": true, + "python.analysis.generateWithTypeAnnotation": true, + "python.terminal.activateEnvInCurrentTerminal": true, + "python.terminal.executeInFileDir": true, + "python.analysis.autoImportCompletions": true, + "python.analysis.autoFormatStrings": true, + "ruff.configuration": "${workspaceFolder}/pyproject.toml", + "ruff.nativeServer": "auto", + "ruff.lint.preview": true, + "ruff.format.preview": true, + "ruff.lineLength": 120, + "ruff.lint.extendSelect": ["ALL"], + "ruff.lint.select": ["ALL"], + "ruff.configurationPreference": "filesystemFirst", + "python.testing.pytestPath": "${workspaceFolder}/.venv/bin/pytest", + "python.testing.pytestEnabled": true, + "python.testing.cwd": "${workspaceFolder}/src/py/tests", + "python.testing.autoTestDiscoverOnSaveEnabled": true, + "python.analysis.autoSearchPaths": true, + "python.analysis.extraPaths": [ + "${workspaceFolder}/src/py" ], + "biome.rename": true, + "biome.lsp.bin": "src/js/node_modules/@biomejs/biome/bin/biome" } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..00a42a30 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,184 @@ +# CLAUDE.md - Development Quick Reference + +This file provides essential guidance for Claude Code when working with the Litestar Fullstack SPA project. For comprehensive architecture documentation, see `docs/architecture/`. + +## πŸš€ Quick Commands + +```bash +# Setup +make install # Fresh installation +cp .env.local.example .env # Setup environment +make start-infra # Start PostgreSQL + Redis + MailHog + Keycloak +make keycloak-setup # Configure Keycloak OAuth client +uv run app run # Start all services +make mailhog # Open MailHog web UI (email testing) +make keycloak # Open Keycloak admin (OAuth testing) + +# Development +make types # Generate TypeScript types (ALWAYS after schema changes!) +make lint # Run all linting +make check-all # Run all checks before commit +make test # Run Python tests + +# Database +app database upgrade # Run migrations +app database make-migrations # Create new migration +``` + +## 🎯 Critical Development Rules + +### ALWAYS + +- Run `make types` after ANY backend schema changes +- Run `make lint` after code changes +- Run `make check-all` before committing +- Use msgspec.Struct for ALL API DTOs (NEVER raw dicts or Pydantic) +- Use the inner `Repo` pattern for services +- Use Advanced Alchemy base classes (UUIDAuditBase) +- Type all function signatures properly +- Handle async operations correctly + +### NEVER + +- Create files unless absolutely necessary +- Create documentation files unless explicitly requested +- Use raw dicts for API responses +- Access database sessions directly (use services) +- Commit without running tests +- Add comments unless requested +- Use emojis unless requested + +## πŸ“ Quick Structure Reference + +``` +src/py/app/ +β”œβ”€β”€ db/models/ # SQLAlchemy models (use Mapped[]) +β”œβ”€β”€ schemas/ # msgspec.Struct DTOs (API contracts) +β”œβ”€β”€ services/ # Business logic (inner Repo pattern) +β”œβ”€β”€ server/routes/ # Litestar controllers +└── lib/ # Core utilities + +src/js/src/ +β”œβ”€β”€ components/ # React components +β”œβ”€β”€ routes/ # TanStack Router pages +β”œβ”€β”€ lib/api/ # Auto-generated client +└── hooks/ # React hooks +``` + +## πŸ”₯ Service Pattern (Copy This!) + +```python +from litestar.plugins.sqlalchemy import repository, service +from app.db import models as m + +class UserService(service.SQLAlchemyAsyncRepositoryService[m.User]): + """Service for user operations.""" + + class Repo(repository.SQLAlchemyAsyncRepository[m.User]): + """User repository.""" + model_type = m.User + + repository_type = Repo + + # Custom service methods here +``` + +## πŸ” Current Authentication Features + +- JWT-based authentication +- Email verification (with tokens) +- Password reset flow (with security tracking) +- 2FA/TOTP support (configurable) +- OAuth Google Integration (backend complete, frontend pending) +- Rate limiting on sensitive operations +- Configurable security requirements + +## πŸ“ Schema Pattern (Copy This!) + +```python +import msgspec + +class UserCreate(msgspec.Struct, gc=False, array_like=True, omit_defaults=True): + """User creation payload.""" + email: str + password: str + name: str | None = None +``` + +## 🚨 Common Pitfalls to Avoid + +1. **Forgetting `make types`** - Frontend will have type errors +2. **Using dict instead of msgspec.Struct** - Performance and validation issues +3. **Direct DB access** - Always use service repositories +4. **Blocking operations in async** - Use async/await properly +5. **Not running tests** - Breaks in CI/CD + +## πŸ§ͺ Testing Commands + +```bash +# Run specific test +uv run pytest src/py/tests/integration/test_email_verification.py -xvs + +# Run with coverage +make test-coverage + +# Run integration tests +make test-all +``` + +## πŸ§ͺ Testing Practices + +- Please only use functions for your pytest test unless instructed otherwise. +- Do not immediately create classes with test functions. +- Always attempt to group and parameterize pytest test cases for efficiency and readability. + +## πŸ”„ Workflow Reminders + +1. **New Feature Flow:** + - Create/update models + - Run `app database make-migrations` + - Create msgspec schemas + - Implement service with inner Repo + - Add controller routes + - Register in `routes/__init__.py` + - Update `signature_namespace` if needed + - Run `make types` + - Implement frontend + +2. **Before Every Commit:** + - `make lint` + - `make test` + - `make check-all` + +## πŸ“§ Email Development with MailHog + +MailHog is configured for development email testing: + +- **Web UI**: (view all emails) +- **SMTP Server**: localhost:11025 (app sends emails here) +- **Access**: `make mailhog` to open web interface +- **Configuration**: Already set in `.env.local.example` + +All emails sent during development are caught by MailHog instead of being delivered. + +## πŸ” OAuth Development with Keycloak + +Keycloak provides a local OAuth server for development: + +- **Admin Console**: (admin/admin) +- **Auto Setup**: `make keycloak-setup` to configure OAuth client +- **Access**: `make keycloak` to open admin interface +- **Test User**: testuser / testpass123 (created automatically) +- **Configuration**: Client credentials provided by setup script + +## πŸ“Š Current Work Context + +- Email verification: βœ… Implemented +- Password reset: βœ… Implemented +- Email service: βœ… SMTP with MailHog for dev +- 2FA/TOTP: βœ… Implemented (configurable) +- OAuth: Backend βœ… | Frontend ❌ | Tests ❌ +- Production validation: Backend 70% βœ… | Frontend ❌ | Tests ❌ +- Rate limiting: βœ… Basic implementation + +For detailed patterns and examples, see `docs/architecture/`. \ No newline at end of file diff --git a/Makefile b/Makefile index 52189193..be1a6e06 100644 --- a/Makefile +++ b/Makefile @@ -44,14 +44,14 @@ install: destroy clean ## Install the project, depe echo "${INFO} Installing Node environment... πŸ“¦"; \ uvx nodeenv .venv --force --quiet; \ fi - @NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" npm install --no-fund + @cd src/js && NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" npm install --no-fund @echo "${OK} Installation complete! πŸŽ‰" .PHONY: upgrade upgrade: ## Upgrade all dependencies to the latest stable versions @echo "${INFO} Updating all dependencies... πŸ”„" @uv lock --upgrade - @NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" uv run npm upgrade --latest + @cd src/js && NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" npm upgrade @echo "${OK} Dependencies updated πŸ”„" @NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" uv run pre-commit autoupdate @echo "${OK} Updated Pre-commit hooks πŸ”„" @@ -59,7 +59,7 @@ upgrade: ## Upgrade all dependencies .PHONY: clean clean: ## Cleanup temporary build artifacts @echo "${INFO} Cleaning working directory..." - @rm -rf pytest_cache .ruff_cache .hypothesis build/ -rf dist/ .eggs/ .coverage coverage.xml coverage.json htmlcov/ .pytest_cache tests/.pytest_cache tests/**/.pytest_cache .mypy_cache .unasyncd_cache/ .auto_pytabs_cache node_modules >/dev/null 2>&1 + @rm -rf pytest_cache .ruff_cache .hypothesis build/ -rf dist/ .eggs/ .coverage coverage.xml coverage.json htmlcov/ .pytest_cache src/py/tests/.pytest_cache src/py/tests/**/.pytest_cache .mypy_cache .unasyncd_cache/ .auto_pytabs_cache node_modules src/js/node_modules >/dev/null 2>&1 @find . -name '*.egg-info' -exec rm -rf {} + >/dev/null 2>&1 @find . -type f -name '*.egg' -exec rm -f {} + >/dev/null 2>&1 @find . -name '*.pyc' -exec rm -f {} + >/dev/null 2>&1 @@ -97,7 +97,7 @@ release: ## Bump version and create re .PHONY: mypy mypy: ## Run mypy @echo "${INFO} Running mypy... πŸ”" - @uv run dmypy run src/app + @uv run dmypy run src/py/app @echo "${OK} Mypy checks passed ✨" .PHONY: pyright @@ -125,6 +125,7 @@ slotscheck: ## Run slotscheck fix: ## Run formatting scripts @echo "${INFO} Running code formatters... πŸ”§" @uv run ruff check --fix --unsafe-fixes + @cd src/js && NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" npm run lint @echo "${OK} Code formatting complete ✨" .PHONY: lint @@ -133,7 +134,7 @@ lint: pre-commit type-check slotscheck ## Run all linting .PHONY: coverage coverage: ## Run the tests and generate coverage report @echo "${INFO} Running tests with coverage... πŸ“Š" - @uv run pytest tests --cov -n auto --quiet + @uv run pytest src/py/tests --cov -n auto --quiet @uv run coverage html >/dev/null 2>&1 @uv run coverage xml >/dev/null 2>&1 @echo "${OK} Coverage report generated ✨" @@ -141,13 +142,13 @@ coverage: ## Run the tests and generate .PHONY: test test: ## Run the tests @echo "${INFO} Running test cases... πŸ§ͺ" - @uv run pytest tests -n 2 --quiet + @uv run pytest src/py/tests -n 2 --quiet @echo "${OK} Tests passed ✨" .PHONY: test-all test-all: ## Run all tests @echo "${INFO} Running all test cases... πŸ§ͺ" - @uv run pytest tests -m '' -n 2 --quiet + @uv run pytest src/py/tests -m '' -n 2 --quiet @echo "${OK} All tests passed ✨" .PHONY: check-all @@ -194,22 +195,71 @@ docs-linkcheck-full: ## Run the full link check on .PHONY: start-infra start-infra: ## Start local containers @echo "${INFO} Starting local infrastructure... πŸš€" - @docker compose -f deploy/docker-compose.infra.yml up -d --force-recreate >/dev/null 2>&1 + @docker compose -f tools/deploy/docker/docker-compose.infra.yml up -d --force-recreate @echo "${OK} Infrastructure is ready" .PHONY: stop-infra stop-infra: ## Stop local containers @echo "${INFO} Stopping infrastructure... πŸ›‘" - @docker compose -f deploy/docker-compose.infra.yml down >/dev/null 2>&1 + @docker compose -f tools/deploy/docker/docker-compose.infra.yml down @echo "${OK} Infrastructure stopped" .PHONY: wipe-infra wipe-infra: ## Remove local container info @echo "${INFO} Wiping infrastructure... 🧹" - @docker compose -f deploy/docker-compose.infra.yml down -v --remove-orphans >/dev/null 2>&1 + @docker compose -f tools/deploy/docker/docker-compose.infra.yml down -v --remove-orphans @echo "${OK} Infrastructure wiped clean" .PHONY: infra-logs infra-logs: ## Tail development infrastructure logs @echo "${INFO} Tailing infrastructure logs... πŸ“‹" - @docker compose -f deploy/docker-compose.infra.yml logs -f + @docker compose -f tools/deploy/docker/docker-compose.infra.yml logs -f + +.PHONY: mailhog +mailhog: ## Open MailHog web interface + @echo "${INFO} Opening MailHog web interface... πŸ“§" + @echo "${INFO} MailHog UI: http://localhost:18025" + @echo "${INFO} SMTP Server: localhost:11025" + @if command -v open >/dev/null 2>&1; then \ + open http://localhost:18025; \ + elif command -v xdg-open >/dev/null 2>&1; then \ + xdg-open http://localhost:18025; \ + else \ + echo "${WARN} Please open http://localhost:18025 in your browser"; \ + fi + +.PHONY: keycloak +keycloak: ## Open Keycloak admin console + @echo "${INFO} Opening Keycloak admin console... πŸ”" + @echo "${INFO} Keycloak Admin: http://localhost:18080" + @echo "${INFO} Username: admin | Password: admin" + @if command -v open >/dev/null 2>&1; then \ + open http://localhost:18080; \ + elif command -v xdg-open >/dev/null 2>&1; then \ + xdg-open http://localhost:18080; \ + else \ + echo "${WARN} Please open http://localhost:18080 in your browser"; \ + fi + +.PHONY: keycloak-setup +keycloak-setup: ## Setup Keycloak with development OAuth client + @echo "${INFO} Setting up Keycloak for development... πŸ”§" + @bash tools/deploy/docker/keycloak-setup.sh + +.PHONY: start-all +start-all: ## Start local containers + @echo "${INFO} Starting local infrastructure... πŸš€" + @docker compose -f tools/deploy/docker/docker-compose.yml -f tools/deploy/docker-compose.override.yml up -d --force-recreate + @echo "${OK} Infrastructure is ready" + +.PHONY: stop-all +stop-all: ## Stop local containers + @echo "${INFO} Stopping infrastructure... πŸ›‘" + @docker compose -f tools/deploy/docker/docker-compose.yml -f tools/deploy/docker/docker-compose.override.yml down -v --remove-orphans + @echo "${OK} Infrastructure stopped" + +.PHONY: types +types: ## Export OpenAPI schema and generate TypeScript types/client + @echo "${INFO} Exporting OpenAPI schema and generating TypeScript types..." + @cd src/js && npm run export-schema && npm run generate-types + @echo "${OK} TypeScript types and client generated from OpenAPI schema." diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..958d17fa --- /dev/null +++ b/biome.json @@ -0,0 +1,53 @@ +{ + "$schema": "src/js/node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false, + "defaultBranch": "main" + }, + "files": { + "ignoreUnknown": true, + "ignore": ["src/js/src/routeTree.gen.ts", "src/js/src/lib/api/types.gen.ts"], + "include": ["src/js/src/**/*", "src/js/index.html", "src/js/*.ts", "src/js/*.js"] + }, + "formatter": { + "enabled": true, + "indentWidth": 2, + "lineWidth": 180, + "indentStyle": "space", + "formatWithErrors": false, + "lineEnding": "lf", + "ignore": ["src/js/node_modules/**/*", "dist/**/*", ".venv/**/*", "src/js/public/**/*", "src/py/**/*", "src/js/src/routeTree.gen.ts", "src/js/src/lib/api/types.gen.ts"] + }, + "organizeImports": { + "enabled": true, + "ignore": ["src/js/node_modules/**/*", "dist/**/*", ".venv/**/*", "src/js/public/**/*", "src/py/**/*", "src/js/src/routeTree.gen.ts", "src/js/src/lib/api/types.gen.ts"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + }, + "complexity": { + "noForEach": "off" + }, + "a11y": { + "noSvgWithoutTitle": "off" + }, + "nursery": { + "useSortedClasses": "warn" + } + }, + "ignore": ["src/js/node_modules/**/*", "dist/**/*", ".venv/**/*", "src/js/public/**/*", "src/py/**/*", "src/js/src/routeTree.gen.ts", "src/js/src/lib/api/types.gen.ts"] + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded" + } + } +} diff --git a/components.json b/components.json deleted file mode 100644 index a3218d5c..00000000 --- a/components.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "resources/main.css", - "baseColor": "slate", - "cssVariables": true - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils" - } -} diff --git a/deploy/docker-compose.infra.yml b/deploy/docker-compose.infra.yml deleted file mode 100644 index 78b12032..00000000 --- a/deploy/docker-compose.infra.yml +++ /dev/null @@ -1,52 +0,0 @@ -services: - cache: - image: valkey/valkey:latest - ports: - - "16379:6379" - hostname: cache - command: redis-server --appendonly yes - volumes: - - cache-data:/data - environment: - ALLOW_EMPTY_PASSWORD: "yes" - restart: unless-stopped - logging: - options: - max-size: 10m - max-file: "3" - healthcheck: - test: - - CMD - - redis-cli - - ping - interval: 1s - timeout: 3s - retries: 30 - db: - image: postgres:latest - ports: - - "15432:5432" - hostname: db - environment: - POSTGRES_PASSWORD: "app" - POSTGRES_USER: "app" - POSTGRES_DB: "app" - volumes: - - db-data:/var/lib/postgresql/data - restart: unless-stopped - logging: - options: - max-size: 10m - max-file: "3" - healthcheck: - test: - - CMD - - pg_isready - - -U - - app - interval: 2s - timeout: 3s - retries: 40 -volumes: - db-data: {} - cache-data: {} diff --git a/deploy/docker/run/Dockerfile.distroless b/deploy/docker/run/Dockerfile.distroless deleted file mode 100644 index 16789236..00000000 --- a/deploy/docker/run/Dockerfile.distroless +++ /dev/null @@ -1,144 +0,0 @@ -ARG PYTHON_BUILDER_IMAGE=3.11-slim-bullseye -ARG PYTHON_RUN_IMAGE=gcr.io/distroless/cc:nonroot - - -## ---------------------------------------------------------------------------------- ## -## ------------------------- Python base -------------------------------------------- ## -## ---------------------------------------------------------------------------------- ## -FROM python:${PYTHON_BUILDER_IMAGE} as python-base -ARG UV_INSTALL_ARGS="" -ENV UV_INSTALL_ARGS="${UV_INSTALL_ARGS}" \ - GRPC_PYTHON_BUILD_WITH_CYTHON=1 \ - PATH="/workspace/app/.venv/bin:/usr/local/bin:$PATH" \ - PIP_DEFAULT_TIMEOUT=100 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 \ - PIP_NO_CACHE_DIR=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONFAULTHANDLER=1 \ - PYTHONHASHSEED=random \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 -## -------------------------- add common compiled libraries --------------------------- ## -RUN apt-get update \ - && apt-get upgrade -y \ - && apt-get install -y --no-install-recommends git tini \ - && apt-get autoremove -y \ - && apt-get clean -y \ - && rm -rf /root/.cache \ - && rm -rf /var/apt/lists/* \ - && rm -rf /var/cache/apt/* \ - && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ - && mkdir -p /workspace/app \ - ## -------------------------- upgrade default packages -------------------------------- ## - && pip install --quiet --upgrade pip wheel setuptools cython virtualenv mypy - -## ---------------------------------------------------------------------------------- ## -## ------------------------- Python build base -------------------------------------- ## -## ---------------------------------------------------------------------------------- ## -FROM python-base AS build-base -ARG UV_INSTALL_ARGS="" -ENV UV_INSTALL_ARGS="${UV_INSTALL_ARGS}" \ - GRPC_PYTHON_BUILD_WITH_CYTHON=1 \ - PATH="/workspace/app/.venv/bin:/usr/local/bin:$PATH" -## -------------------------- add development packages ------------------------------ ## -RUN apt-get install -y --no-install-recommends build-essential curl \ - && apt-get autoremove -y \ - && apt-get clean -y \ - && rm -rf /root/.cache \ - && rm -rf /var/apt/lists/* \ - && rm -rf /var/cache/apt/* \ - && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false -## -------------------------- install application ----------------------------------- ## -WORKDIR /workspace/app -COPY pyproject.toml uv.lock README.md .pre-commit-config.yaml LICENSE Makefile \ - package.json package-lock.json vite.config.ts tsconfig.json \ - tailwind.config.cjs postcss.config.cjs components.json \ - ./ -RUN python -m venv --copies /workspace/app/.venv \ - && /workspace/app/.venv/bin/pip install --quiet uv nodeenv cython mypy -COPY tools ./tools/ -COPY public ./public/ -COPY resources ./resources/ -RUN uv sync ${UV_INSTALL_ARGS} --no-self \ - && uv export ${UV_INSTALL_ARGS} --no-hashes --no-dev --output-file=requirements.txt -COPY src ./src/ - -RUN make build -VOLUME /workspace/app -## ---------------------------------------------------------------------------------- ## -## -------------------------------- runtime build ----------------------------------- ## -## ---------------------------------------------------------------------------------- ## -## ------------------------- use base image ---------------------------------------- ## - -FROM python-base as run-base -ARG ENV_SECRETS="runtime-secrets" -ENV ENV_SECRETS="${ENV_SECRETS}" -RUN addgroup --system --gid 65532 nonroot \ - && adduser --no-create-home --system --uid 65532 nonroot \ - && chown -R nonroot:nonroot /workspace \ - && python -m venv --copies /workspace/app/.venv -## -------------------------- install application ----------------------------------- ## -COPY --from=build-base --chown=65532:65532 /workspace/app/requirements.txt /tmp/requirements.txt -COPY --from=build-base --chown=65532:65532 /workspace/app/dist /tmp/ -WORKDIR /workspace/app -RUN /workspace/app/.venv/bin/pip install --quiet --disable-pip-version-check --no-deps --requirement=/tmp/requirements.txt -RUN /workspace/app/.venv/bin/pip install --quiet --disable-pip-version-check --no-deps /tmp/*.whl - - - -## ---------------------------------------------------------------------------------- ## -## ------------------------- distroless runtime build ------------------------------- ## -## ---------------------------------------------------------------------------------- ## - -## ------------------------- use distroless `cc` image ----------------------------- ## -FROM ${PYTHON_RUN_IMAGE} as run-image -ARG ENV_SECRETS="runtime-secrets" -ARG CHIPSET_ARCH=x86_64-linux-gnu -ARG LITESTAR_APP="app.asgi:create_app" -ENV PATH="/workspace/app/.venv/bin:/bin:/usr/local/bin:$PATH" \ - ENV_SECRETS="${ENV_SECRETS}" \ - CHIPSET_ARCH="${CHIPSET_ARCH}" \ - PIP_DEFAULT_TIMEOUT=100 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 \ - PIP_NO_CACHE_DIR=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONFAULTHANDLER=1 \ - PYTHONHASHSEED=random \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 \ - LITESTAR_APP="${LITESTAR_APP}" -## ------------------------- copy python itself from builder -------------------------- ## - -# this carries more risk than installing it fully, but makes the image a lot smaller -COPY --from=run-base /usr/local/lib/ /usr/local/lib/ -COPY --from=run-base /usr/local/bin/python /usr/local/bin/python -COPY --from=run-base /etc/ld.so.cache /etc/ld.so.cache - -## -------------------------- add common compiled libraries --------------------------- ## - -# add tini -COPY --from=run-base /usr/bin/tini-static /usr/local/bin/tini - -# If seeing ImportErrors, check if in the python-base already and copy as below - -# required by lots of packages - e.g. six, numpy, wsgi -COPY --from=run-base /lib/${CHIPSET_ARCH}/libz.so.1 /lib/${CHIPSET_ARCH}/ -COPY --from=run-base /lib/${CHIPSET_ARCH}/libbz2.so.1.0 /lib/${CHIPSET_ARCH}/ - -# required by google-cloud/grpcio -COPY --from=run-base /usr/lib/${CHIPSET_ARCH}/libffi* /usr/lib/${CHIPSET_ARCH}/ -COPY --from=run-base /lib/${CHIPSET_ARCH}/libexpat* /lib/${CHIPSET_ARCH}/ -## -------------------------- install application ----------------------------------- ## -WORKDIR /workspace/app -COPY --from=run-base --chown=65532:65532 /workspace/app/.venv /workspace/app/.venv - -## --------------------------- standardize execution env ----------------------------- ## - - -STOPSIGNAL SIGINT -EXPOSE 8000 -ENTRYPOINT ["tini","--" ] -CMD [ "litestar","run","--host","0.0.0.0","--port","8000"] -VOLUME /workspace/app diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 67540a61..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,95 +0,0 @@ -services: - cache: - image: valkey/valkey:latest - ports: - - "16379:6379" - hostname: cache - command: redis-server --appendonly yes - volumes: - - cache-data:/data - environment: - ALLOW_EMPTY_PASSWORD: "yes" - restart: unless-stopped - logging: - options: - max-size: 10m - max-file: "3" - healthcheck: - test: - - CMD - - redis-cli - - ping - interval: 1s - timeout: 3s - retries: 30 - db: - image: postgres:latest - ports: - - "15432:5432" - hostname: db - environment: - POSTGRES_PASSWORD: "app" - POSTGRES_USER: "app" - POSTGRES_DB: "app" - volumes: - - db-data:/var/lib/postgresql/data - restart: unless-stopped - logging: - options: - max-size: 10m - max-file: "3" - healthcheck: - test: - - CMD - - pg_isready - - -U - - app - interval: 2s - timeout: 3s - retries: 40 - app: - build: - context: . - dockerfile: deploy/docker/run/Dockerfile - restart: always - depends_on: - db: - condition: service_healthy - cache: - condition: service_healthy - ports: - - "8000:8000" - environment: - VITE_USE_SERVER_LIFESPAN: "false" # true if ssr or separate service - SAQ_USE_SERVER_LIFESPAN: "false" - env_file: - - .env.docker.example - worker: - build: - context: . - dockerfile: deploy/docker/run/Dockerfile - command: litestar workers run - restart: always - depends_on: - db: - condition: service_healthy - cache: - condition: service_healthy - env_file: - - .env.docker.example - migrator: - build: - context: . - dockerfile: deploy/docker/run/Dockerfile - restart: "no" - command: litestar database upgrade --no-prompt - env_file: - - .env.docker.example - depends_on: - db: - condition: service_healthy - cache: - condition: service_healthy -volumes: - db-data: {} - cache-data: {} diff --git a/docs/architecture/01-overview.md b/docs/architecture/01-overview.md new file mode 100644 index 00000000..59c4b772 --- /dev/null +++ b/docs/architecture/01-overview.md @@ -0,0 +1,181 @@ +# Architecture Overview + +## Introduction + +The Litestar Fullstack SPA is a production-ready reference application demonstrating modern web development with a Python backend (Litestar) and React frontend. It showcases enterprise-grade patterns for authentication, team management, and comprehensive tooling. + +## Tech Stack + +### Backend Stack + +- **[Litestar](https://litestar.dev/)** - Modern, fast, and production-ready ASGI framework +- **[SQLAlchemy 2.0](https://www.sqlalchemy.org/)** - Industry-standard ORM with async support +- **[Advanced Alchemy](https://github.com/litestar-org/advanced-alchemy)** - Enhanced patterns for SQLAlchemy +- **[PostgreSQL](https://www.postgresql.org/)** - Primary database for persistent storage +- **[Redis](https://redis.io/)** - Caching and session storage +- **[SAQ](https://github.com/tobymao/saq)** - Simple async job queue for background tasks +- **[msgspec](https://github.com/jcrist/msgspec)** - High-performance serialization + +### Frontend Stack + +- **[React 19](https://react.dev/)** - UI library with latest features +- **[Vite](https://vitejs.dev/)** - Lightning-fast build tool +- **[TanStack Router](https://tanstack.com/router)** - Type-safe routing +- **[TanStack Query](https://tanstack.com/query)** - Powerful data synchronization +- **[shadcn/ui](https://ui.shadcn.com/)** - High-quality UI components +- **[Tailwind CSS v4](https://tailwindcss.com/)** - Utility-first CSS framework +- **[TypeScript](https://www.typescriptlang.org/)** - Type safety throughout + +### Infrastructure & Tools + +- **[Docker](https://www.docker.com/)** - Containerization for consistent environments +- **[uv](https://github.com/astral-sh/uv)** - Fast Python package management +- **[Alembic](https://alembic.sqlalchemy.org/)** - Database migrations +- **[OpenAPI](https://www.openapis.org/)** - API documentation and client generation +- **[Biome](https://biomejs.dev/)** - Fast linting and formatting + +## High-Level Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Frontend (SPA) β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ React 19 β”‚ β”‚ TanStack β”‚ β”‚ shadcn/ui β”‚ β”‚ +β”‚ β”‚ Components β”‚ β”‚ Router/Query β”‚ β”‚ Components β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ Auto-generated β”‚ +β”‚ TypeScript Client β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + HTTPS API + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Backend (Litestar) β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Routes β”‚ β”‚ Services β”‚ β”‚ Repositories β”‚ β”‚ +β”‚ β”‚ Controllers β”‚ β”‚Business Logicβ”‚ β”‚ Data Access β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ SQLAlchemy + Advanced Alchemy β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β” + β”‚PostgreSQL β”‚ β”‚ Redis β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Key Features + +### Authentication & Security + +- **JWT-based authentication** with secure token handling +- **Email verification** system with token management +- **Password reset** flow with security tracking +- **Two-Factor Authentication (2FA)** with TOTP support +- **Configurable security policies** per environment +- **Role-based access control** with guards + +### Team Management + +- **Multi-tenant architecture** with team isolation +- **Team invitations** with email notifications +- **Member management** with role assignments +- **File attachments** per team +- **Activity tracking** and audit logs + +### Developer Experience + +- **Full-stack type safety** from database to UI +- **Auto-generated API client** from OpenAPI schema +- **Hot module replacement** for rapid development +- **Comprehensive testing** setup +- **Pre-configured linting** and formatting +- **Docker-based** development environment + +### Production Ready + +- **Database migrations** with version control +- **Background job processing** with SAQ +- **Email delivery** with template support +- **Error handling** and logging +- **Performance monitoring** hooks +- **Deployment configurations** for various platforms + +## Design Principles + +### 1. Type Safety Everywhere + +From SQLAlchemy models to React components, every layer enforces type safety: + +```python +# Backend - Fully typed models +class User(UUIDAuditBase): + email: Mapped[str] = mapped_column(String(255), unique=True) + is_verified: Mapped[bool] = mapped_column(default=False) +``` + +```typescript +// Frontend - Auto-generated types +import { UserRead } from "@/lib/api/types.gen"; +``` + +### 2. Layered Architecture + +Clear separation of concerns across layers: + +- **Controllers** - HTTP request/response handling +- **Services** - Business logic and orchestration +- **Repositories** - Data access patterns +- **Models** - Database schema definitions + +### 3. Performance First + +- **Async throughout** - Non-blocking I/O operations +- **Optimized queries** - Eager loading strategies +- **Efficient serialization** - msgspec for speed +- **Smart caching** - Redis integration +- **Bundle optimization** - Code splitting with Vite + +### 4. Security by Design + +- **Input validation** at every layer +- **Authentication** with multiple factors +- **Authorization** with granular controls +- **Rate limiting** for sensitive operations +- **Audit logging** for compliance + +## System Requirements + +### Development Environment + +- Python 3.11+ +- Node.js 18+ +- PostgreSQL 15+ +- Redis 7+ +- Docker & Docker Compose + +### Production Environment + +- Same as development plus: +- HTTPS termination (nginx/caddy) +- Process manager (systemd/supervisor) +- Monitoring (Prometheus/Grafana) +- Log aggregation (ELK/Loki) + +## Next Steps + +Now that you understand the high-level architecture: + +1. Explore the [Project Structure](02-project-structure.md) to understand code organization +2. Dive into [Backend Architecture](03-backend-architecture.md) for service patterns +3. Learn about [Authentication & Security](04-authentication-security.md) implementation +4. Review [Frontend Architecture](05-frontend-architecture.md) for React patterns + +--- + +*This overview provides the foundation for understanding the Litestar Fullstack SPA architecture. Each subsequent section will dive deeper into specific aspects of the system.* diff --git a/docs/architecture/02-project-structure.md b/docs/architecture/02-project-structure.md new file mode 100644 index 00000000..66c15ecf --- /dev/null +++ b/docs/architecture/02-project-structure.md @@ -0,0 +1,379 @@ +# Project Structure + +## Overview + +The Litestar Fullstack SPA follows a monorepo structure with clear separation between backend (Python) and frontend (JavaScript/TypeScript) code. This organization promotes maintainability and allows for independent scaling of components. + +## Directory Layout + +``` +litestar-fullstack-spa/ +β”œβ”€β”€ src/ # All source code +β”‚ β”œβ”€β”€ py/ # Python backend +β”‚ β”‚ β”œβ”€β”€ app/ # Main application package +β”‚ β”‚ └── tests/ # Python tests +β”‚ └── js/ # JavaScript/TypeScript frontend +β”‚ β”œβ”€β”€ src/ # React application source +β”‚ β”œβ”€β”€ public/ # Static assets +β”‚ └── package.json # Frontend dependencies +β”œβ”€β”€ docs/ # Documentation +β”‚ β”œβ”€β”€ architecture/ # This documentation +β”‚ └── api/ # Auto-generated API docs +β”œβ”€β”€ tools/ # Build and deployment scripts +β”‚ β”œβ”€β”€ deploy/ # Deployment configurations +β”‚ β”‚ β”œβ”€β”€ docker/ # Docker files +β”‚ β”‚ └── k8s/ # Kubernetes manifests +β”‚ └── *.py # Utility scripts +β”œβ”€β”€ pyproject.toml # Python project configuration +β”œβ”€β”€ Makefile # Common development tasks +└── .env.local.example # Environment variables template +``` + +## Backend Structure (src/py/app/) + +### Core Application Components + +``` +app/ +β”œβ”€β”€ __init__.py # Package initialization +β”œβ”€β”€ __main__.py # Entry point for CLI +β”œβ”€β”€ config.py # Application configuration +β”œβ”€β”€ cli/ # Command-line interface +β”‚ └── commands.py # CLI command definitions +β”œβ”€β”€ db/ # Database layer +β”‚ β”œβ”€β”€ models/ # SQLAlchemy models +β”‚ β”‚ β”œβ”€β”€ user.py # User model +β”‚ β”‚ β”œβ”€β”€ team.py # Team model +β”‚ β”‚ └── ... # Other domain models +β”‚ β”œβ”€β”€ migrations/ # Alembic migrations +β”‚ β”‚ β”œβ”€β”€ versions/ # Migration files +β”‚ β”‚ └── env.py # Migration environment +β”‚ └── fixtures/ # Initial data fixtures +β”œβ”€β”€ schemas/ # API DTOs (msgspec.Struct) +β”‚ β”œβ”€β”€ accounts.py # User-related schemas +β”‚ β”œβ”€β”€ teams.py # Team-related schemas +β”‚ └── base.py # Base schema classes +β”œβ”€β”€ services/ # Business logic layer +β”‚ β”œβ”€β”€ _users.py # User service +β”‚ β”œβ”€β”€ _teams.py # Team service +β”‚ └── ... # Other services +β”œβ”€β”€ server/ # Web server components +β”‚ β”œβ”€β”€ routes/ # API controllers +β”‚ β”‚ β”œβ”€β”€ access.py # Authentication endpoints +β”‚ β”‚ β”œβ”€β”€ user.py # User management +β”‚ β”‚ └── team.py # Team management +β”‚ β”œβ”€β”€ security.py # Security configuration +β”‚ β”œβ”€β”€ plugins.py # Litestar plugins +β”‚ └── asgi.py # ASGI application +└── lib/ # Shared utilities + β”œβ”€β”€ deps.py # Dependency injection + β”œβ”€β”€ exceptions.py # Custom exceptions + β”œβ”€β”€ crypt.py # Cryptography utilities + └── settings.py # Settings management +``` + +### Key Backend Patterns + +#### 1. Models (db/models/) + +SQLAlchemy 2.0 models with full type annotations: + +```python +# db/models/user.py +from sqlalchemy.orm import Mapped, mapped_column +from advanced_alchemy.base import UUIDAuditBase + +class User(UUIDAuditBase): + """User account model.""" + __tablename__ = "user_account" + + email: Mapped[str] = mapped_column(String(255), unique=True) + is_verified: Mapped[bool] = mapped_column(default=False) +``` + +#### 2. Schemas (schemas/) + +msgspec.Struct for high-performance API serialization: + +```python +# schemas/accounts.py +import msgspec + +class UserCreate(msgspec.Struct): + """User creation payload.""" + email: str + password: str + name: str | None = None +``` + +#### 3. Services (services/) + +Business logic with repository pattern: + +```python +# services/_users.py +from litestar.plugins.sqlalchemy import service, repository + +class UserService(service.SQLAlchemyAsyncRepositoryService[User]): + """Handles user operations.""" + + class Repo(repository.SQLAlchemyAsyncRepository[User]): + model_type = User + + repository_type = Repo +``` + +#### 4. Routes (server/routes/) + +Litestar controllers for API endpoints: + +```python +# server/routes/user.py +from litestar import Controller, get, post + +@Controller(path="/api/users") +class UserController: + @get("/{user_id:uuid}") + async def get_user(self, user_id: UUID) -> UserRead: + """Get user by ID.""" +``` + +## Frontend Structure (src/js/) + +### React Application Organization + +``` +src/ +β”œβ”€β”€ components/ # React components +β”‚ β”œβ”€β”€ ui/ # shadcn/ui components +β”‚ β”‚ β”œβ”€β”€ button.tsx # Button component +β”‚ β”‚ β”œβ”€β”€ card.tsx # Card component +β”‚ β”‚ └── ... # Other UI components +β”‚ β”œβ”€β”€ auth/ # Authentication components +β”‚ β”‚ β”œβ”€β”€ login.tsx # Login form +β”‚ β”‚ └── signup.tsx # Registration form +β”‚ β”œβ”€β”€ teams/ # Team management +β”‚ └── admin/ # Admin interfaces +β”œβ”€β”€ routes/ # TanStack Router pages +β”‚ β”œβ”€β”€ __root.tsx # Root layout +β”‚ β”œβ”€β”€ _app.tsx # Authenticated layout +β”‚ β”œβ”€β”€ _public.tsx # Public layout +β”‚ └── index.tsx # Route exports +β”œβ”€β”€ lib/ # Utilities and shared code +β”‚ β”œβ”€β”€ api/ # API client +β”‚ β”‚ β”œβ”€β”€ client.gen.ts # Generated client +β”‚ β”‚ β”œβ”€β”€ types.gen.ts # Generated types +β”‚ β”‚ └── index.ts # API exports +β”‚ β”œβ”€β”€ auth.ts # Auth utilities +β”‚ └── utils.ts # Helper functions +β”œβ”€β”€ hooks/ # Custom React hooks +β”‚ β”œβ”€β”€ use-auth.ts # Authentication hook +β”‚ └── use-mobile.ts # Responsive design +└── main.tsx # Application entry point +``` + +### Frontend Patterns + +#### 1. Components Structure + +Components follow a consistent pattern: + +```typescript +// components/teams/team-list.tsx +import { useQuery } from "@tanstack/react-query"; +import { Card } from "@/components/ui/card"; +import { api } from "@/lib/api"; + +export function TeamList() { + const { data: teams } = useQuery({ + queryKey: ["teams"], + queryFn: () => api.teams.listTeams() + }); + + return ( +
+ {teams?.map(team => ( + + {/* Team content */} + + ))} +
+ ); +} +``` + +#### 2. Route Organization + +File-based routing with TanStack Router: + +``` +routes/ +β”œβ”€β”€ _app/ # Authenticated routes +β”‚ β”œβ”€β”€ teams.tsx # /teams +β”‚ └── teams/ +β”‚ β”œβ”€β”€ $teamId.tsx # /teams/:teamId +β”‚ └── new.tsx # /teams/new +└── _public/ # Public routes + β”œβ”€β”€ login.tsx # /login + └── signup.tsx # /signup +``` + +## Configuration Files + +### Python Configuration (pyproject.toml) + +```toml +[project] +name = "app" +dependencies = [ + "litestar[standard]>=2.0", + "advanced-alchemy>=0.1", + "msgspec>=0.18", +] + +[tool.pytest.ini_options] +testpaths = ["src/py/tests"] + +[tool.ruff] +target-version = "py311" +``` + +### Frontend Configuration + +```json +// package.json +{ + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "biome check --write" + } +} +``` + +## Development Workflow Files + +### Makefile + +Common tasks automated: + +```makefile +install: + uv sync --frozen --all-extras + +start-infra: + docker-compose -f tools/deploy/docker/docker-compose.yml up -d + +types: + uv run app export-openapi-schema + cd src/js && npm run generate-types + +test: + uv run pytest + +lint: + uv run pre-commit run --all-files +``` + +### Docker Development + +```yaml +# tools/deploy/docker/docker-compose.yml +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: app + POSTGRES_USER: app + POSTGRES_PASSWORD: app + ports: + - "5432:5432" + + redis: + image: redis:7-alpine + ports: + - "6379:6379" +``` + +## Testing Structure + +### Backend Tests + +``` +tests/ +β”œβ”€β”€ unit/ # Unit tests +β”‚ β”œβ”€β”€ test_services/ # Service tests +β”‚ β”œβ”€β”€ test_models/ # Model tests +β”‚ └── test_lib/ # Utility tests +└── integration/ # Integration tests + β”œβ”€β”€ test_access.py # Auth flow tests + └── test_teams.py # Team API tests +``` + +### Frontend Tests + +``` +src/__tests__/ +β”œβ”€β”€ components/ # Component tests +β”œβ”€β”€ hooks/ # Hook tests +└── utils/ # Utility tests +``` + +## Best Practices + +### File Naming Conventions + +- **Python**: Snake case (`user_service.py`) +- **TypeScript**: Kebab case (`user-list.tsx`) +- **Tests**: Prefix with `test_` (Python) or `.test.` (TypeScript) + +### Import Organization + +1. Standard library imports +2. Third-party imports +3. Local application imports + +```python +# Python example +import asyncio +from datetime import datetime + +from litestar import Controller +from sqlalchemy import select + +from app.db import models +from app.services import UserService +``` + +```typescript +// TypeScript example +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +``` + +### Code Organization Tips + +1. **Keep files focused** - Single responsibility per file +2. **Use consistent patterns** - Follow established conventions +3. **Colocate related code** - Group by feature, not file type +4. **Document complex logic** - Add comments for non-obvious code +5. **Extract reusable code** - Move to lib/ or hooks/ + +## Next Steps + +Understanding the project structure helps you: + +1. Navigate the codebase efficiently +2. Know where to add new features +3. Follow established patterns +4. Maintain consistency + +Continue to [Backend Architecture](03-backend-architecture.md) for deep dive into service patterns and database design. + +--- + +*This structure promotes scalability, maintainability, and developer productivity. Each directory has a clear purpose, making it easy to locate and organize code.* diff --git a/docs/architecture/03-backend-architecture.md b/docs/architecture/03-backend-architecture.md new file mode 100644 index 00000000..f3dfd269 --- /dev/null +++ b/docs/architecture/03-backend-architecture.md @@ -0,0 +1,599 @@ +# Backend Architecture + +## Overview + +The backend is built on Litestar, a modern ASGI framework that provides high performance, full async support, and excellent developer experience. The architecture follows a layered approach with clear separation of concerns. + +## Architecture Layers + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ API Layer (Routes) β”‚ +β”‚ HTTP Request/Response, Authentication β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Service Layer β”‚ +β”‚ Business Logic, Orchestration β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Repository Layer β”‚ +β”‚ Data Access Patterns β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Model Layer β”‚ +β”‚ SQLAlchemy ORM Models β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Database Layer β”‚ +β”‚ PostgreSQL + Redis β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Service Layer Pattern + +### ⚠️ Critical: Inner Repository Pattern + +All services MUST follow the Advanced Alchemy pattern with an inner `Repo` class: + +```python +from litestar.plugins.sqlalchemy import repository, service +from app.db import models as m + +class UserService(service.SQLAlchemyAsyncRepositoryService[m.User]): + """Handles database operations for users.""" + + class Repo(repository.SQLAlchemyAsyncRepository[m.User]): + """User SQLAlchemy Repository.""" + model_type = m.User + + repository_type = Repo + + # Custom service attributes + default_role = "USER" + match_fields = ["email"] # Fields for get_or_create operations + + async def to_model_on_create( + self, + data: service.ModelDictT[m.User] + ) -> service.ModelDictT[m.User]: + """Transform data before creating model.""" + return await self._populate_model(data) + + async def to_model_on_update( + self, + data: service.ModelDictT[m.User] + ) -> service.ModelDictT[m.User]: + """Transform data before updating model.""" + return await self._populate_model(data) + + async def _populate_model( + self, + data: service.ModelDictT[m.User] + ) -> service.ModelDictT[m.User]: + """Handle special field processing like password hashing.""" + data = service.schema_dump(data) + if service.is_dict(data) and (password := data.pop("password", None)): + data["hashed_password"] = await crypt.get_password_hash(password) + return data + + async def authenticate(self, username: str, password: str) -> m.User: + """Custom business logic method.""" + db_obj = await self.get_one_or_none(email=username) + if db_obj is None: + raise PermissionDeniedException("Invalid credentials") + if not await crypt.verify_password(password, db_obj.hashed_password): + raise PermissionDeniedException("Invalid credentials") + return db_obj +``` + +### Service Methods + +Services provide both inherited and custom methods: + +#### Inherited Methods (from Advanced Alchemy) +- `get(id)` - Get by primary key +- `get_one(**filters)` - Get single record with filters +- `get_one_or_none(**filters)` - Get or return None +- `list(**filters)` - List with optional filtering +- `create(data)` - Create new record +- `update(data)` - Update existing record +- `upsert(data)` - Create or update +- `delete(id)` - Delete by primary key +- `delete_many(**filters)` - Bulk delete + +#### Custom Methods +Add business logic methods specific to your domain: + +```python +async def verify_email(self, user_id: UUID, token: str) -> m.User: + """Verify user's email address.""" + # Custom business logic here + +async def assign_team_role( + self, + user_id: UUID, + team_id: UUID, + role: str +) -> m.TeamMember: + """Assign user to team with role.""" + # Complex orchestration logic +``` + +## Database Models + +### SQLAlchemy 2.0 Patterns + +All models use modern SQLAlchemy 2.0 syntax with full type annotations: + +```python +from datetime import datetime, UTC +from uuid import UUID +from sqlalchemy import String, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +from advanced_alchemy.base import UUIDAuditBase + +class User(UUIDAuditBase): + """User account model with audit fields.""" + + __tablename__ = "user_account" + + # Required fields with type annotations + email: Mapped[str] = mapped_column( + String(255), + unique=True, + index=True + ) + hashed_password: Mapped[str | None] = mapped_column( + String(255), + nullable=True + ) + + # Profile fields + name: Mapped[str | None] = mapped_column(String(100)) + avatar_url: Mapped[str | None] = mapped_column(String(500)) + + # Status flags with defaults + is_active: Mapped[bool] = mapped_column(default=True) + is_superuser: Mapped[bool] = mapped_column(default=False) + is_verified: Mapped[bool] = mapped_column(default=False) + + # Timestamps + verified_at: Mapped[datetime | None] = mapped_column(nullable=True) + joined_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(UTC) + ) + + # Relationships with loading strategies + teams: Mapped[list["TeamMember"]] = relationship( + lazy="selectin", + back_populates="user" + ) + roles: Mapped[list["UserRole"]] = relationship( + lazy="selectin", + back_populates="user" + ) + + # Composite indexes for performance + __table_args__ = ( + Index("ix_user_email_active", "email", "is_active"), + ) +``` + +### Base Classes from Advanced Alchemy + +- **`UUIDAuditBase`** - UUID primary key + created_at/updated_at +- **`UUIDBase`** - Just UUID primary key +- **`AuditColumns`** - Just audit fields +- **`SlugKey`** - Slug-based lookups +- **`UniqueMixin`** - Unique constraint validation + +### Relationship Patterns + +```python +class Team(UUIDAuditBase): + """Team model with relationships.""" + + # One-to-many + members: Mapped[list["TeamMember"]] = relationship( + lazy="selectin", + back_populates="team", + cascade="all, delete-orphan" + ) + + # Many-to-many through association + tags: Mapped[list["Tag"]] = relationship( + secondary="team_tag", + lazy="selectin", + back_populates="teams" + ) +``` + +## API DTOs with msgspec + +### ⚠️ Critical: Always Use msgspec.Struct + +All API request/response bodies MUST use `msgspec.Struct`: + +```python +import msgspec +from typing import TYPE_CHECKING +from datetime import datetime + +if TYPE_CHECKING: + from uuid import UUID + +# Base struct with common options +class BaseStruct(msgspec.Struct, gc=False, array_like=True, omit_defaults=True): + """Base class for all DTOs.""" + pass + +# Request DTOs +class UserCreate(BaseStruct): + """User creation request.""" + email: str + password: str + name: str | None = None + +class UserUpdate(BaseStruct): + """User update request.""" + name: str | None = None + email: str | None = None + +# Response DTOs +class UserRead(BaseStruct): + """User response.""" + id: UUID + email: str + name: str | None = None + is_active: bool = True + is_verified: bool = False + created_at: datetime + +class UserList(BaseStruct): + """Paginated user list.""" + items: list[UserRead] + total: int + page: int + page_size: int +``` + +### Naming Conventions + +- **Create** - For creation requests (`UserCreate`) +- **Update** - For update requests (`UserUpdate`) +- **Read** - For single responses (`UserRead`) +- **List** - For collection responses (`UserList`) +- **Payload** - For generic requests (`LoginPayload`) +- **Response** - For generic responses (`LoginResponse`) + +## Route Controllers + +Controllers handle HTTP concerns and delegate to services: + +```python +from litestar import Controller, get, post, put, delete +from litestar.di import Provide +from litestar.params import Parameter +from uuid import UUID + +from app.schemas import UserCreate, UserRead, UserUpdate +from app.services import UserService +from app.server.deps import provide_users_service + +@Controller( + path="/api/users", + guards=[requires_active_user], + dependencies={"users_service": Provide(provide_users_service)}, +) +class UserController: + """User management endpoints.""" + + @get( + "/{user_id:uuid}", + operation_id="GetUser", + description="Get user by ID", + ) + async def get_user( + self, + user_id: UUID, + users_service: UserService, + ) -> UserRead: + """Get user details.""" + db_obj = await users_service.get(user_id) + return UserRead.from_orm(db_obj) + + @post( + "/", + operation_id="CreateUser", + guards=[requires_superuser], + ) + async def create_user( + self, + data: UserCreate, + users_service: UserService, + ) -> UserRead: + """Create new user.""" + db_obj = await users_service.create(data.model_dump()) + return UserRead.from_orm(db_obj) + + @put( + "/{user_id:uuid}", + operation_id="UpdateUser", + ) + async def update_user( + self, + user_id: UUID, + data: UserUpdate, + users_service: UserService, + current_user: User, + ) -> UserRead: + """Update user.""" + # Authorization check + if user_id != current_user.id and not current_user.is_superuser: + raise PermissionDeniedException() + + db_obj = await users_service.update( + item_id=user_id, + data=data.model_dump(exclude_unset=True) + ) + return UserRead.from_orm(db_obj) +``` + +## Dependency Injection + +### Service Providers + +Configure services with loading strategies: + +```python +# server/deps.py +from app.lib.deps import create_service_provider +from sqlalchemy.orm import selectinload, joinedload, load_only + +provide_users_service = create_service_provider( + UserService, + load=[ + # Eager load relationships + selectinload(m.User.roles).options( + joinedload(m.UserRole.role, innerjoin=True) + ), + selectinload(m.User.teams).options( + joinedload(m.TeamMember.team, innerjoin=True).options( + load_only(m.Team.name, m.Team.slug) + ), + ), + ], + # Ensure unique results + uniquify=True, + # Custom error messages + error_messages={ + "duplicate_key": "This email is already registered.", + "integrity": "User operation failed.", + "not_found": "User not found.", + }, +) +``` + +### Using Dependencies in Routes + +```python +@Controller( + dependencies={ + "users_service": Provide(provide_users_service), + "teams_service": Provide(provide_teams_service), + } +) +class TeamMemberController: + """Team member management.""" + + @post("/teams/{team_id:uuid}/members") + async def add_member( + self, + team_id: UUID, + data: AddMemberRequest, + users_service: UserService, + teams_service: TeamService, + ) -> TeamMemberRead: + """Add member to team.""" + # Services are injected automatically +``` + +## Async Patterns + +### Concurrent Operations + +```python +async def get_user_dashboard(self, user_id: UUID) -> DashboardData: + """Get dashboard data with concurrent queries.""" + # Run multiple queries concurrently + user, teams, notifications = await asyncio.gather( + self.users_service.get(user_id), + self.teams_service.list(user_id=user_id), + self.notifications_service.count_unread(user_id), + ) + + return DashboardData( + user=user, + teams=teams, + unread_count=notifications, + ) +``` + +### Async Context Managers + +```python +async def process_large_dataset(self) -> None: + """Process data in chunks.""" + async with self.repository.session() as session: + # Stream results + async for chunk in self.repository.stream_in_chunks(100): + await self.process_chunk(chunk) +``` + +## Error Handling + +### Custom Exceptions + +```python +# lib/exceptions.py +from litestar.exceptions import HTTPException + +class ApplicationException(HTTPException): + """Base application exception.""" + status_code = 500 + +class ClientException(ApplicationException): + """Client error (4xx).""" + status_code = 400 + +class PermissionDeniedException(ApplicationException): + """Permission denied (403).""" + status_code = 403 + detail = "Permission denied" + +class ResourceNotFoundException(ApplicationException): + """Resource not found (404).""" + status_code = 404 + detail = "Resource not found" +``` + +### Exception Handling in Services + +```python +async def get_team_member( + self, + team_id: UUID, + user_id: UUID +) -> TeamMember: + """Get team member with proper error handling.""" + member = await self.repository.get_one_or_none( + team_id=team_id, + user_id=user_id, + ) + + if member is None: + raise ResourceNotFoundException( + detail=f"Member {user_id} not found in team {team_id}" + ) + + return member +``` + +## Performance Optimization + +### Query Optimization + +```python +# Avoid N+1 queries with proper loading +users = await self.repository.list( + User.is_active == True, + load=[ + selectinload(User.teams), + selectinload(User.roles), + ], +) + +# Use specific columns when needed +team_names = await self.repository.list( + load_only=[Team.id, Team.name], +) +``` + +### Caching with Redis + +```python +from app.lib.cache import cache + +async def get_user_permissions(self, user_id: UUID) -> list[str]: + """Get user permissions with caching.""" + cache_key = f"permissions:{user_id}" + + # Try cache first + cached = await cache.get(cache_key) + if cached: + return cached + + # Compute permissions + permissions = await self._compute_permissions(user_id) + + # Cache for 5 minutes + await cache.set(cache_key, permissions, ttl=300) + + return permissions +``` + +## Background Tasks + +### Using SAQ for Job Queues + +```python +# server/jobs/email.py +from app.lib.worker import job + +@job("send_email") +async def send_welcome_email(user_id: str) -> None: + """Send welcome email in background.""" + user = await users_service.get(UUID(user_id)) + await email_service.send_welcome(user) + +# Queue the job +await send_welcome_email.enqueue(str(user.id)) +``` + +## Best Practices + +### 1. Always Use Type Annotations + +```python +async def create_team( + self, + name: str, + owner_id: UUID, + description: str | None = None, +) -> Team: + """Create team with type safety.""" +``` + +### 2. Transaction Management + +```python +async def transfer_ownership( + self, + team_id: UUID, + new_owner_id: UUID +) -> None: + """Transfer team ownership in transaction.""" + async with self.repository.transaction(): + # All operations in same transaction + team = await self.get(team_id) + old_owner = await self.remove_member(team_id, team.owner_id) + new_owner = await self.add_member(team_id, new_owner_id, "OWNER") + team.owner_id = new_owner_id + await self.repository.update(team) +``` + +### 3. Validation at Service Layer + +```python +async def create_user(self, data: dict) -> User: + """Create user with validation.""" + # Validate email format + if not self._is_valid_email(data["email"]): + raise ClientException("Invalid email format") + + # Check uniqueness + existing = await self.get_one_or_none(email=data["email"]) + if existing: + raise ClientException("Email already registered") + + # Create user + return await self.repository.add(User(**data)) +``` + +## Next Steps + +Now that you understand the backend architecture: + +1. Learn about [Authentication & Security](04-authentication-security.md) +2. Explore [Frontend Architecture](05-frontend-architecture.md) +3. Review [Development Workflow](06-development-workflow.md) + +--- + +*The backend architecture prioritizes type safety, performance, and maintainability. Following these patterns ensures consistent, high-quality code across the application.* diff --git a/docs/architecture/04-authentication-security.md b/docs/architecture/04-authentication-security.md new file mode 100644 index 00000000..20a52a69 --- /dev/null +++ b/docs/architecture/04-authentication-security.md @@ -0,0 +1,757 @@ +# Authentication & Security + +## Overview + +The Litestar Fullstack SPA implements a comprehensive security system with multiple layers of protection. This includes JWT-based authentication, email verification, password reset flows, two-factor authentication (2FA), and configurable security policies. + +## Authentication Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Client (Browser) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ JWT Token Storage β”‚ +β”‚ (HttpOnly Cookies/Memory) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ API Requests with β”‚ +β”‚ Authorization Header β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ JWT Middleware β”‚ +β”‚ Token Validation & User Loading β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Route Guards β”‚ +β”‚ Authorization & Permission Checks β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Route Handlers β”‚ +β”‚ Protected Endpoints β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## JWT Authentication + +### Configuration + +```python +from litestar.security.jwt import OAuth2PasswordBearerAuth +from app.server.security import current_user_from_token + +auth = OAuth2PasswordBearerAuth[m.User]( + retrieve_user_handler=current_user_from_token, + token_secret=settings.app.SECRET_KEY, + token_url="/api/access/login", + exclude=[ + # Public endpoints + "/api/health", + "/api/access/login", + "/api/access/signup", + "/api/access/forgot-password", + "/api/access/reset-password", + "/api/email-verification/*", + # Documentation + "^/schema", + "^/public/", + ], +) +``` + +### Token Generation + +```python +from litestar.security.jwt import Token +from datetime import datetime, timedelta, UTC + +def create_token( + sub: str, + exp: datetime | None = None, + aud: str = "app:auth" +) -> str: + """Create JWT token with claims.""" + if exp is None: + exp = datetime.now(UTC) + timedelta(days=1) + + token = Token( + sub=sub, # User ID + exp=exp, # Expiration + aud=aud, # Audience + iss="litestar-app", # Issuer + ) + + return token.encode( + secret=settings.app.SECRET_KEY, + algorithm="HS256" + ) +``` + +### User Retrieval + +```python +async def current_user_from_token( + token: Token, + connection: ASGIConnection[Any, Any, Any, Any] +) -> m.User | None: + """Load user from JWT token.""" + try: + user_id = UUID(token.sub) + except ValueError: + return None + + # Get user service from DI + users_service = await anext(provide_users_service(connection)) + + # Load user with relationships + user = await users_service.get_one_or_none( + id=user_id, + is_active=True, + ) + + return user +``` + +## Authentication Flow + +### User Registration + +```python +@post( + operation_id="Signup", + path="/api/access/signup", + exclude_from_auth=True +) +async def signup( + self, + data: UserSignup, + users_service: UserService, + email_verification_service: EmailVerificationTokenService, +) -> UserRead: + """Register new user account.""" + # Validate password strength + validate_password_strength(data.password) + + # Check if email exists + existing = await users_service.get_one_or_none(email=data.email) + if existing: + raise ClientException("Email already registered") + + # Create user + user = await users_service.create( + data.model_dump(exclude={"password_confirm"}) + ) + + # Send verification email + if settings.auth.REQUIRE_EMAIL_VERIFICATION: + token = await email_verification_service.create_verification_token( + user_id=user.id, + email=user.email + ) + await email_service.send_verification_email(user, token) + + return UserRead.from_orm(user) +``` + +### Login with 2FA Support + +```python +@post( + operation_id="Login", + path="/api/access/login", + exclude_from_auth=True +) +async def login( + self, + data: OAuth2PasswordRequestForm, + users_service: UserService, + totp_service: TOTPService, +) -> LoginResponse: + """Authenticate user with optional 2FA.""" + # Step 1: Verify credentials + user = await users_service.authenticate( + data.username, + data.password + ) + + # Step 2: Check requirements + auth_settings = get_settings().auth + + # Email verification check + if auth_settings.REQUIRE_EMAIL_VERIFICATION and not user.is_verified: + raise PermissionDeniedException("Email verification required") + + # 2FA check + requires_2fa = False + if auth_settings.ENABLE_2FA: + # Required for certain roles + if any(role.name in auth_settings.REQUIRE_2FA_FOR_ROLES + for role in user.roles): + requires_2fa = True + # Or user enabled it + elif user.has_2fa_enabled: + requires_2fa = True + + if requires_2fa: + # Generate temporary token for 2FA + temp_token = create_token( + sub=str(user.id), + exp=datetime.now(UTC) + timedelta(minutes=5), + aud="2fa-verify" + ) + + return LoginResponse( + access_token=None, + token_type="2fa_required", + requires_2fa=True, + temp_token=temp_token + ) + + # Generate full access token + access_token = create_token(sub=str(user.id)) + + # Update login tracking + user.login_count += 1 + user.last_login_at = datetime.now(UTC) + await users_service.repository.update(user) + + return LoginResponse( + access_token=access_token, + token_type="bearer", + requires_2fa=False + ) +``` + +## Email Verification + +### Token Model + +```python +class EmailVerificationToken(UUIDAuditBase): + """Email verification token model.""" + + __tablename__ = "email_verification_token" + + user_id: Mapped[UUID] = mapped_column( + ForeignKey("user_account.id", ondelete="CASCADE") + ) + email: Mapped[str] = mapped_column(String(255)) + token: Mapped[str] = mapped_column( + String(255), + unique=True, + index=True + ) + expires_at: Mapped[datetime] = mapped_column() + used_at: Mapped[datetime | None] = mapped_column( + nullable=True, + default=None + ) + + # Relationships + user: Mapped["User"] = relationship(lazy="selectin") +``` + +### Verification Service + +```python +class EmailVerificationTokenService( + service.SQLAlchemyAsyncRepositoryService[m.EmailVerificationToken] +): + """Service for email verification.""" + + async def create_verification_token( + self, + user_id: UUID, + email: str + ) -> m.EmailVerificationToken: + """Create verification token.""" + # Expire existing tokens + await self.repository.delete_where( + m.EmailVerificationToken.user_id == user_id, + m.EmailVerificationToken.used_at.is_(None), + ) + + # Generate secure token + token = secrets.token_urlsafe(32) + expires_at = datetime.now(UTC) + timedelta(hours=24) + + return await self.repository.add( + m.EmailVerificationToken( + user_id=user_id, + email=email, + token=token, + expires_at=expires_at, + ) + ) + + async def verify_token(self, token: str) -> m.EmailVerificationToken: + """Verify and consume token.""" + db_obj = await self.repository.get_one_or_none(token=token) + + if db_obj is None: + raise ClientException("Invalid verification token") + + if db_obj.used_at is not None: + raise ClientException("Token already used") + + if db_obj.expires_at < datetime.now(UTC): + raise ClientException("Token expired") + + # Mark as used + db_obj.used_at = datetime.now(UTC) + return await self.repository.update(db_obj) +``` + +### Verification Endpoint + +```python +@Controller(path="/api/email-verification", exclude_from_auth=True) +class EmailVerificationController: + """Email verification endpoints.""" + + @post(operation_id="VerifyEmail", path="/verify") + async def verify_email( + self, + data: EmailVerificationRequest, + user_service: UserService, + verification_service: EmailVerificationTokenService, + ) -> EmailVerificationResponse: + """Verify user's email address.""" + # Validate token + token = await verification_service.verify_token(data.token) + + # Update user + user = await user_service.verify_email( + token.user_id, + token.email + ) + + # Send welcome email + await email_service.send_welcome_email(user) + + return EmailVerificationResponse( + message="Email verified successfully", + user_id=user.id, + is_verified=True + ) +``` + +## Password Reset + +### πŸ”’ Security Features + +- Rate limiting (3 attempts per hour) +- IP address tracking +- User agent logging +- Time-limited tokens (1 hour) +- Single-use tokens + +### Password Reset Service + +```python +class PasswordResetService( + service.SQLAlchemyAsyncRepositoryService[m.PasswordResetToken] +): + """Password reset operations.""" + + async def create_reset_token( + self, + user_id: UUID, + ip_address: str = "unknown", + user_agent: str = "unknown" + ) -> m.PasswordResetToken: + """Create password reset token.""" + # Check rate limit + if await self.check_rate_limit(user_id): + raise ClientException("Too many reset attempts") + + # Expire existing tokens + await self.repository.delete_where( + m.PasswordResetToken.user_id == user_id, + m.PasswordResetToken.used_at.is_(None), + ) + + # Create new token + token = secrets.token_urlsafe(32) + expires_at = datetime.now(UTC) + timedelta(hours=1) + + return await self.repository.add( + m.PasswordResetToken( + user_id=user_id, + token=token, + expires_at=expires_at, + ip_address=ip_address, + user_agent=user_agent, + ) + ) + + async def check_rate_limit(self, user_id: UUID) -> bool: + """Check if user is rate limited.""" + one_hour_ago = datetime.now(UTC) - timedelta(hours=1) + count = await self.repository.count( + m.PasswordResetToken.user_id == user_id, + m.PasswordResetToken.created_at > one_hour_ago, + ) + return count >= 3 +``` + +### Password Validation + +```python +def validate_password_strength(password: str) -> None: + """Validate password meets requirements.""" + if len(password) < 12: + raise PasswordValidationError( + "Password must be at least 12 characters" + ) + + if not any(c.isupper() for c in password): + raise PasswordValidationError( + "Password must contain uppercase letter" + ) + + if not any(c.islower() for c in password): + raise PasswordValidationError( + "Password must contain lowercase letter" + ) + + if not any(c.isdigit() for c in password): + raise PasswordValidationError( + "Password must contain digit" + ) + + special_chars = set("!@#$%^&*()_+-=[]{}|;:,.<>?") + if not any(c in special_chars for c in password): + raise PasswordValidationError( + "Password must contain special character" + ) + + # Check common passwords + common = {"password", "12345678", "qwerty", "abc123"} + if password.lower() in common: + raise PasswordValidationError("Password too common") +``` + +## Two-Factor Authentication (2FA) + +### TOTP Implementation + +```python +class TOTPService( + service.SQLAlchemyAsyncRepositoryService[m.UserTOTPDevice] +): + """TOTP-based 2FA service.""" + + async def setup_totp( + self, + user_id: UUID, + device_name: str + ) -> tuple[UUID, str]: + """Initialize TOTP setup.""" + # Generate secret + secret = pyotp.random_base32() + + # Create inactive device + device = await self.repository.add( + m.UserTOTPDevice( + user_id=user_id, + name=device_name, + secret=await self._encrypt_secret(secret), + is_active=False + ) + ) + + # Generate QR code + totp_uri = pyotp.totp.TOTP(secret).provisioning_uri( + name=user.email, + issuer_name=settings.auth.TOTP_ISSUER + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buffer = BytesIO() + img.save(buffer, format="PNG") + qr_data = base64.b64encode(buffer.getvalue()).decode() + + return device.id, qr_data + + def _verify_code(self, secret: str, code: str) -> bool: + """Verify TOTP code.""" + totp = pyotp.TOTP(secret) + # Allow 1 period drift + return totp.verify( + code, + valid_window=settings.auth.TOTP_VALID_WINDOW + ) +``` + +### Backup Codes + +```python +async def generate_backup_codes( + self, + user_id: UUID +) -> list[str]: + """Generate single-use backup codes.""" + # Delete existing codes + await self.backup_code_repo.delete_where( + m.UserBackupCode.user_id == user_id + ) + + codes = [] + for _ in range(10): + # Generate secure code + code = ''.join( + secrets.choice(string.digits) + for _ in range(8) + ) + + # Store hashed version + await self.backup_code_repo.add( + m.UserBackupCode( + user_id=user_id, + code_hash=await crypt.get_password_hash(code) + ) + ) + + codes.append(code) + + return codes +``` + +## Authorization Guards + +### Built-in Guards + +```python +def requires_active_user( + connection: ASGIConnection[Any, m.User, Token, Any], + _: BaseRouteHandler +) -> None: + """Require active user account.""" + if not connection.user.is_active: + raise PermissionDeniedException("Account inactive") + +def requires_verified_user( + connection: ASGIConnection[Any, m.User, Token, Any], + _: BaseRouteHandler +) -> None: + """Require verified email.""" + if not connection.user.is_verified: + raise PermissionDeniedException("Email verification required") + +def requires_superuser( + connection: ASGIConnection[Any, m.User, Token, Any], + _: BaseRouteHandler +) -> None: + """Require superuser privileges.""" + if not connection.user.is_superuser: + raise PermissionDeniedException("Insufficient privileges") +``` + +### Team-Based Guards + +```python +def requires_team_member( + connection: ASGIConnection[Any, m.User, Token, Any], + _: BaseRouteHandler +) -> None: + """Require team membership.""" + team_id = connection.path_params.get("team_id") + if not team_id: + return + + # Check membership + is_member = any( + str(tm.team_id) == team_id + for tm in connection.user.teams + ) + + if not is_member: + raise PermissionDeniedException("Not a team member") + +def requires_team_role(role: str): + """Require specific team role.""" + def guard( + connection: ASGIConnection[Any, m.User, Token, Any], + _: BaseRouteHandler + ) -> None: + team_id = connection.path_params.get("team_id") + if not team_id: + return + + # Check role + member = next( + (tm for tm in connection.user.teams + if str(tm.team_id) == team_id), + None + ) + + if not member or member.role != role: + raise PermissionDeniedException( + f"Requires {role} role" + ) + + return guard +``` + +## Configurable Security + +### Authentication Settings + +```python +@dataclass +class AuthenticationSettings: + """Configurable auth settings.""" + + # Email verification + REQUIRE_EMAIL_VERIFICATION: bool = field( + default_factory=get_env("AUTH_REQUIRE_EMAIL_VERIFICATION", True) + ) + + # 2FA settings + ENABLE_2FA: bool = field( + default_factory=get_env("AUTH_ENABLE_2FA", True) + ) + REQUIRE_2FA_FOR_ROLES: list[str] = field( + default_factory=get_env( + "AUTH_REQUIRE_2FA_FOR_ROLES", + ["ADMIN", "SUPERUSER"] + ) + ) + + # Password policy + PASSWORD_MIN_LENGTH: int = field( + default_factory=get_env("AUTH_PASSWORD_MIN_LENGTH", 12) + ) + PASSWORD_REQUIRE_UPPERCASE: bool = field( + default_factory=get_env("AUTH_PASSWORD_REQUIRE_UPPERCASE", True) + ) + PASSWORD_REQUIRE_SPECIAL: bool = field( + default_factory=get_env("AUTH_PASSWORD_REQUIRE_SPECIAL", True) + ) + + # Security + MAX_LOGIN_ATTEMPTS: int = field( + default_factory=get_env("AUTH_MAX_LOGIN_ATTEMPTS", 5) + ) + LOGIN_LOCKOUT_MINUTES: int = field( + default_factory=get_env("AUTH_LOGIN_LOCKOUT_MINUTES", 30) + ) +``` + +### Environment-Based Configuration + +```bash +# Development (.env.local) +AUTH_REQUIRE_EMAIL_VERIFICATION=false +AUTH_ENABLE_2FA=false +AUTH_PASSWORD_MIN_LENGTH=8 + +# Production (.env.production) +AUTH_REQUIRE_EMAIL_VERIFICATION=true +AUTH_ENABLE_2FA=true +AUTH_REQUIRE_2FA_FOR_ROLES=["ADMIN", "SUPERUSER"] +AUTH_PASSWORD_MIN_LENGTH=12 +AUTH_MAX_LOGIN_ATTEMPTS=3 +AUTH_LOGIN_LOCKOUT_MINUTES=60 +``` + +## Security Best Practices + +### 1. Input Validation + +All inputs validated through msgspec: + +```python +class UserLogin(msgspec.Struct): + """Login request with validation.""" + email: str = msgspec.field( + validator=lambda x: "@" in x and len(x) < 255 + ) + password: str = msgspec.field( + validator=lambda x: 8 <= len(x) <= 128 + ) +``` + +### 2. Rate Limiting + +Implement for sensitive operations: + +```python +from app.lib.rate_limit import rate_limit + +@post("/api/access/login", exclude_from_auth=True) +@rate_limit(max_calls=5, period=60) # 5 attempts per minute +async def login(...): + ... +``` + +### 3. Security Headers + +Configure security headers: + +```python +from litestar.middleware import SecurityHeadersMiddleware + +app = Litestar( + middleware=[ + SecurityHeadersMiddleware( + content_security_policy="default-src 'self'", + strict_transport_security="max-age=31536000", + x_content_type_options="nosniff", + x_frame_options="DENY", + x_xss_protection="1; mode=block", + ) + ] +) +``` + +### 4. Secrets Management + +Never hardcode secrets: + +```python +# Bad +SECRET_KEY = "hardcoded-secret" + +# Good +SECRET_KEY = os.environ["APP_SECRET_KEY"] +``` + +### 5. Audit Logging + +Log security events: + +```python +async def login(self, ...): + """Login with audit logging.""" + try: + user = await self.authenticate(...) + logger.info( + "Login successful", + user_id=user.id, + ip=request.client.host + ) + except PermissionDeniedException: + logger.warning( + "Login failed", + email=data.username, + ip=request.client.host + ) + raise +``` + +## Next Steps + +Understanding authentication and security is crucial for: + +1. Implementing new protected features +2. Configuring deployment environments +3. Conducting security audits + +Continue to [Frontend Architecture](05-frontend-architecture.md) to see how authentication is handled on the client side. + +--- + +*Security is not an afterthought but a core design principle. Every layer implements defense in depth to protect user data and system integrity.* diff --git a/docs/architecture/05-frontend-architecture.md b/docs/architecture/05-frontend-architecture.md new file mode 100644 index 00000000..3744f3ba --- /dev/null +++ b/docs/architecture/05-frontend-architecture.md @@ -0,0 +1,796 @@ +# Frontend Architecture + +## Overview + +The frontend is built with React 19, TypeScript, and modern tooling to provide a fast, type-safe, and maintainable single-page application. It leverages auto-generated API clients, file-based routing, and a component library for rapid development. + +## Technology Stack + +### Core Technologies + +- **React 19** - Latest React with improved performance and DX +- **TypeScript** - Full type safety across the application +- **Vite** - Lightning-fast build tool with HMR +- **TanStack Router** - Type-safe, file-based routing +- **TanStack Query** - Powerful data synchronization +- **shadcn/ui** - High-quality, customizable components +- **Tailwind CSS v4** - Utility-first styling + +### Development Tools + +- **Biome** - Fast linting and formatting +- **OpenAPI TypeScript** - Auto-generated API types +- **React Hook Form** - Performant form handling +- **Zod** - Runtime type validation + +## Project Structure + +``` +src/ +β”œβ”€β”€ components/ # Reusable components +β”‚ β”œβ”€β”€ ui/ # shadcn/ui base components +β”‚ β”œβ”€β”€ auth/ # Authentication components +β”‚ β”œβ”€β”€ teams/ # Team-related components +β”‚ └── admin/ # Admin interfaces +β”œβ”€β”€ routes/ # File-based routing +β”‚ β”œβ”€β”€ __root.tsx # Root layout +β”‚ β”œβ”€β”€ _app.tsx # Authenticated layout +β”‚ β”œβ”€β”€ _public.tsx # Public layout +β”‚ └── _app/ # Protected routes +β”œβ”€β”€ lib/ # Utilities and clients +β”‚ β”œβ”€β”€ api/ # Generated API client +β”‚ β”œβ”€β”€ auth.ts # Auth utilities +β”‚ └── utils.ts # Helper functions +β”œβ”€β”€ hooks/ # Custom React hooks +└── styles.css # Global styles +``` + +## Component Architecture + +### Component Patterns + +Components follow a consistent structure with TypeScript interfaces: + +```typescript +// components/teams/team-card.tsx +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { TeamRead } from "@/lib/api/types.gen"; + +interface TeamCardProps { + team: TeamRead; + onEdit?: (team: TeamRead) => void; + isOwner?: boolean; +} + +export function TeamCard({ team, onEdit, isOwner = false }: TeamCardProps) { + return ( + + +
+ {team.name} + {isOwner && Owner} +
+
+ +

{team.description}

+ {onEdit && ( + + )} +
+
+ ); +} +``` + +### shadcn/ui Components + +Base UI components are from shadcn/ui, customized for the project: + +```typescript +// components/ui/button.tsx +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); + +Button.displayName = "Button"; + +export { Button, buttonVariants }; +``` + +## Routing System + +### File-Based Routes + +TanStack Router uses file-based routing with layouts: + +```typescript +// routes/__root.tsx - Root layout +import { Outlet, createRootRoute } from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/router-devtools"; + +export const Route = createRootRoute({ + component: () => ( + <> + + + + ), +}); +``` + +```typescript +// routes/_app.tsx - Authenticated layout +import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { AppLayout } from "@/layouts/app-layout"; + +export const Route = createFileRoute("/_app")({ + beforeLoad: async ({ context }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ + to: "/login", + search: { + redirect: location.href, + }, + }); + } + }, + component: () => ( + + + + ), +}); +``` + +### Route Parameters + +Dynamic routes with type-safe parameters: + +```typescript +// routes/_app/teams/$teamId.tsx +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +const teamParamsSchema = z.object({ + teamId: z.string().uuid(), +}); + +export const Route = createFileRoute("/_app/teams/$teamId")({ + params: { + parse: (params) => teamParamsSchema.parse(params), + stringify: (params) => params, + }, + loader: async ({ params, context }) => { + const team = await context.api.teams.getTeam({ + teamId: params.teamId + }); + return { team }; + }, + component: TeamDetailPage, +}); + +function TeamDetailPage() { + const { team } = Route.useLoaderData(); + const { teamId } = Route.useParams(); + + return ; +} +``` + +## Data Management + +### TanStack Query Integration + +Data fetching with caching and synchronization: + +```typescript +// hooks/use-teams.ts +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export function useTeams() { + return useQuery({ + queryKey: ["teams"], + queryFn: () => api.teams.listTeams(), + }); +} + +export function useCreateTeam() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: TeamCreate) => api.teams.createTeam({ + requestBody: data + }), + onSuccess: () => { + // Invalidate and refetch teams + queryClient.invalidateQueries({ queryKey: ["teams"] }); + }, + }); +} + +export function useTeam(teamId: string) { + return useQuery({ + queryKey: ["teams", teamId], + queryFn: () => api.teams.getTeam({ teamId }), + enabled: !!teamId, + }); +} +``` + +### Optimistic Updates + +Immediate UI updates with rollback on error: + +```typescript +export function useUpdateTeam(teamId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: TeamUpdate) => + api.teams.updateTeam({ teamId, requestBody: data }), + + onMutate: async (newData) => { + // Cancel in-flight queries + await queryClient.cancelQueries({ + queryKey: ["teams", teamId] + }); + + // Snapshot previous value + const previousTeam = queryClient.getQueryData(["teams", teamId]); + + // Optimistically update + queryClient.setQueryData(["teams", teamId], (old) => ({ + ...old, + ...newData, + })); + + return { previousTeam }; + }, + + onError: (err, newData, context) => { + // Rollback on error + if (context?.previousTeam) { + queryClient.setQueryData( + ["teams", teamId], + context.previousTeam + ); + } + }, + + onSettled: () => { + // Always refetch after error or success + queryClient.invalidateQueries({ + queryKey: ["teams", teamId] + }); + }, + }); +} +``` + +## API Client + +### Auto-Generated Types + +Types are generated from the OpenAPI schema: + +```typescript +// lib/api/types.gen.ts (auto-generated) +export interface UserRead { + id: string; + email: string; + name?: string | null; + isActive: boolean; + isVerified: boolean; + createdAt: string; + updatedAt: string; +} + +export interface TeamRead { + id: string; + name: string; + slug: string; + description?: string | null; + ownerId: string; + createdAt: string; + updatedAt: string; +} +``` + +### API Client Configuration + +```typescript +// lib/api/client.gen.ts +import { createClient } from "@hey-api/client-fetch"; + +export const client = createClient({ + baseUrl: import.meta.env.VITE_API_URL || "http://localhost:8000", + headers: { + "Content-Type": "application/json", + }, +}); + +// Add auth interceptor +client.interceptors.request.use((request, options) => { + const token = getAuthToken(); + if (token) { + request.headers.set("Authorization", `Bearer ${token}`); + } + return request; +}); + +// Add error interceptor +client.interceptors.response.use(undefined, (error) => { + if (error.response?.status === 401) { + // Handle unauthorized + window.location.href = "/login"; + } + return Promise.reject(error); +}); +``` + +## Authentication Flow + +### Authentication Hook + +```typescript +// hooks/use-auth.ts +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface AuthState { + token: string | null; + user: UserRead | null; + isAuthenticated: boolean; + login: (token: string, user: UserRead) => void; + logout: () => void; +} + +export const useAuth = create()( + persist( + (set) => ({ + token: null, + user: null, + isAuthenticated: false, + + login: (token, user) => set({ + token, + user, + isAuthenticated: true, + }), + + logout: () => { + set({ + token: null, + user: null, + isAuthenticated: false, + }); + window.location.href = "/login"; + }, + }), + { + name: "auth-storage", + partialize: (state) => ({ + token: state.token, + user: state.user, + isAuthenticated: state.isAuthenticated, + }), + } + ) +); +``` + +### Login Component with 2FA + +```typescript +// components/auth/login.tsx +export function LoginForm() { + const [requires2FA, setRequires2FA] = useState(false); + const [tempToken, setTempToken] = useState(null); + const { login } = useAuth(); + const navigate = useNavigate(); + + const loginMutation = useMutation({ + mutationFn: (data: LoginCredentials) => + api.auth.login({ requestBody: data }), + onSuccess: (response) => { + if (response.requires2fa) { + setRequires2FA(true); + setTempToken(response.tempToken!); + } else { + login(response.accessToken!, response.user!); + navigate({ to: "/home" }); + } + }, + }); + + const verify2FAMutation = useMutation({ + mutationFn: (code: string) => + api.twoFactor.verify({ + requestBody: { tempToken: tempToken!, code } + }), + onSuccess: (response) => { + login(response.accessToken!, response.user!); + navigate({ to: "/home" }); + }, + }); + + if (requires2FA) { + return ( + verify2FAMutation.mutate(code)} + isLoading={verify2FAMutation.isPending} + error={verify2FAMutation.error?.message} + /> + ); + } + + return ( +
+ {/* Login form fields */} +
+ ); +} +``` + +## Forms and Validation + +### React Hook Form with Zod + +Type-safe forms with validation: + +```typescript +// components/teams/create-team-form.tsx +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +const createTeamSchema = z.object({ + name: z.string() + .min(3, "Name must be at least 3 characters") + .max(50, "Name must be less than 50 characters"), + description: z.string() + .max(200, "Description must be less than 200 characters") + .optional(), +}); + +type CreateTeamData = z.infer; + +export function CreateTeamForm() { + const createTeam = useCreateTeam(); + + const form = useForm({ + resolver: zodResolver(createTeamSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + const onSubmit = async (data: CreateTeamData) => { + try { + await createTeam.mutateAsync(data); + toast({ + title: "Team created", + description: "Your team has been created successfully.", + }); + } catch (error) { + toast({ + title: "Error", + description: "Failed to create team. Please try again.", + variant: "destructive", + }); + } + }; + + return ( +
+ + ( + + Team Name + + + + + + )} + /> + + ( + + Description + +