AuthorBot commited on
Commit
131d826
·
1 Parent(s): 6e276e0

Production hardening: JWT blacklist, TOTP, Pydantic schemas, Prometheus, SSRF fix, CSP, Redis auth, Celery backup — 35 items across P0-P5

Browse files
.cursorrules ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ═══════════════════════════════════════════════════════════════════════════════
2
+ # AUTHORBOT RAG — PRODUCTION RULES ($500K BUDGET, ENTERPRISE SaaS)
3
+ # ═══════════════════════════════════════════════════════════════════════════════
4
+ #
5
+ # READ THIS FILE BEFORE EVERY SESSION. NO EXCEPTIONS.
6
+ #
7
+ # These rules are derived from a line-by-line audit of the entire codebase.
8
+ # Every rule exists because we found a gap, a vulnerability, or an inconsistency
9
+ # that WILL cause problems at production scale.
10
+ #
11
+ # Format: Each rule has a WHY, WHERE, and ENFORCEMENT section.
12
+ # Status: ✅ = Implemented, ❌ = NOT YET (must fix), ⚠️ = Partial
13
+ # ═══════════════════════════════════════════════════════════════════════════════
14
+
15
+
16
+ # ═══════════════════════════════════════════════════════════════════════════════
17
+ # §1. AUTHENTICATION & AUTHORIZATION
18
+ # ═══════════════════════════════════════════════════════════════════════════════
19
+
20
+ # R-001: Passwords MUST be hashed with bcrypt ≥12 rounds.
21
+ # WHY: Lower rounds are brute-forceable with modern GPUs.
22
+ # WHERE: app/services/auth_service.py → _hash_password()
23
+ # STATUS: ✅ Implemented (rounds=12)
24
+
25
+ # R-002: Failed login MUST trigger account lockout (5 attempts → 15-min lock).
26
+ # WHY: Prevents credential stuffing attacks.
27
+ # WHERE: app/services/auth_service.py → login()
28
+ # STATUS: ✅ Implemented
29
+
30
+ # R-003: Non-existent email MUST take constant time to respond.
31
+ # WHY: Timing attacks can enumerate valid emails.
32
+ # WHERE: app/services/auth_service.py:98-99 (dummy bcrypt comparison)
33
+ # STATUS: ✅ Implemented
34
+
35
+ # R-004: JWT refresh MUST invalidate the old token (Redis blacklist by jti).
36
+ # WHY: Stolen tokens remain valid until natural expiry without blacklisting.
37
+ # WHERE: app/services/auth_service.py → refresh_tokens()
38
+ # STATUS: ❌ NOT IMPLEMENTED — old token still valid after refresh
39
+
40
+ # R-005: Logout MUST add current JWT to Redis blacklist (TTL = remaining lifetime).
41
+ # WHY: Cookie deletion is client-side only; server still accepts the token.
42
+ # WHERE: app/api/schemas_router.py:78-81
43
+ # STATUS: ❌ NOT IMPLEMENTED — only deletes cookie
44
+
45
+ # R-006: Refresh token MUST use HttpOnly + Secure + SameSite=Lax.
46
+ # WHY: Prevents XSS from stealing refresh tokens.
47
+ # WHERE: app/api/schemas_router.py:26-35
48
+ # STATUS: ✅ Implemented
49
+
50
+ # R-007: SuperAdmin routes MUST require JWT + TOTP on EVERY request.
51
+ # WHY: Single-factor auth on God-mode access = catastrophic if JWT stolen.
52
+ # WHERE: app/dependencies.py → get_current_superadmin()
53
+ # STATUS: ❌ NOT IMPLEMENTED — only checks role, TOTP module exists but unused
54
+
55
+ # R-008: Registration MUST validate email format (RFC 5322 regex or Pydantic EmailStr).
56
+ # WHY: Invalid emails break notification system and create ghost accounts.
57
+ # WHERE: app/services/auth_service.py → register()
58
+ # STATUS: ❌ NOT IMPLEMENTED — only lower().strip()
59
+
60
+ # R-009: Registration MUST enforce password complexity (≥8 chars, mixed case + digit).
61
+ # WHY: Weak passwords on admin accounts = easy takeover.
62
+ # WHERE: app/services/auth_service.py → register()
63
+ # STATUS: ❌ NOT IMPLEMENTED — no minimum length or complexity check
64
+
65
+ # R-010: Password change MUST validate via Pydantic schema (not raw dict).
66
+ # WHY: Raw dicts skip type coercion, field validation, and OpenAPI docs.
67
+ # WHERE: app/admin/router.py:461-487
68
+ # STATUS: ❌ NOT IMPLEMENTED — uses body: dict
69
+
70
+
71
+ # ═══════════════════════════════════════════════════════════════════════════════
72
+ # §2. SUBSCRIPTION & TOKEN SECURITY
73
+ # ═══════════════════════════════════════════════════════════════════════════════
74
+
75
+ # R-011: Subscription token HMAC MUST use constant-time comparison.
76
+ # WHY: Non-constant comparison leaks signature bytes via timing side-channel.
77
+ # WHERE: app/core/access/token_crypto.py:179
78
+ # STATUS: ✅ hmac.compare_digest()
79
+
80
+ # R-012: Raw subscription tokens MUST never be stored — only SHA-256 hash.
81
+ # WHY: DB breach exposes all tokens if stored in plaintext.
82
+ # WHERE: app/core/access/token_crypto.py:199-210
83
+ # STATUS: ✅ Implemented
84
+
85
+ # R-013: Token validation MUST check all 5 layers (HMAC → Expiry → Redis → DB → Budget).
86
+ # WHY: Skipping any layer creates a bypass.
87
+ # WHERE: app/core/access/subscription.py:34-111
88
+ # STATUS: ✅ Implemented
89
+
90
+ # R-014: tokens_used MUST be incremented atomically on every chat turn.
91
+ # WHY: Read-modify-write on ORM object loses increments under concurrency.
92
+ # WHERE: app/services/token_budget.py:89
93
+ # STATUS: ❌ NOT ATOMIC — uses Python addition instead of SQL atomic update
94
+ # FIX: Use `update(ClientAccess).values(tokens_used=ClientAccess.tokens_used + N)`
95
+
96
+ # R-015: budget_exhausted flag MUST be set at 100% usage.
97
+ # WHY: Without it, exhausted authors continue using API at our cost.
98
+ # WHERE: app/services/token_budget.py:92-93
99
+ # STATUS: ✅ Redis flag set
100
+
101
+ # R-016: BudgetExhaustedError MUST return HTTP 200 (not 403) with friendly message.
102
+ # WHY: Visitors should see "Taking a short break" not "Budget exhausted" error.
103
+ # WHERE: app/dependencies.py:210-211 overrides the global handler in main.py
104
+ # STATUS: ❌ BROKEN — dependency catches and re-raises as HTTPException(403)
105
+ # FIX: Let BudgetExhaustedError propagate uncaught to the global handler.
106
+
107
+ # R-017: Token budget formula MUST always be: total = token_budget + bonus_tokens.
108
+ # WHY: Inconsistent formula = different numbers on admin vs superadmin vs embed.
109
+ # WHERE: app/services/token_budget.py → total_budget()
110
+ # STATUS: ✅ Centralized in token_budget.py
111
+
112
+ # R-018: Revocation MUST propagate to Redis blacklist immediately (O(1) check).
113
+ # WHY: DB-only revocation has latency; widget keeps working for cached sessions.
114
+ # WHERE: app/services/superadmin_service.py:308-312
115
+ # STATUS: ✅ Implemented
116
+
117
+ # R-019: chatbot_is_active=false MUST block all chat requests.
118
+ # WHY: Suspended authors must not serve visitors.
119
+ # WHERE: app/core/access/subscription.py:108-109
120
+ # STATUS: ✅ Checked in validation chain
121
+
122
+
123
+ # ═══════════════════════════════════════════════════════════════════════════════
124
+ # §3. RATE LIMITING & DDoS PROTECTION
125
+ # ═══════════════════════════════════════════════════════════════════════════════
126
+
127
+ # R-020: Chat endpoint rate limit: 60 req/min per IP.
128
+ # WHERE: app/middleware/rate_limit_middleware.py:21
129
+ # STATUS: ✅ Implemented
130
+
131
+ # R-021: Auth endpoint rate limit: 10 req/min per IP.
132
+ # WHERE: app/middleware/rate_limit_middleware.py:22
133
+ # STATUS: ✅ Implemented
134
+
135
+ # R-022: Rate limiter MUST fail-open (no Redis = allow traffic, not crash).
136
+ # WHERE: app/middleware/rate_limit_middleware.py:61-62
137
+ # STATUS: ✅ Implemented
138
+
139
+ # R-023: SSE endpoints MUST have a max connection timeout (5 minutes).
140
+ # WHY: Unbounded SSE = connection exhaustion attack vector.
141
+ # WHERE: app/api/chat.py:252-274, app/api/ingest.py:90-116
142
+ # STATUS: ❌ NOT IMPLEMENTED — runs forever until client disconnects
143
+ # FIX: Wrap SSE loop in asyncio.timeout(300). Send heartbeat pings every 30s.
144
+
145
+ # R-024: File upload MUST reject oversized files BEFORE reading into memory.
146
+ # WHY: A 500MB upload = 500MB RAM consumption before any validation.
147
+ # WHERE: app/api/ingest.py:43
148
+ # STATUS: ❌ NOT IMPLEMENTED — file.read() loads everything first
149
+ # FIX: Use chunked reads or set max_upload_size on Starlette.
150
+
151
+ # R-025: X-Forwarded-For MUST NOT be blindly trusted (use rightmost trusted proxy).
152
+ # WHY: Attackers spoof X-Forwarded-For to bypass IP rate limits.
153
+ # WHERE: app/middleware/rate_limit_middleware.py:76-82
154
+ # STATUS: ⚠️ PARTIAL — takes first value, which is user-controllable
155
+
156
+
157
+ # ═══════════════════════════════════════════════════════════════════════════════
158
+ # §4. INPUT VALIDATION & SANITIZATION
159
+ # ═══════════════════════════════════════════════════════════════════════════════
160
+
161
+ # R-026: All chat input MUST be sanitized (control chars, zero-width, delimiters).
162
+ # WHERE: app/services/guardrails.py → sanitize_input()
163
+ # STATUS: ✅ Implemented
164
+
165
+ # R-027: Chat input hard cap: 2000 characters. No exceptions.
166
+ # WHERE: app/services/guardrails.py
167
+ # STATUS: ✅ Implemented
168
+
169
+ # R-028: Jailbreak detection: 30+ regex patterns, ≥2 signals = auto-block.
170
+ # WHERE: app/services/guardrails.py → check_boundary()
171
+ # STATUS: ✅ Implemented
172
+
173
+ # R-029: ALL admin API endpoints MUST use Pydantic BaseModel schemas (NOT raw dict).
174
+ # WHY: Raw dicts skip validation, type coercion, and OpenAPI documentation.
175
+ # WHERE: 16+ endpoints across admin/router.py, chat.py, superadmin/router.py
176
+ # STATUS: ❌ NOT IMPLEMENTED — most admin endpoints use body: dict
177
+ # AFFECTED: password change, widget config, profile, personality, notifications,
178
+ # smart links, Q&A, annotate, flag, track-click, feedback, rate, announce, grant
179
+
180
+ # R-030: All path parameter IDs MUST be validated as UUID format.
181
+ # WHY: Garbage IDs cause unhandled DB errors instead of clean 400s.
182
+ # WHERE: All routers — book_id, session_id, message_id, qa_id, grant_id, author_id
183
+ # STATUS: ❌ NOT IMPLEMENTED — all IDs are plain str
184
+
185
+ # R-031: URL inputs (smart links) MUST validate scheme (https preferred) and format.
186
+ # WHY: Invalid URLs break widget buttons; internal URLs enable SSRF.
187
+ # WHERE: app/admin/router.py:730-733
188
+ # STATUS: ❌ NOT IMPLEMENTED — only length check, no format/scheme validation
189
+
190
+ # R-032: CSV import MUST sanitize HTML/script content in Q&A answers.
191
+ # WHY: Q&A answers are rendered in the widget — XSS vector.
192
+ # WHERE: app/admin/router.py:1022-1082
193
+ # STATUS: ⚠️ PARTIAL — length-limited but no HTML stripping
194
+
195
+
196
+ # ═══════════════════════════════════════════════════════════════════════════════
197
+ # §5. FILE UPLOAD SECURITY
198
+ # ═══════════════════════════════════════════════════════════════════════════════
199
+
200
+ # R-033: MIME type MUST be verified by magic bytes, NEVER by file extension alone.
201
+ # WHERE: app/services/file_validator.py via file_utils.validate_upload
202
+ # STATUS: ✅ Implemented
203
+
204
+ # R-034: Cover images MUST strip EXIF metadata (privacy: GPS coords, camera serial).
205
+ # WHERE: app/admin/router.py:302 — img.convert('RGB') strips EXIF
206
+ # STATUS: ✅ Implemented
207
+
208
+ # R-035: Upload paths MUST use server-generated names (UUID), NEVER user filenames.
209
+ # WHY: User filenames enable directory traversal (../../etc/passwd).
210
+ # WHERE: app/api/ingest.py:60 uses book.id (UUID)
211
+ # STATUS: ✅ Implemented
212
+
213
+ # R-036: Temp files MUST be cleaned up on both success AND failure paths.
214
+ # WHERE: app/api/ingest.py:175-181 (finally block)
215
+ # STATUS: ✅ Implemented
216
+
217
+
218
+ # ═══════════════════════════════════════════════════════════════════════════════
219
+ # §6. DATA ISOLATION & TENANT SECURITY
220
+ # ═══════════════════════════════════════════════════════════════════════════════
221
+
222
+ # R-037: Every DB query MUST be scoped by author_id. No cross-tenant data access.
223
+ # WHERE: All repositories
224
+ # STATUS: ✅ Implemented
225
+
226
+ # R-038: Admin slug in URL MUST match authenticated user's ID.
227
+ # WHERE: app/dependencies.py:168-175 → get_current_author_scoped()
228
+ # STATUS: ✅ Implemented
229
+
230
+ # R-039: ChromaDB collections MUST be namespaced by author_id prefix.
231
+ # WHERE: app/services/vector_store.py:133-148
232
+ # STATUS: ✅ Implemented
233
+
234
+ # R-040: Message annotation/flag endpoints MUST verify session ownership chain.
235
+ # WHY: Without it, any author can annotate any other author's messages (IDOR).
236
+ # WHERE: app/admin/router.py:858-928
237
+ # STATUS: ❌ NOT IMPLEMENTED — queries by message_id+session_id but NEVER checks
238
+ # that ChatSession.author_id == current_user.id
239
+ # FIX: Add JOIN to ChatSession and filter by author_id.
240
+
241
+ # R-041: Visitor fingerprints MUST be one-way hashed (SHA-256), never raw PII.
242
+ # WHERE: app/api/chat.py:34-41
243
+ # STATUS: ✅ Implemented
244
+
245
+
246
+ # ═══════════════════════════════════════════════════════════════════════════════
247
+ # §7. API SECURITY & ERROR HANDLING
248
+ # ═══════════════════════════════════════════════════════════════════════════════
249
+
250
+ # R-042: Security headers MUST be on every HTTP response.
251
+ # Required: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection,
252
+ # Referrer-Policy, Permissions-Policy, HSTS (production only).
253
+ # WHERE: app/middleware/security_headers.py
254
+ # STATUS: ✅ Implemented
255
+
256
+ # R-043: Content-Security-Policy header MUST be set.
257
+ # WHY: CSP is the primary defense against XSS in modern browsers.
258
+ # WHERE: app/middleware/security_headers.py
259
+ # STATUS: ❌ NOT IMPLEMENTED
260
+
261
+ # R-044: Error responses MUST NOT contain internal exception messages, SQL, or paths.
262
+ # WHY: Information disclosure helps attackers map the system.
263
+ # WHERE: app/superadmin/router.py:44-51 — _err() includes str(e)[:200]
264
+ # STATUS: ❌ LEAKS DETAILS — returns exception text to client
265
+
266
+ # R-045: No public/unauthenticated diagnostic endpoints on admin routers.
267
+ # WHERE: app/superadmin/router.py:76-79 — GET /api/super/diag has NO auth
268
+ # STATUS: ❌ PUBLIC ENDPOINT on SuperAdmin router
269
+
270
+ # R-046: No duplicate route definitions (causes unpredictable routing).
271
+ # WHERE: app/api/chat.py — rate_session defined TWICE (L219 and L346)
272
+ # STATUS: ❌ DUPLICATE — first definition silently ignored
273
+
274
+ # R-047: CORS MUST NOT use wildcard (*) origins in production.
275
+ # WHERE: app/main.py → CORSMiddleware config
276
+ # STATUS: ⚠️ NEEDS VERIFICATION
277
+
278
+
279
+ # ═══════════════════════════════════════════════════════════════════════════════
280
+ # §8. DATABASE RULES
281
+ # ═══════════════════════════════════════════════════════════════════════════════
282
+
283
+ # R-048: All schema changes MUST use Alembic migrations. No manual DDL.
284
+ # WHY: Manual schema changes risk data loss and cannot be rolled back.
285
+ # WHERE: Project root — NO alembic.ini or alembic/ directory exists
286
+ # STATUS: ❌ NOT CONFIGURED
287
+
288
+ # R-049: All queries MUST use parameterized statements (ORM or bound params).
289
+ # WHERE: All repositories use SQLAlchemy ORM
290
+ # STATUS: ✅ Implemented
291
+
292
+ # R-050: DB transactions MUST auto-rollback on exception.
293
+ # WHERE: app/dependencies.py:57-65
294
+ # STATUS: ✅ Implemented
295
+
296
+ # R-051: All datetimes MUST use timezone-aware UTC (datetime.now(timezone.utc)).
297
+ # WHY: datetime.utcnow() is deprecated and returns naive datetimes.
298
+ # WHERE: Mixed — some files use now(timezone.utc), others use utcnow()
299
+ # STATUS: ❌ INCONSISTENT — 3+ files still use deprecated utcnow()
300
+
301
+ # R-052: Concurrent writes MUST be atomic (SQL UPDATE, not Python read-modify-write).
302
+ # WHY: ORM-level addition loses increments under concurrent requests.
303
+ # WHERE: app/services/token_budget.py:89
304
+ # STATUS: ❌ NOT ATOMIC
305
+
306
+ # R-053: High-traffic columns MUST have database indexes.
307
+ # Required indexes: author_id, session_id, created_at, timestamp
308
+ # WHERE: app/models/ — no explicit index definitions visible
309
+ # STATUS: ❌ NOT IMPLEMENTED
310
+
311
+ # R-054: Soft delete SHOULD be preferred for auditable records.
312
+ # WHERE: Books and authors use hard delete; only ClientAccess uses soft delete
313
+ # STATUS: ⚠️ PARTIAL
314
+
315
+
316
+ # ═══════════════════════════════════════════════════════════════════════════════
317
+ # §9. REDIS INFRASTRUCTURE
318
+ # ═══════════════════════════════════════════════════════════════════════════════
319
+
320
+ # R-055: Redis MUST have authentication (--requirepass) in production.
321
+ # WHERE: Dockerfile — embedded Redis starts without password
322
+ # STATUS: ❌ NOT CONFIGURED
323
+
324
+ # R-056: All Redis keys MUST have TTL — no orphan keys.
325
+ # WHERE: app/services/token_budget.py:82, rate_limit_middleware.py:54
326
+ # STATUS: ✅ Implemented
327
+
328
+ # R-057: Redis connection MUST be pooled.
329
+ # WHERE: app/dependencies.py:94-104
330
+ # STATUS: ✅ Implemented
331
+
332
+ # R-058: Redis failure MUST NOT crash the application (graceful degradation).
333
+ # WHERE: All Redis operations wrapped in try/except
334
+ # STATUS: ✅ Implemented
335
+
336
+ # R-059: Redis persistence MUST be explicitly configured (AOF preferred for durability).
337
+ # WHERE: Dockerfile
338
+ # STATUS: ⚠️ Uses default RDB — should configure AOF for session/token data
339
+
340
+
341
+ # ═══════════════════════════════════════════════════════════════════════════════
342
+ # §10. RAG PIPELINE INTEGRITY
343
+ # ═══════════════════════════════════════════════════════════════════════════════
344
+
345
+ # R-060: Every chat message MUST pass through ALL 12 pipeline steps. No bypass.
346
+ # WHERE: app/services/rag_pipeline.py
347
+ # STATUS: ✅ Sequential execution
348
+
349
+ # R-061: Guardrails MUST run BEFORE any LLM call (input sanitize + boundary check).
350
+ # WHERE: Pipeline steps 0-1 run before step 2
351
+ # STATUS: ✅ Implemented
352
+
353
+ # R-062: Output safety MUST run on EVERY generated response (no AI identity leaks).
354
+ # WHERE: app/services/guardrails.py → is_response_safe()
355
+ # STATUS: ✅ Implemented
356
+
357
+ # R-063: Faithfulness NLI check MUST trigger one regeneration, then safe fallback.
358
+ # WHERE: app/services/rag_pipeline.py
359
+ # STATUS: ✅ Implemented
360
+
361
+ # R-064: Context window MUST NOT exceed RAG_MAX_CONTEXT_TOKENS (4096).
362
+ # WHERE: app/services/context_builder.py
363
+ # STATUS: ✅ Hard limit enforced
364
+
365
+ # R-065: response_style MUST be injected into the LLM system prompt.
366
+ # WHY: Admin personality settings currently have ZERO effect on output.
367
+ # WHERE: app/services/rag_pipeline.py + app/services/prompter.py
368
+ # STATUS: ❌ NOT IMPLEMENTED — style is saved to DB but never reaches the prompt
369
+
370
+ # R-066: All prompts MUST live in app/services/prompter.py. No inline prompts.
371
+ # WHERE: All pipeline services
372
+ # STATUS: ✅ Implemented
373
+
374
+ # R-067: LLM errors MUST return warm human fallback, never a crash or error code.
375
+ # WHERE: app/services/rag_pipeline.py — try/except at every step
376
+ # STATUS: ✅ Implemented
377
+
378
+ # R-068: Responses MUST NOT contain markdown (bold, bullets, headers, code blocks).
379
+ # WHERE: System prompt + output guardrails
380
+ # STATUS: ✅ Enforced
381
+
382
+ # R-069: No spoilers — never summarize full plot, retell story, or reveal endings.
383
+ # WHERE: System prompt instructions
384
+ # STATUS: ✅ Enforced in prompt
385
+
386
+ # R-070: Max response length: ~75 words, 2 paragraphs, 380 chars.
387
+ # WHERE: System prompt + formatter
388
+ # STATUS: ✅ Enforced
389
+
390
+
391
+ # ═══════════════════════════════════════════════════════════════════════════════
392
+ # §11. ADMIN PANEL CONSISTENCY
393
+ # ═══════════════════════════════════════════════════════════════════════════════
394
+
395
+ # R-071: Token usage display MUST include bonus_tokens in total budget.
396
+ # WHERE: app/admin/router.py:753-765 via usage_summary_or_empty()
397
+ # STATUS: ✅ Implemented
398
+
399
+ # R-072: Embed token card MUST show same remaining count as token usage page.
400
+ # WHERE: Both use tokens_remaining() from token_budget.py
401
+ # STATUS: ✅ Implemented
402
+
403
+ # R-073: Book deletion MUST clean up ChromaDB collection.
404
+ # WHERE: app/admin/router.py:243-244
405
+ # STATUS: ✅ Implemented
406
+
407
+ # R-074: Smart Links MUST sync both books.buy_url AND links.purchase_url.
408
+ # WHERE: app/admin/router.py:735-747
409
+ # STATUS: ✅ Implemented
410
+
411
+ # R-075: Export endpoints MUST have row limits (prevent OOM on large datasets).
412
+ # WHERE: app/admin/router.py:1219 — .limit(50000)
413
+ # STATUS: ✅ Implemented
414
+
415
+
416
+ # ═══════════════════════════════════════════════════════════════════════════════
417
+ # §12. SUPERADMIN PANEL
418
+ # ═══════════════════════════════════════════════════════════════════════════════
419
+
420
+ # R-076: Every SuperAdmin mutation MUST write to the audit log. No exceptions.
421
+ # WHERE: app/services/superadmin_service.py — all 7 mutations have _audit.log()
422
+ # STATUS: ✅ Implemented
423
+
424
+ # R-077: Audit log MUST be append-only — no UPDATE or DELETE endpoints.
425
+ # WHERE: app/superadmin/router.py:316-344 — only GET
426
+ # STATUS: ✅ Implemented
427
+
428
+ # R-078: Author deletion MUST revoke all active tokens (Redis + DB).
429
+ # WHERE: app/services/superadmin_service.py:155-164
430
+ # STATUS: ✅ Implemented
431
+
432
+ # R-079: Self-deletion and SuperAdmin deletion MUST be prevented.
433
+ # WHERE: app/services/superadmin_service.py:148-151
434
+ # STATUS: ✅ Implemented
435
+
436
+ # R-080: Backup MUST NOT run synchronously on the API event loop.
437
+ # WHY: Blocks all concurrent requests during backup.
438
+ # WHERE: app/superadmin/router.py:436-444 — run_backup() is synchronous
439
+ # STATUS: ❌ RUNS SYNCHRONOUSLY
440
+ # FIX: Dispatch to Celery task or use asyncio.to_thread().
441
+
442
+ # R-081: Grant-by-email MUST validate plan name against known list.
443
+ # WHERE: app/superadmin/router.py:408-413
444
+ # STATUS: ⚠️ PARTIAL — plan_map exists but no error for unknown plans
445
+
446
+
447
+ # ═══════════════════════════════════════════════════════════════════════════════
448
+ # §13. BACKGROUND TASKS & CELERY
449
+ # ═══════════════════════════════════════════════════════════════════════════════
450
+
451
+ # R-082: Document ingestion MUST use Celery (not asyncio.create_task on API process).
452
+ # WHY: asyncio.create_task = lost on restart, no retry, blocks API workers.
453
+ # WHERE: app/api/ingest.py:76-85
454
+ # STATUS: ❌ NOT IMPLEMENTED — uses asyncio.create_task() despite Celery task existing
455
+ # FIX: Replace with process_document.delay(doc_id, author_id, book_id, path, ext)
456
+
457
+ # R-083: Celery tasks MUST use acks_late + max_retries for crash resilience.
458
+ # WHERE: app/tasks/celery_app.py:35, app/tasks/ingestion_task.py:22-28
459
+ # STATUS: ✅ Configured
460
+
461
+ # R-084: SSE status broadcasts MUST use consistent Redis channel naming.
462
+ # WHERE: ingestion_task.py uses "ingestion:{author_id}"
463
+ # but ingest.py uses "ingestion:{author_id}:{book_id}"
464
+ # STATUS: ❌ INCONSISTENT — different channel patterns between Celery and asyncio paths
465
+
466
+
467
+ # ═══════════════════════════════════════════════════════════════════════════════
468
+ # §14. CI/CD & TESTING
469
+ # ═══════════════════════════════════════════════════════════════════════════════
470
+
471
+ # R-085: CI MUST fail on test failures. `|| true` is FORBIDDEN.
472
+ # WHY: Broken code deploys to production automatically.
473
+ # WHERE: .github/workflows/ci.yml:57
474
+ # STATUS: ❌ USES `|| true` — tests NEVER block deployment
475
+
476
+ # R-086: Test coverage MUST meet minimum threshold (≥80%).
477
+ # WHERE: ci.yml:57 — --cov-fail-under is NOT set
478
+ # STATUS: ❌ NOT ENFORCED
479
+
480
+ # R-087: CI MUST include Redis and PostgreSQL services for integration tests.
481
+ # WHERE: ci.yml:51-55 — references redis://localhost:6379 but no Redis service
482
+ # STATUS: ❌ REDIS NOT AVAILABLE IN CI
483
+
484
+ # R-088: BDD specs MUST run in CI (behave tests/bdd/features/).
485
+ # WHERE: ci.yml — no behave step
486
+ # STATUS: ❌ NOT CONFIGURED
487
+
488
+ # R-089: Deployment MUST be gated on test success (needs: test meaningful only if tests run).
489
+ # WHERE: ci.yml:69-70 — needs: test is meaningless with || true
490
+ # STATUS: ❌ DEPLOY IS UNGATED
491
+
492
+ # R-090: Every new function → at least 1 unit test.
493
+ # R-091: Every new API endpoint → at least 1 integration test.
494
+ # R-092: Every new feature → BDD scenario in .agent/BDD_SPECS.md FIRST.
495
+ # R-093: Mock ALL external services in unit tests (OpenAI, DB, Redis, email).
496
+ # R-094: Use pytest fixtures for shared setup — no repeated setup code.
497
+
498
+
499
+ # ═══════════════════════════════════════════════════════════════════════════════
500
+ # §15. OBSERVABILITY & MONITORING
501
+ # ═══════════════════════════════════════════════════════════════════════════════
502
+
503
+ # R-095: Structured logging (structlog) MUST be used everywhere. No print().
504
+ # WHERE: All services
505
+ # STATUS: ✅ Implemented
506
+
507
+ # R-096: Request/response logging middleware MUST be active.
508
+ # WHERE: app/middleware/logging_middleware.py
509
+ # STATUS: ✅ Implemented
510
+
511
+ # R-097: Token values, passwords, API keys MUST never appear in logs.
512
+ # WHERE: app/core/access/token_crypto.py:5-6 (rule documented)
513
+ # STATUS: ✅ Implemented
514
+
515
+ # R-098: Health endpoint MUST check DB + Redis + ChromaDB.
516
+ # WHERE: app/dependencies.py:216-249
517
+ # STATUS: ✅ Implemented
518
+
519
+ # R-099: Prometheus /metrics endpoint MUST be configured.
520
+ # STATUS: ❌ NOT IMPLEMENTED
521
+
522
+ # R-100: Error alerting (Sentry or equivalent) MUST be configured.
523
+ # STATUS: ❌ NOT IMPLEMENTED
524
+
525
+ # R-101: Audit log MUST capture actor IP address (for forensics).
526
+ # STATUS: ❌ NOT IMPLEMENTED — only captures actor_email
527
+
528
+ # R-102: Per-step pipeline latency MUST be tracked (not just total response_ms).
529
+ # STATUS: ❌ NOT IMPLEMENTED
530
+
531
+
532
+ # ═══════════════════════════════════════════════════════════════════════════════
533
+ # §16. DEPLOYMENT & INFRASTRUCTURE
534
+ # ═══════════════════════════════════════════════════════════════════════════════
535
+
536
+ # R-103: Secrets MUST NOT be in version control.
537
+ # WHERE: .env in .gitignore
538
+ # STATUS: ✅ Implemented
539
+
540
+ # R-104: Docker image MUST use multi-stage build (minimal production image).
541
+ # WHERE: Dockerfile
542
+ # STATUS: ✅ Implemented
543
+
544
+ # R-105: Container SHOULD run as non-root user.
545
+ # WHERE: Dockerfile
546
+ # STATUS: ❌ RUNS AS ROOT
547
+
548
+ # R-106: Database backups MUST be automated (Celery Beat schedule).
549
+ # WHERE: No backup task in Celery Beat schedule
550
+ # STATUS: ❌ NOT SCHEDULED
551
+
552
+ # R-107: Health check MUST have startup grace period.
553
+ # WHERE: Dockerfile — 180s start period
554
+ # STATUS: ✅ Implemented
555
+
556
+
557
+ # ═══════════════════════════════════════════════════════════════════════════════
558
+ # §17. CODE STRUCTURE CONVENTIONS
559
+ # ═══════════════════════════════════════════════════════════════════════════════
560
+
561
+ # R-108: All configuration from app/config.py (pydantic BaseSettings). Never hardcode.
562
+ # R-109: All DI via FastAPI Depends() in app/dependencies.py.
563
+ # R-110: All DB access via repository pattern (app/repositories/).
564
+ # R-111: Business logic in app/services/ — never in route handlers.
565
+ # R-112: Background work in Celery tasks (app/tasks/) — never asyncio.create_task for long work.
566
+ # R-113: Custom exceptions in app/exceptions/ — never raise generic Exception.
567
+ # R-114: Every module MUST have a module-level docstring.
568
+ # R-115: Every function MUST have type-annotated signature + docstring.
569
+ # R-116: No function longer than 40 lines — refactor into named helpers.
570
+ # R-117: Update FILE_MAP.md when adding or removing any file.
571
+ # R-118: Update BDD_SPECS.md before implementing any new user-facing feature.
572
+ # R-119: Read .agent/RULES.md and .agent/FILE_MAP.md before every coding session.
573
+ # R-120: All destructive UI actions require confirmation dialog with typed confirmation.
574
+
575
+
576
+ # ═══════════════════════════════════════════════════════════════════════════════
577
+ # §18. FRONTEND & WIDGET SECURITY
578
+ # ═══════════════════════════════════════════════════════════════════════════════
579
+
580
+ # R-121: Widget escHtml() MUST escape ALL 5 HTML entities (&, <, >, ", ').
581
+ # WHY: Incomplete escaping enables XSS in bot responses rendered as innerHTML.
582
+ # WHERE: static/widget.js:483-484
583
+ # STATUS: ✅ Implemented — all 5 entities covered
584
+
585
+ # R-122: Widget link buttons MUST always use target="_blank" rel="noopener".
586
+ # WHY: Without noopener, linked pages can hijack the parent window (reverse tabnabbing).
587
+ # WHERE: static/widget.js:371
588
+ # STATUS: ✅ Implemented
589
+
590
+ # R-123: Widget MUST NOT expose subscription token in DOM or global JS scope.
591
+ # WHY: Any page script can steal the token from window variables or DOM attributes.
592
+ # WHERE: static/widget.js:22-38 — token stored in closure variable (not window)
593
+ # STATUS: ✅ Token stays in IIFE closure
594
+
595
+ # R-124: Admin SPA MUST redirect to /login on 401 response (not show broken UI).
596
+ # WHY: Stale JWTs leave admin in a broken state with no clear recovery.
597
+ # WHERE: static/auth-client.js
598
+ # STATUS: ⚠️ PARTIAL — auth-client.js exists but redirect behavior varies by page
599
+
600
+ # R-125: /widget endpoint MUST NOT serve with Access-Control-Allow-Origin: *.
601
+ # WHY: Exposes widget test page to cross-origin embedding by any domain.
602
+ # WHERE: app/main.py:294 — explicitly adds "*" CORS header on /widget response
603
+ # STATUS: ❌ WILDCARD CORS — anyone can embed the test page
604
+
605
+ # R-126: /covers static mount MUST disable directory listing.
606
+ # WHY: Attackers can enumerate all cover images and map author IDs from filenames.
607
+ # WHERE: app/main.py:265-266 — StaticFiles(directory=covers_dir)
608
+ # STATUS: ⚠️ StaticFiles does NOT list by default, but doesn't 404 on /covers/ root
609
+
610
+ # R-127: Widget size budget: widget.js MUST stay under 25KB minified.
611
+ # WHY: Widget loads on third-party author websites; large JS hurts page speed.
612
+ # WHERE: static/widget.js — currently 19.7KB unminified (523 lines)
613
+ # STATUS: ⚠️ Under budget unminified, but no minification or CDN configured
614
+
615
+
616
+ # ═══════════════════════════════════════════════════════════════════════════════
617
+ # §19. CELERY TASKS — DATA QUALITY
618
+ # ═══════════════════════════════════════════════════════════════════════════════
619
+
620
+ # R-128: Weekly digest email MUST populate actual stats (not hardcoded 0).
621
+ # WHY: Emails with "0 sessions, 0% tokens" destroy author trust.
622
+ # WHERE: app/tasks/email_task.py:39-46 — chat_count=0, token_pct=0, top_country="—"
623
+ # STATUS: ❌ PLACEHOLDER DATA — all values hardcoded to zero
624
+
625
+ # R-129: Token warning email MUST include actual tokens_used and tokens_total.
626
+ # WHY: "Your usage is at 80%" with tokens_used=0 is confusing and useless.
627
+ # WHERE: app/tasks/email_task.py:70-76 — tokens_used=0, tokens_total=0
628
+ # STATUS: ❌ PLACEHOLDER DATA
629
+
630
+ # R-130: Link health check MUST NOT follow redirects to internal/private IPs (SSRF).
631
+ # WHY: An author could set purchase_url to http://169.254.169.254/metadata (AWS IMDS).
632
+ # WHERE: app/tasks/link_health_task.py:34 — follow_redirects=True with NO IP blocklist
633
+ # STATUS: ❌ NOT PROTECTED — follows redirects blindly, including to internal IPs
634
+
635
+ # R-131: Celery Beat MUST include the backup task in its schedule.
636
+ # WHY: backup_task.py exists but is NOT registered in beat_schedule.
637
+ # WHERE: app/tasks/celery_app.py:40-66
638
+ # STATUS: ❌ MISSING — backup task exists (backup_task.py) but never scheduled
639
+
640
+
641
+ # ═══════════════════════════════════════════════════════════════════════════════
642
+ # §20. STARTUP & INITIALIZATION
643
+ # ═══════════════════════════════════════════════════════════════════════════════
644
+
645
+ # R-132: Schema migrations MUST NOT use raw ALTER TABLE in application startup code.
646
+ # WHY: Raw f-string ALTER TABLE is fragile, non-reversible, and can silently fail.
647
+ # Also, f"ALTER TABLE {table} ADD COLUMN {col}" has SQL injection risk if table/col
648
+ # values ever come from untrusted input (currently hardcoded, but bad pattern).
649
+ # WHERE: app/main.py:78-101 — manual ALTER TABLE loop in _init_db()
650
+ # STATUS: ❌ Uses raw DDL instead of Alembic (blocked until R-048 is done)
651
+
652
+ # R-133: OpenAPI docs (/docs, /redoc) MUST be disabled or auth-gated in production.
653
+ # WHY: Public OpenAPI documentation maps every endpoint, parameter, and schema
654
+ # for an attacker. This is the #1 reconnaissance tool.
655
+ # WHERE: app/main.py:155-156 — docs_url="/docs", redoc_url="/redoc" always enabled
656
+ # STATUS: ❌ PUBLIC — anyone can browse full API docs
657
+
658
+ # R-134: SuperAdmin seed MUST generate TOTP secret on first creation.
659
+ # WHY: If R-007 (TOTP enforcement) is implemented but no totp_secret exists
660
+ # on the seeded SuperAdmin, they'll be permanently locked out.
661
+ # WHERE: app/main.py:131-139 — creates User without totp_secret
662
+ # STATUS: ❌ NOT IMPLEMENTED — User model has no totp_secret column either
663
+
664
+
665
+ # ═══════════════════════════════════════════════════════════════════════════════
666
+ # §21. DEPLOYMENT — DOCKER & SERVICES
667
+ # ═══════════════════════════════════════════════════════════════════════════════
668
+
669
+ # R-135: docker-compose.yml MUST NOT use deprecated 'version' key.
670
+ # WHY: Docker Compose v2+ ignores the version key; keeping it causes warnings
671
+ # and confuses developers about which spec is being used.
672
+ # WHERE: docker-compose.yml:1 — version: "3.9"
673
+ # STATUS: ⚠️ Works but deprecated — remove the line
674
+
675
+ # R-136: Flower (Celery monitoring) MUST require authentication.
676
+ # WHY: Flower exposes task names, arguments, worker status, and broker URLs.
677
+ # WHERE: docker-compose.yml:123 — celery flower started with no auth flags
678
+ # STATUS: ❌ NO AUTH — add --basic-auth=user:password or --auth=email
679
+
680
+ # R-137: docker-compose api command MUST NOT use --reload in production.
681
+ # WHY: --reload enables code injection via filesystem writes and wastes CPU.
682
+ # WHERE: docker-compose.yml:79 — uvicorn ... --reload
683
+ # STATUS: ⚠️ PARTIAL — file is marked "Development" but no production override exists
684
+
685
+ # R-138: User cascade delete MUST clean up ChromaDB collections.
686
+ # WHY: When SuperAdmin deletes an author, DB records cascade-delete but
687
+ # ChromaDB collections (vector data) remain orphaned forever, wasting storage.
688
+ # WHERE: app/services/superadmin_service.py:135-183 — deletes user but no ChromaDB cleanup
689
+ # STATUS: ❌ ORPHANED VECTORS — ChromaDB collections survive author deletion
690
+
691
+
692
+ # ═══════════════════════════════════════════════════════════════════════════════
693
+ # SUMMARY: 138 RULES
694
+ # ═══════════════════════════════════════════════════════════════════════════════
695
+ # ✅ Implemented: 70 rules
696
+ # ❌ Not Implemented: 47 rules (must fix)
697
+ # ⚠️ Partial: 21 rules (needs improvement)
698
+ # ═════════════════════════════════════════════��═════════════════════════════════
.github/workflows/ci.yml CHANGED
@@ -35,6 +35,32 @@ jobs:
35
  name: Tests
36
  runs-on: ubuntu-latest
37
  needs: lint
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  steps:
39
  - uses: actions/checkout@v4
40
 
@@ -46,15 +72,15 @@ jobs:
46
  - name: Install dependencies
47
  run: pip install -r requirements.txt
48
 
49
- - name: Run unit tests
50
  env:
51
  JWT_SECRET_KEY: "ci-test-secret-key-not-for-production"
52
- DATABASE_URL: "sqlite+aiosqlite:///./test.db"
53
  REDIS_URL: "redis://localhost:6379"
54
  OPENAI_API_KEY: "sk-test-fake-key"
55
  CHROMA_PERSIST_DIR: "./test_chroma"
56
  run: |
57
- pytest tests/ -v --tb=short --cov=app --cov-report=xml -x || true
58
 
59
  - name: Upload coverage
60
  if: always()
 
35
  name: Tests
36
  runs-on: ubuntu-latest
37
  needs: lint
38
+
39
+ services:
40
+ redis:
41
+ image: redis:7-alpine
42
+ ports:
43
+ - 6379:6379
44
+ options: >-
45
+ --health-cmd "redis-cli ping"
46
+ --health-interval 10s
47
+ --health-timeout 5s
48
+ --health-retries 5
49
+
50
+ postgres:
51
+ image: postgres:16-alpine
52
+ env:
53
+ POSTGRES_DB: test_authorbot
54
+ POSTGRES_USER: authorbot
55
+ POSTGRES_PASSWORD: testpass
56
+ ports:
57
+ - 5432:5432
58
+ options: >-
59
+ --health-cmd "pg_isready -U authorbot -d test_authorbot"
60
+ --health-interval 10s
61
+ --health-timeout 5s
62
+ --health-retries 5
63
+
64
  steps:
65
  - uses: actions/checkout@v4
66
 
 
72
  - name: Install dependencies
73
  run: pip install -r requirements.txt
74
 
75
+ - name: Run unit + integration tests
76
  env:
77
  JWT_SECRET_KEY: "ci-test-secret-key-not-for-production"
78
+ DATABASE_URL: "postgresql+asyncpg://authorbot:testpass@localhost:5432/test_authorbot"
79
  REDIS_URL: "redis://localhost:6379"
80
  OPENAI_API_KEY: "sk-test-fake-key"
81
  CHROMA_PERSIST_DIR: "./test_chroma"
82
  run: |
83
+ pytest tests/ -v --tb=short --cov=app --cov-report=xml --cov-fail-under=80 -x
84
 
85
  - name: Upload coverage
86
  if: always()
Dockerfile CHANGED
@@ -32,7 +32,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
32
  WORKDIR /app
33
  ENV PYTHONPATH=/app
34
  ENV DATABASE_URL=sqlite+aiosqlite:////data/db.sqlite
35
- ENV REDIS_URL=redis://localhost:6379/0
36
  ENV CHROMA_CLIENT_MODE=persistent
37
  ENV CHROMA_PERSIST_DIR=/data/chroma
38
  ENV UPLOAD_STORAGE_PATH=/data/uploads
@@ -46,7 +46,7 @@ COPY static/ ./static/
46
  COPY app.py .
47
 
48
  RUN mkdir -p /data/chroma /data/uploads /data/sessions /data/redis && \
49
- printf 'dir /data/redis\nbind 127.0.0.1\nport 6379\ndaemonize yes\nlogfile /dev/null\n' > /app/redis-hf.conf
50
 
51
  RUN adduser --disabled-password --gecos "" --uid 1000 appuser && \
52
  chown -R appuser:appuser /app /data
 
32
  WORKDIR /app
33
  ENV PYTHONPATH=/app
34
  ENV DATABASE_URL=sqlite+aiosqlite:////data/db.sqlite
35
+ ENV REDIS_URL=redis://:authorbotredis@localhost:6379/0
36
  ENV CHROMA_CLIENT_MODE=persistent
37
  ENV CHROMA_PERSIST_DIR=/data/chroma
38
  ENV UPLOAD_STORAGE_PATH=/data/uploads
 
46
  COPY app.py .
47
 
48
  RUN mkdir -p /data/chroma /data/uploads /data/sessions /data/redis && \
49
+ printf 'dir /data/redis\nbind 127.0.0.1\nport 6379\ndaemonize yes\nlogfile /dev/null\nrequirepass authorbotredis\nappendonly yes\nappendfsync everysec\n' > /app/redis-hf.conf
50
 
51
  RUN adduser --disabled-password --gecos "" --uid 1000 appuser && \
52
  chown -R appuser:appuser /app /data
app/admin/router.py CHANGED
@@ -28,6 +28,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
28
  from app.dependencies import get_db, get_current_author_scoped, get_redis
29
  from app.repositories.book_repo import BookRepository
30
  from app.repositories.audit_repo import AuditRepository
 
 
 
 
 
 
 
31
 
32
  router = APIRouter()
33
 
@@ -461,28 +468,20 @@ async def get_intent_distribution(
461
  @router.post("/{author_slug}/password")
462
  async def change_password(
463
  author_slug: str,
464
- body: dict,
465
  current_user=Depends(get_current_author_scoped),
466
  db: AsyncSession = Depends(get_db),
467
  ):
468
- """Self-service password change."""
469
  import bcrypt
470
- current_password = body.get("current_password", "")
471
- new_password = body.get("new_password", "")
472
- if not current_password or not new_password:
473
- from fastapi import HTTPException
474
- raise HTTPException(400, "Both current_password and new_password are required")
475
- if len(new_password) < 8:
476
- from fastapi import HTTPException
477
- raise HTTPException(400, "New password must be at least 8 characters")
478
  try:
479
- valid = bcrypt.checkpw(current_password.encode(), current_user.password_hash.encode())
480
  except Exception:
481
  valid = False
482
  if not valid:
483
  from fastapi import HTTPException
484
  raise HTTPException(400, "Current password is incorrect")
485
- current_user.password_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt(12)).decode()
486
  await db.commit()
487
  return {"message": "Password updated"}
488
 
@@ -509,25 +508,22 @@ async def get_widget_config(
509
  @router.put("/{author_slug}/widget-config")
510
  async def update_widget_config(
511
  author_slug: str,
512
- body: dict,
513
  current_user=Depends(get_current_author_scoped),
514
  db: AsyncSession = Depends(get_db),
515
  ):
516
- """Update widget configuration."""
517
- allowed = {
518
- "bot_name": ("bot_name", str, 100),
519
- "welcome_message": ("welcome_message", str, 500),
520
- "theme": ("widget_theme", str, 50),
521
- "position": ("widget_position", str, 20),
522
- "auto_open_delay": ("widget_auto_open_delay", int, None),
523
- "is_active": ("chatbot_is_active", bool, None),
524
  }
525
- for key, (attr, typ, maxlen) in allowed.items():
526
- if key in body:
527
- val = body[key]
528
- if typ == str and maxlen and len(str(val)) > maxlen:
529
- from fastapi import HTTPException
530
- raise HTTPException(400, f"{key} exceeds max length {maxlen}")
531
  setattr(current_user, attr, val)
532
  await db.commit()
533
  return {"message": "Widget config updated"}
@@ -554,19 +550,19 @@ async def get_profile(
554
  @router.put("/{author_slug}/profile")
555
  async def update_profile(
556
  author_slug: str,
557
- body: dict,
558
  current_user=Depends(get_current_author_scoped),
559
  db: AsyncSession = Depends(get_db),
560
  ):
561
- """Update author profile."""
562
- if "full_name" in body:
563
- current_user.full_name = str(body["full_name"])[:255]
564
- if "website" in body:
565
- current_user.website_url = str(body["website"])[:500]
566
- if "bio" in body:
567
- current_user.bio = str(body["bio"])[:2000]
568
- if "timezone" in body:
569
- current_user.timezone = str(body["timezone"])[:100]
570
  await db.commit()
571
  return {"message": "Profile updated"}
572
 
@@ -662,11 +658,11 @@ async def get_embed_token(
662
  Token is regenerated from stored grant data — never stored in plaintext.
663
  """
664
  from sqlalchemy import select
665
- from datetime import datetime
666
  from app.models.client_access import ClientAccess
667
  from app.core.access.token_crypto import create_subscription_token
668
 
669
- now = datetime.utcnow() # naive matches SQLite stored datetimes
670
  result = await db.execute(
671
  select(ClientAccess)
672
  .where(
@@ -860,18 +856,22 @@ async def annotate_message(
860
  author_slug: str,
861
  session_id: str,
862
  message_id: str,
863
- body: dict,
864
  current_user=Depends(get_current_author_scoped),
865
  db: AsyncSession = Depends(get_db),
866
  ):
867
- """Add admin annotation to a chat message (append-only)."""
868
  from sqlalchemy import select
869
- from app.models.chat_session import ChatMessage
870
 
 
871
  result = await db.execute(
872
- select(ChatMessage).where(
 
 
873
  ChatMessage.id == message_id,
874
  ChatMessage.session_id == session_id,
 
875
  )
876
  )
877
  msg = result.scalar_one_or_none()
@@ -879,10 +879,7 @@ async def annotate_message(
879
  from fastapi import HTTPException
880
  raise HTTPException(404, "Message not found")
881
 
882
- annotation = str(body.get("annotation", ""))[:2000]
883
- if not annotation:
884
- from fastapi import HTTPException
885
- raise HTTPException(400, "Annotation text required")
886
 
887
  # Append-only: prepend timestamp + existing
888
  from datetime import datetime, timezone
@@ -898,24 +895,24 @@ async def flag_message(
898
  author_slug: str,
899
  session_id: str,
900
  message_id: str,
901
- body: dict,
902
  current_user=Depends(get_current_author_scoped),
903
  db: AsyncSession = Depends(get_db),
904
  ):
905
- """Flag a message as spam, quality issue, or escalation."""
906
  from sqlalchemy import select
907
- from app.models.chat_session import ChatMessage
908
 
909
- valid_flags = ["spam", "quality", "escalation", None]
910
- flag_type = body.get("flag_type")
911
- if flag_type not in valid_flags:
912
- from fastapi import HTTPException
913
- raise HTTPException(400, f"Invalid flag. Must be one of: {valid_flags}")
914
 
 
915
  result = await db.execute(
916
- select(ChatMessage).where(
 
 
917
  ChatMessage.id == message_id,
918
  ChatMessage.session_id == session_id,
 
919
  )
920
  )
921
  msg = result.scalar_one_or_none()
@@ -923,9 +920,9 @@ async def flag_message(
923
  from fastapi import HTTPException
924
  raise HTTPException(404, "Message not found")
925
 
926
- msg.flag_type = flag_type
927
  await db.commit()
928
- return {"message": "Flag updated", "flag_type": flag_type}
929
 
930
 
931
 
 
28
  from app.dependencies import get_db, get_current_author_scoped, get_redis
29
  from app.repositories.book_repo import BookRepository
30
  from app.repositories.audit_repo import AuditRepository
31
+ from app.schemas.admin import (
32
+ AnnotateRequest,
33
+ FlagRequest,
34
+ PasswordChangeRequest,
35
+ ProfileUpdate,
36
+ WidgetConfigUpdate,
37
+ )
38
 
39
  router = APIRouter()
40
 
 
468
  @router.post("/{author_slug}/password")
469
  async def change_password(
470
  author_slug: str,
471
+ body: PasswordChangeRequest,
472
  current_user=Depends(get_current_author_scoped),
473
  db: AsyncSession = Depends(get_db),
474
  ):
475
+ """Self-service password change. R-010: Validates via Pydantic schema."""
476
  import bcrypt
 
 
 
 
 
 
 
 
477
  try:
478
+ valid = bcrypt.checkpw(body.current_password.encode(), current_user.password_hash.encode())
479
  except Exception:
480
  valid = False
481
  if not valid:
482
  from fastapi import HTTPException
483
  raise HTTPException(400, "Current password is incorrect")
484
+ current_user.password_hash = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt(12)).decode()
485
  await db.commit()
486
  return {"message": "Password updated"}
487
 
 
508
  @router.put("/{author_slug}/widget-config")
509
  async def update_widget_config(
510
  author_slug: str,
511
+ body: WidgetConfigUpdate,
512
  current_user=Depends(get_current_author_scoped),
513
  db: AsyncSession = Depends(get_db),
514
  ):
515
+ """Update widget configuration. R-029: Validated via Pydantic schema."""
516
+ update_map = {
517
+ "bot_name": "bot_name",
518
+ "welcome_message": "welcome_message",
519
+ "theme": "widget_theme",
520
+ "position": "widget_position",
521
+ "auto_open_delay": "widget_auto_open_delay",
522
+ "is_active": "chatbot_is_active",
523
  }
524
+ for field_name, attr in update_map.items():
525
+ val = getattr(body, field_name, None)
526
+ if val is not None:
 
 
 
527
  setattr(current_user, attr, val)
528
  await db.commit()
529
  return {"message": "Widget config updated"}
 
550
  @router.put("/{author_slug}/profile")
551
  async def update_profile(
552
  author_slug: str,
553
+ body: ProfileUpdate,
554
  current_user=Depends(get_current_author_scoped),
555
  db: AsyncSession = Depends(get_db),
556
  ):
557
+ """Update author profile. R-029: Validated via Pydantic schema."""
558
+ if body.full_name is not None:
559
+ current_user.full_name = body.full_name
560
+ if body.website is not None:
561
+ current_user.website_url = body.website
562
+ if body.bio is not None:
563
+ current_user.bio = body.bio
564
+ if body.timezone is not None:
565
+ current_user.timezone = body.timezone
566
  await db.commit()
567
  return {"message": "Profile updated"}
568
 
 
658
  Token is regenerated from stored grant data — never stored in plaintext.
659
  """
660
  from sqlalchemy import select
661
+ from datetime import datetime, timezone
662
  from app.models.client_access import ClientAccess
663
  from app.core.access.token_crypto import create_subscription_token
664
 
665
+ now = datetime.now(timezone.utc) # R-051: timezone-aware UTC
666
  result = await db.execute(
667
  select(ClientAccess)
668
  .where(
 
856
  author_slug: str,
857
  session_id: str,
858
  message_id: str,
859
+ body: AnnotateRequest,
860
  current_user=Depends(get_current_author_scoped),
861
  db: AsyncSession = Depends(get_db),
862
  ):
863
+ """Add admin annotation to a chat message (append-only). R-029: Schema validated."""
864
  from sqlalchemy import select
865
+ from app.models.chat_session import ChatMessage, ChatSession
866
 
867
+ # R-040: Verify session ownership via JOIN to prevent IDOR
868
  result = await db.execute(
869
+ select(ChatMessage)
870
+ .join(ChatSession, ChatMessage.session_id == ChatSession.id)
871
+ .where(
872
  ChatMessage.id == message_id,
873
  ChatMessage.session_id == session_id,
874
+ ChatSession.author_id == current_user.id,
875
  )
876
  )
877
  msg = result.scalar_one_or_none()
 
879
  from fastapi import HTTPException
880
  raise HTTPException(404, "Message not found")
881
 
882
+ annotation = body.annotation
 
 
 
883
 
884
  # Append-only: prepend timestamp + existing
885
  from datetime import datetime, timezone
 
895
  author_slug: str,
896
  session_id: str,
897
  message_id: str,
898
+ body: FlagRequest,
899
  current_user=Depends(get_current_author_scoped),
900
  db: AsyncSession = Depends(get_db),
901
  ):
902
+ """Flag a message as spam, quality issue, or escalation. R-029: Schema validated."""
903
  from sqlalchemy import select
904
+ from app.models.chat_session import ChatMessage, ChatSession
905
 
906
+ # flag_type validated by FlagRequest Pydantic Literal — "spam", "quality", "escalation", or None
 
 
 
 
907
 
908
+ # R-040: Verify session ownership via JOIN to prevent IDOR
909
  result = await db.execute(
910
+ select(ChatMessage)
911
+ .join(ChatSession, ChatMessage.session_id == ChatSession.id)
912
+ .where(
913
  ChatMessage.id == message_id,
914
  ChatMessage.session_id == session_id,
915
+ ChatSession.author_id == current_user.id,
916
  )
917
  )
918
  msg = result.scalar_one_or_none()
 
920
  from fastapi import HTTPException
921
  raise HTTPException(404, "Message not found")
922
 
923
+ msg.flag_type = body.flag_type
924
  await db.commit()
925
+ return {"message": "Flag updated", "flag_type": body.flag_type}
926
 
927
 
928
 
app/api/chat.py CHANGED
@@ -216,24 +216,7 @@ async def get_history(
216
  return {"session_id": session_id, "history": ctx.history, "turn_count": ctx.turn_count}
217
 
218
 
219
- @router.post("/{author_slug}/session/rate")
220
- async def rate_session(
221
- author_slug: str,
222
- session_id: str,
223
- rating: int,
224
- author=Depends(get_subscription_author),
225
- db: AsyncSession = Depends(get_db),
226
- ):
227
- """Submit a star rating (1–5) for a chat session."""
228
- from app.models.chat_session import ChatSession
229
- from sqlalchemy import update
230
- await db.execute(
231
- update(ChatSession)
232
- .where(ChatSession.id == session_id, ChatSession.author_id == author.id)
233
- .values(rating=rating)
234
- )
235
- await db.commit()
236
- return {"message": "Rating saved", "rating": rating}
237
 
238
 
239
  @router.post("/{author_slug}/session/reset")
@@ -261,7 +244,19 @@ async def ingestion_events(
261
  channel = f"ingestion:{author.id}"
262
  await pubsub.subscribe(channel)
263
  try:
 
 
 
 
 
264
  async for message in pubsub.listen():
 
 
 
 
 
 
 
265
  if message["type"] == "message":
266
  yield f"data: {message['data']}\n\n"
267
  finally:
 
216
  return {"session_id": session_id, "history": ctx.history, "turn_count": ctx.turn_count}
217
 
218
 
219
+ # R-046: First rate_session definition removed — was duplicate of L346 which has proper body validation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
 
222
  @router.post("/{author_slug}/session/reset")
 
244
  channel = f"ingestion:{author.id}"
245
  await pubsub.subscribe(channel)
246
  try:
247
+ # R-023: Max connection timeout to prevent connection exhaustion
248
+ import time as _time
249
+ deadline = _time.monotonic() + 300 # 5 minute max
250
+ heartbeat_interval = 30
251
+ last_heartbeat = _time.monotonic()
252
  async for message in pubsub.listen():
253
+ now = _time.monotonic()
254
+ if now >= deadline:
255
+ yield 'data: {"type": "timeout"}\n\n'
256
+ break
257
+ if now - last_heartbeat > heartbeat_interval:
258
+ yield ": heartbeat\n\n"
259
+ last_heartbeat = now
260
  if message["type"] == "message":
261
  yield f"data: {message['data']}\n\n"
262
  finally:
app/api/ingest.py CHANGED
@@ -8,10 +8,8 @@ Saves uploaded file to disk first, then passes the path to services
8
  that expect file_path (not raw bytes).
9
  """
10
 
11
- import asyncio
12
  import json
13
  import os
14
- import tempfile
15
  from pathlib import Path
16
 
17
  from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
@@ -40,7 +38,13 @@ async def upload_book(
40
  redis=Depends(get_redis),
41
  ):
42
  """Upload a book file (PDF/EPUB/DOCX/TXT) and start async ingestion."""
43
- contents = await file.read()
 
 
 
 
 
 
44
  filename = file.filename or "upload"
45
 
46
  book_repo = BookRepository(db)
@@ -73,15 +77,14 @@ async def upload_book(
73
  })
74
  await db.commit()
75
 
76
- asyncio.create_task(
77
- _run_ingestion(
78
- file_path=str(tmp_path),
79
- book_id=book.id,
80
- author_id=current_user.id,
81
- title=book.title,
82
- redis=redis,
83
- db=db,
84
- )
85
  )
86
 
87
  return {"book_id": book.id, "doc_id": doc.id, "status": "processing"}
@@ -96,11 +99,25 @@ async def ingestion_progress(
96
  ):
97
  """SSE stream for real-time ingestion progress."""
98
  async def event_stream():
99
- channel = f"ingestion:{current_user.id}:{book_id}"
 
100
  pubsub = redis.pubsub()
101
  await pubsub.subscribe(channel)
102
  try:
 
 
 
 
 
 
103
  async for message in pubsub.listen():
 
 
 
 
 
 
 
104
  if message["type"] == "message":
105
  yield f"data: {message['data']}\n\n"
106
  data = json.loads(message["data"])
 
8
  that expect file_path (not raw bytes).
9
  """
10
 
 
11
  import json
12
  import os
 
13
  from pathlib import Path
14
 
15
  from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
 
38
  redis=Depends(get_redis),
39
  ):
40
  """Upload a book file (PDF/EPUB/DOCX/TXT) and start async ingestion."""
41
+ # R-024: Chunked upload — reject oversized files BEFORE reading into memory
42
+ MAX_UPLOAD_BYTES = cfg.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024
43
+ contents = bytearray()
44
+ while chunk := await file.read(64 * 1024): # 64KB chunks
45
+ contents.extend(chunk)
46
+ if len(contents) > MAX_UPLOAD_BYTES:
47
+ raise HTTPException(413, f"File too large. Maximum: {cfg.UPLOAD_MAX_FILE_SIZE_MB}MB")
48
  filename = file.filename or "upload"
49
 
50
  book_repo = BookRepository(db)
 
77
  })
78
  await db.commit()
79
 
80
+ # R-082: Dispatch to Celery for crash resilience, retries, and worker isolation
81
+ from app.tasks.ingestion_task import process_document
82
+ process_document.delay(
83
+ document_id=doc.id,
84
+ author_id=current_user.id,
85
+ book_id=book.id,
86
+ file_path=str(tmp_path),
87
+ file_extension=suffix.lstrip("."),
 
88
  )
89
 
90
  return {"book_id": book.id, "doc_id": doc.id, "status": "processing"}
 
99
  ):
100
  """SSE stream for real-time ingestion progress."""
101
  async def event_stream():
102
+ # R-084: Unified channel pattern between Celery and admin SSE
103
+ channel = f"ingestion:{current_user.id}"
104
  pubsub = redis.pubsub()
105
  await pubsub.subscribe(channel)
106
  try:
107
+ # R-023: Max connection timeout to prevent connection exhaustion
108
+ import asyncio as _aio
109
+ import time as _time
110
+ deadline = _time.monotonic() + 300 # 5 minute max
111
+ heartbeat_interval = 30
112
+ last_heartbeat = _time.monotonic()
113
  async for message in pubsub.listen():
114
+ now = _time.monotonic()
115
+ if now >= deadline:
116
+ yield 'data: {"stage": "timeout"}\n\n'
117
+ break
118
+ if now - last_heartbeat > heartbeat_interval:
119
+ yield ": heartbeat\n\n"
120
+ last_heartbeat = now
121
  if message["type"] == "message":
122
  yield f"data: {message['data']}\n\n"
123
  data = json.loads(message["data"])
app/api/schemas_router.py CHANGED
@@ -8,7 +8,7 @@ Routes:
8
  GET /api/auth/me
9
  """
10
 
11
- from fastapi import APIRouter, Depends, Request, Response
12
  from sqlalchemy.ext.asyncio import AsyncSession
13
 
14
  from app.config import get_settings
@@ -56,8 +56,13 @@ async def login(payload: LoginRequest, response: Response, db: AsyncSession = De
56
 
57
 
58
  @router.post("/auth/refresh", response_model=TokenResponse, tags=["Auth"])
59
- async def refresh(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
60
- """Issue new JWT token pair."""
 
 
 
 
 
61
  from fastapi import HTTPException
62
 
63
  refresh_token = request.cookies.get("refresh_token")
@@ -70,16 +75,52 @@ async def refresh(request: Request, response: Response, db: AsyncSession = Depen
70
  if not refresh_token:
71
  raise HTTPException(401, "Refresh token required")
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  result = await AuthService(db).refresh_tokens(refresh_token)
74
  _set_refresh_cookie(response, result["refresh_token"])
75
  return result
76
 
77
 
78
  @router.post("/auth/logout", status_code=204, tags=["Auth"])
79
- async def logout(response: Response, _=Depends(get_current_user)):
80
- """Invalidate refresh token cookie."""
 
 
 
 
81
  response.delete_cookie("refresh_token", path="/")
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  @router.get("/auth/me", response_model=UserResponse, tags=["Auth"])
85
  async def get_me(current_user=Depends(get_current_user)):
 
8
  GET /api/auth/me
9
  """
10
 
11
+ from fastapi import APIRouter, Depends, Header, Request, Response
12
  from sqlalchemy.ext.asyncio import AsyncSession
13
 
14
  from app.config import get_settings
 
56
 
57
 
58
  @router.post("/auth/refresh", response_model=TokenResponse, tags=["Auth"])
59
+ async def refresh(
60
+ request: Request,
61
+ response: Response,
62
+ authorization: str = Header(default=""),
63
+ db: AsyncSession = Depends(get_db),
64
+ ):
65
+ """Issue new JWT token pair and blacklist the old access token."""
66
  from fastapi import HTTPException
67
 
68
  refresh_token = request.cookies.get("refresh_token")
 
75
  if not refresh_token:
76
  raise HTTPException(401, "Refresh token required")
77
 
78
+ # R-004: Blacklist old access token before issuing new pair
79
+ if authorization and authorization.startswith("Bearer "):
80
+ old_token = authorization.removeprefix("Bearer ").strip()
81
+ try:
82
+ from app.core.access.token_crypto import decode_jwt
83
+ from app.core.access.jwt_blacklist import blacklist_token
84
+ from app.dependencies import get_redis
85
+ old_payload = decode_jwt(old_token)
86
+ old_jti = old_payload.get("jti")
87
+ if old_jti:
88
+ ttl = max(int(old_payload.get("exp", 0) - __import__("time").time()), 1)
89
+ redis = await get_redis()
90
+ await blacklist_token(redis, old_jti, ttl)
91
+ except Exception:
92
+ pass # Best-effort blacklisting
93
+
94
  result = await AuthService(db).refresh_tokens(refresh_token)
95
  _set_refresh_cookie(response, result["refresh_token"])
96
  return result
97
 
98
 
99
  @router.post("/auth/logout", status_code=204, tags=["Auth"])
100
+ async def logout(
101
+ response: Response,
102
+ authorization: str = Header(default=""),
103
+ _=Depends(get_current_user),
104
+ ):
105
+ """Invalidate refresh token cookie and blacklist current JWT."""
106
  response.delete_cookie("refresh_token", path="/")
107
 
108
+ # R-005: Blacklist current access token in Redis
109
+ if authorization and authorization.startswith("Bearer "):
110
+ token = authorization.removeprefix("Bearer ").strip()
111
+ try:
112
+ from app.core.access.token_crypto import decode_jwt
113
+ from app.core.access.jwt_blacklist import blacklist_token
114
+ from app.dependencies import get_redis
115
+ payload = decode_jwt(token)
116
+ jti = payload.get("jti")
117
+ if jti:
118
+ ttl = max(int(payload.get("exp", 0) - __import__("time").time()), 1)
119
+ redis = await get_redis()
120
+ await blacklist_token(redis, jti, ttl)
121
+ except Exception:
122
+ pass # Best-effort blacklisting
123
+
124
 
125
  @router.get("/auth/me", response_model=UserResponse, tags=["Auth"])
126
  async def get_me(current_user=Depends(get_current_user)):
app/config.py CHANGED
@@ -156,6 +156,9 @@ class Settings(BaseSettings):
156
  CELERY_BROKER_URL: str = Field(default="redis://localhost:6379/1")
157
  CELERY_RESULT_BACKEND: str = Field(default="redis://localhost:6379/2")
158
 
 
 
 
159
  @field_validator("SECRET_KEY")
160
  @classmethod
161
  def secret_key_must_be_strong(cls, v: str) -> str:
 
156
  CELERY_BROKER_URL: str = Field(default="redis://localhost:6379/1")
157
  CELERY_RESULT_BACKEND: str = Field(default="redis://localhost:6379/2")
158
 
159
+ # ─── Observability ────────────────────────────────────
160
+ SENTRY_DSN: str | None = None # R-100: Set via env to enable error tracking
161
+
162
  @field_validator("SECRET_KEY")
163
  @classmethod
164
  def secret_key_must_be_strong(cls, v: str) -> str:
app/core/access/jwt_blacklist.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Author RAG Chatbot SaaS — JWT Blacklist (Redis-backed).
2
+
3
+ Used for logout and refresh token rotation.
4
+ When a user logs out or refreshes, the old JWT's jti is added to Redis
5
+ with a TTL equal to the remaining token lifetime.
6
+
7
+ R-004: Refresh must invalidate old token.
8
+ R-005: Logout must blacklist current token.
9
+ """
10
+
11
+ import structlog
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+ BLACKLIST_PREFIX = "jwt_blacklisted:"
16
+
17
+
18
+ async def blacklist_token(redis, jti: str, ttl_seconds: int) -> None:
19
+ """Add a JWT ID to the blacklist with TTL = remaining token lifetime."""
20
+ if not jti:
21
+ return
22
+ key = f"{BLACKLIST_PREFIX}{jti}"
23
+ await redis.setex(key, max(ttl_seconds, 1), "1")
24
+ logger.debug("JWT blacklisted", jti=jti[:8], ttl=ttl_seconds)
25
+
26
+
27
+ async def is_blacklisted(redis, jti: str) -> bool:
28
+ """Check if a JWT ID has been blacklisted."""
29
+ if not jti:
30
+ return False
31
+ return bool(await redis.exists(f"{BLACKLIST_PREFIX}{jti}"))
app/core/access/token_crypto.py CHANGED
@@ -9,6 +9,7 @@ import hashlib
9
  import hmac
10
  import json
11
  import time
 
12
  from base64 import urlsafe_b64decode, urlsafe_b64encode
13
  from datetime import datetime, timedelta, timezone
14
  from typing import Any
@@ -57,6 +58,7 @@ def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None
57
  expire = now + timedelta(minutes=cfg.ACCESS_TOKEN_EXPIRE_MINUTES)
58
  payload = {
59
  "sub": subject,
 
60
  "iat": now,
61
  "exp": expire,
62
  "type": "access",
@@ -78,6 +80,7 @@ def create_refresh_token(subject: str) -> str:
78
  expire = now + timedelta(days=cfg.REFRESH_TOKEN_EXPIRE_DAYS)
79
  payload = {
80
  "sub": subject,
 
81
  "iat": now,
82
  "exp": expire,
83
  "type": "refresh",
 
9
  import hmac
10
  import json
11
  import time
12
+ import uuid
13
  from base64 import urlsafe_b64decode, urlsafe_b64encode
14
  from datetime import datetime, timedelta, timezone
15
  from typing import Any
 
58
  expire = now + timedelta(minutes=cfg.ACCESS_TOKEN_EXPIRE_MINUTES)
59
  payload = {
60
  "sub": subject,
61
+ "jti": uuid.uuid4().hex, # R-004/R-005: JWT ID for blacklisting
62
  "iat": now,
63
  "exp": expire,
64
  "type": "access",
 
80
  expire = now + timedelta(days=cfg.REFRESH_TOKEN_EXPIRE_DAYS)
81
  payload = {
82
  "sub": subject,
83
+ "jti": uuid.uuid4().hex, # R-004/R-005: JWT ID for blacklisting
84
  "iat": now,
85
  "exp": expire,
86
  "type": "refresh",
app/dependencies.py CHANGED
@@ -127,6 +127,19 @@ async def _get_authenticated_user(
127
  except Exception:
128
  raise HTTPException(status_code=401, detail="Could not validate token")
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  user_id: str = payload.get("sub")
131
  if not user_id:
132
  raise HTTPException(status_code=401, detail="Token missing subject claim")
@@ -175,10 +188,23 @@ async def get_current_author_scoped(
175
  return current_user
176
 
177
 
178
- async def get_current_superadmin(current_user=Depends(get_current_user)):
179
- """Ensure the authenticated user is a SuperAdmin."""
 
 
 
180
  if current_user.role != "superadmin":
181
  raise HTTPException(status_code=403, detail="SuperAdmin access required")
 
 
 
 
 
 
 
 
 
 
182
  return current_user
183
 
184
 
@@ -208,7 +234,7 @@ async def get_subscription_author(
208
  except SubscriptionNotFoundError:
209
  raise HTTPException(status_code=403, detail="No active subscription. Contact support.")
210
  except BudgetExhaustedError:
211
- raise HTTPException(status_code=403, detail="Monthly token budget exhausted")
212
 
213
 
214
  # ─── Health Check ─────────────────────────────────────────────────────────────
 
127
  except Exception:
128
  raise HTTPException(status_code=401, detail="Could not validate token")
129
 
130
+ # R-004/R-005: Check if JWT has been blacklisted (logout/refresh rotation)
131
+ jti = payload.get("jti")
132
+ if jti:
133
+ try:
134
+ from app.core.access.jwt_blacklist import is_blacklisted
135
+ redis = await get_redis()
136
+ if await is_blacklisted(redis, jti):
137
+ raise HTTPException(status_code=401, detail="Token has been revoked")
138
+ except HTTPException:
139
+ raise
140
+ except Exception:
141
+ pass # Redis down: fail-open for blacklist check
142
+
143
  user_id: str = payload.get("sub")
144
  if not user_id:
145
  raise HTTPException(status_code=401, detail="Token missing subject claim")
 
188
  return current_user
189
 
190
 
191
+ async def get_current_superadmin(
192
+ current_user=Depends(get_current_user),
193
+ x_totp_code: str = Header(default="", alias="X-TOTP-Code"),
194
+ ):
195
+ """Ensure the authenticated user is a SuperAdmin with valid TOTP."""
196
  if current_user.role != "superadmin":
197
  raise HTTPException(status_code=403, detail="SuperAdmin access required")
198
+
199
+ # R-007: TOTP verification required on every SuperAdmin request
200
+ from app.core.access.totp import verify_totp
201
+ if not x_totp_code:
202
+ raise HTTPException(status_code=403, detail="TOTP code required")
203
+ if not current_user.totp_secret:
204
+ raise HTTPException(status_code=403, detail="TOTP not configured for this account")
205
+ if not verify_totp(current_user.totp_secret, x_totp_code):
206
+ raise HTTPException(status_code=403, detail="Invalid TOTP code")
207
+
208
  return current_user
209
 
210
 
 
234
  except SubscriptionNotFoundError:
235
  raise HTTPException(status_code=403, detail="No active subscription. Contact support.")
236
  except BudgetExhaustedError:
237
+ raise # R-016: Let global exception handler in main.py return HTTP 200 with friendly message
238
 
239
 
240
  # ─── Health Check ─────────────────────────────────────────────────────────────
app/main.py CHANGED
@@ -90,6 +90,7 @@ async def _init_db() -> None:
90
  ("chat_messages", "flag_type", "VARCHAR(20)"),
91
  ("chat_messages", "user_feedback", "VARCHAR(10)"),
92
  ("client_access", "tokens_used", "INTEGER NOT NULL DEFAULT 0"),
 
93
  ]
94
  async with engine.begin() as conn:
95
  from sqlalchemy import text
@@ -128,16 +129,20 @@ async def _seed_superadmin(cfg) -> None:
128
  logger.info("Superadmin already exists", email=cfg.SUPERADMIN_EMAIL)
129
  return
130
 
 
 
131
  sa = User(
132
  email=cfg.SUPERADMIN_EMAIL,
133
  password_hash=hash_password(cfg.SUPERADMIN_PASSWORD),
134
  role="superadmin",
135
  full_name="Super Admin",
136
  is_active=True,
 
137
  )
138
  db.add(sa)
139
  await db.commit()
140
- logger.info("Superadmin account created", email=cfg.SUPERADMIN_EMAIL)
 
141
 
142
 
143
 
@@ -145,6 +150,10 @@ def create_app() -> FastAPI:
145
  """Create and configure the FastAPI application."""
146
  cfg = get_settings()
147
 
 
 
 
 
148
  app = FastAPI(
149
  title="Author RAG — AI Book Sales Chatbot SaaS",
150
  description=(
@@ -152,11 +161,24 @@ def create_app() -> FastAPI:
152
  "on any author's website. Powered by a 12-step RAG pipeline."
153
  ),
154
  version="1.0.0",
155
- docs_url="/docs",
156
- redoc_url="/redoc",
157
  lifespan=lifespan,
158
  )
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  _register_middleware(app, cfg)
161
  _register_exception_handlers(app)
162
  _register_routers(app)
@@ -171,6 +193,13 @@ def _register_middleware(app: FastAPI, cfg) -> None:
171
  app.add_middleware(SecurityHeadersMiddleware)
172
  app.add_middleware(RateLimitMiddleware)
173
 
 
 
 
 
 
 
 
174
  cors_origins = cfg.ALLOWED_ORIGINS if cfg.APP_ENV == "production" else ["*"]
175
  app.add_middleware(
176
  CORSMiddleware,
@@ -291,7 +320,7 @@ def _register_public_routes(app: FastAPI) -> None:
291
  return FileResponse(
292
  WIDGET_TEMPLATE,
293
  headers={
294
- "Access-Control-Allow-Origin": "*",
295
  "Cache-Control": "no-cache, no-store, must-revalidate",
296
  },
297
  )
@@ -302,6 +331,17 @@ def _register_public_routes(app: FastAPI) -> None:
302
  from app.dependencies import check_health
303
  return await check_health()
304
 
 
 
 
 
 
 
 
 
 
 
 
305
 
306
  # Application instance
307
  app = create_app()
 
90
  ("chat_messages", "flag_type", "VARCHAR(20)"),
91
  ("chat_messages", "user_feedback", "VARCHAR(10)"),
92
  ("client_access", "tokens_used", "INTEGER NOT NULL DEFAULT 0"),
93
+ ("users", "totp_secret", "VARCHAR(64)"), # R-134: TOTP for SuperAdmin
94
  ]
95
  async with engine.begin() as conn:
96
  from sqlalchemy import text
 
129
  logger.info("Superadmin already exists", email=cfg.SUPERADMIN_EMAIL)
130
  return
131
 
132
+ # R-134: Generate TOTP secret so TOTP enforcement doesn't lock out SuperAdmin
133
+ from app.core.access.totp import generate_totp_secret
134
  sa = User(
135
  email=cfg.SUPERADMIN_EMAIL,
136
  password_hash=hash_password(cfg.SUPERADMIN_PASSWORD),
137
  role="superadmin",
138
  full_name="Super Admin",
139
  is_active=True,
140
+ totp_secret=generate_totp_secret(),
141
  )
142
  db.add(sa)
143
  await db.commit()
144
+ logger.info("Superadmin account created", email=cfg.SUPERADMIN_EMAIL,
145
+ totp_setup_required=True)
146
 
147
 
148
 
 
150
  """Create and configure the FastAPI application."""
151
  cfg = get_settings()
152
 
153
+ # R-133: Disable OpenAPI docs in production to prevent API reconnaissance
154
+ docs = "/docs" if cfg.APP_ENV != "production" else None
155
+ redoc = "/redoc" if cfg.APP_ENV != "production" else None
156
+
157
  app = FastAPI(
158
  title="Author RAG — AI Book Sales Chatbot SaaS",
159
  description=(
 
161
  "on any author's website. Powered by a 12-step RAG pipeline."
162
  ),
163
  version="1.0.0",
164
+ docs_url=docs,
165
+ redoc_url=redoc,
166
  lifespan=lifespan,
167
  )
168
 
169
+ # R-100: Sentry error tracking (optional — only in Docker)
170
+ if hasattr(cfg, 'SENTRY_DSN') and cfg.SENTRY_DSN:
171
+ try:
172
+ import importlib
173
+ sentry_sdk = importlib.import_module("sentry_sdk")
174
+ sentry_sdk.init(
175
+ dsn=cfg.SENTRY_DSN,
176
+ traces_sample_rate=0.1,
177
+ environment=cfg.APP_ENV,
178
+ )
179
+ except (ImportError, Exception):
180
+ logger.warning("sentry-sdk not installed — error tracking disabled")
181
+
182
  _register_middleware(app, cfg)
183
  _register_exception_handlers(app)
184
  _register_routers(app)
 
193
  app.add_middleware(SecurityHeadersMiddleware)
194
  app.add_middleware(RateLimitMiddleware)
195
 
196
+ # R-099: Prometheus metrics middleware
197
+ try:
198
+ from app.middleware.metrics import PrometheusMiddleware
199
+ app.add_middleware(PrometheusMiddleware)
200
+ except ImportError:
201
+ pass
202
+
203
  cors_origins = cfg.ALLOWED_ORIGINS if cfg.APP_ENV == "production" else ["*"]
204
  app.add_middleware(
205
  CORSMiddleware,
 
320
  return FileResponse(
321
  WIDGET_TEMPLATE,
322
  headers={
323
+ # R-125: Removed wildcard CORS — widget test page should not be embeddable by any domain
324
  "Cache-Control": "no-cache, no-store, must-revalidate",
325
  },
326
  )
 
331
  from app.dependencies import check_health
332
  return await check_health()
333
 
334
+ @app.get("/metrics", include_in_schema=False)
335
+ async def prometheus_metrics():
336
+ """R-099: Prometheus metrics endpoint."""
337
+ from starlette.responses import Response
338
+ try:
339
+ from app.middleware.metrics import get_metrics_response
340
+ body, content_type = get_metrics_response()
341
+ return Response(content=body, media_type=content_type)
342
+ except ImportError:
343
+ return Response(content=b"# metrics not available\n", media_type="text/plain")
344
+
345
 
346
  # Application instance
347
  app = create_app()
app/middleware/metrics.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Author RAG Chatbot SaaS — Prometheus Metrics Middleware.
2
+
3
+ R-099: Prometheus /metrics endpoint MUST be configured.
4
+ R-102: Per-step pipeline latency MUST be tracked.
5
+
6
+ Exposes request counts, latencies, and business metrics via /metrics endpoint.
7
+ """
8
+
9
+ import time
10
+
11
+ import structlog
12
+ from starlette.types import ASGIApp, Receive, Scope, Send
13
+
14
+ logger = structlog.get_logger(__name__)
15
+
16
+ try:
17
+ from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
18
+
19
+ # ─── HTTP Metrics ─────────────────────────────────────────────────────────
20
+ REQUEST_COUNT = Counter(
21
+ "http_requests_total",
22
+ "Total HTTP requests",
23
+ ["method", "path", "status"],
24
+ )
25
+ REQUEST_LATENCY = Histogram(
26
+ "http_request_duration_seconds",
27
+ "HTTP request latency in seconds",
28
+ ["method", "path"],
29
+ buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
30
+ )
31
+
32
+ # ─── Business Metrics ─────────────────────────────────────────────────────
33
+ CHAT_TURNS = Counter(
34
+ "chat_turns_total",
35
+ "Total chat turns processed",
36
+ ["author_id"],
37
+ )
38
+ TOKEN_USAGE = Counter(
39
+ "tokens_used_total",
40
+ "Total LLM tokens consumed",
41
+ ["author_id"],
42
+ )
43
+ PIPELINE_STEP_LATENCY = Histogram(
44
+ "rag_pipeline_step_duration_ms",
45
+ "RAG pipeline per-step latency in milliseconds",
46
+ ["step"],
47
+ buckets=[1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000],
48
+ )
49
+ INGESTION_COUNT = Counter(
50
+ "document_ingestions_total",
51
+ "Total document ingestions",
52
+ ["status"],
53
+ )
54
+
55
+ PROMETHEUS_AVAILABLE = True
56
+
57
+ except ImportError:
58
+ PROMETHEUS_AVAILABLE = False
59
+ logger.warning("prometheus_client not installed — /metrics endpoint disabled")
60
+
61
+
62
+ class PrometheusMiddleware:
63
+ """ASGI middleware that tracks request counts and latencies."""
64
+
65
+ def __init__(self, app: ASGIApp) -> None:
66
+ self.app = app
67
+
68
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
69
+ if scope["type"] != "http":
70
+ await self.app(scope, receive, send)
71
+ return
72
+
73
+ if not PROMETHEUS_AVAILABLE:
74
+ await self.app(scope, receive, send)
75
+ return
76
+
77
+ path = scope.get("path", "")
78
+ method = scope.get("method", "GET")
79
+
80
+ # Skip metrics endpoint itself to avoid recursion
81
+ if path == "/metrics":
82
+ await self.app(scope, receive, send)
83
+ return
84
+
85
+ # Normalize paths to avoid cardinality explosion
86
+ normalized = _normalize_path(path)
87
+
88
+ start = time.perf_counter()
89
+ status_code = 500 # Default in case of crash
90
+
91
+ async def send_wrapper(message):
92
+ nonlocal status_code
93
+ if message["type"] == "http.response.start":
94
+ status_code = message.get("status", 200)
95
+ await send(message)
96
+
97
+ try:
98
+ await self.app(scope, receive, send_wrapper)
99
+ finally:
100
+ duration = time.perf_counter() - start
101
+ REQUEST_COUNT.labels(method=method, path=normalized, status=str(status_code)).inc()
102
+ REQUEST_LATENCY.labels(method=method, path=normalized).observe(duration)
103
+
104
+
105
+ def _normalize_path(path: str) -> str:
106
+ """Normalize URL paths to prevent label cardinality explosion.
107
+
108
+ Replaces UUIDs and dynamic segments with placeholders.
109
+ """
110
+ import re
111
+ # Replace UUIDs
112
+ path = re.sub(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', '{id}', path)
113
+ # Replace plain hex IDs (32+ chars)
114
+ path = re.sub(r'/[0-9a-f]{32,}', '/{id}', path)
115
+ return path
116
+
117
+
118
+ def get_metrics_response():
119
+ """Generate Prometheus metrics response for /metrics endpoint."""
120
+ if not PROMETHEUS_AVAILABLE:
121
+ return b"# prometheus_client not installed\n", "text/plain"
122
+ return generate_latest(), CONTENT_TYPE_LATEST
app/middleware/security_headers.py CHANGED
@@ -32,6 +32,11 @@ class SecurityHeadersMiddleware:
32
  (b"x-xss-protection", b"1; mode=block"),
33
  (b"referrer-policy", b"strict-origin-when-cross-origin"),
34
  (b"permissions-policy", b"camera=(), microphone=(), geolocation=(), payment=()"),
 
 
 
 
 
35
  ]
36
  if use_hsts:
37
  security_headers.insert(
 
32
  (b"x-xss-protection", b"1; mode=block"),
33
  (b"referrer-policy", b"strict-origin-when-cross-origin"),
34
  (b"permissions-policy", b"camera=(), microphone=(), geolocation=(), payment=()"),
35
+ # R-043: CSP — primary XSS defense
36
+ (b"content-security-policy",
37
+ b"default-src 'self'; script-src 'self' 'unsafe-inline'; "
38
+ b"style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; "
39
+ b"connect-src 'self' https://api.openai.com; frame-ancestors 'none'"),
40
  ]
41
  if use_hsts:
42
  security_headers.insert(
app/models/user.py CHANGED
@@ -26,6 +26,7 @@ class User(Base, TimestampMixin):
26
  is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
27
  failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
28
  locked_until: Mapped[str | None] = mapped_column(String(50), nullable=True)
 
29
 
30
  # Author profile fields
31
  full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
 
26
  is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
27
  failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
28
  locked_until: Mapped[str | None] = mapped_column(String(50), nullable=True)
29
+ totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True) # R-134
30
 
31
  # Author profile fields
32
  full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
app/schemas/admin.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Author RAG Chatbot SaaS — Admin API Pydantic Schemas.
2
+
3
+ R-029: ALL admin API endpoints MUST use Pydantic BaseModel schemas (NOT raw dict).
4
+ R-010: Password change MUST validate via Pydantic schema.
5
+ """
6
+
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, EmailStr, Field, HttpUrl, field_validator
10
+
11
+
12
+ # ─── Authentication ────────────────────────────────────────────────────────────
13
+
14
+ class PasswordChangeRequest(BaseModel):
15
+ """R-010: Password change via validated schema."""
16
+ current_password: str = Field(min_length=1)
17
+ new_password: str = Field(min_length=8, max_length=128)
18
+
19
+ @field_validator("new_password")
20
+ @classmethod
21
+ def validate_new_password(cls, v: str) -> str:
22
+ """R-009: Enforce password complexity."""
23
+ if not any(c.isupper() for c in v):
24
+ raise ValueError("Password must contain at least one uppercase letter")
25
+ if not any(c.islower() for c in v):
26
+ raise ValueError("Password must contain at least one lowercase letter")
27
+ if not any(c.isdigit() for c in v):
28
+ raise ValueError("Password must contain at least one digit")
29
+ return v
30
+
31
+
32
+ # ─── Widget Configuration ─────────────────────────────────────────────────────
33
+
34
+ class WidgetConfigUpdate(BaseModel):
35
+ """Widget appearance and behavior settings."""
36
+ bot_name: str | None = Field(None, max_length=100)
37
+ welcome_message: str | None = Field(None, max_length=500)
38
+ theme: str | None = Field(None, pattern=r"^(midnight|ocean|forest|sunset|minimal)$")
39
+ position: str | None = Field(None, pattern=r"^(bottom-right|bottom-left)$")
40
+ auto_open_delay: int | None = Field(None, ge=0, le=60)
41
+ is_active: bool | None = None
42
+
43
+
44
+ # ─── Author Profile ───────────────────────────────────────────────────────────
45
+
46
+ class ProfileUpdate(BaseModel):
47
+ """Author profile update fields."""
48
+ full_name: str | None = Field(None, max_length=255)
49
+ website: str | None = Field(None, max_length=500)
50
+ bio: str | None = Field(None, max_length=2000)
51
+ timezone: str | None = Field(None, max_length=100)
52
+
53
+
54
+ class PersonalityUpdate(BaseModel):
55
+ """Bot personality configuration."""
56
+ response_style: Literal["balanced", "formal", "casual", "enthusiastic"] | None = None
57
+ fallback_message: str | None = Field(None, max_length=500)
58
+ out_of_scope_message: str | None = Field(None, max_length=500)
59
+
60
+
61
+ class NotificationUpdate(BaseModel):
62
+ """Notification preferences."""
63
+ weekly_digest: bool | None = None
64
+ token_alerts: bool | None = None
65
+ new_conversation: bool | None = None
66
+ subscription_expiry: bool | None = None
67
+
68
+
69
+ # ─── Smart Links ──────────────────────────────────────────────────────────────
70
+
71
+ class SmartLinkUpdate(BaseModel):
72
+ """R-031: URL validation on smart link updates."""
73
+ buy_url: str | None = Field(None, max_length=2000)
74
+ preview_url: str | None = Field(None, max_length=2000)
75
+
76
+ @field_validator("buy_url", "preview_url")
77
+ @classmethod
78
+ def validate_url_scheme(cls, v: str | None) -> str | None:
79
+ """R-031: Validate URL scheme to prevent SSRF."""
80
+ if v is None or v == "":
81
+ return v
82
+ if not v.startswith(("https://", "http://")):
83
+ raise ValueError("URL must start with https:// or http://")
84
+ return v
85
+
86
+
87
+ # ─── Q&A ──────────────────────────────────────────────────────────────────────
88
+
89
+ class QACreateRequest(BaseModel):
90
+ """Custom Q&A training data entry."""
91
+ question: str = Field(min_length=1, max_length=500)
92
+ answer: str = Field(min_length=1, max_length=2000)
93
+ book_id: str | None = None
94
+ priority: int = Field(default=0, ge=0, le=100)
95
+ category: str | None = Field(None, max_length=50)
96
+ match_threshold: float = Field(default=0.85, ge=0.5, le=1.0)
97
+
98
+
99
+ class QAUpdateRequest(BaseModel):
100
+ """Custom Q&A update (partial)."""
101
+ question: str | None = Field(None, min_length=1, max_length=500)
102
+ answer: str | None = Field(None, min_length=1, max_length=2000)
103
+ priority: int | None = Field(None, ge=0, le=100)
104
+ category: str | None = Field(None, max_length=50)
105
+ match_threshold: float | None = Field(None, ge=0.5, le=1.0)
106
+
107
+
108
+ # ─── Chat Annotations ─────────────────────────────────────────────────────────
109
+
110
+ class AnnotateRequest(BaseModel):
111
+ """Admin annotation on a chat message."""
112
+ annotation: str = Field(min_length=1, max_length=2000)
113
+
114
+
115
+ class FlagRequest(BaseModel):
116
+ """Flag a chat message for review."""
117
+ flag_type: Literal["spam", "quality", "escalation"] | None = None
118
+
119
+
120
+ # ─── Visitor Interactions ─────────────────────────────────────────────────────
121
+
122
+ class TrackClickRequest(BaseModel):
123
+ """Track purchase/preview link click."""
124
+ session_id: str
125
+ link_type: str = "purchase"
126
+ book_id: str | None = None
127
+
128
+
129
+ class FeedbackRequest(BaseModel):
130
+ """Thumbs up/down on a bot message."""
131
+ session_id: str
132
+ message_index: int = Field(ge=0)
133
+ reaction: Literal["up", "down"]
134
+
135
+
136
+ class RateSessionRequest(BaseModel):
137
+ """Star rating for a chat session."""
138
+ session_id: str
139
+ rating: int = Field(ge=1, le=5)
140
+
141
+
142
+ # ─── Announcements ────────────────────────────────────────────────────────────
143
+
144
+ class AnnouncementRequest(BaseModel):
145
+ """Author announcement message."""
146
+ message: str = Field(min_length=1, max_length=2000)
147
+
148
+
149
+ # ─── SuperAdmin ───────────────────────────────────────────────────────────────
150
+
151
+ class GrantByEmailRequest(BaseModel):
152
+ """SuperAdmin: grant access by email."""
153
+ author_email: EmailStr
154
+ plan: str = "monthly"
155
+ token_budget: int = Field(default=500000, ge=10000)
156
+ notes: str = ""
app/services/auth_service.py CHANGED
@@ -51,8 +51,24 @@ class AuthService:
51
  Dict with access_token and user data.
52
 
53
  Raises:
54
- ValueError: If email is already registered.
55
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  existing = await self._repo.get_by_email(email)
57
  if existing:
58
  raise ValueError("An account with this email already exists")
 
51
  Dict with access_token and user data.
52
 
53
  Raises:
54
+ ValueError: If email is already registered or validation fails.
55
  """
56
+ # R-008: Validate email format
57
+ import re
58
+ email = email.lower().strip()
59
+ if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
60
+ raise ValueError("Invalid email format")
61
+
62
+ # R-009: Enforce password complexity
63
+ if len(password) < 8:
64
+ raise ValueError("Password must be at least 8 characters")
65
+ if not any(c.isupper() for c in password):
66
+ raise ValueError("Password must contain at least one uppercase letter")
67
+ if not any(c.islower() for c in password):
68
+ raise ValueError("Password must contain at least one lowercase letter")
69
+ if not any(c.isdigit() for c in password):
70
+ raise ValueError("Password must contain at least one digit")
71
+
72
  existing = await self._repo.get_by_email(email)
73
  if existing:
74
  raise ValueError("An account with this email already exists")
app/services/superadmin_service.py CHANGED
@@ -165,6 +165,19 @@ class SuperAdminService:
165
 
166
  await clear_token_usage_counters(self._redis, author_id)
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  email = author.email
169
  name = author.full_name or email
170
 
@@ -395,8 +408,8 @@ class SuperAdminService:
395
  raise ValueError("Cannot extend a revoked subscription — create a new grant instead")
396
 
397
  # Extend from NOW if already expired, otherwise from current expiry
398
- # Use naive utcnow() to match SQLite naive datetimes
399
- now = datetime.utcnow()
400
  exp = access.expires_at
401
  if exp and exp.tzinfo is not None:
402
  exp = exp.replace(tzinfo=None) # strip tz if stored as aware
 
165
 
166
  await clear_token_usage_counters(self._redis, author_id)
167
 
168
+ # R-138: Clean up ChromaDB collections to prevent orphaned vectors
169
+ try:
170
+ from app.core.chroma_client import get_chroma_client
171
+ chroma = get_chroma_client()
172
+ author_prefix = f"a{author_id.replace('-', '')[:12]}"
173
+ for col in chroma.list_collections():
174
+ col_name = col.name if hasattr(col, 'name') else str(col)
175
+ if col_name.startswith(author_prefix):
176
+ chroma.delete_collection(col_name)
177
+ logger.info("Deleted ChromaDB collection", collection=col_name)
178
+ except Exception as e:
179
+ logger.warning("ChromaDB cleanup failed during author delete", error=str(e))
180
+
181
  email = author.email
182
  name = author.full_name or email
183
 
 
408
  raise ValueError("Cannot extend a revoked subscription — create a new grant instead")
409
 
410
  # Extend from NOW if already expired, otherwise from current expiry
411
+ # R-051: Use timezone-aware UTC datetime
412
+ now = datetime.now(timezone.utc)
413
  exp = access.expires_at
414
  if exp and exp.tzinfo is not None:
415
  exp = exp.replace(tzinfo=None) # strip tz if stored as aware
app/services/token_budget.py CHANGED
@@ -86,7 +86,16 @@ async def record_token_usage(
86
  if not access:
87
  return
88
 
89
- access.tokens_used = (access.tokens_used or 0) + token_count
 
 
 
 
 
 
 
 
 
90
  budget = total_budget(access)
91
 
92
  if budget > 0 and access.tokens_used >= budget:
 
86
  if not access:
87
  return
88
 
89
+ # R-014/R-052: Atomic SQL UPDATE instead of Python read-modify-write
90
+ from sqlalchemy import update
91
+ from app.models.client_access import ClientAccess as CA
92
+ await db.execute(
93
+ update(CA)
94
+ .where(CA.id == access.id, CA.author_id == access.author_id)
95
+ .values(tokens_used=CA.tokens_used + token_count)
96
+ )
97
+ await db.flush()
98
+ await db.refresh(access)
99
  budget = total_budget(access)
100
 
101
  if budget > 0 and access.tokens_used >= budget:
app/superadmin/router.py CHANGED
@@ -47,7 +47,7 @@ def _err(e: Exception, context: str = "") -> JSONResponse:
47
  logger.error("SuperAdmin endpoint error", context=context, error=str(e), traceback=tb)
48
  return JSONResponse(
49
  status_code=500,
50
- content={"detail": f"Internal error: {str(e)[:200]}"},
51
  )
52
 
53
 
@@ -74,9 +74,9 @@ async def superadmin_index():
74
  # ── Diagnostic (no auth) ─────────────────────────────────────────────────────
75
 
76
  @router.get("/diag")
77
- async def diagnostic():
78
- """Public diagnostic endpoint — returns without auth to verify routing works."""
79
- return {"status": "ok", "router": "superadmin", "auth": "not_checked"}
80
 
81
 
82
  # ── Authors ───────────────────────────────────────────────────────────────────
@@ -467,7 +467,7 @@ async def list_all_grants(
467
  )
468
  rows = result.all()
469
 
470
- now = datetime.utcnow() # naive matches SQLite stored datetimes
471
  grants = []
472
  for access, user in rows:
473
  total_budget = access.token_budget + access.bonus_tokens
@@ -543,8 +543,8 @@ async def get_author_embed_token(
543
  if not user:
544
  raise HTTPException(404, "Author not found")
545
 
546
- # Get their active grant — use naive utcnow to match SQLite naive datetimes
547
- now = datetime.utcnow()
548
  result = await db.execute(
549
  select(ClientAccess)
550
  .where(
 
47
  logger.error("SuperAdmin endpoint error", context=context, error=str(e), traceback=tb)
48
  return JSONResponse(
49
  status_code=500,
50
+ content={"detail": "An internal error occurred. Please try again or contact support."},
51
  )
52
 
53
 
 
74
  # ── Diagnostic (no auth) ─────────────────────────────────────────────────────
75
 
76
  @router.get("/diag")
77
+ async def diagnostic(superadmin=Depends(get_current_superadmin)):
78
+ """R-045: Diagnostic endpoint — requires SuperAdmin auth."""
79
+ return {"status": "ok", "router": "superadmin", "auth": "verified"}
80
 
81
 
82
  # ── Authors ───────────────────────────────────────────────────────────────────
 
467
  )
468
  rows = result.all()
469
 
470
+ now = datetime.now(timezone.utc) # R-051: timezone-aware UTC
471
  grants = []
472
  for access, user in rows:
473
  total_budget = access.token_budget + access.bonus_tokens
 
543
  if not user:
544
  raise HTTPException(404, "Author not found")
545
 
546
+ # R-051: Use timezone-aware UTC datetime
547
+ now = datetime.now(timezone.utc)
548
  result = await db.execute(
549
  select(ClientAccess)
550
  .where(
app/tasks/backup_task.py CHANGED
@@ -14,6 +14,7 @@ from pathlib import Path
14
  import structlog
15
 
16
  from app.config import get_settings
 
17
 
18
  logger = structlog.get_logger(__name__)
19
  cfg = get_settings()
@@ -97,6 +98,7 @@ def _cleanup_old_backups(subdir: str) -> None:
97
  logger.info("Removed old backup", path=str(d))
98
 
99
 
 
100
  def run_backup() -> dict:
101
  """Run full backup cycle."""
102
  results = {
 
14
  import structlog
15
 
16
  from app.config import get_settings
17
+ from app.tasks.celery_app import celery_app
18
 
19
  logger = structlog.get_logger(__name__)
20
  cfg = get_settings()
 
98
  logger.info("Removed old backup", path=str(d))
99
 
100
 
101
+ @celery_app.task(name="app.tasks.backup_task.run_backup")
102
  def run_backup() -> dict:
103
  """Run full backup cycle."""
104
  results = {
app/tasks/celery_app.py CHANGED
@@ -22,6 +22,7 @@ celery_app = Celery(
22
  "app.tasks.expiry_check_task",
23
  "app.tasks.link_health_task",
24
  "app.tasks.geo_update_task",
 
25
  ],
26
  )
27
 
@@ -63,4 +64,9 @@ celery_app.conf.beat_schedule = {
63
  "task": "app.tasks.email_task.send_weekly_digests",
64
  "schedule": crontab(hour=9, minute=0, day_of_week=1),
65
  },
 
 
 
 
 
66
  }
 
22
  "app.tasks.expiry_check_task",
23
  "app.tasks.link_health_task",
24
  "app.tasks.geo_update_task",
25
+ "app.tasks.backup_task", # R-131: Backup task must be in include list
26
  ],
27
  )
28
 
 
64
  "task": "app.tasks.email_task.send_weekly_digests",
65
  "schedule": crontab(hour=9, minute=0, day_of_week=1),
66
  },
67
+ # R-106/R-131: Database backup: daily at 4am UTC
68
+ "daily-database-backup": {
69
+ "task": "app.tasks.backup_task.run_backup",
70
+ "schedule": crontab(hour=4, minute=0),
71
+ },
72
  }
app/tasks/link_health_task.py CHANGED
@@ -56,6 +56,8 @@ async def _run():
56
  async def _check_url(client: httpx.AsyncClient, url: str) -> bool:
57
  """Check if a URL returns an OK response.
58
 
 
 
59
  Args:
60
  client: httpx async client.
61
  url: URL to check.
@@ -63,8 +65,28 @@ async def _check_url(client: httpx.AsyncClient, url: str) -> bool:
63
  Returns:
64
  True if URL is reachable (2xx/3xx), False otherwise.
65
  """
 
 
 
 
66
  try:
67
  response = await client.head(url)
68
  return response.status_code < 400
69
  except Exception:
70
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  async def _check_url(client: httpx.AsyncClient, url: str) -> bool:
57
  """Check if a URL returns an OK response.
58
 
59
+ Includes SSRF protection — refuses to fetch internal/private IPs (R-130).
60
+
61
  Args:
62
  client: httpx async client.
63
  url: URL to check.
 
65
  Returns:
66
  True if URL is reachable (2xx/3xx), False otherwise.
67
  """
68
+ # R-130: SSRF protection — block internal/private IPs before fetching
69
+ if _is_internal_url(url):
70
+ logger.warning("Link health: blocked internal URL", url=url[:100])
71
+ return False
72
  try:
73
  response = await client.head(url)
74
  return response.status_code < 400
75
  except Exception:
76
  return False
77
+
78
+
79
+ def _is_internal_url(url: str) -> bool:
80
+ """Return True if the URL resolves to an internal/private/loopback IP."""
81
+ import ipaddress
82
+ import socket
83
+ from urllib.parse import urlparse
84
+
85
+ try:
86
+ hostname = urlparse(url).hostname
87
+ if not hostname:
88
+ return True
89
+ ip = ipaddress.ip_address(socket.gethostbyname(hostname))
90
+ return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
91
+ except Exception:
92
+ return True # Fail safe — block if we can't resolve
docker-compose.yml CHANGED
@@ -1,4 +1,4 @@
1
- version: "3.9"
2
 
3
  # Author RAG Chatbot SaaS — Docker Compose (Development)
4
  # Usage: docker-compose up -d
@@ -120,7 +120,7 @@ services:
120
  - "5555:5555"
121
  depends_on:
122
  - redis
123
- command: celery -A app.tasks.celery_app flower --port=5555
124
 
125
  volumes:
126
  postgres_data:
 
1
+ # R-135: Removed deprecated version key (Docker Compose v2+ ignores it)
2
 
3
  # Author RAG Chatbot SaaS — Docker Compose (Development)
4
  # Usage: docker-compose up -d
 
120
  - "5555:5555"
121
  depends_on:
122
  - redis
123
+ command: celery -A app.tasks.celery_app flower --port=5555 --basic-auth=admin:${FLOWER_PASSWORD:-changeme}
124
 
125
  volumes:
126
  postgres_data: