create-rigel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/cli.js +122 -0
- package/package.json +47 -0
- package/templates/express/.claude/CLAUDE.md +114 -0
- package/templates/express/.claude/agents/arch-validator.md +85 -0
- package/templates/express/.claude/agents/db-optimizer.md +72 -0
- package/templates/express/.claude/agents/doc-gardener.md +67 -0
- package/templates/express/.claude/agents/garbage-collector.md +94 -0
- package/templates/express/.claude/agents/gate-checker.md +176 -0
- package/templates/express/.claude/agents/reviewer.md +84 -0
- package/templates/express/.claude/agents/security-auditor.md +82 -0
- package/templates/express/.claude/hooks/post-write.sh +93 -0
- package/templates/express/.claude/rules/api.md +149 -0
- package/templates/express/.claude/rules/architecture.md +78 -0
- package/templates/express/.claude/rules/database.md +104 -0
- package/templates/express/.claude/rules/jobs.md +81 -0
- package/templates/express/.claude/rules/security.md +92 -0
- package/templates/express/.claude/rules/testing.md +97 -0
- package/templates/express/.claude/settings.json +35 -0
- package/templates/express/.claude/skills/00-infra-setup/SKILL.md +479 -0
- package/templates/express/.claude/skills/01-write-roadmap/SKILL.md +72 -0
- package/templates/express/.claude/skills/02-write-spec/SKILL.md +119 -0
- package/templates/express/.claude/skills/03-write-plan/SKILL.md +124 -0
- package/templates/express/.claude/skills/04-build-layer/SKILL.md +216 -0
- package/templates/express/.claude/skills/05-validate-layer/SKILL.md +43 -0
- package/templates/express/.claude/skills/06-push-layer/SKILL.md +59 -0
- package/templates/express/.claude/skills/07-layer-check/SKILL.md +62 -0
- package/templates/express/.claude/skills/08-garbage-collect/SKILL.md +86 -0
- package/templates/express/.claude/skills/09-doc-garden/SKILL.md +64 -0
- package/templates/express/.claude/skills/10-db-optimize/SKILL.md +96 -0
- package/templates/express/.claude/skills/11-load-test/SKILL.md +77 -0
- package/templates/express/.dockerignore +43 -0
- package/templates/express/.env.example +42 -0
- package/templates/express/.github/CODEOWNERS +22 -0
- package/templates/express/.github/dependabot.yml +48 -0
- package/templates/express/.github/pull_request_template.md +58 -0
- package/templates/express/.github/workflows/ci.yml +278 -0
- package/templates/express/.github/workflows/load-test.yml +41 -0
- package/templates/express/.gitleaks.toml +18 -0
- package/templates/express/.lintstagedrc.json +3 -0
- package/templates/express/.nvmrc +1 -0
- package/templates/express/.prettierrc +8 -0
- package/templates/express/.sequelizerc +8 -0
- package/templates/express/AGENTS.md +94 -0
- package/templates/express/ARCHITECTURE.md +161 -0
- package/templates/express/Dockerfile +40 -0
- package/templates/express/Makefile +79 -0
- package/templates/express/QUICKSTART.md +131 -0
- package/templates/express/docker-compose.yml +114 -0
- package/templates/express/docs/PLANS.md +111 -0
- package/templates/express/docs/QUALITY_SCORE.md +66 -0
- package/templates/express/docs/design-docs/core-beliefs.md +89 -0
- package/templates/express/docs/design-docs/decisions/ADR-000-infrastructure.md +70 -0
- package/templates/express/docs/design-docs/decisions/ADR-001-observability.md +82 -0
- package/templates/express/docs/design-docs/decisions/index.md +23 -0
- package/templates/express/docs/design-docs/index.md +11 -0
- package/templates/express/docs/design-docs/observability.md +194 -0
- package/templates/express/docs/design-docs/team-workflow.md +64 -0
- package/templates/express/docs/exec-plans/active/.gitkeep +0 -0
- package/templates/express/docs/exec-plans/completed/.gitkeep +0 -0
- package/templates/express/docs/exec-plans/tech-debt-tracker.md +31 -0
- package/templates/express/docs/generated/.gitkeep +0 -0
- package/templates/express/docs/product-specs/ROADMAP.md +49 -0
- package/templates/express/docs/product-specs/draft/.gitkeep +0 -0
- package/templates/express/docs/product-specs/index.md +21 -0
- package/templates/express/docs/product-specs/ready/.gitkeep +0 -0
- package/templates/express/eslint.config.mjs +94 -0
- package/templates/express/gitignore +36 -0
- package/templates/express/infra/monitoring/alloy/config.alloy +59 -0
- package/templates/express/jest.config.ts +61 -0
- package/templates/express/package.json +45 -0
- package/templates/express/scripts/openapi.export.ts +77 -0
- package/templates/express/tests/architecture/isolation.test.ts +51 -0
- package/templates/express/tests/architecture/layers.test.ts +83 -0
- package/templates/express/tests/integration/health.test.ts +40 -0
- package/templates/express/tests/integration/isolation.test.template.ts +88 -0
- package/templates/express/tests/load/smoke.js +46 -0
- package/templates/express/tests/load/soak.js +45 -0
- package/templates/express/tests/load/stress.js +44 -0
- package/templates/express/tests/unit/utils/response.util.test.ts +53 -0
- package/templates/express/tsconfig.build.json +8 -0
- package/templates/express/tsconfig.json +20 -0
- package/templates/fastapi/.claude/CLAUDE.md +122 -0
- package/templates/fastapi/.claude/agents/arch-validator.md +64 -0
- package/templates/fastapi/.claude/agents/db-optimizer.md +59 -0
- package/templates/fastapi/.claude/agents/doc-gardener.md +55 -0
- package/templates/fastapi/.claude/agents/garbage-collector.md +74 -0
- package/templates/fastapi/.claude/agents/gate-checker.md +133 -0
- package/templates/fastapi/.claude/agents/reviewer.md +74 -0
- package/templates/fastapi/.claude/agents/security-auditor.md +69 -0
- package/templates/fastapi/.claude/hooks/post-write.sh +65 -0
- package/templates/fastapi/.claude/rules/api.md +119 -0
- package/templates/fastapi/.claude/rules/architecture.md +84 -0
- package/templates/fastapi/.claude/rules/database.md +153 -0
- package/templates/fastapi/.claude/rules/jobs.md +85 -0
- package/templates/fastapi/.claude/rules/security.md +102 -0
- package/templates/fastapi/.claude/rules/testing.md +207 -0
- package/templates/fastapi/.claude/settings.json +50 -0
- package/templates/fastapi/.claude/skills/00-infra-setup/SKILL.md +311 -0
- package/templates/fastapi/.claude/skills/01-write-roadmap/SKILL.md +61 -0
- package/templates/fastapi/.claude/skills/02-write-spec/SKILL.md +82 -0
- package/templates/fastapi/.claude/skills/03-write-plan/SKILL.md +83 -0
- package/templates/fastapi/.claude/skills/04-build-layer/SKILL.md +170 -0
- package/templates/fastapi/.claude/skills/05-validate-layer/SKILL.md +20 -0
- package/templates/fastapi/.claude/skills/06-push-layer/SKILL.md +31 -0
- package/templates/fastapi/.claude/skills/07-layer-check/SKILL.md +51 -0
- package/templates/fastapi/.claude/skills/08-garbage-collect/SKILL.md +47 -0
- package/templates/fastapi/.claude/skills/09-doc-garden/SKILL.md +9 -0
- package/templates/fastapi/.claude/skills/10-db-optimize/SKILL.md +53 -0
- package/templates/fastapi/.claude/skills/11-load-test/SKILL.md +81 -0
- package/templates/fastapi/.dockerignore +30 -0
- package/templates/fastapi/.github/dependabot.yml +24 -0
- package/templates/fastapi/AGENTS.md +87 -0
- package/templates/fastapi/ARCHITECTURE.md +141 -0
- package/templates/fastapi/Makefile +42 -0
- package/templates/fastapi/README.md +179 -0
- package/templates/fastapi/docs/PLANS.md +26 -0
- package/templates/fastapi/docs/QUALITY_SCORE.md +26 -0
- package/templates/fastapi/docs/design-docs/core-beliefs.md +46 -0
- package/templates/fastapi/docs/design-docs/decisions/ADR-000-infrastructure.md +79 -0
- package/templates/fastapi/docs/design-docs/decisions/index.md +7 -0
- package/templates/fastapi/docs/design-docs/index.md +9 -0
- package/templates/fastapi/docs/exec-plans/active/.gitkeep +0 -0
- package/templates/fastapi/docs/exec-plans/completed/.gitkeep +0 -0
- package/templates/fastapi/docs/exec-plans/tech-debt-tracker.md +19 -0
- package/templates/fastapi/docs/generated/.gitkeep +0 -0
- package/templates/fastapi/docs/product-specs/ROADMAP.md +49 -0
- package/templates/fastapi/docs/product-specs/draft/.gitkeep +0 -0
- package/templates/fastapi/docs/product-specs/index.md +18 -0
- package/templates/fastapi/docs/product-specs/ready/.gitkeep +0 -0
- package/templates/fastapi/gitignore +46 -0
- package/templates/fastapi/scripts/gate.sh +68 -0
- package/templates/nestjs/.claude/CLAUDE.md +121 -0
- package/templates/nestjs/.claude/agents/db-optimizer.md +51 -0
- package/templates/nestjs/.claude/agents/doc-gardener.md +45 -0
- package/templates/nestjs/.claude/agents/garbage-collector.md +50 -0
- package/templates/nestjs/.claude/agents/gate-checker.md +127 -0
- package/templates/nestjs/.claude/agents/reviewer.md +68 -0
- package/templates/nestjs/.claude/agents/security-auditor.md +72 -0
- package/templates/nestjs/.claude/hooks/post-write.sh +91 -0
- package/templates/nestjs/.claude/rules/api.md +87 -0
- package/templates/nestjs/.claude/rules/architecture.md +104 -0
- package/templates/nestjs/.claude/rules/database.md +111 -0
- package/templates/nestjs/.claude/rules/security.md +110 -0
- package/templates/nestjs/.claude/rules/testing.md +145 -0
- package/templates/nestjs/.claude/settings.json +33 -0
- package/templates/nestjs/.claude/skills/00-infra-setup/SKILL.md +222 -0
- package/templates/nestjs/.claude/skills/01-write-spec/SKILL.md +25 -0
- package/templates/nestjs/.claude/skills/02-write-plan/SKILL.md +18 -0
- package/templates/nestjs/.claude/skills/03-build-layer/SKILL.md +242 -0
- package/templates/nestjs/.claude/skills/04-validate-layer/SKILL.md +4 -0
- package/templates/nestjs/.claude/skills/05-push-layer/SKILL.md +6 -0
- package/templates/nestjs/.claude/skills/06-layer-check/SKILL.md +12 -0
- package/templates/nestjs/.claude/skills/07-garbage-collect/SKILL.md +2 -0
- package/templates/nestjs/.claude/skills/08-doc-garden/SKILL.md +4 -0
- package/templates/nestjs/.claude/skills/09-api-sync/SKILL.md +36 -0
- package/templates/nestjs/.claude/skills/10-load-test/SKILL.md +29 -0
- package/templates/nestjs/.env.example +11 -0
- package/templates/nestjs/AGENTS.md +88 -0
- package/templates/nestjs/ARCHITECTURE.md +192 -0
- package/templates/nestjs/docs/PLANS.md +22 -0
- package/templates/nestjs/docs/QUALITY_SCORE.md +27 -0
- package/templates/nestjs/docs/design-docs/core-beliefs.md +18 -0
- package/templates/nestjs/docs/design-docs/decisions/ADR-000-infrastructure.md +58 -0
- package/templates/nestjs/docs/design-docs/decisions/index.md +7 -0
- package/templates/nestjs/docs/design-docs/index.md +7 -0
- package/templates/nestjs/docs/exec-plans/active/.gitkeep +0 -0
- package/templates/nestjs/docs/exec-plans/completed/.gitkeep +0 -0
- package/templates/nestjs/docs/exec-plans/tech-debt-tracker.md +16 -0
- package/templates/nestjs/docs/generated/.gitkeep +0 -0
- package/templates/nestjs/docs/product-specs/draft/.gitkeep +0 -0
- package/templates/nestjs/docs/product-specs/index.md +7 -0
- package/templates/nestjs/docs/product-specs/ready/.gitkeep +0 -0
- package/templates/nestjs/gitignore +8 -0
- package/templates/nextjs/.claude/CLAUDE.md +139 -0
- package/templates/nextjs/.claude/agents/arch-validator.md +66 -0
- package/templates/nextjs/.claude/agents/contract-checker.md +78 -0
- package/templates/nextjs/.claude/agents/doc-gardener.md +52 -0
- package/templates/nextjs/.claude/agents/garbage-collector.md +61 -0
- package/templates/nextjs/.claude/agents/gate-checker.md +129 -0
- package/templates/nextjs/.claude/agents/perf-auditor.md +109 -0
- package/templates/nextjs/.claude/agents/reviewer.md +79 -0
- package/templates/nextjs/.claude/agents/security-auditor.md +50 -0
- package/templates/nextjs/.claude/hooks/post-write.sh +121 -0
- package/templates/nextjs/.claude/rules/api-contract.md +166 -0
- package/templates/nextjs/.claude/rules/architecture.md +99 -0
- package/templates/nextjs/.claude/rules/components.md +122 -0
- package/templates/nextjs/.claude/rules/security.md +122 -0
- package/templates/nextjs/.claude/rules/testing.md +187 -0
- package/templates/nextjs/.claude/scripts/infra-setup.sh +550 -0
- package/templates/nextjs/.claude/settings.json +33 -0
- package/templates/nextjs/.claude/skills/00-infra-setup/SKILL.md +471 -0
- package/templates/nextjs/.claude/skills/01-write-roadmap/SKILL.md +77 -0
- package/templates/nextjs/.claude/skills/02-write-spec/SKILL.md +78 -0
- package/templates/nextjs/.claude/skills/03-write-plan/SKILL.md +91 -0
- package/templates/nextjs/.claude/skills/04-build-layer/SKILL.md +141 -0
- package/templates/nextjs/.claude/skills/05-validate-layer/SKILL.md +13 -0
- package/templates/nextjs/.claude/skills/06-push-layer/SKILL.md +20 -0
- package/templates/nextjs/.claude/skills/07-layer-check/SKILL.md +28 -0
- package/templates/nextjs/.claude/skills/08-garbage-collect/SKILL.md +14 -0
- package/templates/nextjs/.claude/skills/09-doc-garden/SKILL.md +12 -0
- package/templates/nextjs/.claude/skills/10-api-sync/SKILL.md +50 -0
- package/templates/nextjs/.claude/skills/11-perf-budget/SKILL.md +51 -0
- package/templates/nextjs/.dockerignore +25 -0
- package/templates/nextjs/.gitattributes +10 -0
- package/templates/nextjs/.github/CODEOWNERS +24 -0
- package/templates/nextjs/.github/dependabot.yml +31 -0
- package/templates/nextjs/.github/pull_request_template.md +59 -0
- package/templates/nextjs/.github/workflows/ci.yml +143 -0
- package/templates/nextjs/.github/workflows/lighthouse.yml +54 -0
- package/templates/nextjs/.github/workflows/load-test.yml +54 -0
- package/templates/nextjs/.husky/pre-commit +1 -0
- package/templates/nextjs/.lintstagedrc.json +4 -0
- package/templates/nextjs/.mcp.json +8 -0
- package/templates/nextjs/.prettierignore +13 -0
- package/templates/nextjs/.prettierrc +9 -0
- package/templates/nextjs/AGENTS.md +88 -0
- package/templates/nextjs/ARCHITECTURE.md +165 -0
- package/templates/nextjs/Dockerfile +38 -0
- package/templates/nextjs/Makefile +50 -0
- package/templates/nextjs/QUICKSTART.md +128 -0
- package/templates/nextjs/docs/PLANS.md +23 -0
- package/templates/nextjs/docs/QUALITY_SCORE.md +27 -0
- package/templates/nextjs/docs/design-docs/core-beliefs.md +43 -0
- package/templates/nextjs/docs/design-docs/decisions/ADR-000-infrastructure.md +72 -0
- package/templates/nextjs/docs/design-docs/decisions/index.md +7 -0
- package/templates/nextjs/docs/design-docs/index.md +7 -0
- package/templates/nextjs/docs/design-docs/team-workflow.md +66 -0
- package/templates/nextjs/docs/exec-plans/active/.gitkeep +0 -0
- package/templates/nextjs/docs/exec-plans/completed/.gitkeep +0 -0
- package/templates/nextjs/docs/exec-plans/tech-debt-tracker.md +19 -0
- package/templates/nextjs/docs/generated/.gitkeep +0 -0
- package/templates/nextjs/docs/product-specs/ROADMAP.md +59 -0
- package/templates/nextjs/docs/product-specs/draft/.gitkeep +0 -0
- package/templates/nextjs/docs/product-specs/index.md +18 -0
- package/templates/nextjs/docs/product-specs/ready/.gitkeep +0 -0
- package/templates/nextjs/eslint.config.mjs +150 -0
- package/templates/nextjs/gitignore +35 -0
- package/templates/nextjs/lighthouserc.js +37 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# .claude/hooks/post-write.sh
|
|
3
|
+
# Fires after every Write/Edit on Python files.
|
|
4
|
+
# Warns on: print(), os.environ, 400-line limit, raw dict cast on DB results.
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
FILE=$(cat | python3 -c "
|
|
9
|
+
import sys, json
|
|
10
|
+
try:
|
|
11
|
+
data = json.load(sys.stdin)
|
|
12
|
+
print(data.get('file_path') or data.get('path') or '')
|
|
13
|
+
except:
|
|
14
|
+
print('')
|
|
15
|
+
" 2>/dev/null || echo "")
|
|
16
|
+
|
|
17
|
+
[[ -z "$FILE" ]] && exit 0
|
|
18
|
+
[[ ! -f "$FILE" ]] && exit 0
|
|
19
|
+
[[ ! "$FILE" =~ \.py$ ]] && exit 0
|
|
20
|
+
|
|
21
|
+
WARNINGS=()
|
|
22
|
+
|
|
23
|
+
# 1. print() in src/ — use structlog logger
|
|
24
|
+
if [[ "$FILE" =~ ^src/ ]] && [[ ! "$FILE" =~ src/providers/logger ]] && [[ ! "$FILE" =~ src/config/ ]]; then
|
|
25
|
+
if grep -qn "^\s*print(" "$FILE" 2>/dev/null; then
|
|
26
|
+
LINE=$(grep -n "^\s*print(" "$FILE" | head -1 | cut -d: -f1)
|
|
27
|
+
WARNINGS+=("⚠ [HOOK] print() at $FILE:$LINE — use logger from src/providers/logger.py")
|
|
28
|
+
fi
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# 2. os.environ outside config/settings.py
|
|
32
|
+
if [[ "$FILE" =~ ^src/ ]] && [[ ! "$FILE" =~ src/config/settings\.py ]]; then
|
|
33
|
+
if grep -qn "os\.environ" "$FILE" 2>/dev/null; then
|
|
34
|
+
LINE=$(grep -n "os\.environ" "$FILE" | head -1 | cut -d: -f1)
|
|
35
|
+
WARNINGS+=("⚠ [HOOK] os.environ at $FILE:$LINE — use settings from src/config/settings.py")
|
|
36
|
+
fi
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# 3. File size > 400 lines
|
|
40
|
+
LINE_COUNT=$(wc -l < "$FILE" 2>/dev/null || echo 0)
|
|
41
|
+
if [[ $LINE_COUNT -gt 400 ]]; then
|
|
42
|
+
WARNINGS+=("⚠ [HOOK] $FILE has $LINE_COUNT lines — exceeds 400-line limit. Split into focused modules.")
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# 4. Unsafe DB result cast in repo files
|
|
46
|
+
if [[ "$FILE" =~ _repo\.py$ ]]; then
|
|
47
|
+
if grep -qn "return row\.__dict__\b\|return dict(row" "$FILE" 2>/dev/null; then
|
|
48
|
+
LINE=$(grep -n "return row\.__dict__\|return dict(row" "$FILE" | head -1 | cut -d: -f1)
|
|
49
|
+
WARNINGS+=("⚠ [HOOK] Unsafe cast at $FILE:$LINE — use Schema.model_validate(row.__dict__)")
|
|
50
|
+
fi
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# 5. HTTPException in service files
|
|
54
|
+
if [[ "$FILE" =~ _service\.py$ ]]; then
|
|
55
|
+
if grep -qn "HTTPException\|raise HTTPException" "$FILE" 2>/dev/null; then
|
|
56
|
+
LINE=$(grep -n "HTTPException" "$FILE" | head -1 | cut -d: -f1)
|
|
57
|
+
WARNINGS+=("⚠ [HOOK] HTTPException at $FILE:$LINE — services must raise DomainError, not HTTPException")
|
|
58
|
+
fi
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
for w in "${WARNINGS[@]}"; do
|
|
62
|
+
echo "$w"
|
|
63
|
+
done
|
|
64
|
+
|
|
65
|
+
exit 0
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "src/runtime/routers/**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# API Rules — Auto-injected on router file edits
|
|
7
|
+
|
|
8
|
+
## Every Route File Checklist
|
|
9
|
+
|
|
10
|
+
### Versioning
|
|
11
|
+
```python
|
|
12
|
+
# ✅ All routers under version prefix
|
|
13
|
+
router = APIRouter(prefix="/applications", tags=["applications"])
|
|
14
|
+
# Mounted in main.py at: app.include_router(router, prefix="/api/v1")
|
|
15
|
+
|
|
16
|
+
# ❌ Never create unversioned routes
|
|
17
|
+
app.include_router(router) # no version prefix
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Required Handler Structure
|
|
21
|
+
```python
|
|
22
|
+
@router.post("/", status_code=status.HTTP_201_CREATED)
|
|
23
|
+
async def create_application(
|
|
24
|
+
body: CreateApplicationInput, # 1. Pydantic validates body
|
|
25
|
+
auth: AuthContext = Depends(require_auth), # 2. Auth
|
|
26
|
+
session: AsyncSession = Depends(get_session), # 3. DB session
|
|
27
|
+
_: None = Depends(user_rate_limit), # 4. Rate limit
|
|
28
|
+
) -> ApplicationResponse: # 5. Typed return
|
|
29
|
+
result = await application_service.create(session, auth.user_id, body)
|
|
30
|
+
return result # 6. FastAPI serialises
|
|
31
|
+
# Errors → global exception handlers → no try/except here
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Rate Limit Tiers
|
|
35
|
+
```python
|
|
36
|
+
# In src/providers/rate_limit.py — three tiers:
|
|
37
|
+
auth_rate_limit = Depends(...) # 10/min — login, register, reset
|
|
38
|
+
public_rate_limit = Depends(...) # 60/min — unauthenticated reads
|
|
39
|
+
user_rate_limit = Depends(...) # 300/min — authenticated endpoints
|
|
40
|
+
|
|
41
|
+
# Apply at router level for all routes in a router:
|
|
42
|
+
router = APIRouter(dependencies=[Depends(user_rate_limit)])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Response Envelope — Always
|
|
46
|
+
```python
|
|
47
|
+
# ✅ Wrap all responses in SuccessEnvelope
|
|
48
|
+
from src.runtime.response_util import ok # builders live in runtime (utils may not import types)
|
|
49
|
+
from src.types.common import SuccessEnvelope
|
|
50
|
+
|
|
51
|
+
@router.get("/{app_id}")
|
|
52
|
+
async def get_application(
|
|
53
|
+
app_id: UUID,
|
|
54
|
+
request: Request, # for request_id
|
|
55
|
+
auth: AuthContext = Depends(require_auth),
|
|
56
|
+
session: AsyncSession = Depends(get_session),
|
|
57
|
+
) -> SuccessEnvelope[ApplicationResponse]:
|
|
58
|
+
result = await application_service.get(session, auth.user_id, app_id)
|
|
59
|
+
return ok(result, request.state.request_id)
|
|
60
|
+
# Response: { "ok": true, "data": {...}, "meta": { "request_id": "...", "timestamp": "..." } }
|
|
61
|
+
|
|
62
|
+
# ✅ For lists — use PageResult wrapped in envelope
|
|
63
|
+
@router.get("/")
|
|
64
|
+
async def list_applications(
|
|
65
|
+
request: Request,
|
|
66
|
+
...
|
|
67
|
+
) -> SuccessEnvelope[PageResult[ApplicationResponse]]:
|
|
68
|
+
result = await application_service.list(session, auth.user_id, cursor, limit)
|
|
69
|
+
return ok(result, request.state.request_id)
|
|
70
|
+
# Response: { "ok": true, "data": { "items": [...], "next_cursor": "...", "has_more": true }, "meta": {...} }
|
|
71
|
+
|
|
72
|
+
# ❌ Never return bare Pydantic models
|
|
73
|
+
@router.get("/{app_id}")
|
|
74
|
+
async def get_application(...) -> ApplicationResponse: # FORBIDDEN
|
|
75
|
+
return await application_service.get(...) # missing envelope
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Error envelope** (handled by global exception handler):
|
|
79
|
+
```python
|
|
80
|
+
# Errors automatically wrapped by exception_handlers.py:
|
|
81
|
+
# { "ok": false, "error": { "code": "NOT_FOUND", "message": "..." }, "meta": {...} }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Pagination Query Params — Opaque Cursor
|
|
85
|
+
```python
|
|
86
|
+
from src.utils.cursor_util import decode_cursor, encode_cursor
|
|
87
|
+
|
|
88
|
+
@router.get("/")
|
|
89
|
+
async def list_applications(
|
|
90
|
+
cursor: str | None = Query(None), # opaque base64url-encoded cursor
|
|
91
|
+
limit: int = Query(default=20, ge=1, le=100),
|
|
92
|
+
auth: AuthContext = Depends(require_auth),
|
|
93
|
+
session: AsyncSession = Depends(get_session),
|
|
94
|
+
) -> SuccessEnvelope[PageResult[ApplicationResponse]]:
|
|
95
|
+
decoded_cursor = decode_cursor(cursor) if cursor else None
|
|
96
|
+
# decoded_cursor = {"id": uuid, "created_at": iso_str} or None
|
|
97
|
+
result = await application_service.list(session, auth.user_id, decoded_cursor, limit)
|
|
98
|
+
return ok(result, request.state.request_id)
|
|
99
|
+
|
|
100
|
+
# ❌ Never expose dual cursor params (leaks implementation details)
|
|
101
|
+
@router.get("/")
|
|
102
|
+
async def list_applications(
|
|
103
|
+
cursor_id: UUID | None = Query(None), # FORBIDDEN
|
|
104
|
+
cursor_created_at: datetime | None = Query(None), # FORBIDDEN
|
|
105
|
+
...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Cursor encoding** (in service layer):
|
|
109
|
+
```python
|
|
110
|
+
# Service returns PageResult with next_cursor as opaque string
|
|
111
|
+
from src.utils.cursor_util import encode_cursor
|
|
112
|
+
|
|
113
|
+
next_cursor = encode_cursor({"id": str(last_item.id), "created_at": last_item.created_at.isoformat()})
|
|
114
|
+
return PageResult(items=items, next_cursor=next_cursor, has_more=has_more)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Security Headers
|
|
118
|
+
Applied globally in `main.py` via middleware — never per-route.
|
|
119
|
+
Verify `SecurityHeadersMiddleware` is in the middleware stack.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "src/**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Architecture Rules — Auto-injected on every src/ file edit
|
|
7
|
+
|
|
8
|
+
## Import Order Enforcement
|
|
9
|
+
|
|
10
|
+
Before writing any import, check which layer you are in:
|
|
11
|
+
|
|
12
|
+
| Your file is in | You may import from |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `src/types/` | Nothing (third-party ok: pydantic, enum) |
|
|
15
|
+
| `src/config/` | `src/types/` only |
|
|
16
|
+
| `src/models/` | `src/types/`, `src/config/` |
|
|
17
|
+
| `src/repo/` | `src/types/`, `src/config/`, `src/models/` |
|
|
18
|
+
| `src/services/` | `src/types/`, `src/config/`, `src/repo/` |
|
|
19
|
+
| `src/runtime/` | `src/types/`, `src/config/`, `src/repo/`, `src/services/` |
|
|
20
|
+
| `src/utils/` | Nothing (stdlib + third-party only) |
|
|
21
|
+
| `src/providers/` | `src/types/`, `src/config/` only |
|
|
22
|
+
|
|
23
|
+
## Correct Patterns Per Layer
|
|
24
|
+
|
|
25
|
+
### Types (`src/types/`)
|
|
26
|
+
```python
|
|
27
|
+
# ✅ Pure Pydantic schema — no imports from other layers
|
|
28
|
+
from pydantic import BaseModel
|
|
29
|
+
from enum import Enum
|
|
30
|
+
|
|
31
|
+
class ApplicationStage(str, Enum):
|
|
32
|
+
SAVED = "SAVED"
|
|
33
|
+
APPLIED = "APPLIED"
|
|
34
|
+
|
|
35
|
+
class ApplicationResponse(BaseModel):
|
|
36
|
+
id: str
|
|
37
|
+
stage: ApplicationStage
|
|
38
|
+
|
|
39
|
+
# ❌ Wrong
|
|
40
|
+
from src.config.settings import settings # FORBIDDEN in types
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Service (`src/services/`)
|
|
44
|
+
```python
|
|
45
|
+
# ✅ Takes typed inputs, returns typed outputs, raises DomainError
|
|
46
|
+
async def create_application(
|
|
47
|
+
session: AsyncSession,
|
|
48
|
+
user_id: str,
|
|
49
|
+
data: CreateApplicationInput,
|
|
50
|
+
) -> ApplicationResponse:
|
|
51
|
+
...
|
|
52
|
+
raise ApplicationNotFoundError("...") # Domain error — ok
|
|
53
|
+
|
|
54
|
+
# ❌ Wrong
|
|
55
|
+
from fastapi import HTTPException # FORBIDDEN in service
|
|
56
|
+
raise HTTPException(status_code=404) # FORBIDDEN — only in runtime
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Runtime (`src/runtime/`)
|
|
60
|
+
```python
|
|
61
|
+
# ✅ Correct handler structure — THIS ORDER always
|
|
62
|
+
@router.post("/applications", status_code=201)
|
|
63
|
+
async def create_application(
|
|
64
|
+
body: CreateApplicationInput, # 1. Pydantic validates
|
|
65
|
+
auth: AuthContext = Depends(require_auth), # 2. Auth
|
|
66
|
+
session: AsyncSession = Depends(get_session), # 3. DB session
|
|
67
|
+
) -> ApplicationResponse:
|
|
68
|
+
return await application_service.create(session, auth.user_id, body)
|
|
69
|
+
# error mapping handled by global exception handlers — not here
|
|
70
|
+
|
|
71
|
+
# ❌ Wrong — business logic in handler
|
|
72
|
+
@router.post("/applications")
|
|
73
|
+
async def create(body: dict, ...): # untyped body
|
|
74
|
+
if body["stage"] == "APPLIED": # logic in handler
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## ruff Configuration
|
|
78
|
+
`ruff.toml` enforces:
|
|
79
|
+
- `no-print` (T20) — use structlog
|
|
80
|
+
- Import order (I) — enforced automatically
|
|
81
|
+
- No unused imports (F401)
|
|
82
|
+
- Type annotations required (ANN)
|
|
83
|
+
|
|
84
|
+
Run anytime: `uv run ruff check src/`
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "src/repo/**/*.py"
|
|
4
|
+
- "src/models/**/*.py"
|
|
5
|
+
- "alembic/versions/**/*.py"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Database Rules — Auto-injected on repo/model/migration edits
|
|
9
|
+
|
|
10
|
+
## The 5 Checks Before Every Repo Method Ships
|
|
11
|
+
|
|
12
|
+
### 1. Pydantic Validation on Every Result
|
|
13
|
+
```python
|
|
14
|
+
# ✅ REQUIRED
|
|
15
|
+
row = result.scalar_one_or_none()
|
|
16
|
+
if not row:
|
|
17
|
+
raise ApplicationNotFoundError(...)
|
|
18
|
+
return ApplicationResponse.model_validate(row.__dict__)
|
|
19
|
+
|
|
20
|
+
# ❌ FORBIDDEN
|
|
21
|
+
return row.__dict__ # raw, unvalidated
|
|
22
|
+
return dict(row.__dict__) # cast, not validated
|
|
23
|
+
return row # ORM object leaking out of repo
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 2. No N+1 — Eager Load Always
|
|
27
|
+
```python
|
|
28
|
+
# ✅ selectinload for related collections
|
|
29
|
+
stmt = (
|
|
30
|
+
select(Application)
|
|
31
|
+
.where(Application.user_id == user_id)
|
|
32
|
+
.options(
|
|
33
|
+
selectinload(Application.notes),
|
|
34
|
+
selectinload(Application.contacts),
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# ❌ N+1 — async loop over lazy loads
|
|
39
|
+
apps = (await session.execute(select(Application))).scalars().all()
|
|
40
|
+
for app in apps:
|
|
41
|
+
notes = await session.execute(select(Note).where(Note.application_id == app.id))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 3. Cursor Pagination on All List Methods
|
|
45
|
+
|
|
46
|
+
`PageResult` has ONE shape across the whole stack — `{ items, next_cursor, has_more }` where
|
|
47
|
+
`next_cursor` is the OPAQUE base64url token produced by `encode_cursor`. The repo decodes the
|
|
48
|
+
incoming token, keyset-paginates, and re-encodes the last row. Never expose `created_at`/`id`
|
|
49
|
+
cursor fields on the API or in `PageResult` (see `.claude/rules/api.md`).
|
|
50
|
+
|
|
51
|
+
**The cursor is adversarial input.** `decode_cursor` MUST catch malformed values
|
|
52
|
+
(`binascii.Error`, `json.JSONDecodeError`, `ValueError`, `UnicodeDecodeError`) and raise
|
|
53
|
+
`InvalidCursorError` (mapped to **400**). A raw `decode_cursor("null")` otherwise throws an
|
|
54
|
+
unhandled exception → **500**. (Found by schemathesis fuzzing `?cursor=null`.)
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
# ✅ Cursor-based — opaque token in, opaque token out
|
|
58
|
+
from src.utils.cursor_util import decode_cursor, encode_cursor
|
|
59
|
+
|
|
60
|
+
async def list_applications(
|
|
61
|
+
session: AsyncSession,
|
|
62
|
+
user_id: UUID,
|
|
63
|
+
cursor: str | None = None, # opaque base64url token (or None for first page)
|
|
64
|
+
limit: int = 20,
|
|
65
|
+
) -> PageResult[ApplicationResponse]:
|
|
66
|
+
stmt = select(Application).where(Application.user_id == user_id, Application.deleted_at.is_(None))
|
|
67
|
+
|
|
68
|
+
decoded = decode_cursor(cursor) if cursor else None # {"created_at": iso_str, "id": uuid} | None
|
|
69
|
+
if decoded:
|
|
70
|
+
cursor_created_at = datetime.fromisoformat(decoded["created_at"])
|
|
71
|
+
cursor_id = UUID(decoded["id"])
|
|
72
|
+
stmt = stmt.where(
|
|
73
|
+
or_(
|
|
74
|
+
Application.created_at < cursor_created_at,
|
|
75
|
+
and_(Application.created_at == cursor_created_at, Application.id < cursor_id),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
stmt = stmt.order_by(Application.created_at.desc(), Application.id.desc()).limit(limit + 1)
|
|
80
|
+
rows = (await session.execute(stmt)).scalars().all()
|
|
81
|
+
|
|
82
|
+
has_more = len(rows) > limit
|
|
83
|
+
items = rows[:limit]
|
|
84
|
+
next_cursor = (
|
|
85
|
+
encode_cursor({"created_at": items[-1].created_at.isoformat(), "id": str(items[-1].id)})
|
|
86
|
+
if has_more and items
|
|
87
|
+
else None
|
|
88
|
+
)
|
|
89
|
+
return PageResult(
|
|
90
|
+
items=[ApplicationResponse.model_validate(r.__dict__) for r in items],
|
|
91
|
+
next_cursor=next_cursor, # single opaque token — matches api.md + frontend contract
|
|
92
|
+
has_more=has_more,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# ❌ FORBIDDEN — offset on large tables
|
|
96
|
+
stmt = select(Application).offset(page * limit).limit(limit)
|
|
97
|
+
|
|
98
|
+
# ❌ FORBIDDEN — leaking dual cursor fields (next_cursor_created_at / next_cursor_id)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 4. Transactions for Multi-Table Writes
|
|
102
|
+
|
|
103
|
+
Atomicity is orchestrated by the SERVICE, but every `session.add` / ORM construction stays
|
|
104
|
+
inside a repo method. The repo is the ONLY layer that touches ORM models — services never
|
|
105
|
+
import `src/models` (see `ARCHITECTURE.md` import rules).
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# ✅ Service opens ONE transaction; repos do the ORM writes
|
|
109
|
+
# in application_service.create():
|
|
110
|
+
async with session.begin():
|
|
111
|
+
app = await application_repo.create(session, user_id, data)
|
|
112
|
+
await note_repo.create(session, app.id, content="Created", type="SYSTEM")
|
|
113
|
+
# commits on context exit, rolls back on exception
|
|
114
|
+
|
|
115
|
+
# ✅ Inside application_repo.create() — the repo owns ORM construction + validation:
|
|
116
|
+
async def create(session, user_id, data) -> ApplicationResponse:
|
|
117
|
+
row = Application(user_id=user_id, **data.model_dump())
|
|
118
|
+
session.add(row)
|
|
119
|
+
await session.flush()
|
|
120
|
+
return ApplicationResponse.model_validate(row.__dict__)
|
|
121
|
+
|
|
122
|
+
# ❌ Never construct ORM models or call session.add() in a service — that is the repo's job
|
|
123
|
+
# ❌ Never call external APIs inside a transaction block
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 5. Soft Delete Pattern
|
|
127
|
+
```python
|
|
128
|
+
# ✅ Model has deleted_at — filter in every query
|
|
129
|
+
stmt = select(Application).where(
|
|
130
|
+
Application.user_id == user_id,
|
|
131
|
+
Application.deleted_at.is_(None) # always include this
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# ✅ Soft delete (timezone-aware — datetime.utcnow() is deprecated in 3.12+)
|
|
135
|
+
row.deleted_at = datetime.now(UTC) # from datetime import UTC — ruff UP017 prefers UTC over timezone.utc
|
|
136
|
+
await session.flush()
|
|
137
|
+
|
|
138
|
+
# ❌ Hard delete — only for GDPR with explicit comment
|
|
139
|
+
await session.delete(row) # only if legally required
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Model Checklist (every new model)
|
|
143
|
+
- [ ] `deleted_at: Mapped[datetime | None]` — soft delete
|
|
144
|
+
- [ ] `id: Mapped[UUID]` with `default=uuid7` — time-ordered UUID
|
|
145
|
+
- [ ] `__table_args__` with indexes on FK + ORDER BY columns
|
|
146
|
+
- [ ] Partial index for `WHERE deleted_at IS NULL` queries
|
|
147
|
+
- [ ] `created_at`, `updated_at` with server defaults
|
|
148
|
+
|
|
149
|
+
## Migration Checklist (every new migration)
|
|
150
|
+
- [ ] Run `alembic revision --autogenerate -m "description"` then review output
|
|
151
|
+
- [ ] New indexes use `postgresql_concurrently=True`
|
|
152
|
+
- [ ] Both `upgrade()` and `downgrade()` implemented
|
|
153
|
+
- [ ] Run `alembic upgrade head` to verify
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "src/runtime/workers/**/*.py"
|
|
4
|
+
- "src/workers/**/*.py"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Job Rules — Auto-injected on worker file edits
|
|
8
|
+
|
|
9
|
+
## Every Celery Task Checklist
|
|
10
|
+
|
|
11
|
+
### 1. Validate payload with Pydantic before touching it
|
|
12
|
+
```python
|
|
13
|
+
@celery_app.task(
|
|
14
|
+
name="reminder.send_email",
|
|
15
|
+
bind=True,
|
|
16
|
+
max_retries=3,
|
|
17
|
+
default_retry_delay=60,
|
|
18
|
+
queue="reminder-email",
|
|
19
|
+
)
|
|
20
|
+
def send_reminder_email_task(self: Task, payload: dict) -> None:
|
|
21
|
+
# ✅ REQUIRED — validate before processing
|
|
22
|
+
data = ReminderEmailPayload.model_validate(payload)
|
|
23
|
+
|
|
24
|
+
# ❌ FORBIDDEN — trusting raw task payload
|
|
25
|
+
reminder_id = payload["reminder_id"] # no validation
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Structured logging — start, complete, failed
|
|
29
|
+
```python
|
|
30
|
+
def send_reminder_email_task(self: Task, payload: dict) -> None:
|
|
31
|
+
data = ReminderEmailPayload.model_validate(payload)
|
|
32
|
+
start = time.time()
|
|
33
|
+
|
|
34
|
+
logger.info("job.start", task=self.name, task_id=self.request.id)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
reminder_service.send_email(data)
|
|
38
|
+
logger.info(
|
|
39
|
+
"job.complete",
|
|
40
|
+
task=self.name,
|
|
41
|
+
task_id=self.request.id,
|
|
42
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
43
|
+
)
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
logger.error("job.failed", task=self.name, err=str(exc))
|
|
46
|
+
raise self.retry(exc=exc, countdown=2 ** self.request.retries * 30)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 3. Retry with exponential backoff
|
|
50
|
+
```python
|
|
51
|
+
@celery_app.task(
|
|
52
|
+
bind=True,
|
|
53
|
+
max_retries=3,
|
|
54
|
+
acks_late=True, # ack after success, not on receipt
|
|
55
|
+
reject_on_worker_lost=True, # re-queue if worker dies mid-task
|
|
56
|
+
)
|
|
57
|
+
def my_task(self, payload: dict) -> None:
|
|
58
|
+
...
|
|
59
|
+
except SomeTransientError as exc:
|
|
60
|
+
raise self.retry(exc=exc, countdown=2 ** self.request.retries * 30)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 4. External calls use tenacity retry + timeout
|
|
64
|
+
```python
|
|
65
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
66
|
+
import httpx
|
|
67
|
+
|
|
68
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
|
|
69
|
+
async def call_email_provider(data: EmailPayload) -> None:
|
|
70
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
71
|
+
response = await client.post(EMAIL_API_URL, json=data.model_dump())
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 5. Never create DB sessions inside a task directly
|
|
76
|
+
```python
|
|
77
|
+
# ✅ Use the service layer which manages its own session
|
|
78
|
+
def send_reminder_email_task(self, payload: dict) -> None:
|
|
79
|
+
data = ReminderEmailPayload.model_validate(payload)
|
|
80
|
+
# Service opens its own session internally
|
|
81
|
+
asyncio.run(reminder_service.process(data))
|
|
82
|
+
|
|
83
|
+
# ❌ Never
|
|
84
|
+
async with AsyncSessionLocal() as session: # not thread-safe in Celery workers
|
|
85
|
+
```
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "src/runtime/**/*.py"
|
|
4
|
+
- "src/repo/**/*.py"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Security Rules — Auto-injected on runtime and repo file edits
|
|
8
|
+
|
|
9
|
+
## Runtime: Every Protected Route Requires This Structure
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
@router.get("/{app_id}")
|
|
13
|
+
async def get_application(
|
|
14
|
+
app_id: UUID,
|
|
15
|
+
auth: AuthContext = Depends(require_auth), # 1. Auth FIRST
|
|
16
|
+
session: AsyncSession = Depends(get_session), # 2. DB session
|
|
17
|
+
_: None = Depends(user_rate_limit), # 3. Rate limit
|
|
18
|
+
) -> ApplicationResponse:
|
|
19
|
+
# 4. Service call with domain types only
|
|
20
|
+
return await application_service.get(session, auth.user_id, app_id)
|
|
21
|
+
# 5. Errors handled by global exception handlers — not here
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Repo: Validation at Every Boundary
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
# ✅ REQUIRED — validate every DB result
|
|
28
|
+
async def find_by_id(session: AsyncSession, app_id: UUID, user_id: UUID) -> ApplicationResponse:
|
|
29
|
+
result = await session.execute(
|
|
30
|
+
select(Application).where(
|
|
31
|
+
Application.id == app_id,
|
|
32
|
+
Application.user_id == user_id, # ownership enforced HERE
|
|
33
|
+
Application.deleted_at.is_(None)
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
row = result.scalar_one_or_none()
|
|
37
|
+
if not row:
|
|
38
|
+
raise ApplicationNotFoundError(f"Application {app_id} not found")
|
|
39
|
+
return ApplicationResponse.model_validate(row.__dict__)
|
|
40
|
+
|
|
41
|
+
# ❌ FORBIDDEN — no ownership check
|
|
42
|
+
row = await session.get(Application, app_id) # anyone can access
|
|
43
|
+
|
|
44
|
+
# ❌ FORBIDDEN — raw dict returned
|
|
45
|
+
return row.__dict__
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## SQL Injection Prevention
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
# ✅ SQLAlchemy ORM — always parameterised
|
|
52
|
+
await session.execute(select(Application).where(Application.user_id == user_id))
|
|
53
|
+
|
|
54
|
+
# ✅ Raw SQL with bound parameters
|
|
55
|
+
await session.execute(text("SELECT * FROM applications WHERE user_id = :uid"), {"uid": user_id})
|
|
56
|
+
|
|
57
|
+
# ❌ BLOCKED — string interpolation
|
|
58
|
+
await session.execute(text(f"SELECT * FROM applications WHERE user_id = '{user_id}'"))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Auth — Always via Depends(), Never Inline
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# ✅ Provider injected
|
|
65
|
+
from src.providers.auth.middleware import require_auth
|
|
66
|
+
|
|
67
|
+
@router.post("/")
|
|
68
|
+
async def create(auth: AuthContext = Depends(require_auth), ...):
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
# ❌ Never roll your own inline
|
|
72
|
+
token = request.headers.get("Authorization")
|
|
73
|
+
payload = jwt.decode(token, os.environ["JWT_SECRET"]) # multiple violations (PyJWT used inline + os.environ)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Error Responses — Never Expose Internals
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# ✅ Global exception handler maps DomainError → HTTPException
|
|
80
|
+
# In src/runtime/exception_handlers.py:
|
|
81
|
+
@app.exception_handler(ApplicationNotFoundError)
|
|
82
|
+
async def not_found_handler(request, exc):
|
|
83
|
+
return JSONResponse(status_code=404, content={"error": {"code": "NOT_FOUND", "message": str(exc)}})
|
|
84
|
+
|
|
85
|
+
# ❌ Never in handlers
|
|
86
|
+
raise HTTPException(status_code=500, detail=str(exc)) # exposes internals
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Password Hashing — argon2id via pwdlib Only
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# ✅ pwdlib with argon2id (passlib is BROKEN on Python 3.13+ — never use it)
|
|
93
|
+
from pwdlib import PasswordHash
|
|
94
|
+
password_hash = PasswordHash.recommended() # argon2id
|
|
95
|
+
hashed = password_hash.hash(plain_password)
|
|
96
|
+
verified = password_hash.verify(plain_password, hashed)
|
|
97
|
+
|
|
98
|
+
# ❌ Never — passlib (broken on 3.13+), bcrypt (GPU-vulnerable), or hashlib
|
|
99
|
+
from passlib.context import CryptContext # FORBIDDEN — broken on Python 3.13+
|
|
100
|
+
import hashlib
|
|
101
|
+
hashlib.md5(password.encode()).hexdigest() # FORBIDDEN
|
|
102
|
+
```
|