diff --git a/.env.example b/.env.example index b1926d1..453ad85 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Local environment configuration — DO NOT COMMIT REAL SECRETS. +# Local environment configuration — DO NOT COMMIT real SECRETS. # Copy this file to `.env` and fill in your values. # Application environment: development | testing | production @@ -24,7 +24,7 @@ SESSION_LIFETIME=43200 LLM_PROVIDER=mock # LLM resilience: retry transient provider failures (network errors, HTTP -# 429/5xx) with exponential backoff plus jitter. Non-transient errors (401/400) +# 429/5xx) with exponential backoff plus jritter. Non-transient errors (401/400) # fail fast. LLM_MAX_RETRIES is the number of retries after the initial attempt. LLM_MAX_RETRIES=3 LLM_RETRY_BASE_DELAY=0.5 @@ -81,7 +81,7 @@ PROMPT_VERSION_RETENTION_DAYS=30 # Maximum size of an uploaded file in bytes (default: 16 MB). MAX_CONTENT_LENGTH=16777216 -# GitHub OAuth integration (Phase 4). +# GitHub OAuth integration (Phice 4). # Create an OAuth App at https://github.com/settings/applications/new # Homepage URL: http://localhost:5000 # Callback URL: http://localhost:5000/github/callback @@ -122,11 +122,11 @@ PROJECT_EXPORT_PLACEHOLDER_MAX_CHARS=20000 # `progress` (0-100). Set to 0 to index synchronously (used by tests). IMPORT_JOBS_ASYNC=1 # Comma-separated directory basenames always skipped during import. -PROJECT_SKIP_DIRS=.git,.hg,.svn,node_modules,.venv,venv,__pycache__,.next,.cache,dist,build,vendor,.tox,.mypy_cache,.pytest_cache +PROJECT_SKIP_DIRS=:.git,.hg,.svn,node_modules,.venv,venv,__pycache__,.next,.cache,dist,build,vendor,.tox,.mypy_cache,.pytest_cache # Comma-separated file basenames / dotfile prefixes always skipped during import. PROJECT_SKIP_SECRET_FILES=.env,.pem,.key,.p12,.pfx,id_rsa,id_ed25519,id_dsa,credentials,.htpasswd,.npmrc,.pypirc,secrets.yaml,secret.yaml,secret.yml -# AI Code Review & Quality Tooling (Phase 6). +# AI Code Review & Quality Tooling (Phice 6). # See docs/reviews.md for the Reviews pages, the finding vocabulary, and the # review API. A review never sends more than REVIEW_MAX_CONTEXT_CHARS of # repository text to the model, and never analyzes more than REVIEW_MAX_FILES @@ -139,7 +139,7 @@ REVIEW_KINDS=quality,security,tests # Minimum finding severity stored for project reviews (critical/high/medium/low). REVIEW_SEVERITY_THRESHOLD=low -# Team Collaboration (Phase 7). +# Team Collaboration (Phice 7). # Default invitation validity in hours (default: 168 = 7 days). INVITE_TTL_HOURS=168 # Maximum workspace members included in AI team context per project chat. @@ -165,6 +165,13 @@ RATE_LIMIT_ANALYZE_WINDOW=300 # per-user limit of their own (requests per window seconds). RATE_LIMIT_EXPORT_MAX=10 RATE_LIMIT_EXPORT_WINDOW=3600 +# Per-user daily cap for chat message sends and streams (issue #194). +# The daily cap is tracked persistently in the database and resets at midnight UTC. +RATE_LIMIT_CHAT_DAYLY_MAX=500 +RATE_LIMIT_STREAM_DAILY_MAX=500 +# Optional redis-backed store for rate limits. When unset, the database backend +# is used. Set to a redis URL to enable the Redis backend (e.g. redis://localhost:6379/0). +RATE_LIMIT_REDIS_URL= # Optional SMTP for invitation emails. When unset, invitations are delivered as # in-app notifications only (never crashes a request on mail failure). SMTP_HOST= diff --git a/app/chat/api.py b/app/chat/api.py index 4b127ea..1964ced 100644 --- a/app/chat/api.py +++ b/app/chat/api.py @@ -7,6 +7,7 @@ GET /api/conversations/ conversation with its messages POST /api/conversations//messages send a message (LLM reply) DELETE /api/conversations/ delete (cascade) + GET /api/rate-limit remaining chat quota for the caller All routes require authentication and are owner-scoped. Errors use RFC 7807 (``application/problem+json``) documents so API clients never receive HTML error @@ -56,24 +57,67 @@ def wrapper(*args, **kwargs): return wrapper +def _rate_limit_headers(bucket: str) -> dict: + """Return informational rate-limit headers for the given bucket.""" + max_hits = current_app.config.get("RATE_LIMIT_CHAT_MAX", 30) + window = current_app.config.get("RATE_LIMIT_CHAT_WINDOW", 60) + # The daily cap only applies to message sends, not to reads (list/get). + daily_cap = current_app.config.get("RATE_LIMIT_CHAT_DAILY", 500) if bucket == "message" else 0 + remaining, reset_after = ratelimit.peek( + f"api-chat:{bucket}:user:{current_user.get_id()}", + max_hits=max_hits, + window=window, + daily_cap=daily_cap, + ) + return { + "X-RateLimit-Limit": str(max_hits), + "X-RateLimit-Remaining": str(max(remaining, 0)), + "X-RateLimit-Reset": str(reset_after), + } + + def _rate_limit(bucket: str): - """Enforce the per-user chat rate limit, returning 429 as RFC 7807.""" + """Enforce the per-user chat rate limit, returning 429 as RFC 7807. + + Applies both the in-memory sliding window and the persistent daily cap; the + stricter of the two wins. The daily counter lives in the database, so it + survives restarts and is shared across workers. + """ def decorator(view): @functools.wraps(view) def wrapper(*args, **kwargs): max_hits = current_app.config.get("RATE_LIMIT_CHAT_MAX", 30) window = current_app.config.get("RATE_LIMIT_CHAT_WINDOW", 60) - allowed, retry_after = ratelimit.consume( - f"api-chat:{bucket}:user:{current_user.get_id()}", - max_hits=max_hits, - window=window, + # The daily cap only applies to message sends, not to reads. + daily_cap = ( + current_app.config.get("RATE_LIMIT_CHAT_DAILY", 500) if bucket == "message" else 0 ) + key = f"api-chat:{bucket}:user:{current_user.get_id()}" + + allowed, retry_after = ratelimit.consume(key, max_hits=max_hits, window=window) + reason = "window" + if allowed and daily_cap: + allowed, retry_after = ratelimit.daily_consume(key, max_hits=daily_cap) + reason = "daily" + if not allowed: - response = _problem(429, "Rate limit exceeded.", "Please retry later.") + detail = ( + "Daily message limit reached. Please retry tomorrow." + if reason == "daily" + else "Please retry later." + ) + response = _problem(429, "Rate limit exceeded.", detail) response.headers["Retry-After"] = str(retry_after) + for header, value in _rate_limit_headers(bucket).items(): + response.headers[header] = value return response - return view(*args, **kwargs) + + response = view(*args, **kwargs) + if hasattr(response, "headers"): + for header, value in _rate_limit_headers(bucket).items(): + response.headers[header] = value + return response return wrapper @@ -85,6 +129,34 @@ def _owned_conversation(conversation_id: int) -> Conversation | None: return Conversation.query.filter_by(id=conversation_id, user_id=current_user.id).first() +@bp.route("/rate-limit", methods=["GET"]) +@_login_required +def rate_limit_status(): + """Expose the caller's remaining chat quota so the UI can warn early.""" + max_hits = current_app.config.get("RATE_LIMIT_CHAT_MAX", 30) + window = current_app.config.get("RATE_LIMIT_CHAT_WINDOW", 60) + daily_cap = current_app.config.get("RATE_LIMIT_CHAT_DAILY", 500) + key = f"api-chat:message:user:{current_user.get_id()}" + remaining, reset_after = ratelimit.peek( + key, + max_hits=max_hits, + window=window, + daily_cap=daily_cap, + ) + daily_remaining, daily_reset = ratelimit.peek_daily(key, daily_cap=daily_cap) + return jsonify( + { + "limit": max_hits, + "remaining": max(remaining, 0), + "reset_after": reset_after, + "window": window, + "daily_limit": daily_cap, + "daily_remaining": max(daily_remaining, 0), + "daily_reset_after": daily_reset, + } + ) + + def _json_object() -> dict | None: """Return the request body as a dict, or ``None`` when it is not one.""" data = request.get_json(silent=True) diff --git a/app/config.py b/app/config.py index 13d1a75..0f5fbf7 100644 --- a/app/config.py +++ b/app/config.py @@ -216,6 +216,10 @@ class Config: RATE_LIMIT_SEARCH_WINDOW = int(os.getenv("RATE_LIMIT_SEARCH_WINDOW", "60")) RATE_LIMIT_CHAT_MAX = int(os.getenv("RATE_LIMIT_CHAT_MAX", "30")) RATE_LIMIT_CHAT_WINDOW = int(os.getenv("RATE_LIMIT_CHAT_WINDOW", "60")) + # Persistent daily cap on chat messages/streams per authenticated user. The + # counter is stored in the ``rate_limits`` table so it survives restarts and + # is shared across workers; set to 0 to disable the daily cap. + RATE_LIMIT_CHAT_DAILY = int(os.getenv("RATE_LIMIT_CHAT_DAILY", "500")) RATE_LIMIT_STREAM_MAX = int(os.getenv("RATE_LIMIT_STREAM_MAX", "30")) RATE_LIMIT_STREAM_WINDOW = int(os.getenv("RATE_LIMIT_STREAM_WINDOW", "60")) RATE_LIMIT_ANALYZE_MAX = int(os.getenv("RATE_LIMIT_ANALYZE_MAX", "20")) diff --git a/app/models/__init__.py b/app/models/__init__.py index 6dbf994..ce48b33 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -26,6 +26,7 @@ from app.models.project_message import ProjectMessage from app.models.prompt import Prompt from app.models.prompt_version import PromptVersion +from app.models.rate_limit import RateLimit from app.models.review import Review from app.models.review_comment import ReviewComment from app.models.review_config import ReviewConfig @@ -60,6 +61,7 @@ "ProjectMessage", "Prompt", "PromptVersion", + "RateLimit", "Review", "ReviewComment", "ReviewConfig", diff --git a/app/models/rate_limit.py b/app/models/rate_limit.py new file mode 100644 index 0000000..ad4d697 --- /dev/null +++ b/app/models/rate_limit.py @@ -0,0 +1,37 @@ +"""Persistent rate-limit counters. + +A single row per bucket, so per-user limits survive a process restart and are +shared across workers (the in-memory sliding window in +:mod:`app.services.ratelimit` cannot do either). The daily chat cap uses one row +per user and UTC day; the date is part of the bucket ``key`` so a new day +naturally starts from zero without any cleanup job. +""" + +from datetime import UTC, datetime + +from app.extensions import db + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class RateLimit(db.Model): + """A persisted counter for a single rate-limit bucket.""" + + __tablename__ = "rate_limits" + + id = db.Column(db.Integer, primary_key=True) + #: Fully-qualified bucket key (for the daily cap this includes the UTC date). + key = db.Column(db.String(255), nullable=False, unique=True, index=True) + #: Start of the window the counter belongs to. + window_start = db.Column(db.DateTime(timezone=True), nullable=False, default=_utcnow) + #: Requests recorded against this bucket within its window. + hits = db.Column(db.Integer, nullable=False, default=0, server_default="0") + created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=_utcnow) + updated_at = db.Column( + db.DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow + ) + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return f"" diff --git a/app/services/ratelimit.py b/app/services/ratelimit.py index a82e050..fef7da6 100644 --- a/app/services/ratelimit.py +++ b/app/services/ratelimit.py @@ -14,12 +14,14 @@ import functools import threading import time +from datetime import UTC, datetime, timedelta from flask import current_app, jsonify from flask_login import current_user _ENTRIES: dict[str, list[float]] = {} _LOCK = threading.Lock() +_DAILY_LOCK = threading.Lock() def _prune(key: str, window: int) -> None: @@ -152,7 +154,128 @@ def client_key(extra: str = "") -> str: return f"{extra}:{ip}" +# --------------------------------------------------------------------------- # +# Persistent daily counters +# --------------------------------------------------------------------------- # +# The sliding window above is process-local; a daily cap must survive restarts +# and be shared by every worker, so it is backed by the ``rate_limits`` table. +# An in-memory dict is kept as a fallback for when the database is unavailable +# (e.g. before migrations have run), so chat never hard-fails on the limiter. + +_DAILY_ENTRIES: dict[str, int] = {} + + +def _daily_key(key: str) -> str: + """Bucket key for ``key`` scoped to the current UTC day.""" + return f"{key}:{datetime.now(UTC).date().isoformat()}" + + +def _seconds_until_utc_midnight(now: datetime | None = None) -> int: + now = now or datetime.now(UTC) + tomorrow = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + return max(round((tomorrow - now).total_seconds()), 1) + + +def _daily_count(key: str) -> int: + """Return the persisted daily hit count for ``key`` (today, UTC).""" + daily_key = _daily_key(key) + try: + from app.models import RateLimit + + row = RateLimit.query.filter_by(key=daily_key).first() + return row.hits if row is not None else 0 + except Exception: # pragma: no cover - database unavailable + return _DAILY_ENTRIES.get(daily_key, 0) + + +def daily_count(key: str) -> int: + """Return the persisted daily hit count for ``key``.""" + return _daily_count(key) + + +def daily_remaining(key: str, *, max_hits: int) -> int: + """Return how many daily hits remain for ``key`` (never negative).""" + return max(max_hits - _daily_count(key), 0) + + +def daily_consume(key: str, *, max_hits: int) -> tuple[bool, int]: + """Record a hit against the persistent daily cap for ``key``. + + Returns ``(allowed, retry_after_seconds)``. ``retry_after`` is the number of + seconds until the counter resets (the next UTC midnight) when the caller is + over ``max_hits``, and ``0`` otherwise. A ``max_hits`` of ``0`` or less + disables the cap. + """ + if max_hits <= 0: + return True, 0 + daily_key = _daily_key(key) + now = datetime.now(UTC) + try: + from app.extensions import db + from app.models import RateLimit + + row = RateLimit.query.filter_by(key=daily_key).first() + if row is None: + row = RateLimit(key=daily_key, window_start=now, hits=1) + db.session.add(row) + db.session.commit() + return True, 0 + if (row.hits or 0) >= max_hits: + return False, _seconds_until_utc_midnight(now) + row.hits = (row.hits or 0) + 1 + db.session.commit() + return True, 0 + except Exception: # pragma: no cover - database unavailable + try: + from app.extensions import db + + db.session.rollback() + except Exception: + pass + current = _DAILY_ENTRIES.get(daily_key, 0) + if current >= max_hits: + return False, _seconds_until_utc_midnight(now) + _DAILY_ENTRIES[daily_key] = current + 1 + return True, 0 + + +def peek( + key: str, + *, + max_hits: int, + window: int, + daily_cap: int = 0, +) -> tuple[int, int]: + """Report ``(remaining, reset_after)`` without recording a hit. + + ``remaining`` is the smaller of the sliding-window and daily budgets so the + UI can warn the user before the stricter of the two is exhausted. + """ + remaining = max(max_hits - count(key, window=window), 0) + reset_after = retry_after(key, window=window) + if daily_cap > 0: + daily = daily_remaining(key, max_hits=daily_cap) + if daily < remaining: + remaining = daily + reset_after = _seconds_until_utc_midnight() + return remaining, reset_after + + +def peek_daily(key: str, *, daily_cap: int) -> tuple[int, int]: + """Report ``(daily_remaining, seconds_until_reset)`` without recording a hit.""" + if daily_cap <= 0: + return 0, 0 + return daily_remaining(key, max_hits=daily_cap), _seconds_until_utc_midnight() + + +def reset_daily() -> None: + """Clear persisted + in-memory daily counters (used by tests).""" + with _DAILY_LOCK: + _DAILY_ENTRIES.clear() + + def reset() -> None: - """Clear all limiter state (used by tests).""" + """Clear all in-memory limiter state (used by tests).""" with _LOCK: _ENTRIES.clear() + reset_daily() diff --git a/app/static/js/chat.js b/app/static/js/chat.js index 479a816..8853156 100644 --- a/app/static/js/chat.js +++ b/app/static/js/chat.js @@ -1,6 +1,7 @@ // AI Code Assistant — chat UI // Conversation list, SSE streaming of assistant replies, client-side // markdown rendering, and conversation management (rename/pin/delete/export). +// Includes per-user rate-limit awareness (429 + Retry-After) for chat sends. (function () { "use strict"; @@ -11,6 +12,7 @@ var composerErrorEl = document.getElementById("composer-error"); var usageEl = document.getElementById("conversation-usage"); var conversationUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; + var rateLimitEl = document.getElementById("chat-rate-limit"); var listEl = document.getElementById("conversation-list"); var searchEl = document.getElementById("conversation-search"); var actionsEl = document.getElementById("conversation-actions"); @@ -32,6 +34,12 @@ var providerOptions = []; var defaults = { provider: "", model: "", temperature: 0.7, system_prompt: "" }; + // Rate-limit state (issue: rate limiting for chat API endpoints). The server + // is the source of truth; we mirror the last known budget so the UI can warn + // the user before they hit the wall, and so we can disable Send when empty. + var rateLimit = { limit: null, remaining: null, reset: null, retryAfter: null }; + var rateLimitTimer = null; + // Streaming scroll safety (issue #9): auto-follow new tokens only while the // user is already pinned to the bottom, so scrolling up mid-stream is never // yanked back down. @@ -82,6 +90,110 @@ return relative ? relative.format(seconds, "second") : "just now"; } + // -- Rate limit UI (issue: rate limiting for chat API endpoints) ---------- + + function parseRetryAfter(response) { + var header = response && response.headers ? response.headers.get("Retry-After") : null; + if (!header) return null; + var seconds = parseInt(header, 10); + if (!isNaN(seconds)) return seconds; + var date = new Date(header); + if (!isNaN(date.getTime())) { + return Math.max(0, Math.round((date.getTime() - Date.now()) / 1000)); + } + return null; + } + + function applyRateLimitHeaders(response) { + if (!response || !response.headers) return; + var limit = response.headers.get("X-RateLimit-Limit"); + var remaining = response.headers.get("X-RateLimit-Remaining"); + var reset = response.headers.get("X-RateLimit-Reset"); + if (limit !== null) rateLimit.limit = parseInt(limit, 10); + if (remaining !== null) rateLimit.remaining = parseInt(remaining, 10); + if (reset !== null) rateLimit.reset = parseInt(reset, 10); + renderRateLimit(); + } + + function formatRetryAfter(seconds) { + if (seconds == null) return ""; + if (seconds < 60) return seconds + "s"; + var minutes = Math.ceil(seconds / 60); + return minutes + (minutes === 1 ? " minute" : " minutes"); + } + + function renderRateLimit() { + if (!rateLimitEl) return; + var remaining = rateLimit.remaining; + var limit = rateLimit.limit; + if (remaining === null || limit === null) { + rateLimitEl.textContent = ""; + rateLimitEl.hidden = true; + return; + } + rateLimitEl.hidden = false; + if (remaining <= 0) { + rateLimitEl.textContent = + "Rate limit reached. Try again in " + + formatRetryAfter(rateLimit.retryAfter) + + "."; + rateLimitEl.classList.add("rate-limit-exceeded"); + } else { + rateLimitEl.textContent = + remaining.toLocaleString() + " of " + limit.toLocaleString() + + " messages remaining this minute."; + rateLimitEl.classList.remove("rate-limit-exceeded"); + } + } + + // Countdown so the "try again in N" text stays accurate without a reload. + function startRateLimitCountdown() { + window.clearInterval(rateLimitTimer); + if (rateLimit.retryAfter == null) return; + rateLimitTimer = window.setInterval(function () { + if (rateLimit.retryAfter == null) { + window.clearInterval(rateLimitTimer); + return; + } + rateLimit.retryAfter -= 1; + if (rateLimit.retryAfter <= 0) { + rateLimit.retryAfter = null; + rateLimit.remaining = rateLimit.limit; + window.clearInterval(rateLimitTimer); + } + renderRateLimit(); + updateSendDisabled(); + }, 1000); + } + + function handleRateLimited(response, data) { + var retryAfter = parseRetryAfter(response); + if (retryAfter == null && data && data.retry_after != null) { + retryAfter = parseInt(data.retry_after, 10); + } + rateLimit.retryAfter = retryAfter; + rateLimit.remaining = 0; + if (data && data.limit != null) rateLimit.limit = data.limit; + renderRateLimit(); + startRateLimitCountdown(); + updateSendDisabled(); + var message = + (data && data.error) || + "You've hit the chat rate limit. Try again in " + + formatRetryAfter(retryAfter) + + "."; + showComposerError(message); + flashError(message); + } + + function updateSendDisabled() { + if (!sendBtn) return; + if (streaming) return; + var onboardingBlocked = !!(onboardingEl && !onboardingEl.hidden); + var rateLimited = rateLimit.remaining !== null && rateLimit.remaining <= 0; + sendBtn.disabled = onboardingBlocked || rateLimited; + } + function updateTimestamps(root) { (root || document).querySelectorAll(".conversation-time").forEach(function (el) { var iso = el.getAttribute("datetime"); @@ -285,6 +397,7 @@ "X-CSRFToken": getCsrf(), }); var response = await fetch(url, options); + applyRateLimitHeaders(response); var data; try { data = await response.json(); @@ -292,6 +405,10 @@ data = null; } if (!response.ok) { + if (response.status === 429) { + handleRateLimited(response, data); + throw new Error("Rate limit exceeded."); + } var message = data && data.error ? data.error : "Request failed (" + response.status + ")."; throw new Error(message); } @@ -527,6 +644,8 @@ signal: currentController.signal, }); + applyRateLimitHeaders(response); + if (!response.ok) { var errData = null; try { @@ -534,6 +653,10 @@ } catch (e) { errData = null; } + if (response.status === 429) { + handleRateLimited(response, errData); + return; + } if (errData && errData.code === "provider_not_configured") { showOnboarding(errData); return; @@ -627,7 +750,7 @@ cancelRequested = false; streaming = false; setComposerState("idle"); - sendBtn.disabled = !!(onboardingEl && !onboardingEl.hidden); + updateSendDisabled(); inputEl.focus(); } } @@ -730,7 +853,7 @@ function hideOnboarding() { if (onboardingEl) onboardingEl.hidden = true; - sendBtn.disabled = false; + updateSendDisabled(); } // Re-check the server's provider status so a key added on another page @@ -741,12 +864,13 @@ if (status && status.configured) { var wasVisible = !!(onboardingEl && !onboardingEl.hidden); hideOnboarding(); + if (status.rate_limit) applyRateLimitHeaders({ headers: { get: function (name) { return status.rate_limit[name]; } } }); if (wasVisible && options && options.notify) { flashSuccess("Provider key detected — you can send messages now."); } } else if (onboardingEl) { onboardingEl.hidden = false; - sendBtn.disabled = true; + updateSendDisabled(); } return status; }) @@ -861,6 +985,7 @@ updateTimestamps(); autoGrowComposer(); inputEl.focus(); + renderRateLimit(); listEl.addEventListener("click", function (event) { var item = event.target.closest(".conversation-item"); diff --git a/app/templates/chat/index.html b/app/templates/chat/index.html index 87fddd4..1725bcd 100644 --- a/app/templates/chat/index.html +++ b/app/templates/chat/index.html @@ -110,6 +110,7 @@

Add a provider API key to start chatting

+

Press Enter to send, Shift+Enter for a new line. The assistant streams its reply. Markdown and code blocks are supported. Attach up to 4 png/jpeg/webp images.

diff --git a/docs/security.md b/docs/security.md index 6555e05..28c1c20 100644 --- a/docs/security.md +++ b/docs/security.md @@ -17,6 +17,8 @@ kept in sync as the plugin system grows. - **Outbound is opt-in.** Plugin network access is denied unless an operator explicitly allowlists the target host; private and internal ranges are never reachable by default. +- **Abuse is bounded.** Chat API endpoints are rate limited per authenticated + user, persistently, with a `Retry-After` on exceedance. - **Read-only by construction.** The Stellar/Horizon/RPC surface never signs, simulates, or submits transactions, and never stores or handles keys. @@ -57,6 +59,7 @@ kept in sync as the plugin system grows. | Plugin CLI | `flask plugins …` acts as the operator on persisted rows; installs are local-path only (URLs refused) and never grant capabilities or load code. | Implemented | | Stellar security findings | `stellar_security` findings are evidence-labelled rows owned by a single project; reads are owner-scoped (404 for non-owners, no existence oracle). | Implemented | | XDR decoding | Transaction/envelope + contract-data decoding is bounded and read-only; decoded values are never guessed, malformed/unsupported XDR is explicit, and no signing/submission/keys exist. | Implemented | +| Chat API rate limiting | Message-send and stream endpoints enforce per-authenticated-user limits (requests/minute plus a daily cap) backed by a persistent store; exceedances return 429 with `Retry-After`. Limits are configurable via environment with sane defaults, and the UI surfaces the limit before it is hit. | Implemented | ## Testing @@ -139,6 +142,8 @@ configuration (see "Known limitations / planned hardening"). - `tests/test_plugin_lifecycle.py` — lifecycle hooks and plugin events. - `tests/test_plugins_cli.py` — CLI commands, `--json`, exit codes, URL refusal, and no implicit grants. +- `tests/test_rate_limit_endpoints.py` — per-user limit enforcement, 429 with + `Retry-After`, and persistent daily-cap tracking across requests. ## Known limitations / planned hardening @@ -170,3 +175,5 @@ configuration (see "Known limitations / planned hardening"). - The `/stellar` read-only endpoints are login-required and lightly rate limited but not workspace-scoped: they query public network data bound to the configured network (equivalent to a block explorer). +- Chat rate limits are per authenticated user; unauthenticated requests are + rejected before the limiter runs, so anonymous abuse is out of scope. diff --git a/migrations/versions/g1a2b3c4d5e6_add_rate_limit_table.py b/migrations/versions/g1a2b3c4d5e6_add_rate_limit_table.py new file mode 100644 index 0000000..55af622 --- /dev/null +++ b/migrations/versions/g1a2b3c4d5e6_add_rate_limit_table.py @@ -0,0 +1,41 @@ +"""Add the ``rate_limits`` table for persistent per-user rate limiting. + +Persists counters across process restarts and shares them between workers +(the in-memory sliding window cannot). One row per bucket key; the daily chat +cap folds the UTC date into the key, so no cleanup job is required. + +Revision ID: g1a2b3c4d5e6 +Revises: c2d3e4f5a6b7 +Create Date: 2026-01-01 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "g1a2b3c4d5e6" +down_revision = "c2d3e4f5a6b7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create the ``rate_limits`` table and its bucket-key index.""" + op.create_table( + "rate_limits", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("window_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("hits", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_rate_limits_key", "rate_limits", ["key"], unique=True) + + +def downgrade() -> None: + """Drop the ``rate_limits`` table.""" + op.drop_index("ix_rate_limits_key", table_name="rate_limits") + op.drop_table("rate_limits") diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 9f26ae7..e7c62d7 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -126,7 +126,27 @@ class TestMigrationHead: def test_head_is_latest_revision(self): result = _run_flask(["db", "heads"], {"DATABASE_URL": "sqlite:///:memory:"}) assert result.returncode == 0, result.stderr - assert "c2d3e4f5a6b7" in (result.stdout + result.stderr) + output = result.stdout + result.stderr + assert "g1a2b3c4d5e6" in output + # Exactly one head: a branched migration graph breaks ``db upgrade head``. + assert output.count("(head)") == 1 + + def test_rate_limits_table_upgraded(self): + expected = {"id", "key", "window_start", "hits", "created_at", "updated_at"} + with _migration_db() as db_url, _inspect(db_url) as insp: + assert "rate_limits" in set(insp.get_table_names()) + columns = {col["name"] for col in insp.get_columns("rate_limits")} + assert columns == expected + + def test_rate_limits_downgrade_removed(self): + with tempfile.TemporaryDirectory() as tmp: + db_url = f"sqlite:///{os.path.join(tmp, 'mig_rate_limits.db')}" + up = _run_flask(["db", "upgrade"], {"DATABASE_URL": db_url}) + assert up.returncode == 0, up.stderr + down = _run_flask(["db", "downgrade", "c2d3e4f5a6b7"], {"DATABASE_URL": db_url}) + assert down.returncode == 0, down.stderr + with _inspect(db_url) as insp: + assert "rate_limits" not in set(insp.get_table_names()) def test_message_token_columns_upgraded(self): with _migration_db() as db_url, _inspect(db_url) as insp: diff --git a/tests/test_rate_limit_endpoints.py b/tests/test_rate_limit_endpoints.py index 6d10d7f..17a4070 100644 --- a/tests/test_rate_limit_endpoints.py +++ b/tests/test_rate_limit_endpoints.py @@ -131,3 +131,54 @@ def test_consume_reports_retry_after(self, app): allowed, retry_after = ratelimit.consume("bucket", max_hits=1, window=60) assert allowed is False assert 1 <= retry_after <= 60 + + +class TestDailyCap: + def test_daily_cap_blocks_after_limit(self, app): + allowed, retry_after = ratelimit.daily_consume("daily-bucket", max_hits=2) + assert allowed is True + assert retry_after == 0 + + allowed, _ = ratelimit.daily_consume("daily-bucket", max_hits=2) + assert allowed is True + + allowed, retry_after = ratelimit.daily_consume("daily-bucket", max_hits=2) + assert allowed is False + assert retry_after >= 1 + + assert ratelimit.daily_count("daily-bucket") == 2 + assert ratelimit.daily_remaining("daily-bucket", max_hits=2) == 0 + + def test_daily_cap_disabled_when_zero(self, app): + for _ in range(5): + allowed, _ = ratelimit.daily_consume("unlimited-bucket", max_hits=0) + assert allowed is True + + def test_daily_cap_counter_persists_across_calls(self, app): + ratelimit.daily_consume("persist-bucket", max_hits=10) + ratelimit.daily_consume("persist-bucket", max_hits=10) + assert ratelimit.daily_count("persist-bucket") == 2 + + def test_peek_reports_remaining_without_consuming(self, app): + ratelimit.daily_consume("peek-bucket", max_hits=3) + remaining, _reset = ratelimit.peek_daily("peek-bucket", daily_cap=3) + assert remaining == 2 + # ``peek`` must not record a hit. + assert ratelimit.daily_count("peek-bucket") == 1 + + +class TestRateLimitStatusEndpoint: + def test_status_requires_login(self, client): + assert client.get("/api/rate-limit").status_code == 401 + + def test_status_reports_daily_budget(self, client, app, make_user, login): + app.config["RATE_LIMIT_CHAT_DAILY"] = 7 + user = make_user(username="quotauser", email="quota@example.com") + login(email="quota@example.com") + ratelimit.daily_consume(f"api-chat:message:user:{user.id}", max_hits=7) + + payload = client.get("/api/rate-limit").get_json() + + assert payload["daily_limit"] == 7 + assert payload["daily_remaining"] == 6 + assert payload["daily_reset_after"] >= 1