GitHub Actions commited on
Commit
889e86b
·
1 Parent(s): 5b5cb48

Sync from GitHub · 2026-09-24 23:37 UTC

Browse files
main.py CHANGED
@@ -11,13 +11,10 @@ load_dotenv()
11
  from fastapi import FastAPI, Request
12
  from fastapi.middleware.cors import CORSMiddleware
13
  from fastapi.responses import JSONResponse
14
- from slowapi import Limiter, _rate_limit_exceeded_handler
15
- from slowapi.util import get_remote_address
16
- from slowapi.errors import RateLimitExceeded
17
  import firebase_admin
18
  from firebase_admin import credentials
19
 
20
- from routes import bff, chat, transcribe, documents, consultation, planning, admin
21
 
22
 
23
  # ─── Firebase Admin SDK initialisation ───────────────────────────────────────
@@ -42,10 +39,6 @@ def _init_firebase():
42
  _init_firebase()
43
 
44
 
45
- # ─── Rate limiter ─────────────────────────────────────────────────────────────
46
- limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
47
-
48
-
49
  # ─── App lifecycle ────────────────────────────────────────────────────────────
50
  @asynccontextmanager
51
  async def lifespan(app: FastAPI):
@@ -65,9 +58,6 @@ app = FastAPI(
65
  lifespan=lifespan,
66
  )
67
 
68
- app.state.limiter = limiter
69
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
70
-
71
 
72
  # ─── CORS ─────────────────────────────────────────────────────────────────────
73
  allowed_origins_str = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000")
@@ -100,6 +90,7 @@ app.include_router(documents.router, prefix="/api/documents", tags=["Docu
100
  app.include_router(consultation.router, prefix="/api/consultation", tags=["Consultation"])
101
  app.include_router(planning.router, prefix="/api/planning", tags=["Planning"])
102
  app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
 
103
 
104
 
105
  # ─── Health check (unauthenticated) ──────────────────────────────────────────
 
11
  from fastapi import FastAPI, Request
12
  from fastapi.middleware.cors import CORSMiddleware
13
  from fastapi.responses import JSONResponse
 
 
 
14
  import firebase_admin
15
  from firebase_admin import credentials
16
 
17
+ from routes import bff, chat, transcribe, documents, consultation, planning, admin, account
18
 
19
 
20
  # ─── Firebase Admin SDK initialisation ───────────────────────────────────────
 
39
  _init_firebase()
40
 
41
 
 
 
 
 
42
  # ─── App lifecycle ────────────────────────────────────────────────────────────
43
  @asynccontextmanager
44
  async def lifespan(app: FastAPI):
 
58
  lifespan=lifespan,
59
  )
60
 
 
 
 
61
 
62
  # ─── CORS ─────────────────────────────────────────────────────────────────────
63
  allowed_origins_str = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000")
 
90
  app.include_router(consultation.router, prefix="/api/consultation", tags=["Consultation"])
91
  app.include_router(planning.router, prefix="/api/planning", tags=["Planning"])
92
  app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
93
+ app.include_router(account.router, prefix="/api/account", tags=["Account"])
94
 
95
 
96
  # ─── Health check (unauthenticated) ──────────────────────────────────────────
requirements.txt CHANGED
@@ -3,7 +3,6 @@ uvicorn[standard]==0.30.6
3
  python-dotenv==1.0.1
4
  firebase-admin==6.5.0
5
  groq==0.11.0
6
- slowapi==0.1.9
7
  python-multipart==0.0.12
8
  pydantic==2.9.2
9
  httpx==0.27.2
 
3
  python-dotenv==1.0.1
4
  firebase-admin==6.5.0
5
  groq==0.11.0
 
6
  python-multipart==0.0.12
7
  pydantic==2.9.2
8
  httpx==0.27.2
routes/account.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Account Routes — self-service actions on the caller's own account.
3
+
4
+ Unlike routes/admin.py, these never take a target uid from the request — the
5
+ uid always comes from the verified Firebase ID token, so a user can only ever
6
+ act on their own account.
7
+ """
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException
10
+ from firebase_admin import auth as fb_auth, firestore
11
+ from auth import verify_firebase_token
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ def delete_all_user_data(uid: str) -> None:
17
+ """
18
+ Deletes every Firestore document owned by a user: each business's
19
+ subcollections, the business docs themselves, xpHistory, and the
20
+ user_data root doc. Shared by this self-delete route and
21
+ routes/admin.py's delete_user so the cascade is defined once.
22
+ """
23
+ db = firestore.client()
24
+ user_ref = db.collection("user_data").document(uid)
25
+
26
+ for biz in user_ref.collection("businesses").stream():
27
+ for sub in ["bff", "profile_output", "documents", "kanban", "milestones", "sessions", "team"]:
28
+ for sdoc in biz.reference.collection(sub).stream():
29
+ sdoc.reference.delete()
30
+ biz.reference.delete()
31
+
32
+ for entry in user_ref.collection("xpHistory").stream():
33
+ entry.reference.delete()
34
+
35
+ user_ref.delete()
36
+
37
+
38
+ @router.delete("/me")
39
+ def delete_own_account(user: dict = Depends(verify_firebase_token)):
40
+ """
41
+ Deletes the caller's own account: all Firestore data (via the Admin SDK,
42
+ which bypasses firestore.rules — user_data deletion is deliberately
43
+ backend-only, see firestore.rules' own comment) and the Firebase Auth
44
+ user record. The frontend re-authenticates the user with their password
45
+ immediately before calling this, as an extra confirmation step.
46
+ """
47
+ uid = user.get("uid")
48
+ if not uid:
49
+ raise HTTPException(status_code=401, detail="Unauthorized.")
50
+
51
+ delete_all_user_data(uid)
52
+
53
+ try:
54
+ fb_auth.delete_user(uid)
55
+ except fb_auth.UserNotFoundError:
56
+ pass # already gone — not an error for a delete
57
+
58
+ return {"success": True}
routes/admin.py CHANGED
@@ -15,6 +15,7 @@ from fastapi import APIRouter, HTTPException, Depends
15
  from pydantic import BaseModel
16
  from firebase_admin import auth as fb_auth, firestore
17
  from auth import verify_firebase_token
 
18
 
19
  router = APIRouter()
20
 
@@ -100,20 +101,11 @@ def delete_user(
100
  ):
101
  admin_uid = _verify_admin(user)
102
  if target_uid == admin_uid:
 
 
103
  raise HTTPException(status_code=400, detail="Admins cannot delete their own account.")
104
 
105
- db = firestore.client()
106
-
107
- # Delete Firestore user data (businesses subcollection + root doc)
108
- user_ref = db.collection("user_data").document(target_uid)
109
- biz_docs = user_ref.collection("businesses").stream()
110
- for biz in biz_docs:
111
- # Delete nested subcollections of each business
112
- for sub in ["bff", "profile_output", "documents", "kanban", "milestones", "sessions", "team"]:
113
- for sdoc in biz.reference.collection(sub).stream():
114
- sdoc.reference.delete()
115
- biz.reference.delete()
116
- user_ref.delete()
117
 
118
  # Delete Firebase Auth account
119
  try:
 
15
  from pydantic import BaseModel
16
  from firebase_admin import auth as fb_auth, firestore
17
  from auth import verify_firebase_token
18
+ from routes.account import delete_all_user_data
19
 
20
  router = APIRouter()
21
 
 
101
  ):
102
  admin_uid = _verify_admin(user)
103
  if target_uid == admin_uid:
104
+ # Self-deletion as an admin goes through DELETE /api/account/me instead,
105
+ # same as any other user — this endpoint is for deleting *other* accounts.
106
  raise HTTPException(status_code=400, detail="Admins cannot delete their own account.")
107
 
108
+ delete_all_user_data(target_uid)
 
 
 
 
 
 
 
 
 
 
 
109
 
110
  # Delete Firebase Auth account
111
  try:
routes/bff.py CHANGED
@@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, Depends
3
  from pydantic import BaseModel
4
  from firebase_admin import firestore
5
  from auth import verify_firebase_token
 
6
  from utils.groq_client import call_groq_json, HEAVY_MODEL
7
  from utils.pinecone_client import query_book_context
8
 
@@ -125,7 +126,7 @@ def _call_groq_json(system_prompt: str, user_prompt: str) -> dict:
125
  @router.post("/generate-followups")
126
  def generate_followups(
127
  body: GenerateFollowupsRequest,
128
- user: dict = Depends(verify_firebase_token),
129
  ):
130
  """
131
  After each BFF section is submitted, generates 3–5 personalized
@@ -195,7 +196,7 @@ Question types:
195
  @router.post("/generate-profile")
196
  def generate_profile(
197
  body: GenerateProfileRequest,
198
- user: dict = Depends(verify_firebase_token),
199
  ):
200
  """
201
  Reads all completed BFF sections from Firestore and generates all 4
 
3
  from pydantic import BaseModel
4
  from firebase_admin import firestore
5
  from auth import verify_firebase_token
6
+ from utils.rate_limiter import with_rate_limit
7
  from utils.groq_client import call_groq_json, HEAVY_MODEL
8
  from utils.pinecone_client import query_book_context
9
 
 
126
  @router.post("/generate-followups")
127
  def generate_followups(
128
  body: GenerateFollowupsRequest,
129
+ user: dict = Depends(with_rate_limit("generation")),
130
  ):
131
  """
132
  After each BFF section is submitted, generates 3–5 personalized
 
196
  @router.post("/generate-profile")
197
  def generate_profile(
198
  body: GenerateProfileRequest,
199
+ user: dict = Depends(with_rate_limit("generation")),
200
  ):
201
  """
202
  Reads all completed BFF sections from Firestore and generates all 4
routes/chat.py CHANGED
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, Depends
3
  from fastapi.responses import StreamingResponse
4
  from pydantic import BaseModel
5
  from firebase_admin import firestore
6
- from auth import verify_firebase_token
7
  from utils.groq_client import call_groq_stream, LIGHT_MODEL, HEAVY_MODEL
8
  from utils.pinecone_client import query_book_context
9
 
@@ -65,7 +65,7 @@ def _get_business_context(user_id: str, business_id: str) -> str:
65
  @router.post("/message")
66
  def chat_message(
67
  body: ChatRequest,
68
- user: dict = Depends(verify_firebase_token),
69
  ):
70
  """
71
  Accepts a user message and returns a streaming SSE response.
 
3
  from fastapi.responses import StreamingResponse
4
  from pydantic import BaseModel
5
  from firebase_admin import firestore
6
+ from utils.rate_limiter import with_rate_limit
7
  from utils.groq_client import call_groq_stream, LIGHT_MODEL, HEAVY_MODEL
8
  from utils.pinecone_client import query_book_context
9
 
 
65
  @router.post("/message")
66
  def chat_message(
67
  body: ChatRequest,
68
+ user: dict = Depends(with_rate_limit("chat")),
69
  ):
70
  """
71
  Accepts a user message and returns a streaming SSE response.
routes/consultation.py CHANGED
@@ -17,7 +17,7 @@ from datetime import datetime
17
  from fastapi import APIRouter, HTTPException, Depends
18
  from pydantic import BaseModel
19
  from firebase_admin import firestore
20
- from auth import verify_firebase_token
21
  from utils.groq_client import call_groq_json, HEAVY_MODEL
22
  from utils.pinecone_client import query_book_context
23
 
@@ -52,7 +52,7 @@ def _read_profile(user_id: str, business_id: str) -> dict:
52
  @router.post("/start")
53
  def start_consultation(
54
  body: StartRequest,
55
- user: dict = Depends(verify_firebase_token),
56
  ):
57
  """
58
  Reads the entrepreneur's business profile and generates a warm, structured
@@ -118,7 +118,7 @@ Return JSON:
118
  @router.post("/report")
119
  def generate_report(
120
  body: ReportRequest,
121
- user: dict = Depends(verify_firebase_token),
122
  ):
123
  """
124
  Takes the full session transcript and generates a structured consultation
 
17
  from fastapi import APIRouter, HTTPException, Depends
18
  from pydantic import BaseModel
19
  from firebase_admin import firestore
20
+ from utils.rate_limiter import with_rate_limit
21
  from utils.groq_client import call_groq_json, HEAVY_MODEL
22
  from utils.pinecone_client import query_book_context
23
 
 
52
  @router.post("/start")
53
  def start_consultation(
54
  body: StartRequest,
55
+ user: dict = Depends(with_rate_limit("generation")),
56
  ):
57
  """
58
  Reads the entrepreneur's business profile and generates a warm, structured
 
118
  @router.post("/report")
119
  def generate_report(
120
  body: ReportRequest,
121
+ user: dict = Depends(with_rate_limit("generation")),
122
  ):
123
  """
124
  Takes the full session transcript and generates a structured consultation
routes/documents.py CHANGED
@@ -17,7 +17,7 @@ import json
17
  from fastapi import APIRouter, HTTPException, Depends
18
  from pydantic import BaseModel
19
  from firebase_admin import firestore
20
- from auth import verify_firebase_token
21
  from utils.groq_client import call_groq_json, HEAVY_MODEL
22
  from utils.pinecone_client import query_book_context
23
 
@@ -137,7 +137,7 @@ Generate a 7-slide Pitch Deck. Return JSON where each key is a slide:
137
  @router.post("/generate-content")
138
  def generate_document_content(
139
  body: GenerateDocRequest,
140
- user: dict = Depends(verify_firebase_token),
141
  ):
142
  """
143
  Generates AI content for Business Plan or Pitch Deck.
 
17
  from fastapi import APIRouter, HTTPException, Depends
18
  from pydantic import BaseModel
19
  from firebase_admin import firestore
20
+ from utils.rate_limiter import with_rate_limit
21
  from utils.groq_client import call_groq_json, HEAVY_MODEL
22
  from utils.pinecone_client import query_book_context
23
 
 
137
  @router.post("/generate-content")
138
  def generate_document_content(
139
  body: GenerateDocRequest,
140
+ user: dict = Depends(with_rate_limit("generation")),
141
  ):
142
  """
143
  Generates AI content for Business Plan or Pitch Deck.
routes/planning.py CHANGED
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta
13
  from fastapi import APIRouter, HTTPException, Depends
14
  from pydantic import BaseModel
15
  from firebase_admin import firestore
16
- from auth import verify_firebase_token
17
  from utils.groq_client import call_groq_json, HEAVY_MODEL
18
  from utils.pinecone_client import query_book_context
19
 
@@ -47,7 +47,7 @@ def _read_context(user_id: str, business_id: str) -> dict:
47
  @router.post("/suggest")
48
  def suggest_planning_items(
49
  body: SuggestRequest,
50
- user: dict = Depends(verify_firebase_token),
51
  ):
52
  if body.type not in ("tasks", "milestones"):
53
  raise HTTPException(status_code=400, detail="type must be 'tasks' or 'milestones'")
 
13
  from fastapi import APIRouter, HTTPException, Depends
14
  from pydantic import BaseModel
15
  from firebase_admin import firestore
16
+ from utils.rate_limiter import with_rate_limit
17
  from utils.groq_client import call_groq_json, HEAVY_MODEL
18
  from utils.pinecone_client import query_book_context
19
 
 
47
  @router.post("/suggest")
48
  def suggest_planning_items(
49
  body: SuggestRequest,
50
+ user: dict = Depends(with_rate_limit("generation")),
51
  ):
52
  if body.type not in ("tasks", "milestones"):
53
  raise HTTPException(status_code=400, detail="type must be 'tasks' or 'milestones'")
routes/transcribe.py CHANGED
@@ -1,5 +1,5 @@
1
  from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
2
- from auth import verify_firebase_token
3
  from utils.groq_client import call_groq_transcribe
4
 
5
  router = APIRouter()
@@ -15,7 +15,7 @@ MAX_AUDIO_BYTES = 25 * 1024 * 1024 # 25 MB — Groq Whisper limit
15
  @router.post("/transcribe")
16
  async def transcribe_audio(
17
  audio: UploadFile = File(...),
18
- user: dict = Depends(verify_firebase_token),
19
  ):
20
  """
21
  Accepts a browser-recorded audio blob, sends it to Groq Whisper,
 
1
  from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
2
+ from utils.rate_limiter import with_rate_limit
3
  from utils.groq_client import call_groq_transcribe
4
 
5
  router = APIRouter()
 
15
  @router.post("/transcribe")
16
  async def transcribe_audio(
17
  audio: UploadFile = File(...),
18
+ user: dict = Depends(with_rate_limit("transcription")),
19
  ):
20
  """
21
  Accepts a browser-recorded audio blob, sends it to Groq Whisper,
tests/test_account.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for routes/account.py's self-delete cascade.
3
+
4
+ Scope: verifies delete_all_user_data's Firestore traversal logic (every business's
5
+ subcollections, xpHistory, the root doc) using a fake Firestore client, per
6
+ docs/qa/test-strategy.md's unit layer. The actual Firebase Auth deletion
7
+ (fb_auth.delete_user) and the FastAPI route wiring are integration-level concerns,
8
+ not covered here.
9
+ """
10
+ from unittest.mock import MagicMock
11
+ from routes.account import delete_all_user_data
12
+
13
+
14
+ def _make_fake_db():
15
+ """
16
+ Builds a fake Firestore client deep enough to exercise delete_all_user_data:
17
+ one business with two subcollections each holding one doc, plus one
18
+ xpHistory entry. Returns (db, deleted_refs) where deleted_refs collects
19
+ every .delete() call made, in call order.
20
+ """
21
+ deleted = []
22
+
23
+ def make_doc(name):
24
+ doc = MagicMock(name=f"doc:{name}")
25
+ doc.reference = MagicMock(name=f"ref:{name}")
26
+ doc.reference.delete = MagicMock(side_effect=lambda n=name: deleted.append(n))
27
+ return doc
28
+
29
+ biz_doc = make_doc("business:biz1")
30
+
31
+ def sub_collection(name):
32
+ coll = MagicMock(name=f"collection:{name}")
33
+ coll.stream = MagicMock(return_value=iter([make_doc(f"{name}:item1")]))
34
+ return coll
35
+
36
+ biz_doc.reference.collection = MagicMock(side_effect=sub_collection)
37
+
38
+ businesses_collection = MagicMock(name="collection:businesses")
39
+ businesses_collection.stream = MagicMock(return_value=iter([biz_doc]))
40
+
41
+ xp_history_collection = MagicMock(name="collection:xpHistory")
42
+ xp_history_collection.stream = MagicMock(return_value=iter([make_doc("xpHistory:entry1")]))
43
+
44
+ user_ref = MagicMock(name="ref:user_data/uid")
45
+ user_ref.delete = MagicMock(side_effect=lambda: deleted.append("user_data/uid"))
46
+
47
+ def user_ref_collection(name):
48
+ return {"businesses": businesses_collection, "xpHistory": xp_history_collection}[name]
49
+
50
+ user_ref.collection = MagicMock(side_effect=user_ref_collection)
51
+
52
+ user_data_collection = MagicMock(name="collection:user_data")
53
+ user_data_collection.document = MagicMock(return_value=user_ref)
54
+
55
+ db = MagicMock(name="firestore_client")
56
+ db.collection = MagicMock(return_value=user_data_collection)
57
+
58
+ return db, deleted
59
+
60
+
61
+ def test_deletes_every_business_subcollection_doc_and_the_business_itself(monkeypatch):
62
+ db, deleted = _make_fake_db()
63
+ monkeypatch.setattr("routes.account.firestore.client", lambda: db)
64
+
65
+ delete_all_user_data("some-uid")
66
+
67
+ # Every one of the 7 known subcollections was consulted for the business...
68
+ biz_doc = db.collection().document().collection("businesses").stream.return_value
69
+ # ...and each subcollection's one doc, plus the business doc itself, got deleted.
70
+ assert "bff:item1" in deleted
71
+ assert "documents:item1" in deleted
72
+ assert "team:item1" in deleted
73
+ assert "business:biz1" in deleted
74
+
75
+
76
+ def test_deletes_xp_history_entries(monkeypatch):
77
+ db, deleted = _make_fake_db()
78
+ monkeypatch.setattr("routes.account.firestore.client", lambda: db)
79
+
80
+ delete_all_user_data("some-uid")
81
+
82
+ assert "xpHistory:entry1" in deleted
83
+
84
+
85
+ def test_deletes_the_root_user_doc_last(monkeypatch):
86
+ db, deleted = _make_fake_db()
87
+ monkeypatch.setattr("routes.account.firestore.client", lambda: db)
88
+
89
+ delete_all_user_data("some-uid")
90
+
91
+ assert deleted[-1] == "user_data/uid"
tests/test_rate_limiter.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for utils/rate_limiter.py's pure counting/enforcement logic.
3
+
4
+ Scope: enforce_rate_limit() itself (fixed-window counting, per-tier limits,
5
+ 429 on breach). The with_rate_limit() FastAPI dependency composition and the
6
+ Firestore violation-logging side effect are thin wrappers around this and are
7
+ not separately unit-tested (per docs/qa/test-strategy.md, the FastAPI/
8
+ Firestore integration belongs at the integration layer, not here).
9
+ """
10
+ import time
11
+ import pytest
12
+ from fastapi import HTTPException
13
+ import utils.rate_limiter as rl
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def reset_limiter_state(monkeypatch):
18
+ # Each test gets a clean set of counters and a violation logger that
19
+ # never touches Firestore (no app is initialized in this test process).
20
+ monkeypatch.setattr(rl, "_counters", rl.defaultdict(rl._Window))
21
+ monkeypatch.setattr(rl, "_violation_counts", rl.defaultdict(int))
22
+ monkeypatch.setattr(rl, "_log_violation", lambda *a, **k: None)
23
+
24
+
25
+ def test_allows_requests_within_the_limit():
26
+ for _ in range(8):
27
+ rl.enforce_rate_limit("uid-1", "generation") # 8/hour is the limit — must not raise
28
+
29
+
30
+ def test_blocks_the_request_that_exceeds_the_limit():
31
+ for _ in range(8):
32
+ rl.enforce_rate_limit("uid-1", "generation")
33
+ with pytest.raises(HTTPException) as exc_info:
34
+ rl.enforce_rate_limit("uid-1", "generation")
35
+ assert exc_info.value.status_code == 429
36
+
37
+
38
+ def test_counters_are_isolated_per_uid():
39
+ for _ in range(8):
40
+ rl.enforce_rate_limit("uid-1", "generation")
41
+ # A different uid must not be affected by uid-1 exhausting its limit.
42
+ rl.enforce_rate_limit("uid-2", "generation")
43
+
44
+
45
+ def test_counters_are_isolated_per_tier():
46
+ for _ in range(8):
47
+ rl.enforce_rate_limit("uid-1", "generation")
48
+ # The same uid hitting a different tier must not be blocked by "generation".
49
+ rl.enforce_rate_limit("uid-1", "chat")
50
+
51
+
52
+ def test_a_tier_with_multiple_windows_enforces_the_tighter_one_first():
53
+ # "chat" is 10/minute and 200/day -- the per-minute window binds first.
54
+ for _ in range(10):
55
+ rl.enforce_rate_limit("uid-1", "chat")
56
+ with pytest.raises(HTTPException) as exc_info:
57
+ rl.enforce_rate_limit("uid-1", "chat")
58
+ assert exc_info.value.status_code == 429
59
+ assert "minute" in exc_info.value.detail
60
+
61
+
62
+ def test_window_resets_after_its_period_elapses(monkeypatch):
63
+ fake_now = [1_000_000.0]
64
+ monkeypatch.setattr(rl.time, "time", lambda: fake_now[0])
65
+
66
+ for _ in range(8):
67
+ rl.enforce_rate_limit("uid-1", "generation")
68
+ with pytest.raises(HTTPException):
69
+ rl.enforce_rate_limit("uid-1", "generation")
70
+
71
+ # Jump past the 1-hour window.
72
+ fake_now[0] += 3600 + 1
73
+ rl.enforce_rate_limit("uid-1", "generation") # must not raise — window reset
74
+
75
+
76
+ def test_unknown_tier_raises_a_programmer_error_not_a_silent_pass():
77
+ with pytest.raises(ValueError):
78
+ rl.enforce_rate_limit("uid-1", "not-a-real-tier")
utils/rate_limiter.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Per-UID rate limiter — design settled 2026-09-09 (see
3
+ docs/blueprint/adr/0010-per-uid-in-memory-rate-limiting.md), built 2026-09-25.
4
+
5
+ Keys by authenticated Firebase UID, not IP — every limited route already
6
+ requires a verified token, and a UID can't be spoofed the way an IP/header
7
+ can. Counters are in-memory, per-process: safe from client/devtools
8
+ tampering by construction, sufficient for the current single-container
9
+ Hugging Face Space deployment, and reset on redeploy. If this ever needs to
10
+ survive across multiple replicas, move the counters into Firestore/Redis —
11
+ the enforce_rate_limit() call site wouldn't need to change.
12
+ """
13
+
14
+ import time
15
+ import threading
16
+ from collections import defaultdict
17
+ from fastapi import Header, HTTPException
18
+ from firebase_admin import firestore
19
+ from auth import verify_firebase_token
20
+
21
+ # (label, limit, period_seconds) per tier. Tiers are named by cost, not by
22
+ # route, so multiple routes can share one (see routes/*.py's with_rate_limit
23
+ # calls for which tier each route uses).
24
+ TIERS: dict[str, list[tuple[str, int, int]]] = {
25
+ "chat": [("minute", 10, 60), ("day", 200, 86400)],
26
+ "generation": [("hour", 8, 3600), ("day", 30, 86400)],
27
+ "transcription": [("day", 20, 86400)],
28
+ }
29
+
30
+
31
+ class _Window:
32
+ __slots__ = ("count", "reset_at")
33
+
34
+ def __init__(self) -> None:
35
+ self.count = 0
36
+ self.reset_at = 0.0
37
+
38
+
39
+ _lock = threading.Lock()
40
+ _counters: dict[tuple[str, str, str], _Window] = defaultdict(_Window)
41
+ # Consecutive-violation count per UID, purely for the "log repeat offenders"
42
+ # requirement — never used to make an enforcement decision itself.
43
+ _violation_counts: dict[str, int] = defaultdict(int)
44
+
45
+
46
+ def _log_violation(uid: str, tier: str, label: str, count: int, limit: int) -> None:
47
+ _violation_counts[uid] += 1
48
+ print(
49
+ f"[rate-limit] uid={uid} tier={tier} window={label} "
50
+ f"count={count}/{limit} (consecutive violations: {_violation_counts[uid]})"
51
+ )
52
+ # Best-effort — a Firestore write failing must never break enforcement itself.
53
+ try:
54
+ firestore.client().collection("rate_limit_violations").add({
55
+ "uid": uid,
56
+ "tier": tier,
57
+ "window": label,
58
+ "count": count,
59
+ "limit": limit,
60
+ "consecutiveViolations": _violation_counts[uid],
61
+ "at": firestore.SERVER_TIMESTAMP,
62
+ })
63
+ except Exception:
64
+ pass
65
+
66
+
67
+ def enforce_rate_limit(uid: str, tier: str) -> None:
68
+ """Raises HTTPException(429) if uid has exceeded any of tier's limits."""
69
+ if tier not in TIERS:
70
+ raise ValueError(f"Unknown rate limit tier: {tier!r}")
71
+
72
+ now = time.time()
73
+ with _lock:
74
+ for label, limit, period in TIERS[tier]:
75
+ window = _counters[(uid, tier, label)]
76
+ if now >= window.reset_at:
77
+ window.count = 0
78
+ window.reset_at = now + period
79
+ window.count += 1
80
+
81
+ if window.count > limit:
82
+ retry_after = max(1, int(window.reset_at - now))
83
+ _log_violation(uid, tier, label, window.count, limit)
84
+ raise HTTPException(
85
+ status_code=429,
86
+ detail=(
87
+ f"You've hit the {label}ly limit for this action "
88
+ f"({limit} per {label}). Try again in {retry_after} seconds."
89
+ ),
90
+ headers={"Retry-After": str(retry_after)},
91
+ )
92
+
93
+
94
+ def with_rate_limit(tier: str):
95
+ """
96
+ FastAPI dependency: verifies the caller's token AND enforces `tier`'s rate
97
+ limit in one pass. Use this in place of `Depends(verify_firebase_token)`
98
+ on any route that should be limited — it calls verify_firebase_token
99
+ itself rather than composing as a second dependency, so the token is only
100
+ verified once per request.
101
+ """
102
+ async def dependency(authorization: str = Header(...)) -> dict:
103
+ user = await verify_firebase_token(authorization)
104
+ enforce_rate_limit(user["uid"], tier)
105
+ return user
106
+ return dependency