fix: code review fixes — date calc, input validation, rate limits, shared components

- Fix monthly_reset_at crash when billing anchor day exceeds next month's length
- Add environment_tags sanitization (max 20 tags, 100 chars each) to prevent prompt injection
- Add @limiter.limit("10/minute") rate limiting to all AI endpoints
- Use getTreeNavigatePath() routing helper instead of hardcoded paths
- Extract shared CreateFlowDropdown component from QuickStartPage and TreeLibraryPage
- Clear useCachedQuota on logout to prevent stale data across user sessions
- Add useRef guard to scaffold useEffect to prevent potential double-fire
- Use node.id as React key instead of array index in BranchDetailView
- Remove redundant dead logic in ai_tree_validator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-21 01:32:38 -05:00
parent 4b9863f22d
commit 37e1202f46
11 changed files with 163 additions and 182 deletions

View File

@@ -11,9 +11,11 @@ import logging
from typing import Annotated
import anthropic
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.core.config import settings
from app.core.ai_conversation_store import (
@@ -86,7 +88,9 @@ async def get_quota(
@router.post("/start", response_model=AIStartResponse, status_code=201)
@limiter.limit("10/minute")
async def start_conversation(
request: Request,
data: AIStartRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
@@ -140,7 +144,9 @@ async def start_conversation(
@router.post("/scaffold", response_model=AIScaffoldResponse)
@limiter.limit("10/minute")
async def scaffold(
request: Request,
data: AIScaffoldRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
@@ -249,7 +255,9 @@ async def scaffold(
@router.post("/branch-detail", response_model=AIBranchDetailResponse)
@limiter.limit("10/minute")
async def branch_detail(
request: Request,
data: AIBranchDetailRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
@@ -364,7 +372,9 @@ async def branch_detail(
@router.post("/assemble", response_model=AIAssembleResponse)
@limiter.limit("10/minute")
async def assemble(
request: Request,
data: AIAssembleRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],

View File

@@ -4,6 +4,7 @@ Enforces monthly and daily limits on AI flow builder usage.
Monthly quota consumed only on successful tree assembly (counts_toward_quota=True).
Daily limit is an anti-abuse guard consumed on conversation start.
"""
import calendar
from datetime import datetime, timezone, timedelta
from typing import Optional
from uuid import UUID
@@ -127,9 +128,13 @@ async def check_ai_quota(
deny_reason = "daily"
# Calculate reset timestamps
next_month = month_start.month % 12 + 1
next_year = month_start.year + (1 if month_start.month == 12 else 0)
max_day = calendar.monthrange(next_year, next_month)[1]
monthly_reset_at = month_start.replace(
month=month_start.month % 12 + 1,
year=month_start.year + (1 if month_start.month == 12 else 0),
month=next_month,
year=next_year,
day=min(month_start.day, max_day),
)
daily_reset_at = day_start + timedelta(hours=24)

View File

@@ -159,7 +159,7 @@ def _check_branch_termination(node: dict[str, Any], errors: list[str]) -> None:
if node_type == "solution":
return # Solution is a valid terminus
if not children and node_type != "solution":
if not children:
errors.append(
f"Node '{node_id}' (type={node_type}) is a dead end — "
"it has no children and is not a solution node"

View File

@@ -2,7 +2,7 @@
from typing import Any, Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# ── Requests ──
@@ -17,7 +17,17 @@ class AIStartRequest(BaseModel):
category_id: Optional[UUID] = None
name: str = Field(..., min_length=1, max_length=255)
description: str = Field("", max_length=2000)
environment_tags: list[str] = Field(default_factory=list)
environment_tags: list[str] = Field(default_factory=list, max_length=20)
@field_validator("environment_tags")
@classmethod
def validate_tags(cls, v: list[str]) -> list[str]:
for tag in v:
if len(tag) > 100:
raise ValueError("Each environment tag must be 100 characters or fewer")
if not tag.strip():
raise ValueError("Environment tags must not be empty")
return v
class AIScaffoldRequest(BaseModel):