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:
@@ -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)],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user