Skip to content

Add Supabase authentication and Railway production deployment - #4

Closed
CodeWithMoin wants to merge 5 commits into
mainfrom
codex/railway-production
Closed

CodeWithMoin wants to merge 5 commits into
mainfrom
codex/railway-production

Conversation

@CodeWithMoin

@CodeWithMoin CodeWithMoin commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • replace direct Google-only auth with Supabase Auth for Google OAuth and email magic links
  • keep the existing FastAPI HTTP-only session boundary after Supabase token verification
  • configure Supabase Postgres, private S3-compatible storage, and Railway production deployment
  • document deployment variables and update local Docker/Makefile workflows
  • pin the supported runtime to Python 3.12

Validation

  • npm --prefix frontend run build
  • npm --prefix frontend run lint
  • .venv/bin/pytest tests/test_auth_service.py tests/test_settings.py -q (8 passed)
  • Ruff and git diff --check
  • Railway Alembic migrations completed
  • production /health/live and /health/ready return 200

Tradeoff

Supabase adds one external token-verification request during sign-in, while subsequent API traffic continues to use the existing secure DocuLens session cookie. This keeps the backend authorization model simple and avoids validating an external JWT on every request.

Summary by CodeRabbit

  • New Features
    • Added an Investigate workspace for streaming document research, evidence, citations, reports, and saved investigation history.
    • Added passwordless email and optional Google sign-in through Supabase, with secure session handling and logout.
    • Added configurable local or S3-compatible document storage.
    • Added lightweight PDF and text extraction for faster processing.
  • Security
    • Added authentication controls, access restrictions, secure cookies, and enhanced browser security headers.
  • Documentation
    • Expanded setup, architecture, evaluation, and Railway deployment guidance.
  • Chores
    • Updated the supported Python version to 3.12.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@CodeWithMoin, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 08ed0745-eec8-490e-bc65-7bf0423fb0f9

📥 Commits

Reviewing files that changed from the base of the PR and between d27c1d9 and b7fe298.

📒 Files selected for processing (3)
  • app/frontend.py
  • app/main.py
  • tests/test_spa_static_files.py
📝 Walkthrough

Walkthrough

DocuLens now supports adaptive, evidence-grounded investigations with LangGraph orchestration, citation validation, durable checkpoints, SSE streaming, Supabase authentication, configurable document storage, lightweight extraction, PostgreSQL/pgvector retrieval, and Railway deployment.

Changes

Investigation platform

Layer / File(s) Summary
Configuration and deployment
.env.example, app/config/*, pyproject.toml, docker/*, railway*.toml
Added investigation, authentication, storage, task, extraction, database, and deployment settings. Python support now targets 3.12.
Authentication and document storage
app/services/auth_service.py, app/services/document_storage.py, app/api/auth_router.py, app/api/endpoint.py, app/database/user.py, app/alembic/versions/*
Added Supabase token verification, cookie sessions, federated identities, local and S3 storage, bounded uploads, and local task execution.
Investigation execution
app/agents/*, app/api/investigation_router.py, app/services/investigation_service.py, app/database/investigation.py, app/main.py
Added typed investigation state, scoped tools, adaptive LangGraph routing, citation verification, persistence, checkpointing, SSE events, and investigation retrieval endpoints.
Extraction and vector retrieval
app/doc_utils/*, app/pipelines/doculens_pipeline.py, app/services/vector_store.py, app/utils/insert_vectors.py
Added lightweight PDF/text extraction, provenance-preserving chunking, tokenizer adaptation, temporary-file cleanup, and direct PostgreSQL/pgvector HNSW operations.
Frontend authentication and investigations
frontend/src/auth/*, frontend/src/api/*, frontend/src/pages/*, frontend/src/main.tsx
Replaced password login with Supabase magic-link and Google flows. Added credentialed API calls, investigation SSE handling, and the investigation page.
Validation and documentation
tests/*, README.md, docs/deploy-railway.md
Added tests for investigation execution, citation recovery, evaluation, authentication policy, settings, uploads, and lightweight extraction. Updated architecture and Railway deployment documentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant InvestigationRouter
  participant InvestigationRuntime
  participant InvestigationTools
  participant InvestigationModel
  participant InvestigationService

  Client->>InvestigationRouter: POST /investigations/stream
  InvestigationRouter->>InvestigationService: Create investigation record
  InvestigationRouter->>InvestigationRuntime: Stream investigation state
  InvestigationRuntime->>InvestigationModel: Request bounded decision
  InvestigationRuntime->>InvestigationTools: Execute scoped tool
  InvestigationTools-->>InvestigationRuntime: Return documents or evidence
  InvestigationRuntime->>InvestigationModel: Generate report
  InvestigationRuntime->>InvestigationRuntime: Validate citations
  InvestigationRuntime-->>InvestigationRouter: Emit activity, evidence, report, and completion events
  InvestigationRouter->>InvestigationService: Persist state snapshots
  InvestigationRouter-->>Client: Stream SSE events
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the primary Supabase authentication and Railway deployment changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/railway-production

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@CodeWithMoin

Copy link
Copy Markdown
Owner Author

Superseded by #5 on the public release/railway-production branch.

@CodeWithMoin
CodeWithMoin deleted the codex/railway-production branch August 10, 2026 19:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (23)
docs/deploy-railway.md-186-189 (1)

186-189: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not describe Profile B as fully Railway-hosted.

Production authentication requires SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY when DOCULENS_REQUIRE_AUTH=true. This profile therefore still depends on a Supabase project for Auth. The current wording can cause a deployment to omit Supabase and fail production validation.

Rename this profile to describe Railway-hosted processing infrastructure, or document a supported non-Supabase authentication path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/deploy-railway.md` around lines 186 - 189, Update the Profile B heading
and description in the deployment documentation to clarify that Railway hosts
the processing infrastructure but authentication still requires a Supabase
project when DOCULENS_REQUIRE_AUTH=true. Remove the “everything on Railway”
wording, or document an explicitly supported non-Supabase authentication path.
docs/deploy-railway.md-228-230 (1)

228-230: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Provide model-provider credentials to Web.

The Web service executes the active SSE investigation request. The agent therefore needs its configured provider credential in Web. Assigning provider keys only to Worker makes /api/v1/investigations/stream fail when it calls the model.

Add the selected provider key to both services when both can execute model calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/deploy-railway.md` around lines 228 - 230, The deployment variable
assignment described in docs/deploy-railway.md must provide the selected
model-provider credential to both the Web service and Worker. Update the service
configuration so Web receives the provider key alongside its existing
authentication and public-domain variables, while preserving Worker’s provider,
database, Redis, and storage variables.
docs/deploy-railway.md-30-31 (1)

30-31: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Make Free-tier cost guidance conditional on actual Serverless sleep eligibility.

Railway Serverless sleeps a service only after at least 10 minutes without outbound traffic. Active database pools, telemetry, or provider traffic can prevent sleep. Do not describe this profile as inherently sleeping or imply a predictable Free-tier cost without an operational verification step. (docs.railway.com)

  • docs/deploy-railway.md#L30-L31: describe Serverless as an optional cost control and document its outbound-traffic condition.
  • docs/deploy-railway.md#L238-L239: replace mutable plan figures with a pricing-reference instruction and require users to set a hard usage limit.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/deploy-railway.md` around lines 30 - 31, Update docs/deploy-railway.md
lines 30-31 to present Railway Serverless as an optional cost-control measure,
explicitly noting that sleeping requires at least 10 minutes without outbound
traffic and must be operationally verified. Update docs/deploy-railway.md lines
238-239 to replace mutable plan-price figures with instructions to consult the
current pricing reference and set a hard usage limit.
app/doc_utils/extraction.py-51-57 (1)

51-57: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject unsupported binary file types.

The non-PDF branch accepts every suffix. A .docx, .xlsx, or other binary upload can decode into non-empty replacement text and be indexed as evidence.

Allow only configured plain-text suffixes in this profile. Raise a clear error for other types, or route them through the Docling profile.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/doc_utils/extraction.py` around lines 51 - 57, Update the non-PDF branch
in the extraction flow to validate the input suffix against the profile’s
configured plain-text suffixes before calling path.read_text. Reject unsupported
binary types with a clear error, or explicitly route them through the Docling
profile, while preserving plain-text extraction for allowed suffixes.
app/services/vector_store.py-116-149 (1)

116-149: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add an Alembic migration for the production vector schema.

The Celery worker has no initialization path. Alembic does not create the vector extension, embeddings table, or indexes. A fresh deployment can process worker tasks before web startup creates this schema.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/vector_store.py` around lines 116 - 149, Add an Alembic
migration that creates the vector extension, embeddings table, metadata GIN
index, and HNSW cosine embedding index represented by the schema setup in the
vector store methods. Include a downgrade that removes these objects in
dependency-safe order, and ensure the migration is available independently of
web startup so Celery workers can rely on the production schema.
app/doc_utils/chunking.py-50-60 (1)

50-60: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Enforce max_tokens during lightweight chunking.

embed_and_upsert_chunks truncates text at 8,191 tokens. It does not enforce the requested chunk limit. CJK text or long unbroken strings can form oversized chunks, causing silent content loss during embedding.

Tokenize each page with OpenAITokenizerWrapper, create overlapping windows of max_tokens, and decode each window before creating LightweightChunk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/doc_utils/chunking.py` around lines 50 - 60, Update the lightweight
chunking loop in the document chunking function to use OpenAITokenizerWrapper
for each page instead of word splitting and an estimated max_words limit.
Tokenize page text, create overlapping token windows capped at max_tokens,
decode each window, and use the decoded text when constructing LightweightChunk
so embed_and_upsert_chunks cannot receive oversized chunks.
app/config/settings.py-86-88 (1)

86-88: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Align the local storage path with the Docker volume mount.

docker/docker-compose.yml mounts ingestion_data at /workspace/data/ingestion for both API and worker services. These defaults use /workspace/app/data/ingestion. Local uploads can remain in the API container filesystem, and workers can fail to find uploaded documents.

  • app/config/settings.py#L86-L88: use /workspace/data/ingestion, or change both Compose mounts to this configured path.
  • .env.example#L33-L35: document the same shared path as the runtime default.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/config/settings.py` around lines 86 - 88, Update app/config/settings.py
lines 86-88 in the storage_local_path setting to use /workspace/data/ingestion,
matching the Docker volume mount. Update .env.example lines 33-35 to document
the same shared path as the runtime default.
Makefile-16-17 (1)

16-17: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Set DOCULENS_TASK_MODE=local for the API in preview. The target omits celery_worker, while the default mode is celery; uploads will queue in Redis without processing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 16 - 17, Update the Makefile preview target’s API
startup configuration to set DOCULENS_TASK_MODE=local, while preserving the
existing service list and detached build behavior.
app/config/database_config.py-20-20 (1)

20-20: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Load database variables through Pydantic Settings.

Settings.model_config.env_file does not apply to nested DatabaseConfig. The import-time os.getenv defaults can therefore ignore .env values and use fallback connection settings. Configure DatabaseConfig with env_file=".env" and validation_alias values for DATABASE_URL, DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, and DATABASE_PASSWORD.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/config/database_config.py` at line 20, Update DatabaseConfig to load all
database fields through Pydantic Settings instead of import-time os.getenv
defaults: configure its model settings with env_file=".env" and add validation
aliases for DATABASE_URL, DATABASE_HOST, DATABASE_PORT, DATABASE_NAME,
DATABASE_USER, and DATABASE_PASSWORD, preserving the existing field types and
defaults.
app/start.sh-7-7 (1)

7-7: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Trust the Railway proxy CIDR.

Uvicorn ignores X-Forwarded-Proto from untrusted peers. Add Railway’s 100.0.0.0/8 proxy range so HTTPS requests receive the HSTS header.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/start.sh` at line 7, Update the uvicorn invocation in start.sh to trust
Railway’s 100.0.0.0/8 proxy CIDR by configuring the appropriate forwarded-header
trusted-host option, while preserving the existing proxy-header handling and
startup arguments.
app/api/investigation_router.py-151-165 (1)

151-165: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The failure handler can raise before it emits the error event.

persist_investigation_state raises LookupError when the record is absent, and _save_snapshot opens a new session that can also fail. The most likely cause of the original exception is a database or connection problem, so the recovery write at line 158 tends to fail for the same reason. That exception propagates out of the generator, and the error event at lines 159-165 never reaches the client. The client then observes a truncated stream with no terminal event and waits indefinitely.

Emit the terminal event first, or isolate the recovery write.

🐛 Proposed fix
         except Exception as exc:
             logger.exception("Investigation %s failed", investigation_id)
             failed_state: InvestigationState = {
                 **final_state,
                 "status": "failed",
                 "error": str(exc),
             }
-            _save_snapshot(investigation_id, failed_state)
+            try:
+                _save_snapshot(investigation_id, failed_state)
+            except Exception:
+                logger.exception(
+                    "Could not persist the failed state for investigation %s", investigation_id
+                )
             yield _sse(
                 "error",
                 {
                     "id": str(investigation_id),
                     "message": "The investigation stopped before completion.",
                 },
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/investigation_router.py` around lines 151 - 165, Update the exception
handler in the investigation stream generator to ensure the terminal “error” SSE
event is emitted even when `_save_snapshot` fails. Emit the event before the
recovery write, or isolate `_save_snapshot(investigation_id, failed_state)` in
its own guarded error-handling block so persistence failures cannot escape the
generator.
app/services/auth_service.py-151-173 (1)

151-173: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject sign-in for deactivated accounts.

_upsert_supabase_user never inspects user.is_active. authenticate_user at lines 85-91 rejects inactive users, and decode_access_token at lines 283-285 rejects them too. The Supabase path does not. A deactivated user therefore receives HTTP 200 and a session cookie from /auth/supabase, and only fails on the next request. Add the same check so deactivation is enforced consistently at the login boundary.

🔒 Proposed fix
         if user is None:
             user = User(
                 ...
             )
         else:
+            if not user.is_active:
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail="This account is deactivated.",
+                )
             user.email = email
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/auth_service.py` around lines 151 - 173, Update
_upsert_supabase_user to check an existing user’s is_active status before
updating, committing, or returning it; reject deactivated users at the Supabase
login boundary using the same behavior as authenticate_user and
decode_access_token. Preserve creation of new users and normal handling of
active users.
app/services/auth_service.py-142-168 (1)

142-168: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Require a verified email before linking a Supabase identity to an existing local account.

Line 144 falls back to get_user_by_email. Lines 164-168 then rebind that existing row to the Supabase subject. The claims are not checked for email verification. If any enabled Supabase provider returns an unverified email address, a sign-in with that address takes over the pre-existing local account and inherits its persona, role, and access_level. The seeded admin@doculens.ai account is the highest-value target.

Read email_confirmed_at from the Supabase user payload and reject the link when the address is unconfirmed.

🔒 Proposed fix
     def _upsert_supabase_user(self, claims: Dict[str, object]) -> User:
         subject = str(claims.get("id") or claims.get("sub") or "").strip()
         email = str(claims.get("email") or "").strip().lower()
         if not subject or not email:
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Supabase account identity is incomplete.",
             )
 
         self._enforce_supabase_allowlist(email=email)
 
+        email_verified = bool(claims.get("email_confirmed_at"))
+
         user = self.get_user_by_provider_subject(provider="supabase", subject=subject)
         if user is None:
-            user = self.get_user_by_email(email=email)
+            existing = self.get_user_by_email(email=email)
+            if existing is not None and not email_verified:
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail="Confirm this email address before linking the account.",
+                )
+            user = existing
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/auth_service.py` around lines 142 - 168, Update the Supabase
authentication flow around get_user_by_email and the existing-user rebinding
logic to read email_confirmed_at from the Supabase user payload and reject
authentication before linking any unconfirmed email to a local account. Only
allow the fallback email lookup and updates to auth_provider, provider_subject,
and related fields when the email is confirmed; preserve new-user creation
behavior for valid confirmed identities.
app/services/auth_service.py-170-172 (1)

170-172: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle IntegrityError on the upsert commit.

The method performs check-then-insert. Two concurrent first-time sign-ins for the same subject both pass the lookups at lines 142-144 and both insert. One commit then violates uq_users_auth_identity or the unique email index. A second case exists without concurrency: a new Supabase subject that presents an email already bound to a different subject violates the unique email index at line 164.

Both cases raise IntegrityError out of an unauthenticated endpoint, so the caller receives HTTP 500. Catch the error, roll back, and either re-read the winning row or return a deterministic conflict response.

🛡️ Proposed fix
-        self.session.add(user)
-        self.session.commit()
-        self.session.refresh(user)
-        return user
+        self.session.add(user)
+        try:
+            self.session.commit()
+        except IntegrityError:
+            self.session.rollback()
+            existing = self.get_user_by_provider_subject(provider="supabase", subject=subject)
+            if existing is None:
+                raise HTTPException(
+                    status_code=status.HTTP_409_CONFLICT,
+                    detail="This email address is already linked to another identity.",
+                ) from None
+            return existing
+        self.session.refresh(user)
+        return user

Add the import:

from sqlalchemy.exc import IntegrityError
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/auth_service.py` around lines 170 - 172, Update the upsert flow
around the session.commit call in the relevant auth service method to catch
SQLAlchemy IntegrityError, roll back the session, and re-read the existing
winning user when the conflict represents a concurrent insert; return a
deterministic conflict response when the email belongs to a different subject.
Add the IntegrityError import and preserve normal refresh behavior for
successful commits.
app/api/auth_router.py-107-117 (1)

107-117: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return None from the 204 logout handler.

FastAPI sets the injected Response.status_code to None and removes content-length. Returning it bypasses the declared 204 response and causes Uvicorn/h11 to reject the status at runtime. Return None so FastAPI creates the 204 response and merges the cookie headers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/auth_router.py` around lines 107 - 117, Update the logout handler to
return None instead of the injected Response object, while preserving the
existing delete_cookie call and its settings. Keep the declared 204 response
behavior and allow FastAPI to merge the cookie headers.
app/api/investigation_router.py-38-39 (1)

38-39: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Authorize document_ids before building document_scope.

The route stores user_id on the investigation, but does not pass it to the document tools. The tools filter only by caller-supplied IDs, so private documents lack an ownership or workspace authorization check before evidence is returned. Also bound each identifier because max_length=100 limits the list count, not individual strings persisted in the JSONB state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/investigation_router.py` around lines 38 - 39, Update the
investigation request flow around the document_ids field and document_scope
construction to authorize every requested document against the authenticated
user or workspace before building the scope or returning evidence; pass the
stored user_id through to the document tools and preserve rejection of
unauthorized IDs. Add a per-identifier length constraint in addition to the
existing maximum list size, so persisted document IDs cannot exceed the intended
bound.
app/agents/tools.py-86-108 (1)

86-108: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Push the document scope into the SQL query.

The query takes the 100 most recent document_upload events, and the scope filter runs afterwards in Python. If a scoped document is not in that window, list_documents reports zero documents and the investigation proceeds without it. The step also loads full data and task_context JSONB for 100 rows on every call.

Filter by the requested document ids in SQL when document_scope is non-empty.

🐛 Proposed fix
     def list_documents(self, document_scope: list[str]) -> ToolObservation:
         with SessionLocal() as session:
-            rows = session.execute(
-                text(
-                    """
-                    SELECT id, data, task_context
-                    FROM events
-                    WHERE data->>'event_type' = 'document_upload'
-                    ORDER BY created_at DESC
-                    LIMIT 100
-                    """
-                )
-            ).mappings()
+            statement = text(
+                """
+                SELECT id, data, task_context
+                FROM events
+                WHERE data->>'event_type' = 'document_upload'
+                  AND (
+                    :scope_is_empty
+                    OR COALESCE(task_context->'metadata'->'document'->>'id', id::text)
+                        = ANY(:scope)
+                  )
+                ORDER BY created_at DESC
+                LIMIT 100
+                """
+            )
+            rows = session.execute(
+                statement,
+                {"scope_is_empty": not document_scope, "scope": document_scope or [""]},
+            ).mappings()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/agents/tools.py` around lines 86 - 108, Update list_documents to apply
document_scope in the SQL query before ordering and limiting, matching ids
extracted from the document metadata while preserving all documents when the
scope is empty. Select only the fields needed for downstream processing instead
of loading full data and task_context JSONB for unrelated rows, and remove the
redundant Python-side scope filtering.
app/agents/graph.py-50-60 (1)

50-60: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

_merge_by_key discards the newest items when the limit is reached.

list(merged.values())[:limit] keeps insertion order, and dict preserves the original position of an overwritten key. Once the evidence ledger reaches max_evidence, every new passage from search_evidence or inspect_document is dropped, while the oldest passages stay. The agent then re-queries, verification keeps failing because the new references are absent from the ledger, and the run consumes the remaining step budget before finishing as partial.

Keep the most recent items instead.

🐛 Proposed fix
     merged = {str(item.get(key)): item for item in existing}
     for item in incoming:
         merged[str(item.get(key))] = item
-    return list(merged.values())[:limit]
+    return list(merged.values())[-limit:] if limit > 0 else []
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/agents/graph.py` around lines 50 - 60, Update _merge_by_key so the limit
retains the most recent merged items rather than slicing from the beginning;
preserve deduplication by key and ensure newer incoming items replace older
entries while remaining eligible for the returned limit.
app/pipelines/doculens_pipeline.py-166-170 (1)

166-170: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Bound and validate the ingestion download.

DocumentUploadEvent.file_url reaches requests.get without URL validation, enabling SSRF. Redirects can bypass an initial host allowlist. response.content also loads the full body into memory, and this path does not enforce max_upload_bytes.

Allowlist the scheme and resolved host, disable unsafe redirects, then stream the response with a byte cap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/pipelines/doculens_pipeline.py` around lines 166 - 170, Update the
download logic in the event.file_url ingestion block to validate the URL scheme
and resolved host against the configured allowlist, disable redirects, and
enforce max_upload_bytes while streaming response chunks to local_path instead
of using response.content. Reject disallowed URLs, redirects, and responses
exceeding the byte cap before completing the write.

Source: Linters/SAST tools

app/agents/tools.py-74-130 (1)

74-130: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Add server-side document authorization.

document_scope comes directly from payload.document_ids, and an empty scope reads all documents. events has no owner or workspace field, and uploads do not record the authenticated user. Add ownership or workspace data at upload time, then enforce it for event and vector queries. Do not rely on the client-provided document_scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/agents/tools.py` around lines 74 - 130, Add server-derived ownership or
workspace metadata during document upload, then update
DocumentInvestigationTools methods including list_documents, search_evidence,
and inspect_document to constrain event and vector queries using the
authenticated server context. Treat payload.document_ids/document_scope only as
an additional filter within that authorized set, never as the authorization
source, and ensure an empty scope cannot expose all documents.
frontend/src/auth/ProtectedRoute.tsx-24-26 (1)

24-26: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

The bypass fails open when the runtime config is missing.

If serverConfig is undefined, !serverConfig?.session_auth_required evaluates to true and the route renders without a user. That state is reachable. SettingsProvider sets isLoaded to true in its finally block even when fetchRuntimeConfig rejects, and leaves serverConfig undefined. A failed or blocked /events/config request therefore unlocks every protected route in the UI.

The backend still enforces authorization on each API call, so this exposes an unauthenticated shell rather than data. Require an explicit config value before bypassing.

🔒️ Proposed fail-closed condition
-  if (serverConfig?.showcase_read_only || !serverConfig?.session_auth_required) {
+  if (serverConfig && (serverConfig.showcase_read_only || !serverConfig.session_auth_required)) {
     return <>{children}</>;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/auth/ProtectedRoute.tsx` around lines 24 - 26, Update the bypass
condition in ProtectedRoute so it only renders children when serverConfig exists
and explicitly indicates showcase_read_only or session_auth_required is false;
keep missing or failed runtime configuration fail-closed and require
authentication instead.
frontend/src/auth/AuthProvider.tsx-96-127 (1)

96-127: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Subscribe to auth state before the awaits, and respect cancelled.

Two defects come from the same cause. initialise registers onAuthStateChange only after awaiting fetchProfile and exchangeToken.

  1. exchangeToken re-throws on failure (line 92). At line 104 it is awaited inside the catch block, so the rejection escapes initialise. The finally block clears the loading flag, then lines 110-119 never run. The provider then has no auth-state subscription. A later SIGNED_IN or TOKEN_REFRESHED event is never exchanged, so the user stays signed out until a full page reload. void initialise() also discards the rejection, so no error is reported.
  2. The cleanup function on lines 123-126 can run before line 118 assigns unsubscribe. If the effect re-runs, the earlier subscription is never removed and leaks.

Register the subscription first, guard it with cancelled, and isolate the exchange failure.

🐛 Proposed fix for subscription resilience and cleanup race
     const initialise = async () => {
+      if (supabaseUrl && publishableKey) {
+        const supabase = getSupabaseClient(supabaseUrl, publishableKey);
+        const { data } = supabase.auth.onAuthStateChange((event, session) => {
+          if ((event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') && session?.access_token) {
+            void exchangeToken(session.access_token).catch(() => clearLocalSession(false));
+          }
+          if (event === 'SIGNED_OUT') clearLocalSession(false);
+        });
+        if (cancelled) {
+          data.subscription.unsubscribe();
+          return;
+        }
+        unsubscribe = () => data.subscription.unsubscribe();
+      }
+
       try {
         const profile = await fetchProfile();
         if (!cancelled) setUser(profile);
       } catch {
         if (supabaseUrl && publishableKey) {
           const supabase = getSupabaseClient(supabaseUrl, publishableKey);
           const { data } = await supabase.auth.getSession();
-          if (data.session?.access_token) await exchangeToken(data.session.access_token);
+          if (data.session?.access_token) {
+            await exchangeToken(data.session.access_token).catch(() => undefined);
+          }
         }
       } finally {
         if (!cancelled) setIsLoading(false);
       }
-
-      if (supabaseUrl && publishableKey) {
-        const supabase = getSupabaseClient(supabaseUrl, publishableKey);
-        const { data } = supabase.auth.onAuthStateChange((event, session) => {
-          if ((event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') && session?.access_token) {
-            void exchangeToken(session.access_token).catch(() => clearLocalSession(false));
-          }
-          if (event === 'SIGNED_OUT') clearLocalSession(false);
-        });
-        unsubscribe = () => data.subscription.unsubscribe();
-      }
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/auth/AuthProvider.tsx` around lines 96 - 127, Update the
initialise flow in AuthProvider so the onAuthStateChange subscription is created
before any awaited fetchProfile or exchangeToken calls, assigning unsubscribe
synchronously for reliable cleanup. Guard the subscription callback and
initialization work with cancelled, and isolate exchangeToken failures with
local handling so a rejection cannot abort initialization or prevent the
subscription. Preserve setIsLoading cleanup and ensure callbacks do nothing
after cancellation.
frontend/src/api/client.ts-243-260 (1)

243-260: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle malformed frames and release the reader.

Wrap the read loop in try/finally and call reader.releaseLock() in finally. Catch JSON.parse errors per frame so one malformed data: frame does not terminate the stream. The backend emits explicit event: fields, so no fallback event name is required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/api/client.ts` around lines 243 - 260, The stream read loop must
release the reader even when processing fails and must continue after malformed
individual frames. Wrap the loop in try/finally with reader.releaseLock() in
finally, and handle JSON.parse failures per frame without terminating subsequent
frame processing. Keep event types sourced exclusively from the explicit event:
line.
🟡 Minor comments (9)
README.md-292-294 (1)

292-294: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Correct the deployment-scope limitation.

Line 290 says that the included deployment manifest is read-only. Lines 163-170 describe a writable Railway application with durable uploads. docs/deploy-railway.md also configures uploads and verifies write operations.

State that the Cloudflare showcase is read-only. State that the Railway profile is a writable single-workspace deployment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 292 - 294, Update the deployment-scope wording in
README.md to distinguish the Cloudflare showcase as read-only from the Railway
profile as a writable single-workspace deployment, aligning with the deployment
manifest and docs/deploy-railway.md. Replace the conflicting limitation
statement without changing the surrounding investigation or retrieval-metrics
descriptions.
app/doc_utils/embedding.py-90-90 (1)

90-90: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Replace UUID v1 identifiers with random UUIDs.

UUID v1 encodes creation time and can expose a host node identifier. These IDs are persisted and returned through vector retrieval paths. Use uuid4() unless time-sortable identifiers are a defined requirement.

  • app/doc_utils/embedding.py#L90-L90: replace uuid1() with uuid4() for chunk IDs.
  • app/utils/insert_vectors.py#L42-L42: replace uuid1() with uuid4() for inserted vector IDs.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/doc_utils/embedding.py` at line 90, Replace uuid1() with uuid4() when
generating chunk IDs in app/doc_utils/embedding.py lines 90-90 and inserted
vector IDs in app/utils/insert_vectors.py lines 42-42, preserving the existing
ID generation flow.
app/main.py-91-94 (1)

91-94: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

The log message promises readiness behavior that the endpoint does not implement.

Line 94 states that readiness will remain unhealthy after a dependency initialization failure. readiness only runs SELECT 1. If initialize_dependencies fails while the database is reachable, for example when vector store index creation fails, the endpoint still returns {"status": "ready"} and Railway routes traffic to a partially initialized process.

Record the failure in application state and report it from readiness.

🐛 Proposed fix
         try:
             initialize_dependencies(runtime)
+            application.state.dependencies_ready = True
         except Exception:
+            application.state.dependencies_ready = False
             logger.exception("Dependency initialization failed; readiness will remain unhealthy")
     def readiness() -> dict[str, str]:
+        if getattr(application.state, "dependencies_ready", True) is False:
+            raise HTTPException(
+                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+                detail="Dependency initialization failed.",
+            )
         try:
             with engine.connect() as connection:

Also applies to: 143-154

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/main.py` around lines 91 - 94, Update the dependency initialization flow
around initialize_dependencies to record failures in shared application state
instead of only logging them. Update readiness to check that state and return an
unhealthy response when initialization failed, while preserving the existing
database connectivity check for successful initialization; remove or revise the
misleading log claim so the endpoint behavior matches the recorded state.
app/agents/graph.py-83-90 (1)

83-90: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A report with no findings reports full citation coverage.

If report.findings is empty and any evidence exists, coverage becomes 1.0 and valid becomes True. finalize then marks the investigation completed, and evaluate_investigation records citation_coverage == 1.0. An empty report passes verification.

Treat an empty findings list as unverified.

🐛 Proposed fix
     finding_count = len(report.findings)
-    coverage = cited_findings / finding_count if finding_count else (1.0 if evidence else 0.0)
+    coverage = cited_findings / finding_count if finding_count else 0.0
     return VerificationResult(
-        valid=bool(evidence) and not unsupported and not unknown,
+        valid=bool(evidence) and finding_count > 0 and not unsupported and not unknown,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/agents/graph.py` around lines 83 - 90, Update the verification logic
around finding_count and VerificationResult so an empty report.findings list is
always treated as unverified, regardless of whether evidence exists. Ensure
coverage and valid do not allow an empty report to pass, while preserving the
existing behavior for reports containing findings.
app/pipelines/doculens_pipeline.py-171-175 (1)

171-175: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against a missing storage reference.

materialize accepts reference: str, but event.filename is optional. Line 162 already handles a missing filename for source_name. If an event carries neither file_url nor filename, Path(None) raises TypeError inside the storage layer instead of a clear pipeline error.

🛡️ Proposed guard
         else:
+            if not event.filename:
+                raise ValueError("document_upload event requires filename or file_url.")
             local_path, temporary_local_path = get_document_storage().materialize(
                 event.filename,
                 destination_dir=ingestion_dir,
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/pipelines/doculens_pipeline.py` around lines 171 - 175, Guard the storage
materialization branch around get_document_storage().materialize so it is only
called when event.filename is present; when both file_url and filename are
missing, raise a clear pipeline error instead of passing None to the storage
layer. Preserve the existing source_name handling and file_url path behavior.
app/evaluation/investigation.py-58-73 (1)

58-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle investigations that produced no report or verification.

InvestigationState initializes report and verification to {}. A failed run or a needs_input run keeps them empty. InvestigationReport.model_validate({}) then raises ValidationError, so the evaluation suite crashes instead of scoring the run as a failure.

Score an empty report and empty verification as zero.

🐛 Proposed fix
-    report = InvestigationReport.model_validate(state["report"])
-    report_text = " ".join(
-        [
-            report.title,
-            report.executive_summary,
-            *(finding.claim for finding in report.findings),
-            *(finding.explanation for finding in report.findings),
-        ]
-    ).lower()
+    raw_report = state.get("report") or {}
+    report = InvestigationReport.model_validate(raw_report) if raw_report else None
+    report_text = (
+        " ".join(
+            [
+                report.title,
+                report.executive_summary,
+                *(finding.claim for finding in report.findings),
+                *(finding.explanation for finding in report.findings),
+            ]
+        ).lower()
+        if report
+        else ""
+    )
@@
-    verification = VerificationResult.model_validate(state["verification"])
+    raw_verification = state.get("verification") or {}
+    verification = (
+        VerificationResult.model_validate(raw_verification)
+        if raw_verification
+        else VerificationResult(valid=False, citation_coverage=0.0)
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/evaluation/investigation.py` around lines 58 - 73, Update the evaluation
flow around InvestigationReport.model_validate and
VerificationResult.model_validate to handle empty state["report"] or
state["verification"] without raising validation errors. Treat either empty
result as a failed run with a score of zero, while preserving the existing
validation and scoring behavior when both outputs are present.
frontend/src/pages/LoginPage.tsx-31-31 (1)

31-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restore the protected route after sign-in. ProtectedRoute stores the requested location in state.from, but LoginPage.tsx ignores it and always redirects authenticated users to /app. Use state.from as the redirect target, with /app as the fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/LoginPage.tsx` at line 31, Update the authenticated
redirect in LoginPage so it reads the stored location from navigation state,
using state.from as the target and /app as the fallback. Preserve the existing
serverConfig?.showcase_read_only condition and replace navigation behavior.
app/api/endpoint.py-95-99 (1)

95-99: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document the local processing contract.

task_id is not consumed by the current frontend or application. In local mode, it is local-<event_id>, not a Celery task ID. Document this format and direct clients to GET /events/{event_id} for task_context. The 202 response is returned before BackgroundTasks runs, and pipeline failures are not recorded. Add explicit failure logging and status handling if clients must observe local failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/endpoint.py` around lines 95 - 99, Document the local-mode contract
near the endpoint logic: state that task_id uses the local-<event_id> format
rather than a Celery ID, direct clients to GET /events/{event_id} for
task_context, and note that the 202 response precedes BackgroundTasks execution.
If local failures must be observable, add explicit failure logging and status
handling around process_incoming_event.run.
frontend/src/api/types.ts-284-293 (1)

284-293: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Handle empty report and verification sentinels

The backend initializes state.report and state.verification as {}. The API can return these values for queued or failed investigations. Since {} is truthy, ?? null does not prevent ReportPanel from rendering. Guard the report shape before accessing findings, recommended_actions, or limitations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/api/types.ts` around lines 284 - 293, Update
InvestigationDetail.state report and verification handling so empty-object
sentinels from queued or failed investigations are treated as absent values
rather than valid data. Validate the expected report/verification shape before
exposing these fields to ReportPanel or accessing report properties such as
findings, recommended_actions, and limitations, while preserving valid populated
responses.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: decfaebc-bbac-44c4-a971-3778206196fc

📥 Commits

Reviewing files that changed from the base of the PR and between 218caef and d27c1d9.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (64)
  • .env.example
  • .github/workflows/backend-ci.yml
  • Makefile
  • README.md
  • app/agents/__init__.py
  • app/agents/graph.py
  • app/agents/model_client.py
  • app/agents/models.py
  • app/agents/tools.py
  • app/alembic/env.py
  • app/alembic/versions/20260730_0005_add_federated_identity.py
  • app/alembic/versions/20260730_0006_create_investigations.py
  • app/api/auth_router.py
  • app/api/endpoint.py
  • app/api/investigation_router.py
  • app/api/router.py
  • app/config/celery_config.py
  • app/config/database_config.py
  • app/config/settings.py
  • app/core/observability.py
  • app/database/database_utils.py
  • app/database/investigation.py
  • app/database/user.py
  • app/doc_utils/chunking.py
  • app/doc_utils/embedding.py
  • app/doc_utils/extraction.py
  • app/doc_utils/search.py
  • app/doc_utils/utils/tokenizer.py
  • app/evaluation/investigation.py
  • app/main.py
  • app/pipelines/doculens_pipeline.py
  • app/services/auth_service.py
  • app/services/document_storage.py
  • app/services/investigation_service.py
  • app/services/vector_store.py
  • app/start.sh
  • app/tasks/tasks.py
  • app/utils/insert_vectors.py
  • docker/Dockerfile.celery
  • docker/Dockerfile.railway
  • docker/docker-compose.yml
  • docs/deploy-railway.md
  • frontend/package.json
  • frontend/src/api/client.ts
  • frontend/src/api/types.ts
  • frontend/src/auth/AuthProvider.tsx
  • frontend/src/auth/ProtectedRoute.tsx
  • frontend/src/auth/supabase.ts
  • frontend/src/auth/types.ts
  • frontend/src/components/layout/AppShell.tsx
  • frontend/src/main.tsx
  • frontend/src/pages/InvestigationPage.tsx
  • frontend/src/pages/LoginPage.tsx
  • pyproject.toml
  • pyrightconfig.json
  • railway.toml
  • railway.worker.toml
  • tests/conftest.py
  • tests/test_api_endpoints.py
  • tests/test_auth_service.py
  • tests/test_investigation_agent.py
  • tests/test_investigation_evaluation.py
  • tests/test_lightweight_extraction.py
  • tests/test_settings.py

Comment on lines +80 to +84
def materialize(self, reference: str, *, destination_dir: Path) -> tuple[Path, bool]:
path = Path(reference)
if not path.exists():
raise FileNotFoundError(f"Stored document '{reference}' does not exist.")
return path.resolve(), False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

The ingestion path trusts caller-supplied source locations in the event payload. Both sites resolve a document source directly from persisted event fields (filename and file_url) with no allowlist or containment check, so a caller who can post an event controls what the worker reads.

  • app/services/document_storage.py#L80-L84: resolve the reference and reject any path that is not under self.root, matching the bucket-prefix check in S3DocumentStorage.materialize.
  • app/pipelines/doculens_pipeline.py#L166-L170: validate the URL scheme and host against an allowlist, and stream the response with a byte cap equal to max_upload_bytes.
📍 Affects 2 files
  • app/services/document_storage.py#L80-L84 (this comment)
  • app/pipelines/doculens_pipeline.py#L166-L170
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/document_storage.py` around lines 80 - 84, The local
materialization path in DocumentStorage.materialize must reject
caller-controlled references outside self.root after resolving the path,
matching S3DocumentStorage.materialize containment behavior; update
app/services/document_storage.py lines 80-84 accordingly. The remote ingestion
logic in app/pipelines/doculens_pipeline.py lines 166-170 must validate the URL
scheme and host against the configured allowlist and stream downloads with a
byte cap of max_upload_bytes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant