From d5f8c60d64826bdf0779678c6cf8fbbb442b9d5d Mon Sep 17 00:00:00 2001 From: Bigint <69431456+bigint@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:19:03 +0530 Subject: [PATCH 01/11] fix: secure chat credentials and async request handling --- AUDIT.md | 75 +++++++++++++++++++ api/bigrag/routers/preferences.py | 2 +- api/bigrag/services/analytics.py | 10 +-- api/bigrag/services/chat/completion.py | 34 ++++++--- api/bigrag/services/chat/provider.py | 15 ++-- api/bigrag/services/chat/turn/credentials.py | 33 +++++++- api/bigrag/services/chat/types.py | 1 + api/bigrag/services/preferences.py | 31 ++++++-- api/bigrag/services/provider_key_binding.py | 21 ++++++ sdks/python/src/bigrag/_core.py | 5 +- sdks/typescript/src/core.ts | 3 +- website/content/docs/api-reference/chat.mdx | 3 + .../docs/api-reference/preferences.mdx | 2 + website/content/docs/api-reference/query.mdx | 2 + website/content/docs/sdks/python.mdx | 6 +- website/content/docs/sdks/typescript.mdx | 6 +- 16 files changed, 206 insertions(+), 43 deletions(-) create mode 100644 AUDIT.md create mode 100644 api/bigrag/services/provider_key_binding.py diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 000000000..35642ba90 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,75 @@ +# Improvement audit ledger + +Baseline: `5ca412d5d2ba8e0db4d1e14d361f3443b86df90c` on `main`, verified against GitHub on 2026-10-04. Work branch: `codex/audit-safety`. + +The original 36 findings below came from a source audit, not live exploitation. An open item remains a hypothesis until independently investigated. This ledger records evidence, dispositions, checks, and the next bounded work so later batches do not repeat or conflict with earlier work. + +## Boundaries and coordination + +- One separate draft PR; no merge, deployment, new service, credential, billing, hosting, or system/network change. +- The existing `/Users/yoginth/rag.computer` checkout is on `yoginth/remove-dead-code` with substantial uncommitted Elixir migration work. It was inspected read-only. All audit changes use a separate clone of the verified baseline; none of that work is copied or modified. +- No `.agents` directory or nested `AGENTS.md` exists in the baseline checkout. Root `AGENTS.md` and `STYLEGUIDE.md` govern this work. Removed test runners and coverage suites stay removed. Behavioral verification uses temporary local smoke scripts with mock transports and fake credentials outside the repository. +- Tenant findings 4–7, related metadata propagation, and replay authorization need a coherent design before schema, reindex, or provisioning work. Tenant provisioning stays unchanged. +- Uncommitted batches are backed up outside this checkout before commit. Each useful batch requires relevant checks and independent review. Do not create changes merely to fill an hourly schedule. + +## Findings + +Paths below are relative to `api/bigrag/` unless another package is named. + +| ID | Finding and reported source | Disposition / evidence | +|---|---|---| +| 1 | Saved chat credential can reach a caller-selected endpoint; `services/chat/turn/credentials.py`, `services/chat/provider.py` | Fixed in batch 1. Mock reproduction accepted a different endpoint at baseline. Save-time verification now records a normalized destination authenticated with a versioned HMAC using the existing provider key. Chat and suggestions reject mismatches, forged bindings, and legacy unbound keys. Existing keys need to be saved again. No schema or new secret. | +| 2 | Idempotency replay precedes current auth; `middleware/idempotency.py`, `middleware/principal.py` | Independently confirmed; open. Both replay paths bypass dependencies/handler authorization. Cookie/bearer selection also differs from auth. A middleware login check alone misses admin, scope, collection-body, tenant, and ownership guards. Next work requires reusable read-only authorization guards, fresh selected-credential validation, and safe handling of legacy cache entries; denials must never execute mutations again. | +| 3 | Document metadata omitted from chunk metadata; `services/queue_embedding/chunk_metadata.py`, `insert_batches.py` | Open; coordinate with tenant metadata design before changing stored vector metadata. | +| 4 | Tenant API-key field absent from API models/admin key creation despite auth reading it; `models/auth.py`, admin API-key routes | Open; tenant workstream, no provisioning or schema change authorized by this ledger. | +| 5 | Suggestions and analytics omit tenant guards; `services/chat/questions/api.py`, `routers/analytics.py` | Open; tenant workstream. Batch 1 only fixes session concurrency, not isolation. | +| 6 | Vector upsert/delete and collection truncate miss tenant safeguards | Open; tenant workstream, validate ownership and collection boundaries together. | +| 7 | Upload sessions owned only by issuing user | Open; tenant workstream, review key/tenant ownership together. | +| 8 | Docker context excludes SDK required by UI `COPY` | Existing PR [#77](https://github.com/bigint/rag.computer/pull/77), head `c121513f3f8fd2192f28aefb88191ae69e5b632f`. Read-only review: its targeted exclusion retains Python exclusion while making TypeScript available. Do not duplicate or merge; Docker runtime verification not performed here. | +| 9 | Ready document/source cleanup precedes fallible followups and may lead to destructive retry; queue processing/finalize | Open; inspect transaction and retry boundaries before editing. | +| 10 | Failed-document dedup prevents retry | Open; validate alongside ingestion finalization. | +| 11 | Lookup-before-insert hash dedup race with nonunique index | Open; schema coordination required if constraints are needed. | +| 12 | Upload limits use unlocked stale counts | Open; inspect concurrent quota accounting. | +| 13 | Queue recovery ignores `lrem` result and sets lease unconditionally | Open; inspect atomic recovery ownership. | +| 14 | Mixed text/scanned PDFs skip OCR | Open; reproduce with generated non-sensitive documents. | +| 15 | ProcessPool timeout leaves conversion process running | Open; inspect worker lifecycle and cancellation. | +| 16 | Whole-document embeddings retained by gather | Open; inspect ingestion memory bounds. | +| 17 | SDK exception retries ignore safety flag; Python `_core.py`, TypeScript `core.ts` | Fixed in batch 1. Python mock transport reproduced three unsafe attempts at baseline; exception retries now honor the flag in both SDKs. Timeouts fail immediately, following the style guide. Reads and keyed mutations retain connection-error retries. Multipart/large-body replay limitations remain under 18. | +| 18 | Idempotency skips multipart/large bodies and has a 60-second unrenewed lease | Open; coordinate with 2. SDK docs now disclose the replay limits; automatic upload retry policy and lease renewal still need investigation. | +| 19 | SSE heartbeat `wait_for(__anext__)` cancels slow provider iterator; `services/chat/completion.py` | Fixed in batch 1. Baseline slow-token smoke returned an empty successful assistant. One pending token task survives heartbeat ticks. Cleanup is shielded from ASGI cancellation, and the raw provider response closes even when disconnected immediately after a delta. | +| 20 | Stale chat finally/collection switch clears newer state; `app/src/features/chat/use-chat-streaming.ts` | Open; inspect stream ownership and collection-switch lifecycle. | +| 21 | EOF without terminal event appears successful; `app/src/lib/chat-stream.ts` | Source inspected; open. Parser breaks on reader EOF without checking terminal completion. Validate with truncated mock SSE before editing. | +| 22 | Password change revokes session then UI logout fails | Source inspected; open. Account tab awaits logout after password change; review backend session deletion and local sign-out cleanup together. | +| 23 | Logout leaves chat/upload Zustand stores | Source inspected; open. Logout currently clears TanStack Query only; inventory all stores and active request ownership before editing. | +| 24 | Upload UI ignores returned `item.status=failed` | Open; validate terminal upload states. | +| 25 | Closed incomplete upload sessions remain uploading | Open; review completion/cancellation state transitions. | +| 26 | Allowed file types and empty-file semantics inconsistent | Open; compare upload entry points and server validation. | +| 27 | Personal-key-only UI gate blocks instance chat credentials | Open; inspect safe readiness contract before removing gate. | +| 28 | Analytics gather shares AsyncSession; `services/analytics.py` | Fixed in batch 1. Mock session reproduced overlapping execute calls at baseline. Period queries now run sequentially in one session, preserving output and five-minute cache. | +| 29 | NDCG duplicates document hits and can exceed 1 | Open; validate relevance unit and duplicate handling. | +| 30 | ASCII/length tokenizer drops Unicode and short terms | Open; inspect backend lexical search contract and generate bounded examples. | +| 31 | Reranking only final top-k and silent degradation | Open; inspect candidate budget and safe error signaling. | +| 32 | Explicit null settings cannot clear values | Open; inspect update models, fields-set semantics, and docs. | +| 33 | Retrieved context in system prompt lacks untrusted-data boundary | Open; inspect prompt/data roles without live provider calls. | +| 34 | Usage estimates characters/4 with incomplete prices/history | Open; describe estimate provenance; do not present as billing or change billing. | +| 35 | Upload polling loads all rows | Open; inspect active-session query and pagination. | +| 36 | Docker Python install ignores verified uv.lock/current Torch | Open; inspect frozen install and platform constraints; do not combine with PR 77. | + +## Batch 1 verification + +Changes: saved provider destination binding, slow-stream heartbeat ownership, safe SDK exception retry policy, sequential analytics queries, and matching documentation. + +Temporary reproductions initially failed for saved-key redirection, slow-stream token loss, unsafe Python connection/timeout retries, and concurrent analytics session use. These now pass. + +- `pnpm check`: passed on the final source, covering Biome, API Ruff lint/format, all workspace typechecks, app/SDK/docs builds, and API compile. The docs build required access for its existing Google font download; an earlier sandboxed build was interrupted after it stalled. No font or network configuration changed. +- Python SDK Ruff lint/format, compile, wheel and source distribution build: passed. +- Backend mocked smoke: eight checks passed, covering saved destination rejection, destination/credential matrix, signed preference binding and redaction/clear, delayed tokens across heartbeats, pending-read cancellation, actual OpenAI AsyncStream response closure after a delta, AnyIO scope cancellation with awaited cleanup, and exclusive analytics session use. +- Python SDK mocked connection/timeout retry matrix: four checks passed. TypeScript SDK runtime smoke: eight checks passed, including generated-key reuse and unkeyed mutation behavior. +- Independent reviewer initially found unsigned legacy preference bindings and provider cleanup gaps. All were reproduced, fixed, and re-reviewed. Final review: no blocking findings. Reviewer separately verified encrypted-key bindings, rejected updates, and legacy rejection for chat and suggestions. +- `git diff --check`: passed. No test runner or coverage suite added. The route generator emits an existing circular-module warning during otherwise successful app typecheck/build. + +Local recovery: verification logs and temporary smoke scripts live beside this checkout in the task workspace. A copy of changed files, binary diff, and manifest is saved under the task's `audit-backups/` before the commit. These copies exclude dependencies, runtime data, and secrets. This ledger is the durable source for later batches; continue in the same draft PR. + +Next bounded batch: validate UI stream ownership/EOF and password-change/logout cleanup (20–23), then handle upload terminal states. Replay authorization (2/18) needs guard extraction design; tenant isolation (3–7) needs explicit coordination with the separate migration work before persistence changes. + +Limitations: no live credential transmission, exploit, production database/vector access, Docker service start, provider billing, or deployed runtime verification. Idempotency authorization and tenant isolation remain open and are not claimed fixed. diff --git a/api/bigrag/routers/preferences.py b/api/bigrag/routers/preferences.py index fb12142eb..4d228df7c 100644 --- a/api/bigrag/routers/preferences.py +++ b/api/bigrag/routers/preferences.py @@ -53,7 +53,7 @@ async def update_preferences( ) existing = dict(existing_row.data) if existing_row else {} incoming = _normalize_sensitive(incoming) - await _validate_sensitive(incoming) + incoming = await _validate_sensitive(incoming) incoming = _encrypt_sensitive(incoming) merged = _remove_cleared_sensitive(_deep_merge(existing, incoming), incoming) diff --git a/api/bigrag/services/analytics.py b/api/bigrag/services/analytics.py index 2eaee42db..7e1124133 100644 --- a/api/bigrag/services/analytics.py +++ b/api/bigrag/services/analytics.py @@ -1,7 +1,5 @@ from __future__ import annotations -import asyncio - import sqlalchemy as sa from bigrag.db.engine import session_factory @@ -49,11 +47,9 @@ async def collection_analytics(collection_name: str) -> dict: ) ).all() - stats_24h, stats_7d, stats_30d = await asyncio.gather( - _period_stats(session, collection_name, 1), - _period_stats(session, collection_name, 7), - _period_stats(session, collection_name, 30), - ) + stats_24h = await _period_stats(session, collection_name, 1) + stats_7d = await _period_stats(session, collection_name, 7) + stats_30d = await _period_stats(session, collection_name, 30) result = { "collection": collection_name, diff --git a/api/bigrag/services/chat/completion.py b/api/bigrag/services/chat/completion.py index 71370f88c..27453ba00 100644 --- a/api/bigrag/services/chat/completion.py +++ b/api/bigrag/services/chat/completion.py @@ -2,8 +2,10 @@ import asyncio from collections.abc import AsyncIterator +from contextlib import suppress from datetime import UTC, datetime +import anyio from fastapi import Request from bigrag.db.engine import session_factory @@ -87,28 +89,38 @@ async def stream_chat_completion( content_parts: list[str] = [] stream = _stream_model(prepared).__aiter__() + pending_delta: asyncio.Task[str] | None = None try: while True: if request is not None and await request.is_disconnected(): return - try: - delta = await asyncio.wait_for( - stream.__anext__(), timeout=_HEARTBEAT_INTERVAL_SECONDS - ) - except TimeoutError: + if pending_delta is None: + pending_delta = asyncio.create_task(stream.__anext__()) + completed, _ = await asyncio.wait( + {pending_delta}, timeout=_HEARTBEAT_INTERVAL_SECONDS + ) + if not completed: yield _HEARTBEAT continue + try: + delta = pending_delta.result() except StopAsyncIteration: break + pending_delta = None content_parts.append(delta) yield _sse("delta", {"delta": delta}) finally: - aclose = getattr(stream, "aclose", None) - if aclose is not None: - try: - await aclose() - except Exception: - pass + with anyio.CancelScope(shield=True): + if pending_delta is not None: + pending_delta.cancel() + with suppress(asyncio.CancelledError, Exception): + await pending_delta + aclose = getattr(stream, "aclose", None) + if aclose is not None: + try: + await aclose() + except Exception: + pass assistant = _chat_message_response( id=str(uuid7()), diff --git a/api/bigrag/services/chat/provider.py b/api/bigrag/services/chat/provider.py index 83cede14e..ce8e9e00d 100644 --- a/api/bigrag/services/chat/provider.py +++ b/api/bigrag/services/chat/provider.py @@ -99,13 +99,14 @@ async def _stream_model(prepared: PreparedChatTurn) -> AsyncIterator[str]: ), timeout=_MODEL_TIMEOUT_SECONDS, ) - async for chunk in stream: - choices = getattr(chunk, "choices", None) or [] - if not choices: - continue - delta = getattr(choices[0].delta, "content", None) - if delta: - yield delta + async with stream: + async for chunk in stream: + choices = getattr(chunk, "choices", None) or [] + if not choices: + continue + delta = getattr(choices[0].delta, "content", None) + if delta: + yield delta return except Exception as exc: last_error = exc diff --git a/api/bigrag/services/chat/turn/credentials.py b/api/bigrag/services/chat/turn/credentials.py index 46e7b30f4..c5aed67f7 100644 --- a/api/bigrag/services/chat/turn/credentials.py +++ b/api/bigrag/services/chat/turn/credentials.py @@ -12,6 +12,7 @@ from bigrag.services import crypto from bigrag.services.chat.types import ProviderCredential from bigrag.services.preferences import decrypt_preferences +from bigrag.services.provider_key_binding import bound_chat_key_destination from bigrag.services.url_security import ( UnsafeOutboundUrlError, normalize_url_root, @@ -43,7 +44,12 @@ async def _resolve_api_credentials( ) prefs = decrypt_preferences(dict(data)) if isinstance(data, dict) else {} chat = prefs.get("chat") if isinstance(prefs.get("chat"), dict) else {} - _append_credential(credentials, chat.get("openai_key"), "saved chat key") + _append_credential( + credentials, + chat.get("openai_key"), + "saved chat key", + base_url=bound_chat_key_destination(chat), + ) if not credentials: env_key = os.environ.get("BIGRAG_CHAT_API_KEY") @@ -61,6 +67,8 @@ def _append_credential( credentials: list[ProviderCredential], raw_api_key: object, source: str, + *, + base_url: str | None = None, ) -> None: if not isinstance(raw_api_key, str) or not raw_api_key.strip(): return @@ -70,7 +78,7 @@ def _append_credential( raise ServerError(msg, public_message=msg) if any(existing.api_key == api_key for existing in credentials): return - credentials.append(ProviderCredential(api_key=api_key, source=source)) + credentials.append(ProviderCredential(api_key=api_key, source=source, base_url=base_url)) async def _resolve_base_url(raw_base_url: str | None, default_base_url: str | None) -> str | None: @@ -89,6 +97,25 @@ def assert_credentials_allowed_for_base_url( *, request_base_url: str | None, ) -> None: + for credential in credentials: + if credential.source != "saved chat key": + continue + if not credential.base_url: + raise ValidationError( + "Save the chat key again in Chat settings to bind it to the configured provider " + "URL." + ) + try: + matches = normalize_url_root(credential.base_url) == normalize_url_root( + base_url or _DEFAULT_OPENAI_CHAT_BASE_URL + ) + except (UnsafeOutboundUrlError, ValueError): + matches = False + if not matches: + raise ValidationError( + "The saved chat key belongs to a different provider URL. " + "Save a key for the configured provider, or pass provider_api_key with the request." + ) has_instance_key = _has_instance_chat_key(credentials) if request_base_url is not None and has_instance_key: raise ValidationError( @@ -125,6 +152,8 @@ async def _clear_saved_chat_key(session: AsyncSession, user: dict) -> None: return cleaned_chat = {**chat} cleaned_chat.pop("openai_key", None) + cleaned_chat.pop("openai_key_base_url", None) + cleaned_chat.pop("openai_key_base_url_signature", None) cleaned = {**data, "chat": cleaned_chat} await session.execute( sa.update(UserPreference) diff --git a/api/bigrag/services/chat/types.py b/api/bigrag/services/chat/types.py index 13c3f4671..5a0005535 100644 --- a/api/bigrag/services/chat/types.py +++ b/api/bigrag/services/chat/types.py @@ -10,6 +10,7 @@ class ProviderCredential: api_key: str source: str + base_url: str | None = None @dataclass diff --git a/api/bigrag/services/preferences.py b/api/bigrag/services/preferences.py index ec5fa5b72..fdbc8823a 100644 --- a/api/bigrag/services/preferences.py +++ b/api/bigrag/services/preferences.py @@ -5,6 +5,8 @@ from bigrag.logging import get_logger from bigrag.services import crypto from bigrag.services.credential_check import CredentialCheckError, verify_provider_credentials +from bigrag.services.provider_key_binding import chat_key_binding_signature +from bigrag.services.url_security import UnsafeOutboundUrlError, normalize_url_root logger = get_logger("bigrag.services.preferences") @@ -24,6 +26,11 @@ def _deep_merge(left: dict, right: dict) -> dict: def _normalize_sensitive(data: dict) -> dict: out = {**data} + if isinstance(out.get("chat"), dict): + chat = {**out["chat"]} + chat.pop("openai_key_base_url", None) + chat.pop("openai_key_base_url_signature", None) + out["chat"] = chat for parent, key in _SENSITIVE_PATHS: sub = out.get(parent) if not isinstance(sub, dict) or key not in sub: @@ -34,14 +41,14 @@ def _normalize_sensitive(data: dict) -> dict: return out -async def _validate_sensitive(data: dict) -> None: +async def _validate_sensitive(data: dict) -> dict: chat = data.get("chat") if not isinstance(chat, dict) or "openai_key" not in chat: - return + return data value = chat.get("openai_key") if value is None or value == "": - return + return data if not isinstance(value, str): raise HTTPException(status_code=422, detail="OpenAI API key must be a string.") @@ -49,13 +56,14 @@ async def _validate_sensitive(data: dict) -> None: runtime = await get_values(["chat_provider", "chat_base_url"]) try: + base_url = normalize_url_root(runtime["chat_base_url"] or "https://api.openai.com/v1") await verify_provider_credentials( runtime["chat_provider"], value, - runtime["chat_base_url"], + base_url, url_purpose="chat", ) - except CredentialCheckError as exc: + except (CredentialCheckError, UnsafeOutboundUrlError, ValueError) as exc: raise HTTPException( status_code=422, detail=( @@ -63,6 +71,14 @@ async def _validate_sensitive(data: dict) -> None: "provider and base URL, then try again." ), ) from exc + return { + **data, + "chat": { + **chat, + "openai_key_base_url": base_url, + "openai_key_base_url_signature": chat_key_binding_signature(value, base_url), + }, + } def _encrypt_sensitive(data: dict) -> dict: @@ -95,6 +111,9 @@ def _remove_cleared_sensitive(merged: dict, incoming: dict) -> dict: continue cleaned = {**sub_out} cleaned.pop(key, None) + if (parent, key) == ("chat", "openai_key"): + cleaned.pop("openai_key_base_url", None) + cleaned.pop("openai_key_base_url_signature", None) out[parent] = cleaned return out @@ -143,6 +162,8 @@ def _public_preferences(data: dict) -> dict: if not isinstance(sub, dict): continue cleaned = {**sub} + cleaned.pop("openai_key_base_url", None) + cleaned.pop("openai_key_base_url_signature", None) if key in cleaned: value = cleaned.pop(key) cleaned[f"has_{key}"] = bool(value) diff --git a/api/bigrag/services/provider_key_binding.py b/api/bigrag/services/provider_key_binding.py new file mode 100644 index 000000000..08960c49a --- /dev/null +++ b/api/bigrag/services/provider_key_binding.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import hashlib +import hmac + + +def chat_key_binding_signature(api_key: str, base_url: str) -> str: + payload = f"bigrag:chat-provider-url:v1:{base_url}".encode() + return hmac.new(api_key.encode(), payload, hashlib.sha256).hexdigest() + + +def bound_chat_key_destination(chat: dict) -> str | None: + api_key = chat.get("openai_key") + base_url = chat.get("openai_key_base_url") + signature = chat.get("openai_key_base_url_signature") + if not all(isinstance(value, str) and value for value in (api_key, base_url, signature)): + return None + expected = chat_key_binding_signature(api_key, base_url) + if not hmac.compare_digest(signature.encode(), expected.encode()): + return None + return base_url diff --git a/sdks/python/src/bigrag/_core.py b/sdks/python/src/bigrag/_core.py index 01106e900..def542d76 100644 --- a/sdks/python/src/bigrag/_core.py +++ b/sdks/python/src/bigrag/_core.py @@ -121,13 +121,10 @@ async def _execute_with_retry(self, send_fn, *, safe_to_retry: bool = False) -> try: response = await send_fn() except httpx.TimeoutException as exc: - last_error = exc - if attempt < self.max_retries: - continue raise APITimeoutError(str(exc)) from exc except httpx.HTTPError as exc: last_error = exc - if attempt < self.max_retries: + if attempt < self.max_retries and safe_to_retry: continue raise APIConnectionError(str(exc)) from exc diff --git a/sdks/typescript/src/core.ts b/sdks/typescript/src/core.ts index 9ad4b7c46..d7876c0a0 100644 --- a/sdks/typescript/src/core.ts +++ b/sdks/typescript/src/core.ts @@ -125,10 +125,9 @@ export class BigRAGCore implements RequestClient { lastError = err instanceof Error ? err : new Error(String(err)); if (opts?.signal?.aborted) throw lastError; if (lastError.name === "TimeoutError" || lastError.name === "AbortError") { - if (attempt < this.maxRetries) continue; throw new APITimeoutError(lastError.message); } - if (attempt < this.maxRetries) continue; + if (attempt < this.maxRetries && safeToRetry) continue; throw new APIConnectionError(lastError.message); } diff --git a/website/content/docs/api-reference/chat.mdx b/website/content/docs/api-reference/chat.mdx index 7684f8346..ebc107383 100644 --- a/website/content/docs/api-reference/chat.mdx +++ b/website/content/docs/api-reference/chat.mdx @@ -16,6 +16,7 @@ Accepts session auth and API-key auth. Scoped API keys can use chat when granted - Supported providers: `openai` and `openai_compatible`. Save the provider key in the admin UI at `chat.openai_key`. - API clients can also pass `provider_api_key` per request to bypass the saved key. +- Saving a chat key binds it to the configured `chat_base_url` at that time. A saved key can only be used with that same normalized endpoint, including its path. To use another endpoint, save a key after configuring that endpoint or send `provider_api_key` explicitly. Existing saved keys without a destination binding must be saved again before use; bigRAG rejects them before calling the provider. - `BIGRAG_CHAT_API_KEY` is an instance-level fallback for the default OpenAI chat endpoint only. If `chat_base_url` or request `provider_base_url` points to a non-default OpenAI-compatible endpoint, a saved chat key or `provider_api_key` is required — the instance fallback key is rejected before any provider call. @@ -110,6 +111,8 @@ data: [DONE] If provider generation fails after streaming starts, the API emits an `error` event followed by `data: [DONE]`. +While waiting for provider tokens, the API emits heartbeat comments every 15 seconds. Heartbeats keep the pending token read alive, and disconnecting the client cancels that read and closes the provider iterator. + diff --git a/website/content/docs/api-reference/preferences.mdx b/website/content/docs/api-reference/preferences.mdx index afdf878ea..a2ca169fd 100644 --- a/website/content/docs/api-reference/preferences.mdx +++ b/website/content/docs/api-reference/preferences.mdx @@ -47,6 +47,8 @@ The body may be either `{"data": {...}}` or a bare object — both are accepted. Sensitive paths such as `chat.openai_key` are Fernet-encrypted at rest when `BIGRAG_MASTER_KEY` is configured. Preference responses do not return the key; they return `chat.has_openai_key` instead. +Saving `chat.openai_key` verifies it against the current instance chat provider and records its normalized destination with an authenticated signature derived from that key. The server owns this binding: incoming `chat.openai_key_base_url` and `chat.openai_key_base_url_signature` values are ignored, ordinary preference updates preserve the existing binding, and clearing the key removes both fields. Neither the binding nor the key is returned in preference responses. After changing `chat_base_url`, save a key for that endpoint again. Keys saved before authenticated destination binding was introduced also require saving again. + There is no size validation beyond the standard request-body limit, but keep payloads small (<64 KB) — this isn't a document store. diff --git a/website/content/docs/api-reference/query.mdx b/website/content/docs/api-reference/query.mdx index 0cc011b2c..88db95dd8 100644 --- a/website/content/docs/api-reference/query.mdx +++ b/website/content/docs/api-reference/query.mdx @@ -171,6 +171,8 @@ Response: `{"results": [...]}` — an array of query responses in the same order Rolling 24-hour, 7-day, and 30-day query statistics, cached for five minutes. +On a cache miss, the period queries run sequentially within one database session, avoiding concurrent use of that session. + **Response** `200`: ```json diff --git a/website/content/docs/sdks/python.mdx b/website/content/docs/sdks/python.mdx index 2db246cca..778bb22d6 100644 --- a/website/content/docs/sdks/python.mdx +++ b/website/content/docs/sdks/python.mdx @@ -417,11 +417,13 @@ except APITimeoutError: ## Retry Behavior -The SDK automatically retries on: +The SDK automatically retries reads and requests carrying an idempotency key on: - HTTP 429 from proxy or infrastructure layers - HTTP 5xx (server errors) -- Connection errors and timeouts +- Connection errors + +Timeouts are never retried automatically. With `auto_idempotency_key=False` and no explicit key, mutations fail after the first connection error. An idempotency header does not guarantee server replay for multipart uploads, large request bodies, or one-time responses; check the operation's result before manually repeating a failed mutation. Retries use exponential backoff: `min(0.5 * 2^attempt, 4)` seconds. Configure with `max_retries` (default: 2). diff --git a/website/content/docs/sdks/typescript.mdx b/website/content/docs/sdks/typescript.mdx index c2c8611d5..857892a76 100644 --- a/website/content/docs/sdks/typescript.mdx +++ b/website/content/docs/sdks/typescript.mdx @@ -396,7 +396,9 @@ try { ## Retry Behavior -- **Retried:** HTTP 500+, HTTP 429 from proxy/infrastructure layers, connection errors, timeouts -- **Not retried:** HTTP 400, 401, 403, 404 +- **Retried:** HTTP 500+, HTTP 429 from proxy/infrastructure layers, and connection errors, only for reads or requests carrying an idempotency key +- **Not retried:** Timeouts, cancellations, HTTP 400, 401, 403, 404, and mutations without an idempotency key - **Backoff:** `min(0.5 * 2^attempt, 4.0)` seconds - **Default retries:** 2 (configurable via `maxRetries`) + +With `autoIdempotencyKey: false` and no explicit key, mutations fail after the first connection error. An idempotency header does not guarantee server replay for multipart uploads, large request bodies, or one-time responses; check the operation's result before manually repeating a failed mutation. From 02c2f032028b920b4338374705f34c2f7f750a21 Mon Sep 17 00:00:00 2001 From: Bigint <69431456+bigint@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:05:22 +0530 Subject: [PATCH 02/11] fix: isolate browser sessions and interrupted chat streams --- AUDIT.md | 29 +++-- api/bigrag/routers/auth.py | 2 + app/src/components/navigation/user-menu.tsx | 3 - app/src/features/chat/assistant-message.tsx | 28 +++-- app/src/features/chat/chat-store.ts | 72 ++++++++----- .../features/chat/use-chat-page-controller.ts | 41 +++---- app/src/features/chat/use-chat-streaming.ts | 67 ++++++------ .../collections/documents/documents-tab.tsx | 7 +- .../collections/upload-session-store.ts | 47 +++++--- .../collections/use-active-upload-session.ts | 27 +++++ .../features/settings/tabs/account-tab.tsx | 8 -- app/src/hooks/use-auth.ts | 101 ++++++++++++++---- app/src/hooks/use-upload-session-documents.ts | 19 +++- app/src/lib/chat-stream.ts | 51 +++++---- app/src/lib/session-state.ts | 71 ++++++++++++ app/src/providers.tsx | 21 +--- app/src/routes/_auth.login.tsx | 3 +- app/src/routes/_auth.setup.tsx | 7 +- .../docs/api-reference/authentication.mdx | 2 +- website/content/docs/api-reference/chat.mdx | 2 + website/content/docs/concepts/security.mdx | 6 ++ 21 files changed, 407 insertions(+), 207 deletions(-) create mode 100644 app/src/features/collections/use-active-upload-session.ts create mode 100644 app/src/lib/session-state.ts diff --git a/AUDIT.md b/AUDIT.md index 35642ba90..148bf2824 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -37,12 +37,12 @@ Paths below are relative to `api/bigrag/` unless another package is named. | 17 | SDK exception retries ignore safety flag; Python `_core.py`, TypeScript `core.ts` | Fixed in batch 1. Python mock transport reproduced three unsafe attempts at baseline; exception retries now honor the flag in both SDKs. Timeouts fail immediately, following the style guide. Reads and keyed mutations retain connection-error retries. Multipart/large-body replay limitations remain under 18. | | 18 | Idempotency skips multipart/large bodies and has a 60-second unrenewed lease | Open; coordinate with 2. SDK docs now disclose the replay limits; automatic upload retry policy and lease renewal still need investigation. | | 19 | SSE heartbeat `wait_for(__anext__)` cancels slow provider iterator; `services/chat/completion.py` | Fixed in batch 1. Baseline slow-token smoke returned an empty successful assistant. One pending token task survives heartbeat ticks. Cleanup is shielded from ASGI cancellation, and the raw provider response closes even when disconnected immediately after a delta. | -| 20 | Stale chat finally/collection switch clears newer state; `app/src/features/chat/use-chat-streaming.ts` | Open; inspect stream ownership and collection-switch lifecycle. | -| 21 | EOF without terminal event appears successful; `app/src/lib/chat-stream.ts` | Source inspected; open. Parser breaks on reader EOF without checking terminal completion. Validate with truncated mock SSE before editing. | -| 22 | Password change revokes session then UI logout fails | Source inspected; open. Account tab awaits logout after password change; review backend session deletion and local sign-out cleanup together. | -| 23 | Logout leaves chat/upload Zustand stores | Source inspected; open. Logout currently clears TanStack Query only; inventory all stores and active request ownership before editing. | -| 24 | Upload UI ignores returned `item.status=failed` | Open; validate terminal upload states. | -| 25 | Closed incomplete upload sessions remain uploading | Open; review completion/cancellation state transitions. | +| 20 | Stale chat finally/collection switch clears newer state; `app/src/features/chat/use-chat-streaming.ts` | Fixed in batch 2. Store-owned controller identity guards start, updates, stop, and finish. Same-collection selection preserves the stream; collection changes, clear, unmount, and account reset abort it. Late completion cannot clear a newer response. | +| 21 | EOF without terminal event appears successful; `app/src/lib/chat-stream.ts` | Fixed in batch 2. Truncated mock SSE reproduced success at baseline. Parser requires done/error/[DONE], handles CRLF and terminal tails, cancels the reader, and ignores post-terminal frames. Partial answers remain visible beside an interruption error. | +| 22 | Password change revokes session then UI logout fails | Fixed in batch 2. Independent review confirmed logout authenticates after password revokes every session. Successful password change now clears the cookie and local session directly, with guarded navigation and no extra logout call. Incorrect-current-password 401 preserves the session. | +| 23 | Logout leaves chat/upload Zustand stores | Fixed in batch 2. Confirmed transitions replace/remount the QueryClient provider, abort generation-owned auth/upload requests and chat, reset stores, and seed the new session. Upload persistence is owner-bound; unowned v1 IDs are discarded. Generation guards prevent late callbacks from restoring tracking. Focus/reconnect and active-tab minute refresh detect confirmed expiry/account changes. | +| 24 | Upload UI ignores returned `item.status=failed` | Source path confirmed; open. File responses can contain failed items, but the UI awaits and discards the response. Reproduce with a mocked file response in the next upload batch. | +| 25 | Closed incomplete upload sessions remain uploading | Independently reproduced locally in batch 2 follow-up audit; open. A closed session with one completed item against three declared files returns uploading. Review close/retry/cancellation semantics before editing. | | 26 | Allowed file types and empty-file semantics inconsistent | Open; compare upload entry points and server validation. | | 27 | Personal-key-only UI gate blocks instance chat credentials | Open; inspect safe readiness contract before removing gate. | | 28 | Analytics gather shares AsyncSession; `services/analytics.py` | Fixed in batch 1. Mock session reproduced overlapping execute calls at baseline. Period queries now run sequentially in one session, preserving output and five-minute cache. | @@ -70,6 +70,21 @@ Temporary reproductions initially failed for saved-key redirection, slow-stream Local recovery: verification logs and temporary smoke scripts live beside this checkout in the task workspace. A copy of changed files, binary diff, and manifest is saved under the task's `audit-backups/` before the commit. These copies exclude dependencies, runtime data, and secrets. This ledger is the durable source for later batches; continue in the same draft PR. -Next bounded batch: validate UI stream ownership/EOF and password-change/logout cleanup (20–23), then handle upload terminal states. Replay authorization (2/18) needs guard extraction design; tenant isolation (3–7) needs explicit coordination with the separate migration work before persistence changes. +Published batch 1: commit `d5f8c60d64826bdf0779678c6cf8fbbb442b9d5d` in draft PR [#90](https://github.com/bigint/rag.computer/pull/90). All six GitHub checks passed: lint, repo-check, SDK typecheck, Python SDK build, website build, and app build. + +Next bounded batch: handle upload terminal states (24–25). Replay authorization (2/18) needs guard extraction design; tenant isolation (3–7) needs explicit coordination with the separate migration work before persistence changes. Limitations: no live credential transmission, exploit, production database/vector access, Docker service start, provider billing, or deployed runtime verification. Idempotency authorization and tenant isolation remain open and are not claimed fixed. + +## Batch 2 verification + +Changes: UI stream terminal/ownership handling, password cookie cleanup, guarded auth transitions, retired query-client isolation, and owner-bound upload tracking. No schema, tenant provisioning, replay middleware, service, or credential changes. + +- `pnpm check`: passed, covering repository lint, API Ruff lint/format, workspace typechecks, app/SDK/docs builds, and API compile. Existing route-generator circular-module warning remains. +- Nine external UI runtime checks passed: truncated SSE, done/error/[DONE] termination and reader cancellation, CRLF/chunked tails, atomic stream ownership, legacy persistence migration, owner/session comparisons, generation cancellation/cache isolation including a real late mutation, and same-owner refresh/sign-out behavior. +- Password backend smoke passed: successful password change deletes sessions and the cookie; validation failure retains the cookie. No database or provider connection used. +- Independent source review and 16 mocked runtime checks passed. Review found stale caller navigation, login-versus-session-refresh adoption, and overlapping auth-gate issues; all fixed and re-reviewed. Independent coverage includes actual QueryObserver cancellation, stale auth responses, upload cancellation before late callbacks, and denied localStorage. +- Browser smoke with intercepted API responses passed for truncated-answer rendering, stop/immediate resend, password validation/success, login, confirmed account switch with a late mutation, and confirmed expiry. Vite's versioned module URLs initially made the fixture inspect a duplicate module; the fixture was corrected to use the running app's imports. Incorrect-password 401 preserves the account; successful change reaches sign-in with empty chat/upload tracking and zero logout requests. +- `git diff --check`: passed. Temporary scripts, browser artifacts, and logs stay outside the production checkout; no removed runner or suite restored. + +Limitations: confirmed `/me` responses drive expiry/account-switch cleanup; no claim of instantaneous detection across tabs. Canceling browser work cannot undo API work already accepted, and cross-tab cookie races remain outside this client lifecycle change. Explicit sign-out clears tracking IDs; same-account reload/refresh retains them. diff --git a/api/bigrag/routers/auth.py b/api/bigrag/routers/auth.py index c1cd55669..5d74be773 100644 --- a/api/bigrag/routers/auth.py +++ b/api/bigrag/routers/auth.py @@ -276,6 +276,7 @@ async def whoami(user: dict = Depends(get_current_user)) -> WhoamiResponse: async def change_password( body: ChangePasswordRequest, request: Request, + response: Response, user: dict = Depends(require_session), session: AsyncSession = Depends(get_session), ) -> StatusResponse: @@ -289,6 +290,7 @@ async def change_password( await session.execute(sa.delete(UserSession).where(UserSession.user_id == target.id)) await session.commit() await invalidate_auth_principals() + await clear_session_cookie(response) audit.record( request, user=user, diff --git a/app/src/components/navigation/user-menu.tsx b/app/src/components/navigation/user-menu.tsx index 3feaf2eb1..534e7493d 100644 --- a/app/src/components/navigation/user-menu.tsx +++ b/app/src/components/navigation/user-menu.tsx @@ -1,5 +1,4 @@ import { Menu } from "@base-ui/react/menu"; -import { useNavigate } from "@tanstack/react-router"; import { LogOut } from "lucide-react"; import { ThemeControl } from "@/features/theme/theme-control"; import { useLogout, useSession } from "@/hooks/use-auth"; @@ -14,7 +13,6 @@ const initials = (name: string, email: string) => { }; export const UserMenu = () => { - const navigate = useNavigate(); const { data } = useSession(); const logout = useLogout(); const user = data?.user; @@ -23,7 +21,6 @@ export const UserMenu = () => { const onSignOut = async () => { await logout.mutateAsync(); - navigate({ to: "/login", replace: true }); }; return ( diff --git a/app/src/features/chat/assistant-message.tsx b/app/src/features/chat/assistant-message.tsx index cd0cfa5ea..8955ec7ea 100644 --- a/app/src/features/chat/assistant-message.tsx +++ b/app/src/features/chat/assistant-message.tsx @@ -12,7 +12,6 @@ import { type MutableRefObject, memo, useCallback, useEffect, useRef, useState } import { type ChatMessage, formatWholeMs } from "@/features/chat/chat-message-types"; import { MarkdownContent } from "@/features/chat/markdown/cited-markdown"; import { SourcesPanel } from "@/features/chat/sources-panel"; -import { cn } from "@/lib/cn"; const useHighlightTimerCleanup = (highlightTimer: MutableRefObject) => { useEffect( @@ -131,27 +130,24 @@ export const AssistantMessage = memo( -
- {hasError ? ( - - - {message.errorMessage} - - ) : message.content ? ( + {hasError && ( +
+ + {message.errorMessage} +
+ )} +
+ {message.content ? ( - ) : ( + ) : hasError ? null : ( - Retrieving context and drafting answer... + {message.status === "stopped" + ? "Response stopped." + : "Retrieving context and drafting answer..."} )} {isStreaming && ( diff --git a/app/src/features/chat/chat-store.ts b/app/src/features/chat/chat-store.ts index abf7be4b4..c9cc8b8da 100644 --- a/app/src/features/chat/chat-store.ts +++ b/app/src/features/chat/chat-store.ts @@ -5,11 +5,13 @@ type ChatStoreState = { collection: string; isStreaming: boolean; messages: ChatMessage[]; - appendMessages: (messages: ChatMessage[]) => void; + activeStream: AbortController | null; clearMessages: () => void; + reset: () => void; selectCollection: (collection: string) => void; - selectFirstCollection: (collections: readonly { name: string }[]) => void; - setStreaming: (isStreaming: boolean) => void; + startStream: (collection: string, messages: ChatMessage[]) => AbortController | null; + finishStream: (controller: AbortController) => void; + stopStream: (controller?: AbortController) => void; setMessages: (messages: ChatMessage[]) => void; updateMessage: (id: string, update: (message: ChatMessage) => ChatMessage) => void; }; @@ -18,33 +20,51 @@ const initialState = { collection: "", isStreaming: false, messages: [], -} satisfies Pick; + activeStream: null, +} satisfies Pick; -export const useChatStore = create()((set) => ({ +export const useChatStore = create()((set, get) => ({ ...initialState, - appendMessages: (messages) => - set((state) => ({ - messages: [...state.messages, ...messages], - })), - clearMessages: () => + clearMessages: () => { + get().activeStream?.abort(); + set({ activeStream: null, isStreaming: false, messages: [] }); + }, + reset: () => { + get().activeStream?.abort(); + set(initialState); + }, + selectCollection: (collection) => { + if (get().collection === collection) return; + get().activeStream?.abort(); + set({ collection, activeStream: null, isStreaming: false, messages: [] }); + }, + startStream: (collection, messages) => { + const state = get(); + if (state.activeStream || state.collection !== collection) return null; + const controller = new AbortController(); set({ - isStreaming: false, - messages: [], - }), - selectCollection: (collection) => - set((state) => ({ - collection, - messages: state.collection === collection ? state.messages : [], - isStreaming: false, - })), - selectFirstCollection: (collections) => - set((state) => { - const first = collections[0]; - if (state.collection || !first) return state; - return { collection: first.name }; - }), + activeStream: controller, + isStreaming: true, + messages: [...state.messages, ...messages], + }); + return controller; + }, + finishStream: (controller) => { + if (get().activeStream !== controller) return; + set({ activeStream: null, isStreaming: false }); + }, + stopStream: (controller) => { + const state = get(); + if (!state.activeStream || (controller && state.activeStream !== controller)) return; + state.activeStream.abort(); + const messages = state.messages.map((message, index) => + index === state.messages.length - 1 && message.role === "assistant" && !message.status + ? { ...message, status: "stopped" as const } + : message, + ); + set({ activeStream: null, isStreaming: false, messages }); + }, setMessages: (messages) => set({ messages }), - setStreaming: (isStreaming) => set({ isStreaming }), updateMessage: (id, update) => set((state) => { const index = state.messages.findIndex((message) => message.id === id); diff --git a/app/src/features/chat/use-chat-page-controller.ts b/app/src/features/chat/use-chat-page-controller.ts index 38892ad03..0ce07c335 100644 --- a/app/src/features/chat/use-chat-page-controller.ts +++ b/app/src/features/chat/use-chat-page-controller.ts @@ -32,29 +32,17 @@ export const useChatPageController = () => { const generateQuestions = useGenerateChatQuestions(); const { data: collectionsData, isPending: collectionsLoading } = useCollections(); const collections = useMemo(() => collectionsData?.collections ?? [], [collectionsData]); - const { - appendMessages, - clearMessages, - collection, - isStreaming, - messages, - selectCollection, - setMessages, - setStreaming, - updateMessage, - } = useChatStore( - useShallow((state) => ({ - appendMessages: state.appendMessages, - clearMessages: state.clearMessages, - collection: state.collection, - isStreaming: state.isStreaming, - messages: state.messages, - selectCollection: state.selectCollection, - setMessages: state.setMessages, - setStreaming: state.setStreaming, - updateMessage: state.updateMessage, - })), - ); + const { clearMessages, collection, isStreaming, messages, selectCollection, setMessages } = + useChatStore( + useShallow((state) => ({ + clearMessages: state.clearMessages, + collection: state.collection, + isStreaming: state.isStreaming, + messages: state.messages, + selectCollection: state.selectCollection, + setMessages: state.setMessages, + })), + ); useSelectAvailableCollection(collections, collection, selectCollection); @@ -106,12 +94,8 @@ export const useChatPageController = () => { const questionsQuery = useChatQuestionSuggestions(collection); const { handleSend, stopStreaming } = useChatStreaming({ - appendMessages, collection, - isStreaming, - setStreaming, state, - updateMessage, }); const handleCollectionChange = useCallback( @@ -137,11 +121,12 @@ export const useChatPageController = () => { const resendFrom = useCallback( (messageIndex: number, content: string) => { + stopStreaming(); const currentMessages = useChatStore.getState().messages; setMessages(currentMessages.slice(0, messageIndex)); void handleSend(content); }, - [handleSend, setMessages], + [handleSend, setMessages, stopStreaming], ); const handleEditUserMessage = useCallback( diff --git a/app/src/features/chat/use-chat-streaming.ts b/app/src/features/chat/use-chat-streaming.ts index 3c898e150..750ff6108 100644 --- a/app/src/features/chat/use-chat-streaming.ts +++ b/app/src/features/chat/use-chat-streaming.ts @@ -5,50 +5,39 @@ import type { ChatState } from "@/features/chat/chat-input"; import type { ChatMessage } from "@/features/chat/chat-messages"; import { createChatMessageId } from "@/features/chat/chat-page-defaults"; import { normalizeTimings, timingsFromRetrieval } from "@/features/chat/chat-page-timings"; -import type { useChatStore } from "@/features/chat/chat-store"; +import { useChatStore } from "@/features/chat/chat-store"; import { streamChat } from "@/lib/chat-stream"; import { queryKeys } from "@/lib/query-keys"; -type ChatStoreState = ReturnType; - type ChatStreamingOptions = { - appendMessages: ChatStoreState["appendMessages"]; collection: string; - isStreaming: boolean; - setStreaming: ChatStoreState["setStreaming"]; state: ChatState; - updateMessage: ChatStoreState["updateMessage"]; }; -export const useChatStreaming = ({ - appendMessages, - collection, - isStreaming, - setStreaming, - state, - updateMessage, -}: ChatStreamingOptions) => { +export const useChatStreaming = ({ collection, state }: ChatStreamingOptions) => { const queryClient = useQueryClient(); const abortRef = useRef(null); + const flushRef = useRef<(() => void) | null>(null); + const { startStream, finishStream, stopStream, updateMessage } = useChatStore.getState(); useEffect( () => () => { - abortRef.current?.abort(); + flushRef.current?.(); + if (abortRef.current) stopStream(abortRef.current); abortRef.current = null; - setStreaming(false); }, - [setStreaming], + [stopStream], ); const stopStreaming = useCallback(() => { - abortRef.current?.abort(); + flushRef.current?.(); + if (abortRef.current) stopStream(abortRef.current); abortRef.current = null; - setStreaming(false); - }, [setStreaming]); + }, [stopStream]); const handleSend = useCallback( async (text: string) => { - if (isStreaming) return; + if (useChatStore.getState().activeStream) return; if (!state.hasOpenAIKey) { toast.error("Add your OpenAI API key first"); return; @@ -63,15 +52,19 @@ export const useChatStreaming = ({ let currentAssistantId = assistantId; const userMsg: ChatMessage = { id: userId, role: "user", content: text }; const assistantMsg: ChatMessage = { id: assistantId, role: "assistant", content: "" }; - appendMessages([userMsg, assistantMsg]); - setStreaming(true); - - const controller = new AbortController(); + const controller = startStream(collection, [userMsg, assistantMsg]); + if (!controller) return; abortRef.current = controller; + const ownsStream = () => useChatStore.getState().activeStream === controller; let deltaBuffer = ""; let deltaFrame: number | null = null; const flushDelta = () => { + if (deltaFrame !== null) window.cancelAnimationFrame(deltaFrame); deltaFrame = null; + if (!ownsStream()) { + deltaBuffer = ""; + return; + } if (!deltaBuffer) return; const delta = deltaBuffer; deltaBuffer = ""; @@ -80,6 +73,7 @@ export const useChatStreaming = ({ content: message.content + delta, })); }; + flushRef.current = flushDelta; const enqueueDelta = (delta: string) => { deltaBuffer += delta; if (deltaFrame === null) { @@ -108,6 +102,7 @@ export const useChatStreaming = ({ system_prompt: state.systemPrompt, }, onEvent: (event) => { + if (!ownsStream()) return; if (event.event === "sources") { updateMessage(currentAssistantId, (message) => ({ ...message, @@ -152,7 +147,15 @@ export const useChatStreaming = ({ } }, }); + if (ownsStream()) { + flushDelta(); + updateMessage(currentAssistantId, (message) => ({ + ...message, + status: message.status ?? "complete", + })); + } } catch (err) { + if (!ownsStream()) return; if (err instanceof DOMException && err.name === "AbortError") { flushDelta(); updateMessage(currentAssistantId, (chatMessage) => ({ @@ -175,16 +178,18 @@ export const useChatStreaming = ({ window.cancelAnimationFrame(deltaFrame); flushDelta(); } - setStreaming(false); - abortRef.current = null; + finishStream(controller); + if (abortRef.current === controller) { + abortRef.current = null; + flushRef.current = null; + } } }, [ - appendMessages, collection, - isStreaming, queryClient, - setStreaming, + finishStream, + startStream, state.hasOpenAIKey, state.model, state.multimodal, diff --git a/app/src/features/collections/documents/documents-tab.tsx b/app/src/features/collections/documents/documents-tab.tsx index c34274382..bf07707ca 100644 --- a/app/src/features/collections/documents/documents-tab.tsx +++ b/app/src/features/collections/documents/documents-tab.tsx @@ -22,7 +22,7 @@ import { } from "@/features/collections/documents/upload-session-status-cards"; import { useDocumentUpload } from "@/features/collections/documents/use-document-upload"; import { useDocumentsTabState } from "@/features/collections/documents/use-documents-tab-state"; -import { useUploadSessionStore } from "@/features/collections/upload-session-store"; +import { useActiveUploadSession } from "@/features/collections/use-active-upload-session"; import { WorkerOfflineBanner } from "@/features/workers/worker-status-banner"; import { useCollection } from "@/hooks/use-collections"; import { @@ -44,9 +44,8 @@ type DocumentsTabProps = { export const DocumentsTab = ({ filters, name, onFiltersChange }: DocumentsTabProps) => { const activeFilters = filters ?? defaultDocumentsTabFilters; - const activeSessionId = useUploadSessionStore((state) => state.activeSessionIds[name] ?? null); - const clearActiveSessionId = useUploadSessionStore((state) => state.clearActiveSessionId); - const setActiveSessionId = useUploadSessionStore((state) => state.setActiveSessionId); + const { activeSessionId, clearActiveSessionId, setActiveSessionId } = + useActiveUploadSession(name); const { data: collection } = useCollection(name); const documentFilters: DocumentListFilters = { diff --git a/app/src/features/collections/upload-session-store.ts b/app/src/features/collections/upload-session-store.ts index 44ceb515c..b6b07f2ac 100644 --- a/app/src/features/collections/upload-session-store.ts +++ b/app/src/features/collections/upload-session-store.ts @@ -2,12 +2,14 @@ import { create } from "zustand"; import { type PersistStorage, persist, type StorageValue } from "zustand/middleware"; type UploadSessionStoreState = { + ownerId: string | null; activeSessionIds: Record; - clearActiveSessionId: (collection: string) => void; + setOwner: (ownerId: string | null) => void; + clearActiveSessionId: (collection: string, sessionId: string | null) => void; setActiveSessionId: (collection: string, sessionId: string) => void; }; -type PersistedUploadSessionState = Pick; +type PersistedUploadSessionState = Pick; const STORAGE_KEY = "bigrag:upload-sessions"; @@ -18,40 +20,55 @@ const getLocalStorage = () => { const uploadSessionStorage: PersistStorage = { getItem: (name) => { - const value = getLocalStorage()?.getItem(name); - return value ? (JSON.parse(value) as StorageValue) : null; + try { + const value = getLocalStorage()?.getItem(name); + return value ? (JSON.parse(value) as StorageValue) : null; + } catch { + return null; + } }, removeItem: (name) => { - getLocalStorage()?.removeItem(name); + try { + getLocalStorage()?.removeItem(name); + } catch {} }, setItem: (name, value) => { - getLocalStorage()?.setItem(name, JSON.stringify(value)); + try { + getLocalStorage()?.setItem(name, JSON.stringify(value)); + } catch {} }, }; export const useUploadSessionStore = create()( persist( (set) => ({ + ownerId: null, activeSessionIds: {}, - clearActiveSessionId: (collection) => + setOwner: (ownerId) => + set((state) => ({ + ownerId, + activeSessionIds: ownerId && ownerId === state.ownerId ? state.activeSessionIds : {}, + })), + clearActiveSessionId: (collection, sessionId) => set((state) => { + if (state.activeSessionIds[collection] !== sessionId) return state; const activeSessionIds = { ...state.activeSessionIds }; delete activeSessionIds[collection]; return { activeSessionIds }; }), setActiveSessionId: (collection, sessionId) => - set((state) => ({ - activeSessionIds: { - ...state.activeSessionIds, - [collection]: sessionId, - }, - })), + set((state) => + state.ownerId + ? { activeSessionIds: { ...state.activeSessionIds, [collection]: sessionId } } + : state, + ), }), { name: STORAGE_KEY, - partialize: (state) => ({ activeSessionIds: state.activeSessionIds }), + partialize: (state) => ({ ownerId: state.ownerId, activeSessionIds: state.activeSessionIds }), + migrate: () => ({ ownerId: null, activeSessionIds: {} }), storage: uploadSessionStorage, - version: 1, + version: 2, }, ), ); diff --git a/app/src/features/collections/use-active-upload-session.ts b/app/src/features/collections/use-active-upload-session.ts new file mode 100644 index 000000000..43c347f69 --- /dev/null +++ b/app/src/features/collections/use-active-upload-session.ts @@ -0,0 +1,27 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useUploadSessionStore } from "@/features/collections/upload-session-store"; +import { getSessionState, isCurrentSession } from "@/lib/session-state"; + +export const useActiveUploadSession = (collection: string) => { + const client = useQueryClient(); + const ownerId = useUploadSessionStore((state) => state.ownerId); + const storedId = useUploadSessionStore((state) => state.activeSessionIds[collection] ?? null); + const activeSessionId = + isCurrentSession(client) && ownerId && ownerId === getSessionState().ownerId ? storedId : null; + const setActiveSessionId = useCallback( + (name: string, sessionId: string) => { + if (!isCurrentSession(client)) return; + useUploadSessionStore.getState().setActiveSessionId(name, sessionId); + }, + [client], + ); + const clearActiveSessionId = useCallback( + (name: string) => { + if (!isCurrentSession(client)) return; + useUploadSessionStore.getState().clearActiveSessionId(name, activeSessionId); + }, + [activeSessionId, client], + ); + return { activeSessionId, clearActiveSessionId, setActiveSessionId }; +}; diff --git a/app/src/features/settings/tabs/account-tab.tsx b/app/src/features/settings/tabs/account-tab.tsx index 67f1efd0c..5d0149c52 100644 --- a/app/src/features/settings/tabs/account-tab.tsx +++ b/app/src/features/settings/tabs/account-tab.tsx @@ -1,5 +1,4 @@ import { useForm, useStore } from "@tanstack/react-form"; -import { useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -19,7 +18,6 @@ import { AccountActiveSessionsSection } from "@/features/settings/tabs/account-a import { AccountProfileSection } from "@/features/settings/tabs/account-profile-section"; import { useChangePassword, - useLogout, useLogoutAll, useSession, useUpdateCurrentUserProfile, @@ -27,11 +25,9 @@ import { import { errorText, firstString, submitWith } from "@/lib/form"; export const AccountTab = () => { - const navigate = useNavigate(); const { data: session } = useSession(); const updateProfile = useUpdateCurrentUserProfile(); const changePassword = useChangePassword(); - const logout = useLogout(); const logoutAll = useLogoutAll(); const [profileValues, setProfileValues] = useState(defaultProfileFormValues); const [profileError, setProfileError] = useState(null); @@ -44,9 +40,6 @@ export const AccountTab = () => { onSubmit: async ({ value }) => { try { await changePassword.mutateAsync(passwordBodyFromValues(value)); - toast.success("Password updated"); - await logout.mutateAsync(); - navigate({ to: "/login", replace: true }); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed"); } @@ -97,7 +90,6 @@ export const AccountTab = () => { try { await logoutAll.mutateAsync(); setSignOutAllOpen(false); - navigate({ to: "/login", replace: true }); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed"); } diff --git a/app/src/hooks/use-auth.ts b/app/src/hooks/use-auth.ts index 7e07479bc..a01692160 100644 --- a/app/src/hooks/use-auth.ts +++ b/app/src/hooks/use-auth.ts @@ -1,8 +1,27 @@ import { APIError, type User as CurrentUser, type SessionResponse } from "@bigrag/client/browser"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { toast } from "sonner"; import { AUTH_TIMEOUT_MS, apiClient } from "@/lib/api"; import { queryKeys } from "@/lib/query-keys"; +import { adoptSession, isCurrentSession, sessionSignal } from "@/lib/session-state"; + +const pendingAuthentication = new WeakMap(); +const authenticationLifecycle = (client: QueryClient) => ({ + onMutate: async () => { + sessionSignal(client).throwIfAborted(); + pendingAuthentication.set(client, (pendingAuthentication.get(client) ?? 0) + 1); + await client.cancelQueries({ queryKey: queryKeys.auth.session() }); + }, + onSettled: () => { + const remaining = (pendingAuthentication.get(client) ?? 1) - 1; + if (remaining > 0) pendingAuthentication.set(client, remaining); + else pendingAuthentication.delete(client); + if (remaining <= 0 && isCurrentSession(client)) { + void client.invalidateQueries({ queryKey: queryKeys.auth.session() }); + } + }, +}); export type { CurrentUser }; @@ -24,17 +43,26 @@ export const useSetupStatus = () => retry: false, }); -export const useSession = () => - useQuery({ +export const useSession = () => { + const qc = useQueryClient(); + return useQuery({ queryKey: queryKeys.auth.session(), queryFn: async ({ signal }) => { + if (pendingAuthentication.get(qc)) { + return qc.getQueryData(queryKeys.auth.session()) ?? null; + } try { - return await apiClient.get("v1/auth/me", { + const session = await apiClient.get("v1/auth/me", { signal, timeoutMs: AUTH_TIMEOUT_MS, }); + signal.throwIfAborted(); + if (!pendingAuthentication.get(qc)) adoptSession(qc, session); + return session; } catch (err) { if (err instanceof APIError && err.status === 401) { + signal.throwIfAborted(); + if (!pendingAuthentication.get(qc)) adoptSession(qc, null); return null; } throw err; @@ -42,52 +70,67 @@ export const useSession = () => }, retry: false, staleTime: 30_000, + refetchInterval: 60_000, + refetchOnWindowFocus: "always", + refetchOnReconnect: "always", }); +}; -export const useLogin = () => { +export const useLogin = (onAuthenticated?: () => void) => { const qc = useQueryClient(); return useMutation({ mutationFn: (body: { email: string; password: string }) => - apiClient.post("v1/auth/login", body), + apiClient.post("v1/auth/login", body, { signal: sessionSignal(qc) }), + ...authenticationLifecycle(qc), onSuccess: (data) => { - qc.setQueryData(queryKeys.auth.session(), data); - qc.invalidateQueries({ queryKey: queryKeys.auth.all() }); + if (adoptSession(qc, data, true)) onAuthenticated?.(); }, }); }; export const useLogout = () => { + const navigate = useNavigate(); const qc = useQueryClient(); return useMutation({ - mutationFn: () => apiClient.post("v1/auth/logout"), + mutationFn: async () => { + try { + await apiClient.post("v1/auth/logout", undefined, { signal: sessionSignal(qc) }); + } catch (err) { + if (!(err instanceof APIError && err.status === 401)) throw err; + } + }, + ...authenticationLifecycle(qc), onSuccess: () => { - qc.clear(); - qc.setQueryData(queryKeys.auth.session(), null); + if (!adoptSession(qc, null, true)) return; toast.success("Signed out"); + navigate({ to: "/login", replace: true }); }, }); }; export const useLogoutAll = () => { + const navigate = useNavigate(); const qc = useQueryClient(); return useMutation({ - mutationFn: () => apiClient.post("v1/auth/logout-all"), + mutationFn: () => + apiClient.post("v1/auth/logout-all", undefined, { signal: sessionSignal(qc) }), + ...authenticationLifecycle(qc), onSuccess: () => { - qc.clear(); - qc.setQueryData(queryKeys.auth.session(), null); + if (!adoptSession(qc, null, true)) return; toast.success("Signed out of all devices"); + navigate({ to: "/login", replace: true }); }, }); }; -export const useSetup = () => { +export const useSetup = (onAuthenticated?: () => void) => { const qc = useQueryClient(); return useMutation({ mutationFn: (body: { email: string; password: string; display_name: string }) => - apiClient.post("v1/auth/setup", body), + apiClient.post("v1/auth/setup", body, { signal: sessionSignal(qc) }), + ...authenticationLifecycle(qc), onSuccess: (data) => { - qc.setQueryData(queryKeys.auth.session(), data); - qc.invalidateQueries({ queryKey: queryKeys.auth.all() }); + if (adoptSession(qc, data, true)) onAuthenticated?.(); }, }); }; @@ -96,8 +139,11 @@ export const useUpdateCurrentUserProfile = () => { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, ...body }: UpdateCurrentUserProfileBody) => - apiClient.patch(`v1/admin/users/${encodeURIComponent(id)}`, body), + apiClient.patch(`v1/admin/users/${encodeURIComponent(id)}`, body, { + signal: sessionSignal(qc), + }), onSuccess: (user) => { + if (!isCurrentSession(qc)) return; qc.setQueryData(queryKeys.auth.session(), { user }); qc.invalidateQueries({ queryKey: queryKeys.auth.all() }); toast.success("Profile updated"); @@ -105,8 +151,19 @@ export const useUpdateCurrentUserProfile = () => { }); }; -export const useChangePassword = () => - useMutation({ +export const useChangePassword = () => { + const navigate = useNavigate(); + const qc = useQueryClient(); + return useMutation({ mutationFn: (body: { current_password: string; new_password: string }) => - apiClient.post<{ status: string; message: string }>("v1/auth/password", body), + apiClient.post<{ status: string; message: string }>("v1/auth/password", body, { + signal: sessionSignal(qc), + }), + ...authenticationLifecycle(qc), + onSuccess: () => { + if (!adoptSession(qc, null, true)) return; + toast.success("Password updated"); + navigate({ to: "/login", replace: true }); + }, }); +}; diff --git a/app/src/hooks/use-upload-session-documents.ts b/app/src/hooks/use-upload-session-documents.ts index 365a35731..c97d9eb36 100644 --- a/app/src/hooks/use-upload-session-documents.ts +++ b/app/src/hooks/use-upload-session-documents.ts @@ -5,6 +5,7 @@ import { apiClient } from "@/lib/api"; import { runWithConcurrency } from "@/lib/concurrency"; import { errorToast } from "@/lib/mutation-toast"; import { queryKeys } from "@/lib/query-keys"; +import { isCurrentSession, sessionSignal } from "@/lib/session-state"; import type { UploadSession, UploadSessionFileResponse } from "@/types/bigrag"; const uploadConcurrency = 4; @@ -51,6 +52,8 @@ export const useUploadSessionDocuments = ( const qc = useQueryClient(); return useMutation({ mutationFn: async (files: File[]) => { + const signal = sessionSignal(qc); + signal.throwIfAborted(); const totalBytes = files.reduce((sum, file) => sum + file.size, 0); const session = await apiClient.post( `v1/collections/${encodeURIComponent(collection)}/upload-sessions`, @@ -59,10 +62,13 @@ export const useUploadSessionDocuments = ( total_bytes: totalBytes, metadata: {}, }, + { signal }, ); + signal.throwIfAborted(); options?.onSessionStart?.(session); const errors: { filename: string; error: string }[] = []; await runWithConcurrency(files, uploadConcurrency, async (file, index) => { + signal.throwIfAborted(); const form = new FormData(); form.append("client_item_id", uploadSessionClientId(file, index)); form.append("file", file, uploadSessionFileName(file)); @@ -70,22 +76,27 @@ export const useUploadSessionDocuments = ( await apiClient.postForm( `v1/collections/${encodeURIComponent(collection)}/upload-sessions/${session.id}/files`, form, + { signal }, ); } catch (err) { + signal.throwIfAborted(); errors.push({ filename: uploadSessionFileName(file), error: err instanceof Error ? err.message : "Upload failed", }); } }); + signal.throwIfAborted(); const sessionPath = `v1/collections/${encodeURIComponent(collection)}/upload-sessions/${session.id}`; const finalSession = errors.length < files.length - ? await apiClient.post(`${sessionPath}/complete`) - : await apiClient.get(sessionPath); + ? await apiClient.post(`${sessionPath}/complete`, undefined, { signal }) + : await apiClient.get(sessionPath, { signal }); + signal.throwIfAborted(); return { errors, session: finalSession }; }, onSuccess: ({ errors, session }) => { + if (!isCurrentSession(qc)) return; qc.invalidateQueries({ queryKey: queryKeys.documents.lists() }); qc.invalidateQueries({ queryKey: queryKeys.documents.uploadSession({ collection, id: session.id }), @@ -98,7 +109,9 @@ export const useUploadSessionDocuments = ( ); } }, - onError: errorToast("Upload session failed"), + onError: (error) => { + if (isCurrentSession(qc)) errorToast("Upload session failed")(error); + }, }); }; diff --git a/app/src/lib/chat-stream.ts b/app/src/lib/chat-stream.ts index 427e45292..73af32962 100644 --- a/app/src/lib/chat-stream.ts +++ b/app/src/lib/chat-stream.ts @@ -48,45 +48,52 @@ export const streamChat = async (opts: StreamOptions): Promise => { let buffer = ""; let finished = false; + const dispatchFrame = (frame: string) => { + const parsed = parseFrame(frame); + if (!parsed) return; + if ("done" in parsed) { + finished = true; + return; + } + opts.onEvent(parsed.event); + finished = parsed.event.event === "done" || parsed.event.event === "error"; + }; + try { while (!finished) { + opts.signal?.throwIfAborted(); const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); - - const frames = buffer.split("\n\n"); + const frames = buffer.split(/\r?\n\r?\n/); buffer = frames.pop() ?? ""; - for (const frame of frames) { - const parsed = parseFrame(frame); - if (!parsed) continue; - if ("done" in parsed) { - finished = true; - continue; - } - opts.onEvent(parsed.event); + dispatchFrame(frame); + if (finished) break; } } + opts.signal?.throwIfAborted(); buffer += decoder.decode(); - if (!finished && buffer.trim()) { - const parsed = parseFrame(buffer); - if (parsed && !("done" in parsed)) { - opts.onEvent(parsed.event); - } - } + if (!finished && buffer.trim()) dispatchFrame(buffer); + if (!finished) throw new ChatStreamError("Chat stream interrupted. Please retry."); } finally { - reader.releaseLock(); + try { + await reader.cancel(); + } catch { + } finally { + reader.releaseLock(); + } } }; const parseFrame = (frame: string): { done: true } | { event: ChatStreamEvent } | null => { let eventName = "message"; const data: string[] = []; - for (const line of frame.split("\n")) { - if (line.startsWith("event: ")) { - eventName = line.slice(7).trim(); - } else if (line.startsWith("data: ")) { - data.push(line.slice(6)); + for (const line of frame.split(/\r?\n/)) { + if (line.startsWith("event:")) { + eventName = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + data.push(line.slice(5).replace(/^ /, "")); } } const payload = data.join("\n"); diff --git a/app/src/lib/session-state.ts b/app/src/lib/session-state.ts new file mode 100644 index 000000000..7b64a40a6 --- /dev/null +++ b/app/src/lib/session-state.ts @@ -0,0 +1,71 @@ +import type { SessionResponse } from "@bigrag/client/browser"; +import { QueryClient } from "@tanstack/react-query"; +import { useChatStore } from "@/features/chat/chat-store"; +import { useUploadSessionStore } from "@/features/collections/upload-session-store"; +import { queryKeys } from "@/lib/query-keys"; + +const createClient = () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 60_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }); + +type SessionState = { + client: QueryClient; + generation: number; + ownerId: string | null | undefined; + controller: AbortController; +}; + +let state: SessionState = { + client: createClient(), + generation: 0, + ownerId: undefined, + controller: new AbortController(), +}; +const listeners = new Set<() => void>(); + +export const getSessionState = () => state; +export const subscribeSessionState = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; +export const isCurrentSession = (client: QueryClient) => state.client === client; +export const sessionSignal = (client: QueryClient) => { + if (!isCurrentSession(client)) throw new DOMException("Session changed", "AbortError"); + return state.controller.signal; +}; + +export const adoptSession = ( + expectedClient: QueryClient, + session: SessionResponse | null, + force = false, +) => { + if (!isCurrentSession(expectedClient)) return false; + const ownerId = session?.user.id ?? null; + if (!force && state.ownerId === ownerId) return false; + const previous = state; + const client = createClient(); + client.setQueryData(queryKeys.auth.session(), session); + state = { + client, + generation: previous.generation + 1, + ownerId, + controller: new AbortController(), + }; + previous.controller.abort(); + useChatStore.getState().reset(); + useUploadSessionStore.getState().setOwner(ownerId); + void previous.client.cancelQueries(); + previous.client.clear(); + for (const listener of listeners) listener(); + return true; +}; diff --git a/app/src/providers.tsx b/app/src/providers.tsx index bab4ecaee..ef0d37f12 100644 --- a/app/src/providers.tsx +++ b/app/src/providers.tsx @@ -1,26 +1,15 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { useSyncExternalStore } from "react"; import { Toaster } from "sonner"; import { AdminThemeProvider } from "@/features/theme/theme-provider"; +import { getSessionState, subscribeSessionState } from "@/lib/session-state"; export const Providers = ({ children }: { children: React.ReactNode }) => { - const [client] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - gcTime: 60_000, - retry: 1, - refetchOnWindowFocus: false, - }, - }, - }), - ); + const { client, generation } = useSyncExternalStore(subscribeSessionState, getSessionState); return ( - + {children} { const LoginPage = () => { const navigate = useNavigate(); const { from } = Route.useSearch(); - const login = useLogin(); + const login = useLogin(() => navigate({ to: from ?? "/", replace: true })); const setup = useInstanceSetupStatus(); const form = useForm({ defaultValues: defaultLoginFormValues(), @@ -49,7 +49,6 @@ const LoginPage = () => { onSubmit: async ({ value }) => { try { await login.mutateAsync(loginBodyFromValues(value)); - navigate({ to: from ?? "/", replace: true }); } catch (err) { toast.error(err instanceof Error ? err.message : "Login failed"); } diff --git a/app/src/routes/_auth.setup.tsx b/app/src/routes/_auth.setup.tsx index adb6af945..b689d318f 100644 --- a/app/src/routes/_auth.setup.tsx +++ b/app/src/routes/_auth.setup.tsx @@ -23,7 +23,10 @@ export const Route = createFileRoute("/_auth/setup")({ const SetupPage = () => { const navigate = useNavigate(); - const setup = useSetup(); + const setup = useSetup(() => { + toast.success("Admin account created"); + navigate({ to: "/onboarding", replace: true }); + }); const instanceSetup = useInstanceSetupStatus(); const form = useForm({ defaultValues: defaultSetupFormValues(), @@ -33,8 +36,6 @@ const SetupPage = () => { onSubmit: async ({ value }) => { try { await setup.mutateAsync(setupBodyFromValues(value)); - toast.success("Admin account created"); - navigate({ to: "/onboarding", replace: true }); } catch (err) { toast.error(err instanceof Error ? err.message : "Setup failed"); } diff --git a/website/content/docs/api-reference/authentication.mdx b/website/content/docs/api-reference/authentication.mdx index 79871b4c5..313deb6ec 100644 --- a/website/content/docs/api-reference/authentication.mdx +++ b/website/content/docs/api-reference/authentication.mdx @@ -136,7 +136,7 @@ If a key is pinned to a single `collection`, that pin is enforced even on the gl - Argon2id hashed at rest - Minimum 8 characters (enforced on `/setup`, user create, and password change) -- Changing a password revokes every existing session for that user +- Changing a password revokes every existing session for that user and clears the browser session cookie. The admin UI returns to sign-in after a successful password change. ## Error responses diff --git a/website/content/docs/api-reference/chat.mdx b/website/content/docs/api-reference/chat.mdx index ebc107383..569180a0e 100644 --- a/website/content/docs/api-reference/chat.mdx +++ b/website/content/docs/api-reference/chat.mdx @@ -111,6 +111,8 @@ data: [DONE] If provider generation fails after streaming starts, the API emits an `error` event followed by `data: [DONE]`. +The admin UI requires a terminal `done`, `error`, or `[DONE]` frame. An earlier connection close keeps any partial answer visible and marks it interrupted so you can retry. Stopping a response, clearing chat, changing collection, or signing out cancels the active browser stream; a late callback cannot change a newer response. + While waiting for provider tokens, the API emits heartbeat comments every 15 seconds. Heartbeats keep the pending token read alive, and disconnecting the client cancels that read and closes the provider iterator. diff --git a/website/content/docs/concepts/security.mdx b/website/content/docs/concepts/security.mdx index 5bd495cf3..24823e450 100644 --- a/website/content/docs/concepts/security.mdx +++ b/website/content/docs/concepts/security.mdx @@ -116,3 +116,9 @@ See [Production](/docs/deployment/production) for the full hardening checklist. Anything holding an API key — CI runner, laptop, container image layer — can act as that key until you revoke it. Mint a key per service, set `expires_at`, and rotate on any suspicion. + +## Browser session cleanup + +Signing out, signing out all devices, or successfully changing a password clears cached account data, chat messages, and local upload tracking. Confirmed session expiry or an account change from `/v1/auth/me` performs the same cleanup. The admin UI refreshes this check on focus, reconnect, and once per minute while the tab is active. Late responses from the previous session stay in its retired cache. + +Upload tracking is bound to the signed-in account. Reloading the UI as the same account retains its upload-session IDs; switching accounts clears them. Older tracking entries without an account binding are discarded. Sign-out also aborts pending browser upload requests. Work already accepted by the API can continue; this does not cancel the server's ingestion job. From eb9486f9eeeab1e2e3f828cf385126b241f79b3b Mon Sep 17 00:00:00 2001 From: Bigint <69431456+bigint@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:29:17 +0530 Subject: [PATCH 03/11] fix: report upload failures and close incomplete sessions --- AUDIT.md | 23 +++++++- .../services/upload_session_files/context.py | 2 +- api/bigrag/services/upload_sessions.py | 7 ++- .../documents/documents-tab-model.ts | 4 +- .../documents/upload-session-panel.tsx | 37 ++++++++---- app/src/hooks/use-upload-session-documents.ts | 58 +++++++++++++++---- .../content/docs/api-reference/documents.mdx | 4 +- 7 files changed, 105 insertions(+), 30 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 148bf2824..b430d4822 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -41,8 +41,8 @@ Paths below are relative to `api/bigrag/` unless another package is named. | 21 | EOF without terminal event appears successful; `app/src/lib/chat-stream.ts` | Fixed in batch 2. Truncated mock SSE reproduced success at baseline. Parser requires done/error/[DONE], handles CRLF and terminal tails, cancels the reader, and ignores post-terminal frames. Partial answers remain visible beside an interruption error. | | 22 | Password change revokes session then UI logout fails | Fixed in batch 2. Independent review confirmed logout authenticates after password revokes every session. Successful password change now clears the cookie and local session directly, with guarded navigation and no extra logout call. Incorrect-current-password 401 preserves the session. | | 23 | Logout leaves chat/upload Zustand stores | Fixed in batch 2. Confirmed transitions replace/remount the QueryClient provider, abort generation-owned auth/upload requests and chat, reset stores, and seed the new session. Upload persistence is owner-bound; unowned v1 IDs are discarded. Generation guards prevent late callbacks from restoring tracking. Focus/reconnect and active-tab minute refresh detect confirmed expiry/account changes. | -| 24 | Upload UI ignores returned `item.status=failed` | Source path confirmed; open. File responses can contain failed items, but the UI awaits and discards the response. Reproduce with a mocked file response in the next upload batch. | -| 25 | Closed incomplete upload sessions remain uploading | Independently reproduced locally in batch 2 follow-up audit; open. A closed session with one completed item against three declared files returns uploading. Review close/retry/cancellation semantics before editing. | +| 24 | Upload UI ignores returned `item.status=failed` | Fixed in batch 3. Baseline mock returned a failed item under HTTP 201 while the UI reported it queued. File responses now record failed/canceled items, final counts drive warnings, and issue panels remain visible. Failed validation rows keep their status during persistence. | +| 25 | Closed incomplete upload sessions remain uploading | Fixed in batch 3. Baseline closed sessions with missing files remained preparing/uploading. Missing files after closure now fail once accepted work is inactive; active work stays ingesting. New file requests reject closed sessions. The UI closes after all attempts, including all failed requests, and recognizes cancellation conflicts. | | 26 | Allowed file types and empty-file semantics inconsistent | Open; compare upload entry points and server validation. | | 27 | Personal-key-only UI gate blocks instance chat credentials | Open; inspect safe readiness contract before removing gate. | | 28 | Analytics gather shares AsyncSession; `services/analytics.py` | Fixed in batch 1. Mock session reproduced overlapping execute calls at baseline. Period queries now run sequentially in one session, preserving output and five-minute cache. | @@ -72,7 +72,7 @@ Local recovery: verification logs and temporary smoke scripts live beside this c Published batch 1: commit `d5f8c60d64826bdf0779678c6cf8fbbb442b9d5d` in draft PR [#90](https://github.com/bigint/rag.computer/pull/90). All six GitHub checks passed: lint, repo-check, SDK typecheck, Python SDK build, website build, and app build. -Next bounded batch: handle upload terminal states (24–25). Replay authorization (2/18) needs guard extraction design; tenant isolation (3–7) needs explicit coordination with the separate migration work before persistence changes. +Next coordination: replay authorization (2/18) needs guard extraction design; tenant isolation (3–7) needs explicit coordination with the separate migration work before persistence changes. Other bounded findings remain available for independent reproduction. Limitations: no live credential transmission, exploit, production database/vector access, Docker service start, provider billing, or deployed runtime verification. Idempotency authorization and tenant isolation remain open and are not claimed fixed. @@ -88,3 +88,20 @@ Changes: UI stream terminal/ownership handling, password cookie cleanup, guarded - `git diff --check`: passed. Temporary scripts, browser artifacts, and logs stay outside the production checkout; no removed runner or suite restored. Limitations: confirmed `/me` responses drive expiry/account-switch cleanup; no claim of instantaneous detection across tabs. Canceling browser work cannot undo API work already accepted, and cross-tab cookie races remain outside this client lifecycle change. Explicit sign-out clears tracking IDs; same-account reload/refresh retains them. + +Published batch 2: commit `02c2f032028b920b4338374705f34c2f7f750a21` in draft PR #90. All six GitHub checks passed on this head. + +## Batch 3 verification + +Changes: upload item failure reporting, preservation of validation failures, closed/missing-file terminal states, rejection of new requests after closure, cancellation handling, and retained issue panels with matching documentation. + +- `pnpm check`: passed, covering repository lint, API Ruff lint/format, workspace typechecks, app/SDK/docs builds, and API compile. The final badge-only follow-up also passed app typecheck/build and Biome lint. +- Eleven backend mocked checks passed: open/closed count and active-work combinations, cancellation preservation, full-count partial success, failed-item persistence, and rejection after closure. +- Eleven UI runtime checks passed: failed/canceled HTTP-success items, accepted wording, closing after all failed requests, missing/late-failure warnings, cancellation from responses and file/finalization conflicts, unrelated conflicts, and issue-panel retention. +- Independent review reran both sets and four additional in-memory checks using the real SDK ConflictError: pending attempts stop after cancellation, unrelated conflicts stay errors, session abort blocks closure, and failed cancellation lookup does not report false cancellation. Review approved the final warning badge for completed sessions containing failed/canceled files. +- Intercepted browser smoke passed for a rejected HTTP 201 file with one completion call, a closed session with missing files, and a completed session with a canceled item. Tracking and issue panels remained visible; no browser runtime errors occurred. Screenshots were visually checked. +- `git diff --check`: passed. Temporary scripts, logs, and browser artifacts remain outside the checkout. No test runner or coverage suite restored. + +Limitations: requests admitted before closure can still finish; stronger serialization, document registration, and quota races need a separate coordinated change. The batch does not claim transactional cancellation, automatic ingestion retry, or tenant isolation. + +Hourly continuation: a thread-heartbeat creation was attempted, but the app returned “Automations are only supported for local threads.” No recurring automation was created. This delegated task needs continuation from its local parent task; no standalone cron workaround was installed. diff --git a/api/bigrag/services/upload_session_files/context.py b/api/bigrag/services/upload_session_files/context.py index fd72fb85f..c42e894fe 100644 --- a/api/bigrag/services/upload_session_files/context.py +++ b/api/bigrag/services/upload_session_files/context.py @@ -34,7 +34,7 @@ async def load_context( session_id, user_id=uuid.UUID(user["id"]), ) - if upload_session.status in TERMINAL_SESSION_STATUSES: + if upload_session.closed_at is not None or upload_session.status in TERMINAL_SESSION_STATUSES: raise HTTPException(status_code=409, detail="Upload session is closed") item_key = client_item_id or str(uuid7()) diff --git a/api/bigrag/services/upload_sessions.py b/api/bigrag/services/upload_sessions.py index 2ed14cedb..f1da86c9f 100644 --- a/api/bigrag/services/upload_sessions.py +++ b/api/bigrag/services/upload_sessions.py @@ -108,10 +108,13 @@ def session_status_value(upload_session: UploadSession, counts: dict[str, int]) if upload_session.status == "canceled": return "canceled" active = counts["queued_files"] + counts["processing_files"] - if counts["uploaded_files"] < upload_session.total_files: + missing_files = counts["uploaded_files"] < upload_session.total_files + if missing_files and upload_session.closed_at is None: return "uploading" if counts["uploaded_files"] else "preparing" if active: return "ingesting" + if missing_files: + return "failed" if not counts["completed_files"] and (counts["failed_files"] or counts["canceled_files"]): return "failed" return "complete" @@ -129,7 +132,7 @@ async def upload_session_response( active = counts["queued_files"] + counts["processing_files"] if persist_counts: for item, document_status, _error in rows: - if deleted_document_item(item, document_status): + if item.status != "failed" and deleted_document_item(item, document_status): item.status = "canceled" item.error_message = item.error_message or "Document deleted" upload_session.status = status diff --git a/app/src/features/collections/documents/documents-tab-model.ts b/app/src/features/collections/documents/documents-tab-model.ts index 1508dd7dd..926906153 100644 --- a/app/src/features/collections/documents/documents-tab-model.ts +++ b/app/src/features/collections/documents/documents-tab-model.ts @@ -20,7 +20,9 @@ export const defaultDocumentsTabFilters: DocumentsTabFilters = { export const shouldDismissUploadSession = (session: UploadSession) => (session.status === "complete" || session.status === "failed" || session.status === "canceled") && session.active_files === 0 && - session.failed_files === 0; + session.failed_files === 0 && + session.canceled_files === 0 && + session.uploaded_files >= session.total_files; export const getErrorStatus = (error: unknown) => { if (!error || typeof error !== "object") return undefined; diff --git a/app/src/features/collections/documents/upload-session-panel.tsx b/app/src/features/collections/documents/upload-session-panel.tsx index 49e17973a..afbf05e30 100644 --- a/app/src/features/collections/documents/upload-session-panel.tsx +++ b/app/src/features/collections/documents/upload-session-panel.tsx @@ -34,6 +34,7 @@ const sessionProgress = (session: UploadSession) => { session.completed_files + session.failed_files + session.canceled_files + + (session.closed_at ? Math.max(0, session.total_files - session.uploaded_files) : 0) + session.processing_files * 0.6 + session.queued_files * 0.25; return Math.round(Math.max(0, Math.min(1, weighted / session.total_files)) * 100); @@ -59,11 +60,12 @@ export const UploadSessionPanel = ({ const active = session.recent_items.find( (item) => item.status === "queued" || item.status === "ingesting", ); - const failedItems = session.recent_items.filter((item) => item.status === "failed"); - const statusLabel = - session.status === "complete" && session.failed_files - ? "finished with failures" - : session.status; + const rejectedItems = session.recent_items.filter( + (item) => item.status === "failed" || item.status === "canceled", + ); + const completedWithIssues = + session.status === "complete" && session.failed_files + session.canceled_files > 0; + const statusLabel = completedWithIssues ? "finished with issues" : session.status; return ( @@ -72,7 +74,7 @@ export const UploadSessionPanel = ({
Upload session - + {statusLabel}
@@ -114,7 +116,11 @@ export const UploadSessionPanel = ({ label="Ingesting" value={session.processing_files + session.queued_files} /> - + } label="Failed" @@ -142,11 +148,20 @@ export const UploadSessionPanel = ({
)} - {failedItems.length > 0 && ( + {session.closed_at && remaining > 0 && ( +

+ {remaining} file{remaining === 1 ? " was" : "s were"} not received before the upload + closed. Select those files again to retry. +

+ )} + + {rejectedItems.length > 0 && (
- {failedItems.slice(0, 2).map((item) => ( -
- failed + {rejectedItems.slice(0, 2).map((item) => ( +
+ + {item.status} + {item.filename} {item.error_message ?? "Upload failed"} diff --git a/app/src/hooks/use-upload-session-documents.ts b/app/src/hooks/use-upload-session-documents.ts index c97d9eb36..6fd1b9d5b 100644 --- a/app/src/hooks/use-upload-session-documents.ts +++ b/app/src/hooks/use-upload-session-documents.ts @@ -1,3 +1,4 @@ +import { APIError } from "@bigrag/client/browser"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; import { toast } from "sonner"; @@ -67,19 +68,41 @@ export const useUploadSessionDocuments = ( signal.throwIfAborted(); options?.onSessionStart?.(session); const errors: { filename: string; error: string }[] = []; + const sessionPath = `v1/collections/${encodeURIComponent(collection)}/upload-sessions/${session.id}`; + let canceledSession: UploadSession | null = null; + const cancellationFromError = async (error: unknown) => { + if (!(error instanceof APIError && error.status === 409)) return null; + const current = await apiClient.get(sessionPath, { signal }); + signal.throwIfAborted(); + return current.status === "canceled" ? current : null; + }; await runWithConcurrency(files, uploadConcurrency, async (file, index) => { signal.throwIfAborted(); + if (canceledSession) return; const form = new FormData(); form.append("client_item_id", uploadSessionClientId(file, index)); form.append("file", file, uploadSessionFileName(file)); try { - await apiClient.postForm( + const response = await apiClient.postForm( `v1/collections/${encodeURIComponent(collection)}/upload-sessions/${session.id}/files`, form, { signal }, ); + signal.throwIfAborted(); + if (response.session.status === "canceled") canceledSession = response.session; + if (response.item.status === "failed" || response.item.status === "canceled") { + errors.push({ + filename: uploadSessionFileName(file), + error: response.item.error_message ?? "File was not accepted", + }); + } } catch (err) { signal.throwIfAborted(); + const canceled = await cancellationFromError(err); + if (canceled) { + canceledSession = canceled; + return; + } errors.push({ filename: uploadSessionFileName(file), error: err instanceof Error ? err.message : "Upload failed", @@ -87,25 +110,40 @@ export const useUploadSessionDocuments = ( } }); signal.throwIfAborted(); - const sessionPath = `v1/collections/${encodeURIComponent(collection)}/upload-sessions/${session.id}`; - const finalSession = - errors.length < files.length - ? await apiClient.post(`${sessionPath}/complete`, undefined, { signal }) - : await apiClient.get(sessionPath, { signal }); + let finalSession: UploadSession; + if (canceledSession) { + finalSession = canceledSession; + } else { + try { + finalSession = await apiClient.post(`${sessionPath}/complete`, undefined, { + signal, + }); + } catch (error) { + const canceled = await cancellationFromError(error); + if (!canceled) throw error; + finalSession = canceled; + } + } signal.throwIfAborted(); return { errors, session: finalSession }; }, - onSuccess: ({ errors, session }) => { + onSuccess: ({ session }) => { if (!isCurrentSession(qc)) return; qc.invalidateQueries({ queryKey: queryKeys.documents.lists() }); qc.invalidateQueries({ queryKey: queryKeys.documents.uploadSession({ collection, id: session.id }), }); - if (errors.length) { - toast.warning(`${errors.length} file${errors.length === 1 ? "" : "s"} need retry`); + if (session.status === "canceled") { + toast.info("Upload session canceled"); + return; + } + const missing = Math.max(0, session.total_files - session.uploaded_files); + const unsuccessful = session.failed_files + session.canceled_files + missing; + if (unsuccessful) { + toast.warning(`${unsuccessful} file${unsuccessful === 1 ? " needs" : "s need"} attention`); } else { toast.success( - `Queued ${session.uploaded_files} file${session.uploaded_files === 1 ? "" : "s"}`, + `Accepted ${session.uploaded_files} file${session.uploaded_files === 1 ? "" : "s"}`, ); } }, diff --git a/website/content/docs/api-reference/documents.mdx b/website/content/docs/api-reference/documents.mdx index 46a31ace7..1d2b05904 100644 --- a/website/content/docs/api-reference/documents.mdx +++ b/website/content/docs/api-reference/documents.mdx @@ -301,7 +301,7 @@ curl -X POST http://localhost:4000/v1/collections/docs/upload-sessions/$SESSION_ -F "file=@contract.pdf" ``` -Each accepted file is validated, deduped by `content_hash`, staged temporarily, converted into a `Document`, and queued for ingestion. Failed files are recorded as upload-session items and do not abort the session. +Each accepted file is validated, deduped by `content_hash`, staged temporarily, converted into a `Document`, and queued for ingestion. Failed files are recorded as upload-session items and do not abort the session. A successful HTTP response can still contain an item with `status: "failed"` or `"canceled"`; inspect the item status and `error_message`. The admin UI reports these outcomes and keeps rejected or missing files visible for review. **Session lifecycle endpoints:** @@ -312,7 +312,7 @@ POST /v1/collections/{collection_name}/upload-sessions/{session_id}/cancel ``` - `GET` — returns aggregate counts plus the latest active or failed items. -- `complete` — marks the upload phase closed; ingestion can still continue until all linked documents are `ready` or `failed`. If a linked document is deleted after upload, its session item is reported as `canceled` instead of `queued`. +- `complete` — marks the upload phase closed. Call it after all file attempts settle, even if every attempt failed. New file requests are rejected after closure; requests admitted earlier can still finish. Ingestion continues until all linked documents are `ready` or `failed`. A closed session with missing files remains `ingesting` while accepted work is active, then becomes `failed`; it no longer waits for missing uploads. If a linked document is deleted after upload, its session item is reported as `canceled` instead of `queued`. - `cancel` — closes the session and cancels active queued documents created by that session. From 7eef3b38d1db60a261fe3ac3c63ff21c6b446c4a Mon Sep 17 00:00:00 2001 From: Bigint <69431456+bigint@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:29:00 +0530 Subject: [PATCH 04/11] fix: bound evaluation scores and clear nullable collection settings --- AUDIT.md | 24 +++++++- api/bigrag/routers/evaluation.py | 5 +- .../services/collections/apply_update.py | 4 +- docs/audit/authorization-options.md | 56 +++++++++++++++++++ sdks/python/src/bigrag/types/collections.py | 4 +- sdks/typescript/src/types/collections.ts | 4 +- .../docs/api-reference/collections.mdx | 6 +- .../content/docs/api-reference/evaluation.mdx | 6 +- 8 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 docs/audit/authorization-options.md diff --git a/AUDIT.md b/AUDIT.md index b430d4822..539e9852f 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -11,6 +11,7 @@ The original 36 findings below came from a source audit, not live exploitation. - No `.agents` directory or nested `AGENTS.md` exists in the baseline checkout. Root `AGENTS.md` and `STYLEGUIDE.md` govern this work. Removed test runners and coverage suites stay removed. Behavioral verification uses temporary local smoke scripts with mock transports and fake credentials outside the repository. - Tenant findings 4–7, related metadata propagation, and replay authorization need a coherent design before schema, reindex, or provisioning work. Tenant provisioning stays unchanged. - Uncommitted batches are backed up outside this checkout before commit. Each useful batch requires relevant checks and independent review. Do not create changes merely to fill an hourly schedule. +- Parent continuation requires approximately one combined commit per hour, anchored to the latest publication. Journal is running in its separate coordinator; this work must not start or modify Journal. The parent handles continuation scheduling. ## Findings @@ -46,10 +47,10 @@ Paths below are relative to `api/bigrag/` unless another package is named. | 26 | Allowed file types and empty-file semantics inconsistent | Open; compare upload entry points and server validation. | | 27 | Personal-key-only UI gate blocks instance chat credentials | Open; inspect safe readiness contract before removing gate. | | 28 | Analytics gather shares AsyncSession; `services/analytics.py` | Fixed in batch 1. Mock session reproduced overlapping execute calls at baseline. Period queries now run sequentially in one session, preserving output and five-minute cache. | -| 29 | NDCG duplicates document hits and can exceed 1 | Open; validate relevance unit and duplicate handling. | +| 29 | NDCG duplicates document hits and can exceed 1 | Corrected in the pending batch 4. Actual baseline helper scored three hits for one relevant document as 2.1309297535714578. Each relevant ID now earns credit only once at its original rank; hit_ids, recall, MRR, and unique rankings stay unchanged. See pending-batch verification before publication. | | 30 | ASCII/length tokenizer drops Unicode and short terms | Open; inspect backend lexical search contract and generate bounded examples. | | 31 | Reranking only final top-k and silent degradation | Open; inspect candidate budget and safe error signaling. | -| 32 | Explicit null settings cannot clear values | Open; inspect update models, fields-set semantics, and docs. | +| 32 | Explicit null settings cannot clear values | Partially corrected in pending batch 4 for nullable collection default_min_score and metadata_schema. Baseline explicit null changed neither field; updates now use fields-set semantics and both SDK update types admit null. Omission, zero, and empty schema retain their semantics. API-key expiry and other nullable update contracts remain open for separate validation. | | 33 | Retrieved context in system prompt lacks untrusted-data boundary | Open; inspect prompt/data roles without live provider calls. | | 34 | Usage estimates characters/4 with incomplete prices/history | Open; describe estimate provenance; do not present as billing or change billing. | | 35 | Upload polling loads all rows | Open; inspect active-session query and pagination. | @@ -105,3 +106,22 @@ Changes: upload item failure reporting, preservation of validation failures, clo Limitations: requests admitted before closure can still finish; stronger serialization, document registration, and quota races need a separate coordinated change. The batch does not claim transactional cancellation, automatic ingestion retry, or tenant isolation. Hourly continuation: a thread-heartbeat creation was attempted, but the app returned “Automations are only supported for local threads.” No recurring automation was created. This delegated task needs continuation from its local parent task; no standalone cron workaround was installed. + +Published batch 3: commit `eb9486f9eeeab1e2e3f828cf385126b241f79b3b` in draft PR #90 at approximately 10:00 UTC. All six GitHub checks passed. The parent subsequently confirmed it handles scheduling; no automation setup is needed here. The next routine combined publication is held until approximately 11:00 UTC. + +## Pending batch 4 + +Related retrieval correctness changes: unique nDCG relevance credit and explicit clearing of nullable collection retrieval/validation settings, with SDK types and matching docs. No schema or reindex change. Reproductions and smoke scripts live outside the checkout. + +Design proposals are in [authorization-options.md](docs/audit/authorization-options.md). Replay response quarantine versus shared current-authorization guards and tenant collection quarantine versus shared/separate durable layouts require a decision before larger implementation. Proposals include current reachability, compatibility, legacy handling, and schema/reindex boundaries; this batch implements none of those options. + +Verification before the held publication: + +- `pnpm check`: passed on final code; final documentation build also passed after compatibility clarification. +- 25 external runtime checks and 364 ranking combinations passed. Clearing metadata validation still requires tenant metadata where configured. +- Independent review passed 43,688 ranked-list/label-set comparisons against a first-occurrence oracle, including unchanged recall/MRR and 520 unchanged unique rankings. Isolated handler execution preserved hit IDs, top-k, per-case/aggregate output, and one retrieval per case. +- Actual Python SDK MockTransport and compiled TypeScript SDK mocked fetch preserved explicit null, omission, zero, and empty schema; the public TypeScript null contract compiled. An initial external type-check invocation omitted the SDK's Node type settings; it passed after using the existing Node type root. +- Pre-commit hooks, Python SDK Ruff/format, wheel/source-distribution build, and diff checks passed. The package build initially hit sandbox DNS while resolving its existing Hatchling backend; it passed with authorized network access. No build configuration or dependency declaration changed. +- Independent design review corrected credential-cache precedence, fresh role/session requirements, suggestions generation, global aggregate coverage, tenant creation, T0/replay interaction, and T2 session policy. No blocker remains to presenting the proposals; larger implementation requires the requested policy decision. + +No new commit or push yet: retain one combined batch for the next approximately 11:00 UTC publication window. Existing PR head remains eb9486f9; its six CI jobs passed. diff --git a/api/bigrag/routers/evaluation.py b/api/bigrag/routers/evaluation.py index 89f491918..ae5abb92c 100644 --- a/api/bigrag/routers/evaluation.py +++ b/api/bigrag/routers/evaluation.py @@ -68,10 +68,11 @@ def _ndcg_at_k(hit_ids: list[str], expected: set[str]) -> float: if not expected: return 0.0 dcg = 0.0 + credited: set[str] = set() for idx, h in enumerate(hit_ids, start=1): - rel = 1 if h in expected else 0 - if rel: + if h in expected and h not in credited: dcg += 1.0 / math.log2(idx + 1) + credited.add(h) k = len(hit_ids) ideal = sum(1.0 / math.log2(i + 1) for i in range(1, min(len(expected), k) + 1)) return dcg / ideal if ideal else 0.0 diff --git a/api/bigrag/services/collections/apply_update.py b/api/bigrag/services/collections/apply_update.py index 2d15779c9..e7c9f6e2e 100644 --- a/api/bigrag/services/collections/apply_update.py +++ b/api/bigrag/services/collections/apply_update.py @@ -63,7 +63,7 @@ async def apply_collection_update( if body.default_top_k is not None: collection.default_top_k = body.default_top_k fields.append("default_top_k") - if body.default_min_score is not None: + if "default_min_score" in body.model_fields_set: collection.default_min_score = body.default_min_score fields.append("default_min_score") if body.default_search_mode is not None: @@ -72,7 +72,7 @@ async def apply_collection_update( if body.chunk_strategy is not None: collection.chunk_strategy = body.chunk_strategy fields.append("chunk_strategy") - if body.metadata_schema is not None: + if "metadata_schema" in body.model_fields_set: collection.metadata_schema = body.metadata_schema fields.append("metadata_schema") return fields diff --git a/docs/audit/authorization-options.md b/docs/audit/authorization-options.md new file mode 100644 index 000000000..0a0e4d8af --- /dev/null +++ b/docs/audit/authorization-options.md @@ -0,0 +1,56 @@ +# Replay authorization and tenant isolation options + +Source baseline: `eb9486f9eeeab1e2e3f828cf385126b241f79b3b`, reviewed locally on 2026-10-04. These are proposed designs; this batch does not implement them, provision tenants, change schemas, or reindex data. Journal remains with its separate coordinator. + +## Replay authorization + +Reachability: with Redis available, a non-auth mutation carrying an Idempotency-Key and a cacheable body can return a saved successful response before the router runs. Both the direct cache hit and the failed-lock-acquisition fallback call `_send_cached` without executing dependencies or handler guards (`api/bigrag/middleware/idempotency.py`). A retained token/key value can still address that cache after logout, expiry, key revocation, role demotion, or scope changes. CSRF and maintenance middleware run outside replay, but do not establish current resource authorization. + +`middleware/principal.py` chooses bearer before cookie. The uncached database auth path tries a session cookie before bearer, but the principal-cache path can choose a warm bearer entry when a valid cookie has no cache entry. Canonical selection must resolve that inconsistency explicitly. Auth principal caches also cannot be the sole fresh-revocation check. Route-specific gates include admin-session-only dependencies, collection pins supplied in JSON bodies, tenant filters, document ownership, and upload-session ownership. A successful login check alone is insufficient. + +| Option | Minimal safe behavior | Compatibility and scope | +|---|---|---| +| R1: quarantine saved responses first | Return a generic conflict for completed cached requests instead of returning the saved body or running the handler again. Retain completion markers for their existing TTL. Apply the same generic conflict to every completed record in both replay branches, including mismatched-body, one-time, and legacy entries; preserve the remaining TTL and existing cache identity. Keep fresh requests on their existing authorization path. | Smallest immediate containment; authenticated retries that previously recovered a success now require operation-specific reconciliation. This blocks disclosure through replay but does not provide authenticated replay or solve the lease/body limits. | +| R2: shared read-only authorization before replay | Extract canonical credential selection and fresh database validation, then reuse endpoint read-only guards before either replay branch. Default to denial for routes lacking a complete guard. Only return a stored response after current admin/session, scope, collection/body, tenant, and ownership checks pass. | Preserves recovery for supported endpoints but requires coordinated auth/router changes and a complete route inventory. No database schema is inherently required. More reads occur on replay. Removed/changed resources can make a previously successful mutation unreplayable. | + +Recommendation: R1 as a separately approved containment step, followed by R2 incrementally. R1 is a policy change, not a claim that finding 2 is fully fixed. Rejecting a cache hit must never evict its record and execute the mutation again. R2 should use one canonical selected credential: fresh-valid cookie first, then bearer when no valid cookie exists. This deliberately fixes the warm-bearer/cold-valid-cookie case rather than claiming all current cache paths already follow that rule. Under R2, validate the selected credential and current authorization before exposing cached bodies or cache-specific outcomes. R1 deliberately refuses all completed replay with one generic conflict; it is containment pending those shared guards. + +Migration constraints for R2: + +- Extract a database-read-only lookup; the existing `_user_from_api_key` also touches `last_used_at`, so calling it unchanged is not a read-only guard. Keep observability/cache updates separate from replay authorization. +- Preserve credential-scoped request identity rather than sharing cache entries across a user's keys/sessions. Unify identity selection only with compatibility handling for existing bearer-first cache keys; switching namespaces and treating old completions as fresh requests can repeat mutations. +- Inventory CSRF alongside canonical selection: current CSRF skips cookie checks for a Bearer header before knowing which credential auth selects. A shared trusted selected auth method, or equivalent fail-closed transport policy, must keep session CSRF checks consistent with cookie-first selection (`middleware/csrf.py`). +- Include explicit cache/guard versioning. Unsupported legacy completions remain tombstones/conflicts until expiry; do not discard them to make the retry succeed. Authorization denial returns the normal denial and never reaches a mutation handler. +- Separate authorization from mutable existence checks where a completed DELETE removed its own target. A route must define enough saved identity/authorization evidence to check its current boundary; missing evidence fails closed. +- Keep sensitive one-time responses unreplayable. Multipart/large-body bypass, Redis unavailability, and the 60-second unrenewed in-flight lease remain separate reliability limits (finding 18). Lease ownership/renewal and safe compare-and-delete need their own coordinated change. + +Required mocked acceptance checks: expired/revoked session/key, demoted admin, reduced scopes, changed pin/tenant, wrong upload owner, warm-bearer/cold-valid-cookie, stale cookie cache, invalid-cookie/valid-bearer fallback, both cache branches, legacy entries, deleted targets, missing guard, and mismatched request bodies. Every denied replay must produce zero mutation/provider/vector writes. + +Decision needed: choose R1 containment first or approve the R2 shared-guard extraction directly, and accept conflicts for legacy/unsupported recovery cases. No replay implementation proceeds before that choice. + +## Tenant isolation + +Current reachability is broader than tenant-key provisioning. Ordinary API-key create/update models do not expose `permissions.tenant`; tenant principals mainly depend on preexisting permissions. Collection creation already accepts `tenant_field`, and non-tenant-scoped callers can reach several operations on those collections: + +- Suggestions check the collection pin but sample ready documents across the collection and store one suggestion row per collection (`services/chat/questions/api.py`, `db/models/document.py`). Collection analytics also aggregates without a tenant boundary (`routers/analytics.py`, `db/models/observability.py`). GET suggestion retrieval and analytics are unaffected by replay containment. POST suggestion generation also samples across the collection and sends those samples to the configured model before saving collection-wide questions. +- Vector upsert already forces tenant metadata; the gap is storage identity/overwrite ownership. `_point_id` uses collection and public ID only, so metadata stamping alone cannot prevent another tenant's existing ID from being replaced. Vector delete uses IDs without tenant filters (`routers/vectors.py`, `services/vector_store/base.py`). Truncate is collection-wide and lacks a tenant gate (`routers/collections/update_delete.py`). +- Upload sessions are checked against collection and issuing user, not issuing key/tenant (`services/upload_sessions.py`). Keys belonging to the same user are not separate tenant owners. +- Document ingestion builds chunk metadata from offsets/elements without document metadata (`services/queue_embedding/chunk_metadata.py`). Existing vectors can therefore lack the tenant field even when document metadata has it. Backfilling relational metadata alone does not repair vectors. + +These are source-level reachability findings, not proof of live tenant data exposure. No live database, provider, vector, credential, or exploitation call was used. Standard API-key tenant assignment remains unavailable and is not added here. Tenant-marked collection creation is already reachable; a containment policy must gate it explicitly rather than assume all provisioning is currently disabled. + +| Option | Minimal safe behavior | Compatibility and persistence decisions | +|---|---|---| +| T0: quarantine tenant-marked collections | Deny non-global-admin access to tenant-marked collections across reads, writes, body-selected collections, suggestions, analytics, uploads, vector mutation, and destructive operations. Include collection list/details/stats and global status/usage/timelines: filter only with verified attribution or deny mixed/unattributable aggregates. Gate tenant-marked collection creation before vector provisioning to fresh global session admins; do not add tenant-key assignment. | No schema/reindex required. Existing tenant keys and member sessions lose access to these collections until a supported model is ready. This is containment, not multi-tenant support. Direct ordinary-collection operations stay unchanged, but global summaries may change or become unavailable. The organizational admin exception requires a fresh current session/role check, not a cached admin role. | +| T1: shared collection with enforced tenant identity | Carry trusted tenant identity through every read/write, queue job, cache, vector storage ID, and ownership check. Propagate document metadata to chunks without overriding reserved fields; reject tenant-field names that collide with reserved payload identity/content fields. Tenant-safe overwrite/delete must be atomic or use tenant-qualified identity; lookup-before-write alone is unsafe. | Requires decisions about API-key tenant assignment, session behavior, global-admin exceptions, upload ownership, per-tenant suggestions/analytics, and legacy rows/vectors. Existing permissions JSON can represent a key tenant without a new key column; durable ownership, suggestion keys, and analytics attribution may require schema changes. Existing vectors require controlled backfill/reindex before enabling reads. | +| T2: separate collection/namespace per tenant | Operators allocate separate collections and pin each tenant's keys. Tenant access uses pinned keys; sessions must be restricted to approved global staff or receive an explicitly designed tenant mapping. Audit every global/body/document/queue/cache path to honor those boundaries before advertising isolation. Avoid shared raw-vector IDs, suggestions, analytics, and upload-session collections. | Simplifies storage boundaries and can use existing models for operator-managed allocation. Shared collections need an approved split/migration/reindex plan; tenant registry/self-service would be a separate schema/product decision. Existing collection names/keys change during migration. Member sessions and unpinned keys currently have no tenant mapping, so their access policy is an explicit compatibility decision. | + +A route-only T0 gate would still be bypassed by existing completed-response replay. Apply R1 with T0, or implement trusted collection/global-admin authorization before both replay branches with path/body/document target resolution. Do not approve T0 as a handler-only fix. + +Existing document reads and upload dedup already apply some pin/tenant checks (`services/documents/queries.py`, `services/documents/tenant.py`); the gaps above must be repaired around those existing checks, not by replacing them with a weaker generic login guard. + +Recommendation: choose T0 for immediate containment if tenant-marked collections are not intended for current member/key use. For supported tenancy, prefer T2 when per-tenant collections are acceptable; otherwise T1 requires a larger coordinated design with the existing migration work. R1 does not protect these normal tenant routes. + +Approvals required before persistence work: choose shared versus separate collection layout; define session/global-admin access and key-versus-tenant upload ownership; approve any columns/composite keys, legacy-row attribution, vector identity rewrite, and reindex/split strategy. Ambiguous legacy data must remain inaccessible to tenant callers until attribution is verified. Do not enable tenant API provisioning as a standalone patch. + +Decision needed: approve T0 containment now or preserve current access while designing T1/T2; then select the durable layout. No tenant gate, provisioning, schema, or reindex implementation is included in this batch. diff --git a/sdks/python/src/bigrag/types/collections.py b/sdks/python/src/bigrag/types/collections.py index 93132b9d5..c86d58856 100644 --- a/sdks/python/src/bigrag/types/collections.py +++ b/sdks/python/src/bigrag/types/collections.py @@ -80,7 +80,7 @@ class UpdateCollectionBody(TypedDict, total=False): multimodal_enabled: bool multimodal_enrichment_enabled: bool default_top_k: int - default_min_score: float + default_min_score: float | None default_search_mode: SearchMode chunk_strategy: str - metadata_schema: dict[str, Any] + metadata_schema: dict[str, Any] | None diff --git a/sdks/typescript/src/types/collections.ts b/sdks/typescript/src/types/collections.ts index af05fd2f5..c83d112cc 100644 --- a/sdks/typescript/src/types/collections.ts +++ b/sdks/typescript/src/types/collections.ts @@ -80,8 +80,8 @@ export interface UpdateCollectionBody { multimodal_enabled?: boolean; multimodal_enrichment_enabled?: boolean; default_top_k?: number; - default_min_score?: number; + default_min_score?: number | null; default_search_mode?: "semantic" | "keyword" | "hybrid"; chunk_strategy?: "paragraph" | "recursive"; - metadata_schema?: Record; + metadata_schema?: Record | null; } diff --git a/website/content/docs/api-reference/collections.mdx b/website/content/docs/api-reference/collections.mdx index e86bfd6a9..88da99698 100644 --- a/website/content/docs/api-reference/collections.mdx +++ b/website/content/docs/api-reference/collections.mdx @@ -138,7 +138,7 @@ Only the fields below are mutable. Embedding provider / model / dimension, chunk |-------|------|-------| | `description` | string | — | | `metadata` | object | Replaces entire metadata object | -| `metadata_schema` | object | Updates JSON Schema validation | +| `metadata_schema` | object or `null` | Updates JSON Schema validation; `null` removes the validation schema | | `chunk_strategy` | string | `paragraph` or `recursive` | | `embedding_api_key` | string | Validated against the provider before saving; empty values are rejected. Saving a key here switches the collection to a per-collection override and detaches it from any preset | | `reranking_enabled` | boolean | — | @@ -147,9 +147,11 @@ Only the fields below are mutable. Embedding provider / model / dimension, chunk | `multimodal_enabled` | boolean | Disabling it also disables enrichment for future ingestions | | `multimodal_enrichment_enabled` | boolean | Enabling it also enables `multimodal_enabled` | | `default_top_k` | integer | 1–200 | -| `default_min_score` | float | — | +| `default_min_score` | float or `null` | `null` removes the default minimum score | | `default_search_mode` | string | `semantic`, `keyword`, `hybrid` | +Omitted fields keep their current values. Sending `null` explicitly clears `metadata_schema` or `default_min_score`; `{}` and `0` remain valid values. These updates change future validation or retrieval defaults and do not reindex existing documents. + **Response** `200`: Updated collection object. **Errors:** `404`, `422` (when `embedding_api_key` is empty or rejected by the provider). diff --git a/website/content/docs/api-reference/evaluation.mdx b/website/content/docs/api-reference/evaluation.mdx index cf558ca47..6fdc453c0 100644 --- a/website/content/docs/api-reference/evaluation.mdx +++ b/website/content/docs/api-reference/evaluation.mdx @@ -47,7 +47,7 @@ The admin UI exposes the same runner under **Settings → Evaluation**. | `collection` | string | yes | Target collection name | | `cases[]` | array | yes | 1–500 labelled cases | | `cases[].query` | string | yes | Non-empty | -| `cases[].relevant_ids` | string[] | yes | Document IDs (or chunk IDs of the form `#`); at least one | +| `cases[].relevant_ids` | string[] | yes | IDs matched against `hit_ids`; at least one | | `cases[].filters` | object | no | Per-case metadata filters; overrides request-level `filters` for that case | | `cases[].top_k` | integer | no | 1–100. Overrides the request-level `top_k` for this case | | `filters` | object | no | Request-level metadata filters applied to cases that omit `cases[].filters` | @@ -82,7 +82,9 @@ The collection's saved reranking config is applied automatically — there is no - **Recall@k** — fraction of `relevant_ids` that appear anywhere in the top-k hits - **MRR** — `1 / rank` of the first relevant hit, averaged across cases -- **nDCG@k** — rank-discounted relevance, averaged across cases +- **nDCG@k** — rank-discounted relevance in `[0, 1]`, averaged across cases. Each relevant ID receives credit only at its first occurrence. + +Ingested chunk hits use their document ID; hits without a document ID use their own ID. Repeated hits remain in `hit_ids` and occupy their original ranks, but do not add relevance credit. The metrics score the retrieved top-k hits without fetching extra hits to replace duplicates. Rankings with unique IDs retain their previous scores. Previously returned or cached evaluation responses are not rewritten; run a new evaluation to obtain corrected scores. From d0077c8dcb1405fa597daac724d8f6b1cd148ff8 Mon Sep 17 00:00:00 2001 From: Bigint <69431456+bigint@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:28:38 +0530 Subject: [PATCH 05/11] fix: honor explicit null API-key expiration updates --- AUDIT.md | 38 ++++++++++++------- api/bigrag/routers/admin_api_keys.py | 2 +- .../content/docs/api-reference/api-keys.mdx | 2 + 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 539e9852f..2d9dc737f 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -27,14 +27,14 @@ Paths below are relative to `api/bigrag/` unless another package is named. | 6 | Vector upsert/delete and collection truncate miss tenant safeguards | Open; tenant workstream, validate ownership and collection boundaries together. | | 7 | Upload sessions owned only by issuing user | Open; tenant workstream, review key/tenant ownership together. | | 8 | Docker context excludes SDK required by UI `COPY` | Existing PR [#77](https://github.com/bigint/rag.computer/pull/77), head `c121513f3f8fd2192f28aefb88191ae69e5b632f`. Read-only review: its targeted exclusion retains Python exclusion while making TypeScript available. Do not duplicate or merge; Docker runtime verification not performed here. | -| 9 | Ready document/source cleanup precedes fallible followups and may lead to destructive retry; queue processing/finalize | Open; inspect transaction and retry boundaries before editing. | -| 10 | Failed-document dedup prevents retry | Open; validate alongside ingestion finalization. | +| 9 | Ready document/source cleanup precedes fallible followups and may lead to destructive retry; queue processing/finalize | Independently confirmed; open. Actual orchestration with in-memory dependencies commits ready then pending/failed after transient success-followup errors, deletes vectors/source, and exhausts retry on missing source. Persistent Redis failure can interrupt compensation and retain ready; ordinary asynchronous EventBus errors do not trigger the downgrade. Repair needs a durable success boundary, safe followup reconciliation, and ready-row redelivery guards. | +| 10 | Failed-document dedup prevents retry | Independently confirmed; open. Single/batch/open-session uploads reuse failed hash matches without ingestion; the oldest failed batch match can shadow a newer ready row. Collection/tenant predicates remain effective in the synthetic checks. Define a shared status-aware reuse/retry policy with freshly staged bytes and guarded ownership before changing IDs or compensation. | | 11 | Lookup-before-insert hash dedup race with nonunique index | Open; schema coordination required if constraints are needed. | | 12 | Upload limits use unlocked stale counts | Open; inspect concurrent quota accounting. | -| 13 | Queue recovery ignores `lrem` result and sets lease unconditionally | Open; inspect atomic recovery ownership. | -| 14 | Mixed text/scanned PDFs skip OCR | Open; reproduce with generated non-sensitive documents. | -| 15 | ProcessPool timeout leaves conversion process running | Open; inspect worker lifecycle and cancellation. | -| 16 | Whole-document embeddings retained by gather | Open; inspect ingestion memory bounds. | +| 13 | Queue recovery ignores `lrem` result and sets lease unconditionally | Independently confirmed; open. Ten fake-Redis checks reproduce duplicate recovery, stale TTL decisions, admission gaps, stale renewal/cleanup affecting a replacement lease, and repeated admission release. Checking LREM alone is insufficient; atomic token ownership and compare-token renewal/recovery/finalization need a coherent protocol. No live Redis or queue change. | +| 14 | Mixed text/scanned PDFs skip OCR | Independently reviewed; open. A generated digital/raster/blank PDF plus actual installed PDFium extraction and direct conversion shortcut returns only digital text with OCR enabled. Actual queue branch bodies skip OCR on injected nonempty parse results. Native Docling/OCR recognition and element output remain untested; page coverage, blank pages, ordering, cost and cancellation need a reviewed design. | +| 15 | ProcessPool timeout leaves conversion process running | Independently confirmed; open. Actual wrapper with a synthetic local child times out and releases its semaphore slot while the child keeps running; shutdown waits for completion. Process, admission and staged-file ownership must be designed together. No model or service was invoked. | +| 16 | Whole-document embeddings retained by gather | Independently confirmed; open. Actual orchestration retains all synthetic batch vectors before insertion, schedules every batch despite bounded active concurrency, and propagates a permanent error with seven siblings still pending. Production finalizers do not cancel/join them. Evidence counts retained objects/control flow, not RSS; bounded insertion and joined cancellation must preserve ownership/retry semantics. | | 17 | SDK exception retries ignore safety flag; Python `_core.py`, TypeScript `core.ts` | Fixed in batch 1. Python mock transport reproduced three unsafe attempts at baseline; exception retries now honor the flag in both SDKs. Timeouts fail immediately, following the style guide. Reads and keyed mutations retain connection-error retries. Multipart/large-body replay limitations remain under 18. | | 18 | Idempotency skips multipart/large bodies and has a 60-second unrenewed lease | Open; coordinate with 2. SDK docs now disclose the replay limits; automatic upload retry policy and lease renewal still need investigation. | | 19 | SSE heartbeat `wait_for(__anext__)` cancels slow provider iterator; `services/chat/completion.py` | Fixed in batch 1. Baseline slow-token smoke returned an empty successful assistant. One pending token task survives heartbeat ticks. Cleanup is shielded from ASGI cancellation, and the raw provider response closes even when disconnected immediately after a delta. | @@ -47,14 +47,14 @@ Paths below are relative to `api/bigrag/` unless another package is named. | 26 | Allowed file types and empty-file semantics inconsistent | Open; compare upload entry points and server validation. | | 27 | Personal-key-only UI gate blocks instance chat credentials | Open; inspect safe readiness contract before removing gate. | | 28 | Analytics gather shares AsyncSession; `services/analytics.py` | Fixed in batch 1. Mock session reproduced overlapping execute calls at baseline. Period queries now run sequentially in one session, preserving output and five-minute cache. | -| 29 | NDCG duplicates document hits and can exceed 1 | Corrected in the pending batch 4. Actual baseline helper scored three hits for one relevant document as 2.1309297535714578. Each relevant ID now earns credit only once at its original rank; hit_ids, recall, MRR, and unique rankings stay unchanged. See pending-batch verification before publication. | -| 30 | ASCII/length tokenizer drops Unicode and short terms | Open; inspect backend lexical search contract and generate bounded examples. | -| 31 | Reranking only final top-k and silent degradation | Open; inspect candidate budget and safe error signaling. | -| 32 | Explicit null settings cannot clear values | Partially corrected in pending batch 4 for nullable collection default_min_score and metadata_schema. Baseline explicit null changed neither field; updates now use fields-set semantics and both SDK update types admit null. Omission, zero, and empty schema retain their semantics. API-key expiry and other nullable update contracts remain open for separate validation. | +| 29 | NDCG duplicates document hits and can exceed 1 | Fixed in batch 4. Actual baseline helper scored three hits for one relevant document as 2.1309297535714578. Each relevant ID now earns credit only once at its original rank; hit_ids, recall, MRR, and unique rankings stay unchanged. | +| 30 | ASCII/length tokenizer drops Unicode and short terms | Independently confirmed; open. Synthetic tokenizer/provider-payload/scorer checks drop single-character and Chinese/Cyrillic terms; accented words split or truncate and can score zero even with a supplied candidate. Provider analyzer, punctuation/normalization/boundaries, local scoring, request limits, and query-cache version need explicit examples before choosing a fix. No index/reindex or live provider change. | +| 31 | Reranking only final top-k and silent degradation | Independently confirmed; open. Actual mocked pipelines pass only final top-k hits to reranking, including after hybrid expansion. Missing configuration/dependency or provider failures return original hits; client acquisition errors propagate. Transient fallback results can be cached without outcome provenance. Candidate budgets, safe status metadata, score thresholds, cache policy and fan-out/cost limits need coordinated API/SDK examples before changing defaults. | +| 32 | Explicit null settings cannot clear values | Partially fixed in batch 4 for nullable collection default_min_score and metadata_schema. API-key expiry is corrected in pending batch 5: synthetic actual-router reproduction accepted null but retained expiration; the update now distinguishes explicit null from omission. Existing SDKs and nullable ORM/migration already support it. Other nullable update contracts retain their current semantics pending separate validation. | | 33 | Retrieved context in system prompt lacks untrusted-data boundary | Open; inspect prompt/data roles without live provider calls. | | 34 | Usage estimates characters/4 with incomplete prices/history | Open; describe estimate provenance; do not present as billing or change billing. | -| 35 | Upload polling loads all rows | Open; inspect active-session query and pagination. | -| 36 | Docker Python install ignores verified uv.lock/current Torch | Open; inspect frozen install and platform constraints; do not combine with PR 77. | +| 35 | Upload polling loads all rows | Independently confirmed; open. Each session snapshot materializes all joined items twice despite returning at most 20; a 10,000-item session transfers 20,000 tuples per foreground two-second poll. Per-file context/response scans also grow cumulatively. A single aggregate/limited-recent candidate preserves synthetic status counts and ordering, including local in-memory SQLite execution, but PostgreSQL plans/concurrency and lifecycle persistence remain unvalidated. Never globally limit rows used by cancellation or budgets. | +| 36 | Docker Python install ignores verified uv.lock/current Torch | Independently confirmed from source; open. Docker omits the lock, installs unpinned CPU Torch, resolves project ranges again, and removes declared dependencies. Freezing the current lock would include Linux CUDA dependencies; a CPU source/lock policy must preserve both published architectures and be reviewed first. CPU wheel availability and image runtime remain untested. PR 77 stays separate. | ## Batch 1 verification @@ -109,7 +109,7 @@ Hourly continuation: a thread-heartbeat creation was attempted, but the app retu Published batch 3: commit `eb9486f9eeeab1e2e3f828cf385126b241f79b3b` in draft PR #90 at approximately 10:00 UTC. All six GitHub checks passed. The parent subsequently confirmed it handles scheduling; no automation setup is needed here. The next routine combined publication is held until approximately 11:00 UTC. -## Pending batch 4 +## Batch 4 verification Related retrieval correctness changes: unique nDCG relevance credit and explicit clearing of nullable collection retrieval/validation settings, with SDK types and matching docs. No schema or reindex change. Reproductions and smoke scripts live outside the checkout. @@ -124,4 +124,14 @@ Verification before the held publication: - Pre-commit hooks, Python SDK Ruff/format, wheel/source-distribution build, and diff checks passed. The package build initially hit sandbox DNS while resolving its existing Hatchling backend; it passed with authorized network access. No build configuration or dependency declaration changed. - Independent design review corrected credential-cache precedence, fresh role/session requirements, suggestions generation, global aggregate coverage, tenant creation, T0/replay interaction, and T2 session policy. No blocker remains to presenting the proposals; larger implementation requires the requested policy decision. -No new commit or push yet: retain one combined batch for the next approximately 11:00 UTC publication window. Existing PR head remains eb9486f9; its six CI jobs passed. +Published batch 4: commit `7eef3b38d1db60a261fe3ac3c63ff21c6b446c4a` in draft PR #90 at 10:59 UTC. The eight reviewed files remained unchanged through publication. Fresh full checks, smoke/oracle/SDK checks, installed hooks, and all six CI jobs passed. The checkout was clean after push. The next routine combined publication is held until approximately 12:00 UTC. + +## Pending batch 5 + +Bounded request-semantics correction: explicit null clears API-key expiration, omission preserves it, and a timestamp replaces it. Only the expiration presence guard changes; admin-session authorization, MCP exclusion, active state, scopes/collection/tenant permissions, key identity/secret, principal invalidation, and audit attribution retain their existing behavior. Docs explain that removing expiration from an active expired key restores expiration eligibility without reactivating an inactive key. + +The user authorized this correction with synthetic-only verification. No credential is created, rotated, transmitted, or changed in a deployed account. Actual route/model/auth-guard bodies run with an in-memory session and fake key; credential generation and unrelated/live dependencies are forbidden. The original reproduction and pre-fix null-clearing failure are retained outside the checkout. Both SDKs already accept and serialize null, and ORM/migration fields already allow NULL; no SDK/schema change is needed. + +Independent route/docs review passed 48 synthetic state/payload combinations and 14 denied requests, covering already-null/expired/inactive keys, preservation of identity/hash/permissions/pins, authorization/validation, audit fields, and invalidation order. Root reran that fixture and its own route smoke. Full pnpm check passed, including lint/format, workspace typechecks, app/SDK/docs builds, and API compile. Credential generation and live dependencies were never called. Back up this batch before publication and retain the approximately hourly cadence anchored to 7eef3b38. No small early push. + +Further replay review establishes a feasible schema-free first step for new versioned completions on POST /v1/collections/{collection_name}/query and POST /v1/evaluation, limited to collections unmarked for tenancy both originally and currently. Fresh credential/scope/pin/stable-collection checks must match saved authorization evidence; tenant, legacy, outer MCP, and unsupported completions remain conflicts without eviction or rerun. This refines the earlier broad options and does not implement them. The full compatibility brief and exact 41-pattern recovery inventory were returned to the parent for informed user coordination. Tenant provisioning, schema/layout choices, replay/tenant code, Journal, merge, and deployment remain unchanged. diff --git a/api/bigrag/routers/admin_api_keys.py b/api/bigrag/routers/admin_api_keys.py index 7f6be8dd0..3aabd6eaf 100644 --- a/api/bigrag/routers/admin_api_keys.py +++ b/api/bigrag/routers/admin_api_keys.py @@ -166,7 +166,7 @@ async def update_api_key( if body.active is not None: key.active = body.active fields.append("active") - if body.expires_at is not None: + if "expires_at" in body.model_fields_set: key.expires_at = body.expires_at fields.append("expires_at") existing = dict(key.permissions or {}) diff --git a/website/content/docs/api-reference/api-keys.mdx b/website/content/docs/api-reference/api-keys.mdx index 72b72206a..6291b21dc 100644 --- a/website/content/docs/api-reference/api-keys.mdx +++ b/website/content/docs/api-reference/api-keys.mdx @@ -101,6 +101,8 @@ PATCH /v1/admin/api-keys/{key_id} Any combination of `name`, `active`, `expires_at`, `scopes`, and `collection` is permitted. Updating `scopes` to `[]` stores `["*:*"]` for full access. Deactivating (`active: false`) is the non-destructive way to freeze a leaked key while you investigate; the audit log still references it. +Omitting `expires_at` preserves the current expiration. An RFC3339 timestamp replaces it; explicit `null` removes expiration. Removing expiration from an active expired key restores its expiration eligibility. This does not reactivate an inactive key or change its scopes, collection pin, or secret. + **Response** `200`: updated key (without `key`). From e15d6cb8f6bd5c66a08c5af9d5db47d1152992ee Mon Sep 17 00:00:00 2001 From: Bigint <69431456+bigint@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:28:33 +0530 Subject: [PATCH 06/11] fix: correct upload retry limits and usage presentation Subtract an existing upload item's bytes only when they were included in the session sum. Failed retries now reject over-cap totals while exact-cap and new-file behavior remain intact; concurrent admission remains unresolved. Block empty Studio file-filter saves and require the actual full supported set before writing the existing unrestricted sentinel. Clarify current-document token/cost estimates and the query-only window without changing API values or the rate card. Record partial audit dispositions, independently validated follow-up evidence, matching docs and prior batch publication. Verified with full pnpm check, final docs build, external source/runtime and mocked browser checks, and complete eight-file independent review. No schema, replay/tenant, Journal, deployment or actual credential changes. --- AUDIT.md | 33 ++++++++++++++----- .../services/upload_session_files/staging.py | 6 +++- .../allowed-file-types-card.tsx | 10 ++++-- .../collection-settings.tsx | 11 +++++-- app/src/features/usage/usage-page.tsx | 20 ++++++----- .../content/docs/api-reference/documents.mdx | 2 ++ website/content/docs/api-reference/usage.mdx | 16 ++++++--- website/content/docs/concepts/collections.mdx | 6 ++++ 8 files changed, 75 insertions(+), 29 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 2d9dc737f..c65858a35 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -9,7 +9,7 @@ The original 36 findings below came from a source audit, not live exploitation. - One separate draft PR; no merge, deployment, new service, credential, billing, hosting, or system/network change. - The existing `/Users/yoginth/rag.computer` checkout is on `yoginth/remove-dead-code` with substantial uncommitted Elixir migration work. It was inspected read-only. All audit changes use a separate clone of the verified baseline; none of that work is copied or modified. - No `.agents` directory or nested `AGENTS.md` exists in the baseline checkout. Root `AGENTS.md` and `STYLEGUIDE.md` govern this work. Removed test runners and coverage suites stay removed. Behavioral verification uses temporary local smoke scripts with mock transports and fake credentials outside the repository. -- Tenant findings 4–7, related metadata propagation, and replay authorization need a coherent design before schema, reindex, or provisioning work. Tenant provisioning stays unchanged. +- Tenant findings 4–7, related metadata propagation, and replay authorization need a coherent design before schema, reindex, or provisioning work. Tenant provisioning stays unchanged. Replay implementation is held until explicit policy approval is forwarded; tenant quarantine/redesign requires its own coordination. - Uncommitted batches are backed up outside this checkout before commit. Each useful batch requires relevant checks and independent review. Do not create changes merely to fill an hourly schedule. - Parent continuation requires approximately one combined commit per hour, anchored to the latest publication. Journal is running in its separate coordinator; this work must not start or modify Journal. The parent handles continuation scheduling. @@ -29,8 +29,8 @@ Paths below are relative to `api/bigrag/` unless another package is named. | 8 | Docker context excludes SDK required by UI `COPY` | Existing PR [#77](https://github.com/bigint/rag.computer/pull/77), head `c121513f3f8fd2192f28aefb88191ae69e5b632f`. Read-only review: its targeted exclusion retains Python exclusion while making TypeScript available. Do not duplicate or merge; Docker runtime verification not performed here. | | 9 | Ready document/source cleanup precedes fallible followups and may lead to destructive retry; queue processing/finalize | Independently confirmed; open. Actual orchestration with in-memory dependencies commits ready then pending/failed after transient success-followup errors, deletes vectors/source, and exhausts retry on missing source. Persistent Redis failure can interrupt compensation and retain ready; ordinary asynchronous EventBus errors do not trigger the downgrade. Repair needs a durable success boundary, safe followup reconciliation, and ready-row redelivery guards. | | 10 | Failed-document dedup prevents retry | Independently confirmed; open. Single/batch/open-session uploads reuse failed hash matches without ingestion; the oldest failed batch match can shadow a newer ready row. Collection/tenant predicates remain effective in the synthetic checks. Define a shared status-aware reuse/retry policy with freshly staged bytes and guarded ownership before changing IDs or compensation. | -| 11 | Lookup-before-insert hash dedup race with nonunique index | Open; schema coordination required if constraints are needed. | -| 12 | Upload limits use unlocked stale counts | Open; inspect concurrent quota accounting. | +| 11 | Lookup-before-insert hash dedup race with nonunique index | Independently confirmed in source and controlled in-memory flows; open. Single, batch and session uploads can both miss the same scoped hash then stage/enqueue separate documents; repeats within one batch remain deduped. The document hash index is nonunique. This is not PostgreSQL concurrency proof. Shared status-aware dedup ownership must preserve tenant boundaries before locks or constraints are chosen; no schema change. | +| 12 | Upload limits use unlocked stale counts | Partially corrected in pending batch 6: failed retry bytes were excluded from the session sum and then subtracted again. Only previously counted existing bytes are now subtracted. Independently reproduced over-cap acceptance and preserved exact-cap/new-file behavior. Snapshot admission races for concurrent distinct items remain open: controlled actual-flow interleavings admit two items into one slot or 1,400,000 logical bytes into a 1 MiB cap. Same-client-ID uniqueness prevents duplicate identities but does not reserve capacity. No PostgreSQL concurrency proof, reservation, lock protocol or schema change. | | 13 | Queue recovery ignores `lrem` result and sets lease unconditionally | Independently confirmed; open. Ten fake-Redis checks reproduce duplicate recovery, stale TTL decisions, admission gaps, stale renewal/cleanup affecting a replacement lease, and repeated admission release. Checking LREM alone is insufficient; atomic token ownership and compare-token renewal/recovery/finalization need a coherent protocol. No live Redis or queue change. | | 14 | Mixed text/scanned PDFs skip OCR | Independently reviewed; open. A generated digital/raster/blank PDF plus actual installed PDFium extraction and direct conversion shortcut returns only digital text with OCR enabled. Actual queue branch bodies skip OCR on injected nonempty parse results. Native Docling/OCR recognition and element output remain untested; page coverage, blank pages, ordering, cost and cancellation need a reviewed design. | | 15 | ProcessPool timeout leaves conversion process running | Independently confirmed; open. Actual wrapper with a synthetic local child times out and releases its semaphore slot while the child keeps running; shutdown waits for completion. Process, admission and staged-file ownership must be designed together. No model or service was invoked. | @@ -44,15 +44,15 @@ Paths below are relative to `api/bigrag/` unless another package is named. | 23 | Logout leaves chat/upload Zustand stores | Fixed in batch 2. Confirmed transitions replace/remount the QueryClient provider, abort generation-owned auth/upload requests and chat, reset stores, and seed the new session. Upload persistence is owner-bound; unowned v1 IDs are discarded. Generation guards prevent late callbacks from restoring tracking. Focus/reconnect and active-tab minute refresh detect confirmed expiry/account changes. | | 24 | Upload UI ignores returned `item.status=failed` | Fixed in batch 3. Baseline mock returned a failed item under HTTP 201 while the UI reported it queued. File responses now record failed/canceled items, final counts drive warnings, and issue panels remain visible. Failed validation rows keep their status during persistence. | | 25 | Closed incomplete upload sessions remain uploading | Fixed in batch 3. Baseline closed sessions with missing files remained preparing/uploading. Missing files after closure now fail once accepted work is inactive; active work stays ingesting. New file requests reject closed sessions. The UI closes after all attempts, including all failed requests, and recognizes cancellation conflicts. | -| 26 | Allowed file types and empty-file semantics inconsistent | Open; compare upload entry points and server validation. | +| 26 | Allowed file types and empty-file semantics inconsistent | Partially corrected in pending batch 6: Studio cannot save an empty draft as a restricted selection; all-selected retains the existing unrestricted [] contract. Labels/docs scope the filter to Studio. Direct API and upload-session extensionless-file validation differences remain open; no server policy or ingest behavior change. | | 27 | Personal-key-only UI gate blocks instance chat credentials | Open; inspect safe readiness contract before removing gate. | | 28 | Analytics gather shares AsyncSession; `services/analytics.py` | Fixed in batch 1. Mock session reproduced overlapping execute calls at baseline. Period queries now run sequentially in one session, preserving output and five-minute cache. | | 29 | NDCG duplicates document hits and can exceed 1 | Fixed in batch 4. Actual baseline helper scored three hits for one relevant document as 2.1309297535714578. Each relevant ID now earns credit only once at its original rank; hit_ids, recall, MRR, and unique rankings stay unchanged. | | 30 | ASCII/length tokenizer drops Unicode and short terms | Independently confirmed; open. Synthetic tokenizer/provider-payload/scorer checks drop single-character and Chinese/Cyrillic terms; accented words split or truncate and can score zero even with a supplied candidate. Provider analyzer, punctuation/normalization/boundaries, local scoring, request limits, and query-cache version need explicit examples before choosing a fix. No index/reindex or live provider change. | | 31 | Reranking only final top-k and silent degradation | Independently confirmed; open. Actual mocked pipelines pass only final top-k hits to reranking, including after hybrid expansion. Missing configuration/dependency or provider failures return original hits; client acquisition errors propagate. Transient fallback results can be cached without outcome provenance. Candidate budgets, safe status metadata, score thresholds, cache policy and fan-out/cost limits need coordinated API/SDK examples before changing defaults. | -| 32 | Explicit null settings cannot clear values | Partially fixed in batch 4 for nullable collection default_min_score and metadata_schema. API-key expiry is corrected in pending batch 5: synthetic actual-router reproduction accepted null but retained expiration; the update now distinguishes explicit null from omission. Existing SDKs and nullable ORM/migration already support it. Other nullable update contracts retain their current semantics pending separate validation. | +| 32 | Explicit null settings cannot clear values | Partially fixed in batch 4 for nullable collection default_min_score and metadata_schema. API-key expiry was corrected in batch 5: synthetic actual-router reproduction accepted null but retained expiration; the update now distinguishes explicit null from omission. Existing SDKs and nullable ORM/migration already support it. Other nullable update contracts retain their current semantics pending separate validation. | | 33 | Retrieved context in system prompt lacks untrusted-data boundary | Open; inspect prompt/data roles without live provider calls. | -| 34 | Usage estimates characters/4 with incomplete prices/history | Open; describe estimate provenance; do not present as billing or change billing. | +| 34 | Usage estimates characters/4 with incomplete prices/history | Partially corrected in pending batch 6: UI/docs identify current stored-document character-based estimates, a query-only window, model-name rate lookup independent of provider, and unpriced zero values. Provider-measured usage, rate coverage and historical accounting remain open; no API values, rate card or billing behavior changes. | | 35 | Upload polling loads all rows | Independently confirmed; open. Each session snapshot materializes all joined items twice despite returning at most 20; a 10,000-item session transfers 20,000 tuples per foreground two-second poll. Per-file context/response scans also grow cumulatively. A single aggregate/limited-recent candidate preserves synthetic status counts and ordering, including local in-memory SQLite execution, but PostgreSQL plans/concurrency and lifecycle persistence remain unvalidated. Never globally limit rows used by cancellation or budgets. | | 36 | Docker Python install ignores verified uv.lock/current Torch | Independently confirmed from source; open. Docker omits the lock, installs unpinned CPU Torch, resolves project ranges again, and removes declared dependencies. Freezing the current lock would include Linux CUDA dependencies; a CPU source/lock policy must preserve both published architectures and be reviewed first. CPU wheel availability and image runtime remain untested. PR 77 stays separate. | @@ -126,12 +126,29 @@ Verification before the held publication: Published batch 4: commit `7eef3b38d1db60a261fe3ac3c63ff21c6b446c4a` in draft PR #90 at 10:59 UTC. The eight reviewed files remained unchanged through publication. Fresh full checks, smoke/oracle/SDK checks, installed hooks, and all six CI jobs passed. The checkout was clean after push. The next routine combined publication is held until approximately 12:00 UTC. -## Pending batch 5 +## Batch 5 verification Bounded request-semantics correction: explicit null clears API-key expiration, omission preserves it, and a timestamp replaces it. Only the expiration presence guard changes; admin-session authorization, MCP exclusion, active state, scopes/collection/tenant permissions, key identity/secret, principal invalidation, and audit attribution retain their existing behavior. Docs explain that removing expiration from an active expired key restores expiration eligibility without reactivating an inactive key. The user authorized this correction with synthetic-only verification. No credential is created, rotated, transmitted, or changed in a deployed account. Actual route/model/auth-guard bodies run with an in-memory session and fake key; credential generation and unrelated/live dependencies are forbidden. The original reproduction and pre-fix null-clearing failure are retained outside the checkout. Both SDKs already accept and serialize null, and ORM/migration fields already allow NULL; no SDK/schema change is needed. -Independent route/docs review passed 48 synthetic state/payload combinations and 14 denied requests, covering already-null/expired/inactive keys, preservation of identity/hash/permissions/pins, authorization/validation, audit fields, and invalidation order. Root reran that fixture and its own route smoke. Full pnpm check passed, including lint/format, workspace typechecks, app/SDK/docs builds, and API compile. Credential generation and live dependencies were never called. Back up this batch before publication and retain the approximately hourly cadence anchored to 7eef3b38. No small early push. +Independent route/docs review passed 48 synthetic state/payload combinations and 14 denied requests, covering already-null/expired/inactive keys, preservation of identity/hash/permissions/pins, authorization/validation, audit fields, and invalidation order. Root reran that fixture and its own route smoke. Full pnpm check passed, including lint/format, workspace typechecks, app/SDK/docs builds, and API compile. Credential generation and live dependencies were never called. Independent final three-file review also passed. Pre-commit hooks and all six CI jobs passed on the published head. Backup retained under the task workspace audit-backups/batch-5-20261004T115405Z; no small early push. Further replay review establishes a feasible schema-free first step for new versioned completions on POST /v1/collections/{collection_name}/query and POST /v1/evaluation, limited to collections unmarked for tenancy both originally and currently. Fresh credential/scope/pin/stable-collection checks must match saved authorization evidence; tenant, legacy, outer MCP, and unsupported completions remain conflicts without eviction or rerun. This refines the earlier broad options and does not implement them. The full compatibility brief and exact 41-pattern recovery inventory were returned to the parent for informed user coordination. Tenant provisioning, schema/layout choices, replay/tenant code, Journal, merge, and deployment remain unchanged. + + +Published batch 5: commit `d0077c8dcb1405fa597daac724d8f6b1cd148ff8` in draft PR [#90](https://github.com/bigint/rag.computer/pull/90), pushed at 11:59 UTC. All six CI jobs passed on that exact head, verified at 12:03 UTC. The parent received the full decision brief; replay and tenant implementation remain held. The next routine combined publication is targeted for approximately 13:00 UTC. + +## Pending batch 6 + +Bounded corrections for findings 12, 26 and 34: prevent saving an empty file-type draft, require actual all-type membership before writing the unrestricted sentinel, and clarify query-window versus current-document token/cost estimates. A failed upload retry also stops subtracting bytes already excluded from its session sum. Documentation describes retry accounting, the browser-only upload filter and current estimate provenance. File-content validation, usage values, rate card, schemas, tenant/replay policy, and deployed credentials stay unchanged. The byte-cap threshold is unchanged; over-cap failed retries now reject correctly. + +The original actual-hook/save reproduction showed an empty draft saving [] with a misleading zero-type restriction toast; the new expectation failed before the source fix. The actual save handler now rejects before transport, and subset/all-type saves preserve unrelated metadata. An equal-count set of unknown extensions no longer silently removes its filter. Seven existing unrestricted representations and other file-helper edge cases retain their behavior. The temporary VM harness initially used an Error from a different realm; injecting the host constructor corrected the harness without a production change, and both logs are retained. + +The initial UI-only full pnpm check passed: repository lint, API Ruff lint/format, workspace typechecks, app/SDK/docs builds, and API compile. Independent UI/source review passed 20 actual-card states, 11 save-body cases and 10 usage renders; synthetic actual usage SQL/body checks confirm current-document aggregation, model-only rates and query-window scope. Built-UI browser verification passed keyboard subset selection/save/reload, empty Save disabled, all-selected sentinel save/reload, preserved custom metadata, query windows 30/1, unchanged stored-token estimates, and mobile viewport containment with zero page errors. Screenshots were visually inspected. An incomplete admin onboarding fixture initially timed out; the corrected synthetic member fixture passed. No deployed account was involved. The final full pnpm check after the backend addition passed, and the subsequent precise open/nonterminal retry wording passed a final docs build. Independent failed-retry review passed eight additional actual-helper cases, rejecting over-cap and cap-plus-one while preserving exact-cap, under-cap, counted replacement and new-file outcomes. Fresh combined source/docs review and isolated UI/usage/staging reruns found no blocker. Final snapshot review and publication remain pending. All temporary fixtures and receipts remain outside the repository; no package test runner or coverage suite is restored. Back up the reviewed batch before the approximately hourly publication. + + +The failed-retry byte-accounting correction was independently validated against the actual staging helper: a 1 MiB cap admitted 1,100,000 counted bytes because 300,000 excluded failed bytes were subtracted again. Root's separate actual-body fixture failed on the captured baseline and passes 11 cases after the guard, covering over/exact caps, excluded failed rows, counted replacements, new files, canceled bytes and empty files. Its initial AST harness lacked synthetic-node locations; the harness was corrected and original/fixed logs retained. Failure persistence and content validation were mocked, so this does not prove transport, database serialization or concurrent capacity enforcement. The broader finding 12 remains open. + + +Further read-only validation independently exercised eight actual-flow checks for findings 11/12, and root reran them with the API import path after an initial import-path error. Single/batch/session lookup barriers permit duplicate same-scope/hash document creation in synthetic transactions; one batch's repeated hashes remain deduped. Initial session locking is released before staging, so distinct-item stale snapshots permit file-slot/byte overshoot. Actual tiny-byte streaming helpers still enforce per-file and per-batch limits with cleanup. These fixtures forbid socket connections and use in-memory locks/storage/transactions; they establish source reachability, not PostgreSQL concurrency, physical storage billing, global tenant quotas or real framework/proxy behavior. Capacity admission and shared dedup/recovery policy need coordinated ownership/transaction design; neither is implemented here. diff --git a/api/bigrag/services/upload_session_files/staging.py b/api/bigrag/services/upload_session_files/staging.py index 3bfa4ed67..43370e32a 100644 --- a/api/bigrag/services/upload_session_files/staging.py +++ b/api/bigrag/services/upload_session_files/staging.py @@ -64,7 +64,11 @@ async def staged_rejection( item.file_size for item, _status, _error in context.rows if item.status != "failed" ) max_session_bytes = limits["max_upload_session_size_mb"] * 1024 * 1024 - existing_size = context.existing.file_size if context.existing is not None else 0 + existing_size = ( + context.existing.file_size + if context.existing is not None and context.existing.status != "failed" + else 0 + ) if uploaded_bytes - existing_size + staged.size > max_session_bytes: return await failure_response( db, diff --git a/app/src/features/collections/collection-settings/allowed-file-types-card.tsx b/app/src/features/collections/collection-settings/allowed-file-types-card.tsx index 1b4d4e9ad..f798935b2 100644 --- a/app/src/features/collections/collection-settings/allowed-file-types-card.tsx +++ b/app/src/features/collections/collection-settings/allowed-file-types-card.tsx @@ -22,7 +22,8 @@ export const AllowedFileTypesCard = ({ saving: boolean; onSave: () => void; }) => { - const allSelected = allowedTypes.size === ALL_FILE_TYPES.length; + const allSelected = + allowedTypes.size === ALL_FILE_TYPES.length && ALL_FILE_TYPES.every((t) => allowedTypes.has(t)); const noneSelected = allowedTypes.size === 0; return ( @@ -30,7 +31,7 @@ export const AllowedFileTypesCard = ({ Allowed file types - Restrict uploads by extension. Leave all selected to allow the default ingest set. + Filter Studio uploads by extension. Select all to remove the upload filter. @@ -66,8 +67,11 @@ export const AllowedFileTypesCard = ({ ); })}
+

+ Select at least one file type, or choose Select all. +

-
diff --git a/app/src/features/collections/collection-settings/collection-settings.tsx b/app/src/features/collections/collection-settings/collection-settings.tsx index f5683112b..09c641299 100644 --- a/app/src/features/collections/collection-settings/collection-settings.tsx +++ b/app/src/features/collections/collection-settings/collection-settings.tsx @@ -81,7 +81,12 @@ export const CollectionSettings = ({ name }: { name: string }) => { const saveFileTypes = async () => { const list = draft.fileTypesDraft; - const unrestricted = list.length === ALL_FILE_TYPES.length; + if (list.length === 0) { + toast.error("Select at least one file type, or choose Select all."); + return; + } + const unrestricted = + list.length === ALL_FILE_TYPES.length && ALL_FILE_TYPES.every((type) => list.includes(type)); try { await update.mutateAsync({ metadata: { @@ -91,8 +96,8 @@ export const CollectionSettings = ({ name }: { name: string }) => { }); toast.success( unrestricted - ? "All file types allowed" - : `Restricted to ${list.length} type${list.length === 1 ? "" : "s"}`, + ? "Studio upload filter removed" + : `Studio uploads limited to ${list.length} type${list.length === 1 ? "" : "s"}`, ); } catch (err) { toast.error(err instanceof Error ? err.message : "Save failed"); diff --git a/app/src/features/usage/usage-page.tsx b/app/src/features/usage/usage-page.tsx index 34c44d6d3..0c84ba687 100644 --- a/app/src/features/usage/usage-page.tsx +++ b/app/src/features/usage/usage-page.tsx @@ -57,7 +57,7 @@ export const UsagePage = () => { @@ -65,16 +65,18 @@ export const UsagePage = () => { Usage and cost - Per-collection document, chunk, query, and embedding-cost totals. Cost is an estimate - from a local rate card; cross-check with your provider's dashboard for the source of - truth. + The query window applies to query counts, latency, and the timeline. Document totals + reflect currently stored data. Document tokens are estimated from text length; cost uses + a local model-name rate card. Unpriced models display $0. Check your provider's + dashboard for actual token usage and spend.
- Window + Query window