Skip to content

Add OC_URL annotations: clickable hyperlinks anchored to highlighted text#1648

Open
JSv4 wants to merge 17 commits into
mainfrom
claude/add-url-annotations-3C2ua
Open

Add OC_URL annotations: clickable hyperlinks anchored to highlighted text#1648
JSv4 wants to merge 17 commits into
mainfrom
claude/add-url-annotations-3C2ua

Conversation

@JSv4
Copy link
Copy Markdown
Collaborator

@JSv4 JSv4 commented May 14, 2026

Annotations carrying the new OC_URL label render as hyperlinks (underline +
external-link icon, pointer cursor) and open their link_url on click in both
the PDF viewer and the text/markdown viewer.

Backend: new Annotation.link_url URLField (max 2048, nullable) with
migration 0072. New add_url_annotation GraphQL mutation auto-ensures the
OC_URL label per-corpus via Corpus.ensure_label_and_labelset (mirrors the
OC_SECTION pattern). Both add_annotation and update_annotation now accept
link_url. Validation lives in a shared validate_link_url() helper invoked
from Annotation.save() unconditionally and from the GraphQL mutation /
DRF serializer layers — only http(s) and site-relative paths are allowed,
so javascript: / data: schemes are rejected before persistence.

Frontend: linkUrl propagates through RawServerAnnotationType, the
ServerSpanAnnotation / ServerTokenAnnotation classes, the primary
annotation fetch queries (GET_DOCUMENT_KNOWLEDGE_AND_ANNOTATIONS,
GET_ANNOTATIONS, GET_ANNOTATIONS_FOR_ANALYSIS, GET_ANNOTATIONS_FOR_CARDS,
and similar), and the create/update mutations. A new useCreateUrlAnnotation
hook fronts the addUrlAnnotation mutation; create/url-create handlers are
threaded as props (DocumentKnowledgeBase → DocumentViewer → PDF → PDFPage →
SelectionLayer; TxtAnnotatorWrapper → TxtAnnotator) so the renderers stay
decoupled from Apollo and remain unit-testable. The selection action menu
gains an "Add link…" entry in both PDF and text viewers, which opens a
shared CreateUrlAnnotationModal that prompts for a URL. The pencil icon on
an existing OC_URL annotation opens the URL-edit modal instead of the
label-edit modal. Holding Shift / Ctrl / Cmd while clicking a link
annotation falls back to the normal toggle-selection behaviour. External
URLs open in a new tab with noopener,noreferrer; site-relative paths
navigate in the current tab so the SPA router can resolve them. A defensive
isSafeUrl mirror of the backend allow-list lives in the renderer so a
stale cached unsafe URL still won't reach window.open.

…text

Annotations carrying the new OC_URL label render as hyperlinks (underline +
external-link icon, pointer cursor) and open their link_url on click in both
the PDF viewer and the text/markdown viewer.

Backend: new Annotation.link_url URLField (max 2048, nullable) with
migration 0072. New add_url_annotation GraphQL mutation auto-ensures the
OC_URL label per-corpus via Corpus.ensure_label_and_labelset (mirrors the
OC_SECTION pattern). Both add_annotation and update_annotation now accept
link_url. Validation lives in a shared validate_link_url() helper invoked
from Annotation.save() unconditionally and from the GraphQL mutation /
DRF serializer layers — only http(s) and site-relative paths are allowed,
so javascript: / data: schemes are rejected before persistence.

Frontend: linkUrl propagates through RawServerAnnotationType, the
ServerSpanAnnotation / ServerTokenAnnotation classes, the primary
annotation fetch queries (GET_DOCUMENT_KNOWLEDGE_AND_ANNOTATIONS,
GET_ANNOTATIONS, GET_ANNOTATIONS_FOR_ANALYSIS, GET_ANNOTATIONS_FOR_CARDS,
and similar), and the create/update mutations. A new useCreateUrlAnnotation
hook fronts the addUrlAnnotation mutation; create/url-create handlers are
threaded as props (DocumentKnowledgeBase → DocumentViewer → PDF → PDFPage →
SelectionLayer; TxtAnnotatorWrapper → TxtAnnotator) so the renderers stay
decoupled from Apollo and remain unit-testable. The selection action menu
gains an "Add link…" entry in both PDF and text viewers, which opens a
shared CreateUrlAnnotationModal that prompts for a URL. The pencil icon on
an existing OC_URL annotation opens the URL-edit modal instead of the
label-edit modal. Holding Shift / Ctrl / Cmd while clicking a link
annotation falls back to the normal toggle-selection behaviour. External
URLs open in a new tab with noopener,noreferrer; site-relative paths
navigate in the current tab so the SPA router can resolve them. A defensive
isSafeUrl mirror of the backend allow-list lives in the renderer so a
stale cached unsafe URL still won't reach window.open.
Comment thread config/graphql/annotation_mutations.py Fixed
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review — PR #1648: OC_URL Link Annotations

Overview

This is a well-designed, security-conscious feature that wires clickable hyperlinks through the full stack. The layered URL validation approach (model clean(), GraphQL mutation, serializer, and a frontend mirror in isSafeUrl) shows good defence-in-depth thinking. The prop-threading pattern for createUrlAnnotation is sensible for testability. A few issues to address before merge.


Bug — UpdateAnnotation.mutate() never receives link_url

File: config/graphql/annotation_mutations.py

link_url is added to UpdateAnnotation.Arguments, and the frontend sends it in every updateAnnotation call (including null to clear). But the diff shows no change to UpdateAnnotation.mutate() — the parameter is not in the method signature and the field is never written to the instance.

Result: clicking "Save link" on an existing OC_URL annotation will silently do nothing to the URL. The column stays whatever it was before.

# Arguments now has link_url, but mutate() needs to:
def mutate(root, info, id, ..., link_url=None):
    ...
    if link_url is not None:  # None = don't touch; "" = clear; str = set
        annotation.link_url = link_url if link_url else None
    annotation.save()

Bug — Double validate_link_url() call in save()

File: opencontractserver/annotations/models.py

save() calls self.clean() first (which already runs validate_link_url unconditionally), and then calls validate_link_url() again directly right after:

self.clean()  # ← validate_link_url() runs here if self.link_url is set

# link_url validation always runs, regardless of the JSON-validation flag...
if self.link_url:
    validate_link_url(self.link_url)  # ← runs again, redundant

The second call is harmless but the comment justifying it ("always runs regardless of JSON-validation flag") is already true of clean() — the validate_link_url block in clean() is unconditional, not gated behind VALIDATE_ANNOTATION_JSON. Remove the second call.


Magic values in AddUrlAnnotation.mutate()

File: config/graphql/annotation_mutations.py, lines ~155–158

color="#2563EB",
icon="link",
description="Click-through hyperlink annotation",

These should be constants (in opencontractserver/constants/annotations.py for backend, and the TS counterpart). CLAUDE.md explicitly calls out no magic numbers.


label_type flips per call to ensure_label_and_labelset

File: config/graphql/annotation_mutations.py, AddUrlAnnotation.mutate()

label = corpus.ensure_label_and_labelset(
    label_text=OC_URL_LABEL,
    label_type=annotation_type.value,   # TOKEN_LABEL or SPAN_LABEL
    ...
)

The first call for a corpus wins. If a PDF annotation creates the label as TOKEN_LABEL, subsequent text-document calls with SPAN_LABEL silently get the wrong label type back (because ensure_label_and_labelset is idempotent by text). This may or may not matter at render-time, but it's worth pinning to one canonical type (e.g. TOKEN_LABEL, matching OC_SECTION) or filtering by label_type in the lookup to avoid the ambiguity.


Frontend — linkUrl always sent in update even for non-URL annotations

File: frontend/src/components/annotator/hooks/AnnotationHooks.tsx, useUpdateAnnotation

linkUrl: annotation.linkUrl ?? null,

This always includes linkUrl: null in the mutation variables for every annotation update, even for annotations that never had a URL. That's functional but it's noisy, and once the backend mutate() bug above is fixed it will write NULL on every update. Consider only including linkUrl in the variables when updating a URL annotation (or when the field is explicitly being cleared).


Minor — Inline style in CreateUrlAnnotationModal

File: frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx

The modal uses inline style objects throughout (hardcoded pixel values, hex colours). The rest of the codebase uses styled-components. Consider moving these to a styled component or at minimum extracting the colour tokens to constants so they pick up any future theme changes.


Missing backend test coverage

The PR adds a new mutation (AddUrlAnnotation), a new model method path (validate_link_url called in clean()/save()), and a new label auto-creation flow (ensure_label_and_labelset for OC_URL). None of these have new backend tests. Worth adding at minimum:

  • validate_link_url unit tests covering javascript:, data:, empty string, valid http/https, and site-relative paths
  • AddUrlAnnotation mutation test (creates annotation + auto-creates label)
  • UpdateAnnotation with link_url (set, clear, rejection of unsafe scheme)

Positives worth noting

  • The noopener,noreferrer on window.open and the frontend isSafeUrl mirror are exactly the right approach — defence in depth so a stale cache can't bypass backend validation.
  • Prop-threading createUrlAnnotation down to SelectionLayer / TxtAnnotator (rather than calling the hook directly) makes those components independently testable — good design.
  • The modifier-key fallback (Shift/Ctrl/Cmd to keep selection behaviour) is a clean UX detail that authors will appreciate.
  • transaction.atomic() wrapping ensure_label_and_labelset + annotation.save() in AddUrlAnnotation is correct.
  • The REQUEST_ADD_URL_ANNOTATION mutation and TypeScript types are complete and match each other.

JSv4 added 6 commits May 13, 2026 20:48
types-requests upgrade made requests.post/get expect MutableMapping[str, str | bytes],
which is invariant in values, so dict[str, str] no longer matches. Broaden header
annotations and maybe_add_cloud_run_auth() signature accordingly.
Covers validate_link_url (model + GraphQL), addUrlAnnotation (happy path,
label idempotency, IDOR, unsafe schemes), addAnnotation linkUrl argument,
and updateAnnotation linkUrl clearing/validation.
Unit tests:
  - urlAnnotation.ts: isUrlAnnotation matrix + openAnnotationUrl
    allow-list (http(s)/relative), refusal of javascript: and data: URLs
  - useCreateUrlAnnotation hook: corpus-missing short-circuit, blank URL
    short-circuit, success path persistence with server linkUrl, server
    ok=false path

Component tests (Playwright):
  - CreateUrlAnnotationModal: visibility, create/edit headers, empty +
    unsafe scheme validation, onConfirm/onCancel callbacks, Enter to
    submit, trimmed URL, site-relative paths.
…lidation in clean/save

Constants:
  - Add OC_URL_LABEL_COLOR / OC_URL_LABEL_ICON / OC_URL_LABEL_DESCRIPTION
    in opencontractserver/constants/annotations.py and use them in
    AddUrlAnnotation.mutate (no more magic values).

Model:
  - Annotation.clean() now validates link_url BEFORE the JSON-validation
    early-return so the check is always run regardless of the
    VALIDATE_ANNOTATION_JSON flag.
  - Annotation.save() only re-validates link_url when clean() was
    skipped (VALIDATE_ANNOTATION_JSON=False), removing the redundant
    double-call.
Comment thread config/graphql/annotation_mutations.py Fixed
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review: OC_URL Link Annotations (#1648)

Overview

This is a well-structured feature that adds clickable hyperlink annotations anchored to highlighted text in both the PDF and text viewers. The security considerations are clearly thought through (defense-in-depth URL validation on both backend and frontend, noopener,noreferrer on new-tab opens), and test coverage is solid across backend, unit, and component layers.


Issues

Bug: URLField rejects site-relative paths during full_clean()

link_url is declared as django.db.models.URLField, which attaches Django's URLValidator to the field. That validator requires an absolute URL with a scheme — it will reject any site-relative path like /corpus/foo with a ValidationError whenever full_clean() / clean_fields() runs.

The custom validate_link_url() helper (correctly) allows relative paths, but it lives in clean() while clean_fields() runs the field-level URLField validator first. If Annotation.save() calls full_clean() (as the PR description implies), any saved relative path would fail at the field level before your own validator even sees it.

Fix: use CharField instead of URLField (you own the validation logic already), or pass validators=[] to suppress the built-in validator and rely solely on validate_link_url.

except Exception too broad; str(ValidationError) produces ugly output

In both AddAnnotation.mutate() and AddUrlAnnotation.mutate():

except Exception as exc:
    return AddAnnotation(ok=False, annotation=None, message=str(exc))

validate_link_url only raises ValidationError. Catching bare Exception silently swallows programming errors (e.g. a TypeError in the validator itself). Additionally str(ValidationError({"link_url": "..."})) gives ["{'link_url': ['...']}"] — a Python dict string. Prefer except ValidationError and return a fixed human-readable string.

Redundant link_url or None in AddAnnotation.mutate()

link_url=link_url or None,

The if link_url: guard above already ensures link_url is truthy at this point. link_url or None is dead code — just use link_url=link_url.


Minor Issues

Two separate lucide-react imports in TxtAnnotator.tsx — the Link2 import added by this PR should be merged with the existing lucide import on the line above.

REQUEST_UPDATE_ANNOTATION response fragment doesn't include linkUrl — the mutation sends linkUrl and the backend persists it, but the hook reconstructs the updated annotation from local state rather than the server-confirmed value. If the server ever rejects the URL the client would still display the old value. Adding linkUrl to the response fields would close this gap.

Asymmetric createUrlAnnotation wiring (architecture note, not a bug) — the PDF path prop-drills createUrlAnnotationHandler from DocumentKnowledgeBase down to SelectionLayer with a corpus guard at the top. The text path sources it directly from useCreateUrlAnnotation() inside TxtAnnotatorWrapper, relying on the hook's own null-corpus guard. Both are safe today, but the inconsistency makes control flow harder to follow.


Positives

  • Multi-layer security is well-designed: validation in Annotation.clean(), in both mutations, in the DRF serializer, and a mirrored client-side allow-list in the renderer — safe even with a stale cached annotation.
  • The modifier-key escape hatch (Shift/Ctrl/Cmd falls back to toggle-selection) is a thoughtful UX detail for authors who need to re-edit or delete a link annotation.
  • useCreateUrlAnnotation is correctly structured as a hook rather than wiring Apollo directly into renderers, keeping renderers unit-testable.
  • Backend tests in test_url_annotation.py and frontend unit tests in AnnotationHooks.test.tsx / urlAnnotation.test.ts explicitly cover security edge cases (javascript:, data:, empty, ok=false).
  • noopener,noreferrer on new-tab opens and window.location.assign for SPA-relative paths are both correct.

@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

placeholder test comment - will be replaced

@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

OpenContracts is an MIT-licensed enterprise document analytics platform for PDFs and text-based formats. It features a Django/GraphQL backend with PostgreSQL + pgvector, a React/TypeScript frontend with Jotai state management, and pluggable document processing pipelines powered by machine learning models.

Baseline Commit Rules

  1. Always ensure all affected (or new) tests pass - backend tests suite should only be run in its entirety for good reason as it takes 30+ minutes.
  2. Always make sure typescript compiles and pre-commits pass before committing new code.
  3. Never credit Claude or Claude Code in commit messages, PR messages, comments, or any other artifacts. This includes Co-Authored-By lines, "Generated by Claude", etc.

Essential Commands

Backend (Django)

# Run backend tests (sequential, use --keepdb to speed up subsequent runs)
docker compose -f test.yml run django python manage.py test --keepdb

# Run backend tests in PARALLEL (recommended - ~4x faster)
# Uses pytest-xdist with 4 workers, --dist loadscope keeps class tests together
docker compose -f test.yml run django pytest -n 4 --dist loadscope

# Run parallel tests with auto-detected worker count (uses all CPU cores)
docker compose -f test.yml run django pytest -n auto --dist loadscope

# Run parallel tests with fresh databases (first run or after schema changes)
docker compose -f test.yml run django pytest -n 4 --dist loadscope --create-db

# Run specific test file
docker compose -f test.yml run django python manage.py test opencontractserver.tests.test_notifications --keepdb

# Run specific test file in parallel
docker compose -f test.yml run django pytest opencontractserver/tests/test_notifications.py -n 4 --dist loadscope

# Run specific test class/method
docker compose -f test.yml run django python manage.py test opencontractserver.tests.test_notifications.TestNotificationModel.test_create_notification --keepdb

# Apply database migrations
docker compose -f local.yml run django python manage.py migrate

# Create new migration
docker compose -f local.yml run django python manage.py makemigrations

# Django shell
docker compose -f local.yml run django python manage.py shell

# Code quality (runs automatically via pre-commit hooks)
pre-commit run --all-files

Frontend (React/TypeScript)

cd frontend

# Start development server (proxies to Django on :8000)
yarn start

# Run unit tests (Vitest) - watches by default
yarn test:unit

# Run component tests (Playwright) - CRITICAL: Use --reporter=list to prevent hanging
yarn test:ct --reporter=list

# Run component tests with grep filter
yarn test:ct --reporter=list -g "test name pattern"

# Run E2E tests
yarn test:e2e

# Coverage reports (unit tests via Vitest, component tests via Playwright + Istanbul)
yarn test:coverage:unit
yarn test:coverage:ct

# Linting and formatting
yarn lint
yarn fix-styles

# Build for production
yarn build

# Preview production build locally
yarn serve

Production Deployment

# CRITICAL: Always run migrations FIRST in production
docker compose -f production.yml --profile migrate up migrate

# Then start main services
docker compose -f production.yml up

High-Level Architecture

Backend Architecture

Stack: Django 4.x + GraphQL (Graphene) + PostgreSQL + pgvector + Celery

Key Patterns:

  1. GraphQL Schema Organization:

    • config/graphql/graphene_types.py - All GraphQL type definitions
    • config/graphql/queries.py - Query resolvers
    • config/graphql/*_mutations.py - Mutation files (organized by feature)
    • config/graphql/schema.py - Schema composition
  2. Permission System (CRITICAL - see docs/permissioning/consolidated_permissioning_guide.md):

    • Annotations & Relationships: NO individual permissions - inherited from document + corpus
    • Documents & Corpuses: Direct object-level permissions via django-guardian
    • Analyses & Extracts: Hybrid model (own permissions + corpus permissions + document filtering)
    • Formula: Effective Permission = MIN(document_permission, corpus_permission)
    • Structural items are ALWAYS read-only except for superusers
    • Use Model.objects.visible_to_user(user) pattern (NOT resolve_oc_model_queryset - DEPRECATED)
  3. AnnotatePermissionsForReadMixin:

    • Most GraphQL types inherit this mixin (see config/graphql/permissioning/permission_annotator/mixins.py)
    • Adds my_permissions, is_published, object_shared_with fields
    • Requires model to have guardian permission tables ({model}userobjectpermission_set)
    • Notifications use simple ownership model and DON'T use this mixin
  4. Django Signal Handlers:

    • Automatic notification creation on model changes (see opencontractserver/notifications/signals.py)
    • Must be imported in app's apps.py ready() method
    • Use _skip_signals attribute to prevent duplicate notifications in tests
  5. Pluggable Parser Pipeline:

    • Base classes in opencontractserver/pipeline/base/
    • Parsers, embedders, thumbnailers auto-discovered and registered
    • Multiple backends: Docling (ML-based), LlamaParse, Text
    • All convert to unified PAWLs format for frontend
  6. Agent Tool Architecture (see docs/architecture/llms/README.md):

    • CoreTool (framework-agnostic) → PydanticAIToolWrapper (pydantic-ai specific)
    • All production tools MUST be async (a-prefixed in core_tools.py). The wrapper supports sync functions for lightweight helpers/tests but does NOT wrap them in a thread pool — sync ORM calls will raise SynchronousOnlyOperation.
    • Tool fault tolerance (issue Agent Tools Not Fault Tolerant #820): operational exceptions are caught and returned as error strings to the LLM; security exceptions (PermissionError, ToolConfirmationRequired) propagate.
    • Pre-execution checks run on every call (not cached): permission validation, resource ID validation, approval gates.

Frontend Architecture

Stack: React 18 + TypeScript + Apollo Client + Jotai (atoms) + PDF.js + Vite

Key Patterns:

  1. State Management - Jotai Atoms:

    • Global state via atoms in frontend/src/atoms/ (NOT Redux/Context)
    • Key atoms: selectedFolderIdAtom, folderCorpusIdAtom, selectedMessageIdAtom
    • Derived atoms automatically update when dependencies change
    • Apollo reactive vars in frontend/src/graphql/cache.ts for UI state
    • AuthGate pattern ensures auth completes before rendering
  2. Central Routing System (see docs/frontend/routing_system.md):

    • Single source of truth: frontend/src/routing/CentralRouteManager.tsx
    • URL paths → Entity resolution via GraphQL slug queries
    • URL params ↔ Reactive vars (bidirectional sync)
    • Components consume state via reactive vars, never touch URLs directly
    • Deep linking and canonical redirects handled automatically
  3. PDF Annotation System (see .cursor/rules/pdf-viewer-and-annotator-architecture.mdc):

    • Virtualized rendering: Only visible pages (+overscan) rendered for performance
    • Binary search to find visible page range (O(log n))
    • Height caching per zoom level
    • Two-phase scroll-to-annotation system
    • Dual-layer architecture: Document layer (annotations) + Knowledge layer (summaries)
  4. Unified Filtering Architecture:

    • useVisibleAnnotations hook provides single-source-of-truth filtering for both annotations and relationship-connected annotations
    • Reads from Jotai atoms via useAnnotationDisplay, useAnnotationControls, and useAnnotationSelection (from UISettingsAtom)
    • Ensures consistency across all components
    • Forced visibility for selected items and their relationship connections
  5. Component Testing (see .cursor/rules/test-document-knowledge-base.mdc):

    • ALWAYS mount components through test wrappers (e.g., DocumentKnowledgeBaseTestWrapper)
    • Wrapper provides: MockedProvider + InMemoryCache + Jotai Provider + asset mocking
    • Use --reporter=list flag to prevent hanging
    • Increase timeouts (20s+) for PDF rendering in Chromium
    • GraphQL mocks must match variables EXACTLY (null vs undefined matters)
    • Mock same query multiple times for refetches
    • Use page.mouse for PDF canvas interactions (NOT locator.dragTo)
    • Add settle time after drag operations (500ms UI, 1000ms Apollo cache)
  6. Development Server Configuration:

    • Vite dev server on :3000 proxies to Django on :8000
    • WebSocket proxy for /wsws://localhost:8000
    • GraphQL proxy for /graphqlhttp://localhost:8000
    • REST API proxy for /apihttp://localhost:8000
    • Auth0 optional via REACT_APP_USE_AUTH0 environment variable

Data Flow Architecture

Document Processing:

  1. Upload → Parser Selection (Docling/LlamaParse/Text)
  2. Parser generates PAWLs JSON (tokens with bounding boxes)
  3. Text layer extracted from PAWLs
  4. Annotations created for structure (headers, sections, etc.)
  5. Relationships detected between elements
  6. Vector embeddings generated for search

GraphQL Permission Flow:

  1. Query resolver filters objects with .visible_to_user(user)
  2. GraphQL types resolve my_permissions via AnnotatePermissionsForReadMixin
  3. Frontend uses permissions to enable/disable UI features
  4. Mutations check permissions and return consistent errors to prevent IDOR

Critical Security Patterns

  1. IDOR Prevention:

    • Query by both ID AND user-owned field: Model.objects.get(pk=pk, recipient=user)
    • Return same error message whether object doesn't exist or belongs to another user
    • Prevents enumeration via timing or different error messages
  2. Permission Checks:

    • NEVER trust frontend - always check server-side
    • Use visible_to_user() manager method for querysets
    • Check user_has_permission_for_obj() for individual objects (in opencontractserver.utils.permissioning)
  3. XSS Prevention:

    • User-generated content in JSON fields must be escaped on frontend
    • GraphQL's GenericScalar handles JSON serialization safely
    • Document this requirement in resolver comments

Critical Concepts

  1. No dead code - when deprecating or replacing code, always try to fully replace older code and, once it's no longer in use, delete it and related texts.
  2. DRY - please always architect code for maximal dryness and always see if you can consolidate related code and remove duplicative code.
  3. Single Responsibility Principle - Generally, ensure that each module / script has a single purpose or related purpose.
  4. No magic numbers - we have constants files in opencontractserver/constants/ (backend) and frontend/src/assets/configurations/constants.ts (frontend). Use them for any hardcoded values.
  5. Don't touch old tests without permission - if pre-existing tests fail after changes, try to identify why and present user with root cause analysis. If the test logic is correct but expectations need updating due to intentional behavior changes, document the change clearly.
  6. Utility functions belong in utility files:
    • Before writing new utilities: Check existing utility files first (frontend/src/utils/, opencontractserver/utils/)
    • When reviewing code: If you find utility functions defined inline in components/views, check if they already exist in utility files. If not, consider whether they should be extracted there for reuse.
    • Frontend utilities: frontend/src/utils/formatters.ts (formatting), frontend/src/utils/files.ts (file operations), etc.
    • Backend utilities: opencontractserver/utils/ contains permissioning, PDF processing, and other shared utilities

Testing Patterns

Manual Test Scripts

Location: docs/test_scripts/

When performing manual testing (e.g., testing migrations, verifying database state, testing API endpoints interactively), always document the test steps in a markdown file under docs/test_scripts/. These scripts will later be used to build automated integration tests.

Format:

# Test: [Brief description]

## Purpose
What this test verifies.

## Prerequisites
- Required state (e.g., "migration at 0058")
- Required data (e.g., "at least one document exists")

## Steps
1. Step one with exact command
   ```bash
   docker compose -f local.yml run --rm django python manage.py shell -c "..."
  1. Step two...

Expected Results

  • What should happen after each step
  • Success criteria

Cleanup

Commands to restore original state if needed.


**When to document**:
- Migration testing (rollback, create test data, migrate forward)
- Database constraint validation
- Race condition verification
- Any manual verification requested during code review

### Backend Tests

**Location**: `opencontractserver/tests/`

**Parallel Testing** (pytest-xdist):
- Run with `-n 4` (or `-n auto`) for parallel execution across workers
- Use `--dist loadscope` to keep tests from the same class on the same worker (respects `setUpClass`)
- Each worker gets its own database (test_db_gw0, test_db_gw1, etc.)
- Use `@pytest.mark.serial` to mark tests that cannot run in parallel
- First run or after DB schema changes: add `--create-db` flag

**Patterns**:
- Use `TransactionTestCase` for tests with signals/asynchronous behavior
- Use `TestCase` for faster tests without transaction isolation
- Clear auto-created notifications when testing moderation: `Notification.objects.filter(recipient=user).delete()`
- Use `_skip_signals` attribute on instances to prevent signal handlers during fixtures

### Frontend Component Tests

**Location**: `frontend/tests/`

**Critical Requirements**:
- Mount through test wrappers that provide all required context
- GraphQL mocks must match query variables exactly
- Include mocks for empty-string variants (unexpected boot calls)
- Wait for visible evidence, not just network-idle
- Use `page.mouse` for PDF canvas interactions (NOT `locator.dragTo`)
- Add settle time after drag operations (500ms UI, 1000ms Apollo cache)

**Test Wrapper Pattern**:
```typescript
// ALWAYS use wrappers, never mount components directly
const component = await mount(
  <DocumentKnowledgeBaseTestWrapper corpusId="corpus-1" documentId="doc-1">
    <DocumentKnowledgeBase />
  </DocumentKnowledgeBaseTestWrapper>
);

Automated Documentation Screenshots

Location: docs/assets/images/screenshots/auto/ (output) | frontend/tests/utils/docScreenshot.ts (utility)

Screenshots for documentation are captured during Playwright component tests and pushed back to a PR branch by the screenshots.yml workflow. The workflow is manual / on-demand only (workflow_dispatch) — it does not auto-trigger on PR push or merge. Run it from the Actions tab or via gh workflow run "Update Documentation Screenshots" -f pr_number=<N> when you want to refresh the screenshots on a PR.

How it works:

  1. Import docScreenshot from ./utils/docScreenshot in any .ct.tsx test file
  2. Call await docScreenshot(page, "area--component--state") after the component reaches the desired visual state
  3. Reference the image in markdown: ![Alt text](../assets/images/screenshots/auto/area--component--state.png)
  4. Trigger the screenshot workflow on demand for the PR; it captures screenshots and commits any changes back to the PR branch

Naming convention (-- separates segments, - within words):

Segment Purpose Examples
area Feature area landing, badges, corpus, versioning, annotations
component Specific view or component hero-section, celebration-modal, list-view
state Visual state captured anonymous, with-data, empty, auto-award

At least 2 segments required, 3 recommended. All lowercase alphanumeric with single hyphens.

Example:

import { docScreenshot } from "./utils/docScreenshot";

// After the component renders and assertions pass:
await docScreenshot(page, "badges--celebration-modal--auto-award");

Rules:

  • Place docScreenshot() calls AFTER assertions that confirm the desired visual state
  • The filename IS the contract between tests and docs — keep names stable
  • Never manually edit files in docs/assets/images/screenshots/auto/ — they are overwritten by CI
  • Manually curated screenshots stay in docs/assets/images/screenshots/ (parent directory)

Release Screenshots (Point-in-Time)

For release notes, use releaseScreenshot to capture screenshots that are locked in amber — they show the UI at a specific release and never change.

Location: docs/assets/images/screenshots/releases/{version}/ (output)

import { releaseScreenshot } from "./utils/docScreenshot";

await releaseScreenshot(page, "v3.0.0.b3", "landing-page", { fullPage: true });

Key differences from docScreenshot:

  • Output: docs/assets/images/screenshots/releases/{version}/{name}.png
  • Write-once: If the file already exists, the function is a no-op (won't overwrite)
  • CI never touches the releases/ directory
  • Name is a simple kebab-case string (no -- segment convention)
  • Version must match v{major}.{minor}.{patch} format (with optional suffix)

When to use which:

  • docScreenshot → README, quickstart, guides (always fresh)
  • releaseScreenshot → Release notes (frozen at release time)

Authenticated Playwright Testing (Live Frontend Debugging)

When you need to interact with the running frontend as an authenticated user (e.g., debugging why a query returns empty results), use Django admin session cookies to authenticate GraphQL requests.

Architecture context: The frontend uses Auth0 for authentication, but the Django backend also accepts session cookie auth. Apollo Client sends GraphQL requests directly to http://localhost:8000/graphql/ (cross-origin from the Vite dev server at localhost:5173). Since fetch defaults to credentials: 'same-origin', browser cookies aren't sent cross-origin. The workaround is to inject the session cookie into request headers via Playwright's route interception.

Step 1: Set a password for the superuser (one-time setup):

docker compose -f local.yml exec django python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.filter(is_superuser=True).first()
u.set_password('testpass123')
u.save()
print(f'Password set for {u.username}')
"

Step 2: Playwright script pattern:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const context = await browser.newContext();
  const page = await context.newPage();

  // Collect console messages for debugging
  const consoleMsgs = [];
  page.on('console', msg => consoleMsgs.push('[' + msg.type() + '] ' + msg.text()));

  // 1. Login to Django admin to get session cookie
  await page.goto('http://localhost:8000/admin/login/');
  await page.fill('#id_username', '<superuser-username>');
  await page.fill('#id_password', 'testpass123');
  await page.click('input[type=submit]');
  await page.waitForTimeout(2000);

  // 2. Extract the session cookie
  const cookies = await context.cookies();
  const sessionCookie = cookies.find(c => c.name === 'sessionid');

  // 3. Intercept GraphQL requests to inject the session cookie
  //    (needed because Apollo sends cross-origin requests to :8000)
  await page.route('**/graphql/**', async (route) => {
    const headers = {
      ...route.request().headers(),
      'Cookie': 'sessionid=' + sessionCookie.value,
    };
    await route.continue({ headers });
  });

  // 4. Navigate to the frontend page under test
  await page.goto('http://localhost:5173/extracts');
  await page.waitForTimeout(5000);

  // 5. Inspect results
  const bodyText = await page.textContent('body');
  console.log(bodyText);

  await browser.close();
})();

Run from the frontend directory (where playwright is a dependency):

cd frontend && node /path/to/script.js

Key details:

  • The Django admin login sets a sessionid cookie for localhost
  • page.route('**/graphql/**') intercepts Apollo's requests to localhost:8000/graphql/ and injects the cookie header
  • The AuthGate will still show anonymous state (no Auth0 session), but GraphQL queries execute as the authenticated Django user
  • This is useful for verifying backend query results, permission filtering, and data flow through the real frontend
  • For verifying just the GraphQL response without the full frontend, curl with the session cookie also works:
    curl -s -X POST http://localhost:8000/graphql/ \
      -H "Content-Type: application/json" \
      -H "Cookie: sessionid=<session-key>" \
      -d '{"query":"query { extracts { edges { node { id name } } } }"}' | python3 -m json.tool

Alternative — create a session programmatically (no admin login needed):

docker compose -f local.yml exec django python manage.py shell -c "
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.filter(is_superuser=True).first()
session = SessionStore()
session['_auth_user_id'] = str(user.pk)
session['_auth_user_backend'] = 'django.contrib.auth.backends.ModelBackend'
session['_auth_user_hash'] = user.get_session_auth_hash()
session.save()
print(f'Session key: {session.session_key}')
"

Then use the printed session key directly in curl or Playwright route interception.

Documentation Locations

  • Permissioning: docs/permissioning/consolidated_permissioning_guide.md
  • Frontend Routing: docs/frontend/routing_system.md
  • Frontend Auth Flow: docs/frontend/auth_flow.md
  • PDF Data Layer: docs/architecture/PDF-data-layer.md
  • PAWLs Format (v1/v2): docs/architecture/pawls-format.md
  • Parser Pipeline: docs/pipelines/pipeline_overview.md
  • LLM Framework: docs/architecture/llms/README.md
  • Collaboration System: docs/commenting_system/README.md
  • Auth Pattern (detailed): frontend/src/docs/AUTHENTICATION_PATTERN.md
  • Documentation Screenshots: docs/development/screenshots.md
  • Query Permission Patterns: docs/architecture/query_permission_patterns.md
  • OS-Legal-Style Migration Guide: docs/frontend/os-legal-style-migration-guide.md

Branch Strategy

This project follows trunk-based development:

  • Work directly on main branch or use short-lived feature branches
  • Feature branches: feature/description-issue-number
  • Merge feature branches quickly (within a day or two)
  • Commit message format: Descriptive with issue references (e.g., "Closes Epic: Notification System #562")

Changelog Maintenance

IMPORTANT: Always update CHANGELOG.md when making significant changes to the codebase.

The changelog follows Keep a Changelog format:

## [Unreleased] - YYYY-MM-DD

### Added
- New features

### Fixed
- Bug fixes with file locations and line numbers

### Changed
- Changes to existing functionality

### Technical Details
- Implementation specifics, architectural notes

When to update:

  • New features or models added
  • Production code bugs fixed (document file location, line numbers, and impact)
  • Breaking changes to APIs or data models
  • Test suite fixes that reveal production issues
  • Database migrations
  • Architecture changes

What to include:

  • File paths and line numbers for code changes
  • Clear description of the issue and fix
  • Impact on system behavior
  • Migration notes if applicable

Pre-commit Hooks

Automatically run on commit:

  • black (Python formatting)
  • isort (import sorting)
  • flake8 (linting)
  • prettier (frontend formatting)
  • pyupgrade (Python syntax modernization)

Run manually: pre-commit run --all-files

Common Pitfalls

  1. Frontend tests hanging: Always use --reporter=list flag
  2. Permission N+1 queries: Use .visible_to_user() NOT individual permission checks. For Conversation list queries in GraphQL resolvers, use ConversationQueryOptimizer from opencontractserver.conversations.query_optimizer - it provides request-level caching to avoid repeated corpus/document visibility subqueries
  3. Missing GraphQL mocks: Check variables match exactly (null vs undefined matters), add duplicates for refetches
  4. Notification duplication in tests: Moderation methods auto-create ModerationAction records
  5. Structural annotation editing: Always read-only except for superusers
  6. Missing signal imports: Import signal handlers in apps.py ready() method
  7. PDF rendering slow in tests: Increase timeouts to 20s+ for Chromium
  8. Cache serialization crashes: Keep InMemoryCache definition inside wrapper, not test file
  9. Backend Tests Waiting > 10 seconds on Postgres to be Ready: Docker network issue. Fix with: docker compose -f test.yml down && docker kill $(docker ps -q) && docker compose -f test.yml down
  10. Empty lists on direct navigation: AuthGate pattern solves this (don't check auth status, it's always ready)
  11. URL desynchronization: Use CentralRouteManager, don't bypass routing system
  12. Jotai state not updating: Ensure atoms are properly imported and used with useAtom hook
  13. Writing sync agent tools: All agent tools must be async. The PydanticAIToolWrapper accepts sync functions but calls them directly (no thread pool) — sync Django ORM calls will raise SynchronousOnlyOperation. Use the a-prefixed async versions in core_tools.py.
  14. PydanticAI system_prompt silently dropped: When creating PydanticAIAgent, use instructions= NOT system_prompt=. The system_prompt parameter is only included when message_history is None, but OpenContracts' chat() persists a HUMAN message before calling pydantic-ai's run(), so history is always non-empty. This causes the system prompt to be silently dropped. See docs/architecture/llms/README.md for full details.
  15. Apollo cache keyArgs must use field argument names, not variable names: In cache.ts, relayStylePagination(["corpus", "name_Contains"]) uses the GraphQL field argument names (e.g., extracts(corpus: $id, name_Contains: $text)), NOT the variable names ($id, $text). Mismatched keyArgs silently fail to isolate cache entries, causing queries with different filters to share stale cached results.
  16. Playwright CT split-import rule: In frontend/tests/*.ct.tsx, keep JSX-component imports in their own import { Wrapper } from "./Wrapper" statement, separate from any helper/constant imports from the same file. Playwright CT's babel transform only rewrites component references into importRefs when every specifier in the statement is a JSX component; mixing a component with helpers leaves the component unrewritten and mount() throws. Do not let auto-import organisers merge them.
  17. Corrupted Docker iptables chains (RARE): If you see Chain 'DOCKER-ISOLATION-STAGE-2' does not exist errors, Docker's iptables chains have been corrupted during docker cycling. Run this nuclear fix:
    sudo systemctl stop docker && sudo systemctl stop docker.socket && sudo ip link delete docker0 2>/dev/null || true && sudo iptables -t nat -F && sudo iptables -t nat -X && sudo iptables -t filter -F && sudo iptables -t filter -X 2>/dev/null || true && sudo iptables -t mangle -F && sudo iptables -t mangle -X && sudo iptables -t filter -N INPUT 2>/dev/null || true && sudo iptables -t filter -N FORWARD 2>/dev/null || true && sudo iptables -t filter -N OUTPUT 2>/dev/null || true && sudo iptables -P INPUT ACCEPT && sudo iptables -P FORWARD ACCEPT && sudo iptables -P OUTPUT ACCEPT && sudo systemctl start docker
    This completely resets Docker's networking and iptables state. Docker will recreate all required chains on startup.

@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

test2

@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

test

1 similar comment
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

test

JSv4 added 2 commits May 13, 2026 21:27
- Annotation.link_url is now CharField (was URLField). URLField
  attaches Django's URLValidator which runs at clean_fields() time
  and rejects site-relative paths like /corpus/foo before our own
  validate_link_url (in clean()) gets a chance to allow them.
  Migration 0072 updated to the same shape.

- AddAnnotation / AddUrlAnnotation catch ValidationError instead
  of bare Exception, and surface a clean human-readable message via
  _format_link_url_error (extracts the dict-detail's first message
  instead of the noisy ["{'link_url': ['...']}"] repr).

- Empty-string normalisation comment clarified on AddAnnotation so
  the link_url or None is no longer dead code on re-read.
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

test

Comment on lines +417 to +427
def mutate(
root,
info,
json,
page,
raw_text,
corpus_id,
document_id,
annotation_type,
link_url,
) -> "AddUrlAnnotation":
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

hello

@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review: OC_URL Clickable Hyperlink Annotations

This is a well-designed feature with particularly strong security coverage for a user-controlled URL reflection path. The three-layer validation approach (model save → mutation layer → frontend renderer) is exactly right for this attack surface. A few items below worth addressing before merge.


Overview

Adds OC_URL-labelled annotations that turn highlighted text into clickable hyperlinks. Users supply a target URL; the selection menu gains an "Add link…" entry; clicking a rendered OC_URL annotation opens the target. Shift/Ctrl/Cmd fallback preserves the normal selection flow for editing/deleting.


What Works Well

  • Defence-in-depth on URL validation: validate_link_url() runs unconditionally in both clean() and save() regardless of VALIDATE_ANNOTATION_JSON, and the frontend renderer re-checks via isSafeUrl() before calling window.open. The javascript: / data: rejection is enforced at every layer.
  • noopener,noreferrer applied correctly on external tab opens.
  • ensure_label_and_labelset idempotency mirrors the existing OC_SECTION pattern cleanly — no label sprawl.
  • CharField over URLField for link_url with the rationale clearly documented in the model comment — URLField's validator rejects site-relative paths.
  • urlAnnotation.ts is a clean central abstraction shared by both PDF and text renderers.
  • Test coverage is thorough: scheme rejection cases, modifier-key fallback, server ok=false path, blank-URL guard, and Playwright component tests for the modal.

Issues

1. TypeScript type mismatch — NewUrlAnnotationOutputType.linkUrl is non-nullable

File: frontend/src/graphql/mutations.ts:126

// Current — wrong for nullable server field
annotation: {
  id: string;
  linkUrl: string;   // ← should be `string | null`
  ...
} | null;

The GQL schema returns String (nullable) for linkUrl. The type says string which is non-nullable. If the server ever returns null for linkUrl on a successfully-created annotation (shouldn't happen for addUrlAnnotation, but defensively) created.linkUrl would be undefined at runtime while TypeScript considers it safe to use. Should be string | null to match RawServerAnnotationType.linkUrl.

2. Magic string "__pending_oc_url__" should be a named constant

File: frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx:1279

const placeholder: AnnotationLabelType = {
  id: "__pending_oc_url__",   // ← magic string
  text: "OC_URL",
  ...

text: "OC_URL" already uses the OC_URL_LABEL constant (imported), but id: "__pending_oc_url__" is a raw string that could silently mismatch if any downstream code checks this ID. Extract it to a constant in constants.ts alongside OC_URL_LABEL.

3. CreateUrlAnnotationModal uses inline styles and magic color values

File: frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx

The rest of the annotator components use styled-components. This modal uses raw inline style objects with hardcoded hex values (#dc2626, #6b7280, #d1d5db, etc.). Per CLAUDE.md, magic numbers belong in constants files. Either extract to constants.ts / use styled-components, or at minimum pull the colour literals into named constants at the top of the file. This is a style inconsistency that will make future theming harder.

4. Client-side URL validation uses two different mechanisms

File: frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx:346 vs frontend/src/components/annotator/utils/urlAnnotation.ts:53

// CreateUrlAnnotationModal — regex
const URL_PATTERN = /^(https?:\/\/.+|\/.+)$/i;

// urlAnnotation.ts — startsWith
normalized.toLowerCase().startsWith("http://") ||
normalized.toLowerCase().startsWith("https://") ||
normalized.startsWith("/")

These are semantically close but not identical (the regex requires at least one character after the scheme, startsWith does not). More importantly, there are now two separate implementations of the same allow-list. Consider exporting a isValidLinkUrl(url: string): boolean from urlAnnotation.ts and using it in CreateUrlAnnotationModal instead of the local regex, so the allow-list is a single source of truth in three places: backend validate_link_url, frontend isSafeUrl, and the modal form.

5. handleSpanClick created inline per span per render in TxtAnnotator

File: frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx:1506-1513

const handleSpanClick = linkAnnotation
  ? (event: React.MouseEvent) => { ... }
  : undefined;

This is inside the render of each span and creates a new function on every re-render for spans with a URL annotation. It won't cause visible perf issues at normal annotation densities, but it breaks React's referential equality for event handlers. A useCallback can't be applied in a loop, so the standard fix is to extract the handler up with the annotation as a parameter, or accept it as-is and add a comment explaining the tradeoff.

6. mypy.ini exemption for the new test file

File: mypy.ini:568

[mypy-opencontractserver.tests.test_url_annotation]
ignore_errors = True

This is listed alongside other test exemptions which is consistent. However, the test file is well-typed (uses get_user_model(), TestCase, proper imports). Investigate whether graphene.test.Client is the only untyped symbol causing this — if so, a narrow # type: ignore[...] on that import would be better than silencing the whole file.


Minor Notes

  • GET_DOCUMENT_STRUCTURAL_ANNOTATIONS gets linkUrl added (queries.ts). Structural annotations are read-only and will never carry link_url, so this adds unnecessary over-fetching. Harmless but worth trimming.
  • The REQUEST_ADD_URL_ANNOTATION GQL document uses an anonymous mutation (...) without a named operation — consistent with other mutations in the file, but named operations help with Apollo DevTools and error tracing.
  • The _MutationContext helper in test_url_annotation.py is minimal but functional; it mirrors the existing pattern in other test files so no issue there.

Summary

The security fundamentals are solid and the test coverage is genuinely good. The main items to address before merge: fix the linkUrl TypeScript nullability mismatch (#1), deduplicate the frontend URL allow-list (#4), and either move the modal's inline styles/magic hex values to constants (#3) or file a follow-up ticket to align it with the styled-components pattern used elsewhere.

@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

OpenContracts is an MIT-licensed enterprise document analytics platform for PDFs and text-based formats. It features a Django/GraphQL backend with PostgreSQL + pgvector, a React/TypeScript frontend with Jotai state management, and pluggable document processing pipelines powered by machine learning models.

Baseline Commit Rules

  1. Always ensure all affected (or new) tests pass - backend tests suite should only be run in its entirety for good reason as it takes 30+ minutes.
  2. Always make sure typescript compiles and pre-commits pass before committing new code.
  3. Never credit Claude or Claude Code in commit messages, PR messages, comments, or any other artifacts. This includes Co-Authored-By lines, "Generated by Claude", etc.

Essential Commands

Backend (Django)

# Run backend tests (sequential, use --keepdb to speed up subsequent runs)
docker compose -f test.yml run django python manage.py test --keepdb

# Run backend tests in PARALLEL (recommended - ~4x faster)
# Uses pytest-xdist with 4 workers, --dist loadscope keeps class tests together
docker compose -f test.yml run django pytest -n 4 --dist loadscope

# Run parallel tests with auto-detected worker count (uses all CPU cores)
docker compose -f test.yml run django pytest -n auto --dist loadscope

# Run parallel tests with fresh databases (first run or after schema changes)
docker compose -f test.yml run django pytest -n 4 --dist loadscope --create-db

# Run specific test file
docker compose -f test.yml run django python manage.py test opencontractserver.tests.test_notifications --keepdb

# Run specific test file in parallel
docker compose -f test.yml run django pytest opencontractserver/tests/test_notifications.py -n 4 --dist loadscope

# Run specific test class/method
docker compose -f test.yml run django python manage.py test opencontractserver.tests.test_notifications.TestNotificationModel.test_create_notification --keepdb

# Apply database migrations
docker compose -f local.yml run django python manage.py migrate

# Create new migration
docker compose -f local.yml run django python manage.py makemigrations

# Django shell
docker compose -f local.yml run django python manage.py shell

# Code quality (runs automatically via pre-commit hooks)
pre-commit run --all-files

Frontend (React/TypeScript)

cd frontend

# Start development server (proxies to Django on :8000)
yarn start

# Run unit tests (Vitest) - watches by default
yarn test:unit

# Run component tests (Playwright) - CRITICAL: Use --reporter=list to prevent hanging
yarn test:ct --reporter=list

# Run component tests with grep filter
yarn test:ct --reporter=list -g "test name pattern"

# Run E2E tests
yarn test:e2e

# Coverage reports (unit tests via Vitest, component tests via Playwright + Istanbul)
yarn test:coverage:unit
yarn test:coverage:ct

# Linting and formatting
yarn lint
yarn fix-styles

# Build for production
yarn build

# Preview production build locally
yarn serve

Production Deployment

# CRITICAL: Always run migrations FIRST in production
docker compose -f production.yml --profile migrate up migrate

# Then start main services
docker compose -f production.yml up

High-Level Architecture

Backend Architecture

Stack: Django 4.x + GraphQL (Graphene) + PostgreSQL + pgvector + Celery

Key Patterns:

  1. GraphQL Schema Organization:

    • config/graphql/graphene_types.py - All GraphQL type definitions
    • config/graphql/queries.py - Query resolvers
    • config/graphql/*_mutations.py - Mutation files (organized by feature)
    • config/graphql/schema.py - Schema composition
  2. Permission System (CRITICAL - see docs/permissioning/consolidated_permissioning_guide.md):

    • Annotations & Relationships: NO individual permissions - inherited from document + corpus
    • Documents & Corpuses: Direct object-level permissions via django-guardian
    • Analyses & Extracts: Hybrid model (own permissions + corpus permissions + document filtering)
    • Formula: Effective Permission = MIN(document_permission, corpus_permission)
    • Structural items are ALWAYS read-only except for superusers
    • Use Model.objects.visible_to_user(user) pattern (NOT resolve_oc_model_queryset - DEPRECATED)
  3. AnnotatePermissionsForReadMixin:

    • Most GraphQL types inherit this mixin (see config/graphql/permissioning/permission_annotator/mixins.py)
    • Adds my_permissions, is_published, object_shared_with fields
    • Requires model to have guardian permission tables ({model}userobjectpermission_set)
    • Notifications use simple ownership model and DON'T use this mixin
  4. Django Signal Handlers:

    • Automatic notification creation on model changes (see opencontractserver/notifications/signals.py)
    • Must be imported in app's apps.py ready() method
    • Use _skip_signals attribute to prevent duplicate notifications in tests
  5. Pluggable Parser Pipeline:

    • Base classes in opencontractserver/pipeline/base/
    • Parsers, embedders, thumbnailers auto-discovered and registered
    • Multiple backends: Docling (ML-based), LlamaParse, Text
    • All convert to unified PAWLs format for frontend
  6. Agent Tool Architecture (see docs/architecture/llms/README.md):

    • CoreTool (framework-agnostic) → PydanticAIToolWrapper (pydantic-ai specific)
    • All production tools MUST be async (a-prefixed in core_tools.py). The wrapper supports sync functions for lightweight helpers/tests but does NOT wrap them in a thread pool — sync ORM calls will raise SynchronousOnlyOperation.
    • Tool fault tolerance (issue Agent Tools Not Fault Tolerant #820): operational exceptions are caught and returned as error strings to the LLM; security exceptions (PermissionError, ToolConfirmationRequired) propagate.
    • Pre-execution checks run on every call (not cached): permission validation, resource ID validation, approval gates.

Frontend Architecture

Stack: React 18 + TypeScript + Apollo Client + Jotai (atoms) + PDF.js + Vite

Key Patterns:

  1. State Management - Jotai Atoms:

    • Global state via atoms in frontend/src/atoms/ (NOT Redux/Context)
    • Key atoms: selectedFolderIdAtom, folderCorpusIdAtom, selectedMessageIdAtom
    • Derived atoms automatically update when dependencies change
    • Apollo reactive vars in frontend/src/graphql/cache.ts for UI state
    • AuthGate pattern ensures auth completes before rendering
  2. Central Routing System (see docs/frontend/routing_system.md):

    • Single source of truth: frontend/src/routing/CentralRouteManager.tsx
    • URL paths → Entity resolution via GraphQL slug queries
    • URL params ↔ Reactive vars (bidirectional sync)
    • Components consume state via reactive vars, never touch URLs directly
    • Deep linking and canonical redirects handled automatically
  3. PDF Annotation System (see .cursor/rules/pdf-viewer-and-annotator-architecture.mdc):

    • Virtualized rendering: Only visible pages (+overscan) rendered for performance
    • Binary search to find visible page range (O(log n))
    • Height caching per zoom level
    • Two-phase scroll-to-annotation system
    • Dual-layer architecture: Document layer (annotations) + Knowledge layer (summaries)
  4. Unified Filtering Architecture:

    • useVisibleAnnotations hook provides single-source-of-truth filtering for both annotations and relationship-connected annotations
    • Reads from Jotai atoms via useAnnotationDisplay, useAnnotationControls, and useAnnotationSelection (from UISettingsAtom)
    • Ensures consistency across all components
    • Forced visibility for selected items and their relationship connections
  5. Component Testing (see .cursor/rules/test-document-knowledge-base.mdc):

    • ALWAYS mount components through test wrappers (e.g., DocumentKnowledgeBaseTestWrapper)
    • Wrapper provides: MockedProvider + InMemoryCache + Jotai Provider + asset mocking
    • Use --reporter=list flag to prevent hanging
    • Increase timeouts (20s+) for PDF rendering in Chromium
    • GraphQL mocks must match variables EXACTLY (null vs undefined matters)
    • Mock same query multiple times for refetches
    • Use page.mouse for PDF canvas interactions (NOT locator.dragTo)
    • Add settle time after drag operations (500ms UI, 1000ms Apollo cache)
  6. Development Server Configuration:

    • Vite dev server on :3000 proxies to Django on :8000
    • WebSocket proxy for /wsws://localhost:8000
    • GraphQL proxy for /graphqlhttp://localhost:8000
    • REST API proxy for /apihttp://localhost:8000
    • Auth0 optional via REACT_APP_USE_AUTH0 environment variable

Data Flow Architecture

Document Processing:

  1. Upload → Parser Selection (Docling/LlamaParse/Text)
  2. Parser generates PAWLs JSON (tokens with bounding boxes)
  3. Text layer extracted from PAWLs
  4. Annotations created for structure (headers, sections, etc.)
  5. Relationships detected between elements
  6. Vector embeddings generated for search

GraphQL Permission Flow:

  1. Query resolver filters objects with .visible_to_user(user)
  2. GraphQL types resolve my_permissions via AnnotatePermissionsForReadMixin
  3. Frontend uses permissions to enable/disable UI features
  4. Mutations check permissions and return consistent errors to prevent IDOR

Critical Security Patterns

  1. IDOR Prevention:

    • Query by both ID AND user-owned field: Model.objects.get(pk=pk, recipient=user)
    • Return same error message whether object doesn't exist or belongs to another user
    • Prevents enumeration via timing or different error messages
  2. Permission Checks:

    • NEVER trust frontend - always check server-side
    • Use visible_to_user() manager method for querysets
    • Check user_has_permission_for_obj() for individual objects (in opencontractserver.utils.permissioning)
  3. XSS Prevention:

    • User-generated content in JSON fields must be escaped on frontend
    • GraphQL's GenericScalar handles JSON serialization safely
    • Document this requirement in resolver comments

Critical Concepts

  1. No dead code - when deprecating or replacing code, always try to fully replace older code and, once it's no longer in use, delete it and related texts.
  2. DRY - please always architect code for maximal dryness and always see if you can consolidate related code and remove duplicative code.
  3. Single Responsibility Principle - Generally, ensure that each module / script has a single purpose or related purpose.
  4. No magic numbers - we have constants files in opencontractserver/constants/ (backend) and frontend/src/assets/configurations/constants.ts (frontend). Use them for any hardcoded values.
  5. Don't touch old tests without permission - if pre-existing tests fail after changes, try to identify why and present user with root cause analysis. If the test logic is correct but expectations need updating due to intentional behavior changes, document the change clearly.
  6. Utility functions belong in utility files:
    • Before writing new utilities: Check existing utility files first (frontend/src/utils/, opencontractserver/utils/)
    • When reviewing code: If you find utility functions defined inline in components/views, check if they already exist in utility files. If not, consider whether they should be extracted there for reuse.
    • Frontend utilities: frontend/src/utils/formatters.ts (formatting), frontend/src/utils/files.ts (file operations), etc.
    • Backend utilities: opencontractserver/utils/ contains permissioning, PDF processing, and other shared utilities

Testing Patterns

Manual Test Scripts

Location: docs/test_scripts/

When performing manual testing (e.g., testing migrations, verifying database state, testing API endpoints interactively), always document the test steps in a markdown file under docs/test_scripts/. These scripts will later be used to build automated integration tests.

Format:

# Test: [Brief description]

## Purpose
What this test verifies.

## Prerequisites
- Required state (e.g., "migration at 0058")
- Required data (e.g., "at least one document exists")

## Steps
1. Step one with exact command
   ```bash
   docker compose -f local.yml run --rm django python manage.py shell -c "..."
  1. Step two...

Expected Results

  • What should happen after each step
  • Success criteria

Cleanup

Commands to restore original state if needed.


**When to document**:
- Migration testing (rollback, create test data, migrate forward)
- Database constraint validation
- Race condition verification
- Any manual verification requested during code review

### Backend Tests

**Location**: `opencontractserver/tests/`

**Parallel Testing** (pytest-xdist):
- Run with `-n 4` (or `-n auto`) for parallel execution across workers
- Use `--dist loadscope` to keep tests from the same class on the same worker (respects `setUpClass`)
- Each worker gets its own database (test_db_gw0, test_db_gw1, etc.)
- Use `@pytest.mark.serial` to mark tests that cannot run in parallel
- First run or after DB schema changes: add `--create-db` flag

**Patterns**:
- Use `TransactionTestCase` for tests with signals/asynchronous behavior
- Use `TestCase` for faster tests without transaction isolation
- Clear auto-created notifications when testing moderation: `Notification.objects.filter(recipient=user).delete()`
- Use `_skip_signals` attribute on instances to prevent signal handlers during fixtures

### Frontend Component Tests

**Location**: `frontend/tests/`

**Critical Requirements**:
- Mount through test wrappers that provide all required context
- GraphQL mocks must match query variables exactly
- Include mocks for empty-string variants (unexpected boot calls)
- Wait for visible evidence, not just network-idle
- Use `page.mouse` for PDF canvas interactions (NOT `locator.dragTo`)
- Add settle time after drag operations (500ms UI, 1000ms Apollo cache)

**Test Wrapper Pattern**:
```typescript
// ALWAYS use wrappers, never mount components directly
const component = await mount(
  <DocumentKnowledgeBaseTestWrapper corpusId="corpus-1" documentId="doc-1">
    <DocumentKnowledgeBase />
  </DocumentKnowledgeBaseTestWrapper>
);

Automated Documentation Screenshots

Location: docs/assets/images/screenshots/auto/ (output) | frontend/tests/utils/docScreenshot.ts (utility)

Screenshots for documentation are captured during Playwright component tests and pushed back to a PR branch by the screenshots.yml workflow. The workflow is manual / on-demand only (workflow_dispatch) — it does not auto-trigger on PR push or merge. Run it from the Actions tab or via gh workflow run "Update Documentation Screenshots" -f pr_number=<N> when you want to refresh the screenshots on a PR.

How it works:

  1. Import docScreenshot from ./utils/docScreenshot in any .ct.tsx test file
  2. Call await docScreenshot(page, "area--component--state") after the component reaches the desired visual state
  3. Reference the image in markdown: ![Alt text](../assets/images/screenshots/auto/area--component--state.png)
  4. Trigger the screenshot workflow on demand for the PR; it captures screenshots and commits any changes back to the PR branch

Naming convention (-- separates segments, - within words):

Segment Purpose Examples
area Feature area landing, badges, corpus, versioning, annotations
component Specific view or component hero-section, celebration-modal, list-view
state Visual state captured anonymous, with-data, empty, auto-award

At least 2 segments required, 3 recommended. All lowercase alphanumeric with single hyphens.

Example:

import { docScreenshot } from "./utils/docScreenshot";

// After the component renders and assertions pass:
await docScreenshot(page, "badges--celebration-modal--auto-award");

Rules:

  • Place docScreenshot() calls AFTER assertions that confirm the desired visual state
  • The filename IS the contract between tests and docs — keep names stable
  • Never manually edit files in docs/assets/images/screenshots/auto/ — they are overwritten by CI
  • Manually curated screenshots stay in docs/assets/images/screenshots/ (parent directory)

Release Screenshots (Point-in-Time)

For release notes, use releaseScreenshot to capture screenshots that are locked in amber — they show the UI at a specific release and never change.

Location: docs/assets/images/screenshots/releases/{version}/ (output)

import { releaseScreenshot } from "./utils/docScreenshot";

await releaseScreenshot(page, "v3.0.0.b3", "landing-page", { fullPage: true });

Key differences from docScreenshot:

  • Output: docs/assets/images/screenshots/releases/{version}/{name}.png
  • Write-once: If the file already exists, the function is a no-op (won't overwrite)
  • CI never touches the releases/ directory
  • Name is a simple kebab-case string (no -- segment convention)
  • Version must match v{major}.{minor}.{patch} format (with optional suffix)

When to use which:

  • docScreenshot → README, quickstart, guides (always fresh)
  • releaseScreenshot → Release notes (frozen at release time)

Authenticated Playwright Testing (Live Frontend Debugging)

When you need to interact with the running frontend as an authenticated user (e.g., debugging why a query returns empty results), use Django admin session cookies to authenticate GraphQL requests.

Architecture context: The frontend uses Auth0 for authentication, but the Django backend also accepts session cookie auth. Apollo Client sends GraphQL requests directly to http://localhost:8000/graphql/ (cross-origin from the Vite dev server at localhost:5173). Since fetch defaults to credentials: 'same-origin', browser cookies aren't sent cross-origin. The workaround is to inject the session cookie into request headers via Playwright's route interception.

Step 1: Set a password for the superuser (one-time setup):

docker compose -f local.yml exec django python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.filter(is_superuser=True).first()
u.set_password('testpass123')
u.save()
print(f'Password set for {u.username}')
"

Step 2: Playwright script pattern:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const context = await browser.newContext();
  const page = await context.newPage();

  // Collect console messages for debugging
  const consoleMsgs = [];
  page.on('console', msg => consoleMsgs.push('[' + msg.type() + '] ' + msg.text()));

  // 1. Login to Django admin to get session cookie
  await page.goto('http://localhost:8000/admin/login/');
  await page.fill('#id_username', '<superuser-username>');
  await page.fill('#id_password', 'testpass123');
  await page.click('input[type=submit]');
  await page.waitForTimeout(2000);

  // 2. Extract the session cookie
  const cookies = await context.cookies();
  const sessionCookie = cookies.find(c => c.name === 'sessionid');

  // 3. Intercept GraphQL requests to inject the session cookie
  //    (needed because Apollo sends cross-origin requests to :8000)
  await page.route('**/graphql/**', async (route) => {
    const headers = {
      ...route.request().headers(),
      'Cookie': 'sessionid=' + sessionCookie.value,
    };
    await route.continue({ headers });
  });

  // 4. Navigate to the frontend page under test
  await page.goto('http://localhost:5173/extracts');
  await page.waitForTimeout(5000);

  // 5. Inspect results
  const bodyText = await page.textContent('body');
  console.log(bodyText);

  await browser.close();
})();

Run from the frontend directory (where playwright is a dependency):

cd frontend && node /path/to/script.js

Key details:

  • The Django admin login sets a sessionid cookie for localhost
  • page.route('**/graphql/**') intercepts Apollo's requests to localhost:8000/graphql/ and injects the cookie header
  • The AuthGate will still show anonymous state (no Auth0 session), but GraphQL queries execute as the authenticated Django user
  • This is useful for verifying backend query results, permission filtering, and data flow through the real frontend
  • For verifying just the GraphQL response without the full frontend, curl with the session cookie also works:
    curl -s -X POST http://localhost:8000/graphql/ \
      -H "Content-Type: application/json" \
      -H "Cookie: sessionid=<session-key>" \
      -d '{"query":"query { extracts { edges { node { id name } } } }"}' | python3 -m json.tool

Alternative — create a session programmatically (no admin login needed):

docker compose -f local.yml exec django python manage.py shell -c "
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.filter(is_superuser=True).first()
session = SessionStore()
session['_auth_user_id'] = str(user.pk)
session['_auth_user_backend'] = 'django.contrib.auth.backends.ModelBackend'
session['_auth_user_hash'] = user.get_session_auth_hash()
session.save()
print(f'Session key: {session.session_key}')
"

Then use the printed session key directly in curl or Playwright route interception.

Documentation Locations

  • Permissioning: docs/permissioning/consolidated_permissioning_guide.md
  • Frontend Routing: docs/frontend/routing_system.md
  • Frontend Auth Flow: docs/frontend/auth_flow.md
  • PDF Data Layer: docs/architecture/PDF-data-layer.md
  • PAWLs Format (v1/v2): docs/architecture/pawls-format.md
  • Parser Pipeline: docs/pipelines/pipeline_overview.md
  • LLM Framework: docs/architecture/llms/README.md
  • Collaboration System: docs/commenting_system/README.md
  • Auth Pattern (detailed): frontend/src/docs/AUTHENTICATION_PATTERN.md
  • Documentation Screenshots: docs/development/screenshots.md
  • Query Permission Patterns: docs/architecture/query_permission_patterns.md
  • OS-Legal-Style Migration Guide: docs/frontend/os-legal-style-migration-guide.md

Branch Strategy

This project follows trunk-based development:

  • Work directly on main branch or use short-lived feature branches
  • Feature branches: feature/description-issue-number
  • Merge feature branches quickly (within a day or two)
  • Commit message format: Descriptive with issue references (e.g., "Closes Epic: Notification System #562")

Changelog Maintenance

IMPORTANT: Always update CHANGELOG.md when making significant changes to the codebase.

The changelog follows Keep a Changelog format:

## [Unreleased] - YYYY-MM-DD

### Added
- New features

### Fixed
- Bug fixes with file locations and line numbers

### Changed
- Changes to existing functionality

### Technical Details
- Implementation specifics, architectural notes

When to update:

  • New features or models added
  • Production code bugs fixed (document file location, line numbers, and impact)
  • Breaking changes to APIs or data models
  • Test suite fixes that reveal production issues
  • Database migrations
  • Architecture changes

What to include:

  • File paths and line numbers for code changes
  • Clear description of the issue and fix
  • Impact on system behavior
  • Migration notes if applicable

Pre-commit Hooks

Automatically run on commit:

  • black (Python formatting)
  • isort (import sorting)
  • flake8 (linting)
  • prettier (frontend formatting)
  • pyupgrade (Python syntax modernization)

Run manually: pre-commit run --all-files

Common Pitfalls

  1. Frontend tests hanging: Always use --reporter=list flag
  2. Permission N+1 queries: Use .visible_to_user() NOT individual permission checks. For Conversation list queries in GraphQL resolvers, use ConversationQueryOptimizer from opencontractserver.conversations.query_optimizer - it provides request-level caching to avoid repeated corpus/document visibility subqueries
  3. Missing GraphQL mocks: Check variables match exactly (null vs undefined matters), add duplicates for refetches
  4. Notification duplication in tests: Moderation methods auto-create ModerationAction records
  5. Structural annotation editing: Always read-only except for superusers
  6. Missing signal imports: Import signal handlers in apps.py ready() method
  7. PDF rendering slow in tests: Increase timeouts to 20s+ for Chromium
  8. Cache serialization crashes: Keep InMemoryCache definition inside wrapper, not test file
  9. Backend Tests Waiting > 10 seconds on Postgres to be Ready: Docker network issue. Fix with: docker compose -f test.yml down && docker kill $(docker ps -q) && docker compose -f test.yml down
  10. Empty lists on direct navigation: AuthGate pattern solves this (don't check auth status, it's always ready)
  11. URL desynchronization: Use CentralRouteManager, don't bypass routing system
  12. Jotai state not updating: Ensure atoms are properly imported and used with useAtom hook
  13. Writing sync agent tools: All agent tools must be async. The PydanticAIToolWrapper accepts sync functions but calls them directly (no thread pool) — sync Django ORM calls will raise SynchronousOnlyOperation. Use the a-prefixed async versions in core_tools.py.
  14. PydanticAI system_prompt silently dropped: When creating PydanticAIAgent, use instructions= NOT system_prompt=. The system_prompt parameter is only included when message_history is None, but OpenContracts' chat() persists a HUMAN message before calling pydantic-ai's run(), so history is always non-empty. This causes the system prompt to be silently dropped. See docs/architecture/llms/README.md for full details.
  15. Apollo cache keyArgs must use field argument names, not variable names: In cache.ts, relayStylePagination(["corpus", "name_Contains"]) uses the GraphQL field argument names (e.g., extracts(corpus: $id, name_Contains: $text)), NOT the variable names ($id, $text). Mismatched keyArgs silently fail to isolate cache entries, causing queries with different filters to share stale cached results.
  16. Playwright CT split-import rule: In frontend/tests/*.ct.tsx, keep JSX-component imports in their own import { Wrapper } from "./Wrapper" statement, separate from any helper/constant imports from the same file. Playwright CT's babel transform only rewrites component references into importRefs when every specifier in the statement is a JSX component; mixing a component with helpers leaves the component unrewritten and mount() throws. Do not let auto-import organisers merge them.
  17. Corrupted Docker iptables chains (RARE): If you see Chain 'DOCKER-ISOLATION-STAGE-2' does not exist errors, Docker's iptables chains have been corrupted during docker cycling. Run this nuclear fix:
    sudo systemctl stop docker && sudo systemctl stop docker.socket && sudo ip link delete docker0 2>/dev/null || true && sudo iptables -t nat -F && sudo iptables -t nat -X && sudo iptables -t filter -F && sudo iptables -t filter -X 2>/dev/null || true && sudo iptables -t mangle -F && sudo iptables -t mangle -X && sudo iptables -t filter -N INPUT 2>/dev/null || true && sudo iptables -t filter -N FORWARD 2>/dev/null || true && sudo iptables -t filter -N OUTPUT 2>/dev/null || true && sudo iptables -P INPUT ACCEPT && sudo iptables -P FORWARD ACCEPT && sudo iptables -P OUTPUT ACCEPT && sudo systemctl start docker
    This completely resets Docker's networking and iptables state. Docker will recreate all required chains on startup.

1 similar comment
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

OpenContracts is an MIT-licensed enterprise document analytics platform for PDFs and text-based formats. It features a Django/GraphQL backend with PostgreSQL + pgvector, a React/TypeScript frontend with Jotai state management, and pluggable document processing pipelines powered by machine learning models.

Baseline Commit Rules

  1. Always ensure all affected (or new) tests pass - backend tests suite should only be run in its entirety for good reason as it takes 30+ minutes.
  2. Always make sure typescript compiles and pre-commits pass before committing new code.
  3. Never credit Claude or Claude Code in commit messages, PR messages, comments, or any other artifacts. This includes Co-Authored-By lines, "Generated by Claude", etc.

Essential Commands

Backend (Django)

# Run backend tests (sequential, use --keepdb to speed up subsequent runs)
docker compose -f test.yml run django python manage.py test --keepdb

# Run backend tests in PARALLEL (recommended - ~4x faster)
# Uses pytest-xdist with 4 workers, --dist loadscope keeps class tests together
docker compose -f test.yml run django pytest -n 4 --dist loadscope

# Run parallel tests with auto-detected worker count (uses all CPU cores)
docker compose -f test.yml run django pytest -n auto --dist loadscope

# Run parallel tests with fresh databases (first run or after schema changes)
docker compose -f test.yml run django pytest -n 4 --dist loadscope --create-db

# Run specific test file
docker compose -f test.yml run django python manage.py test opencontractserver.tests.test_notifications --keepdb

# Run specific test file in parallel
docker compose -f test.yml run django pytest opencontractserver/tests/test_notifications.py -n 4 --dist loadscope

# Run specific test class/method
docker compose -f test.yml run django python manage.py test opencontractserver.tests.test_notifications.TestNotificationModel.test_create_notification --keepdb

# Apply database migrations
docker compose -f local.yml run django python manage.py migrate

# Create new migration
docker compose -f local.yml run django python manage.py makemigrations

# Django shell
docker compose -f local.yml run django python manage.py shell

# Code quality (runs automatically via pre-commit hooks)
pre-commit run --all-files

Frontend (React/TypeScript)

cd frontend

# Start development server (proxies to Django on :8000)
yarn start

# Run unit tests (Vitest) - watches by default
yarn test:unit

# Run component tests (Playwright) - CRITICAL: Use --reporter=list to prevent hanging
yarn test:ct --reporter=list

# Run component tests with grep filter
yarn test:ct --reporter=list -g "test name pattern"

# Run E2E tests
yarn test:e2e

# Coverage reports (unit tests via Vitest, component tests via Playwright + Istanbul)
yarn test:coverage:unit
yarn test:coverage:ct

# Linting and formatting
yarn lint
yarn fix-styles

# Build for production
yarn build

# Preview production build locally
yarn serve

Production Deployment

# CRITICAL: Always run migrations FIRST in production
docker compose -f production.yml --profile migrate up migrate

# Then start main services
docker compose -f production.yml up

High-Level Architecture

Backend Architecture

Stack: Django 4.x + GraphQL (Graphene) + PostgreSQL + pgvector + Celery

Key Patterns:

  1. GraphQL Schema Organization:

    • config/graphql/graphene_types.py - All GraphQL type definitions
    • config/graphql/queries.py - Query resolvers
    • config/graphql/*_mutations.py - Mutation files (organized by feature)
    • config/graphql/schema.py - Schema composition
  2. Permission System (CRITICAL - see docs/permissioning/consolidated_permissioning_guide.md):

    • Annotations & Relationships: NO individual permissions - inherited from document + corpus
    • Documents & Corpuses: Direct object-level permissions via django-guardian
    • Analyses & Extracts: Hybrid model (own permissions + corpus permissions + document filtering)
    • Formula: Effective Permission = MIN(document_permission, corpus_permission)
    • Structural items are ALWAYS read-only except for superusers
    • Use Model.objects.visible_to_user(user) pattern (NOT resolve_oc_model_queryset - DEPRECATED)
  3. AnnotatePermissionsForReadMixin:

    • Most GraphQL types inherit this mixin (see config/graphql/permissioning/permission_annotator/mixins.py)
    • Adds my_permissions, is_published, object_shared_with fields
    • Requires model to have guardian permission tables ({model}userobjectpermission_set)
    • Notifications use simple ownership model and DON'T use this mixin
  4. Django Signal Handlers:

    • Automatic notification creation on model changes (see opencontractserver/notifications/signals.py)
    • Must be imported in app's apps.py ready() method
    • Use _skip_signals attribute to prevent duplicate notifications in tests
  5. Pluggable Parser Pipeline:

    • Base classes in opencontractserver/pipeline/base/
    • Parsers, embedders, thumbnailers auto-discovered and registered
    • Multiple backends: Docling (ML-based), LlamaParse, Text
    • All convert to unified PAWLs format for frontend
  6. Agent Tool Architecture (see docs/architecture/llms/README.md):

    • CoreTool (framework-agnostic) → PydanticAIToolWrapper (pydantic-ai specific)
    • All production tools MUST be async (a-prefixed in core_tools.py). The wrapper supports sync functions for lightweight helpers/tests but does NOT wrap them in a thread pool — sync ORM calls will raise SynchronousOnlyOperation.
    • Tool fault tolerance (issue Agent Tools Not Fault Tolerant #820): operational exceptions are caught and returned as error strings to the LLM; security exceptions (PermissionError, ToolConfirmationRequired) propagate.
    • Pre-execution checks run on every call (not cached): permission validation, resource ID validation, approval gates.

Frontend Architecture

Stack: React 18 + TypeScript + Apollo Client + Jotai (atoms) + PDF.js + Vite

Key Patterns:

  1. State Management - Jotai Atoms:

    • Global state via atoms in frontend/src/atoms/ (NOT Redux/Context)
    • Key atoms: selectedFolderIdAtom, folderCorpusIdAtom, selectedMessageIdAtom
    • Derived atoms automatically update when dependencies change
    • Apollo reactive vars in frontend/src/graphql/cache.ts for UI state
    • AuthGate pattern ensures auth completes before rendering
  2. Central Routing System (see docs/frontend/routing_system.md):

    • Single source of truth: frontend/src/routing/CentralRouteManager.tsx
    • URL paths → Entity resolution via GraphQL slug queries
    • URL params ↔ Reactive vars (bidirectional sync)
    • Components consume state via reactive vars, never touch URLs directly
    • Deep linking and canonical redirects handled automatically
  3. PDF Annotation System (see .cursor/rules/pdf-viewer-and-annotator-architecture.mdc):

    • Virtualized rendering: Only visible pages (+overscan) rendered for performance
    • Binary search to find visible page range (O(log n))
    • Height caching per zoom level
    • Two-phase scroll-to-annotation system
    • Dual-layer architecture: Document layer (annotations) + Knowledge layer (summaries)
  4. Unified Filtering Architecture:

    • useVisibleAnnotations hook provides single-source-of-truth filtering for both annotations and relationship-connected annotations
    • Reads from Jotai atoms via useAnnotationDisplay, useAnnotationControls, and useAnnotationSelection (from UISettingsAtom)
    • Ensures consistency across all components
    • Forced visibility for selected items and their relationship connections
  5. Component Testing (see .cursor/rules/test-document-knowledge-base.mdc):

    • ALWAYS mount components through test wrappers (e.g., DocumentKnowledgeBaseTestWrapper)
    • Wrapper provides: MockedProvider + InMemoryCache + Jotai Provider + asset mocking
    • Use --reporter=list flag to prevent hanging
    • Increase timeouts (20s+) for PDF rendering in Chromium
    • GraphQL mocks must match variables EXACTLY (null vs undefined matters)
    • Mock same query multiple times for refetches
    • Use page.mouse for PDF canvas interactions (NOT locator.dragTo)
    • Add settle time after drag operations (500ms UI, 1000ms Apollo cache)
  6. Development Server Configuration:

    • Vite dev server on :3000 proxies to Django on :8000
    • WebSocket proxy for /wsws://localhost:8000
    • GraphQL proxy for /graphqlhttp://localhost:8000
    • REST API proxy for /apihttp://localhost:8000
    • Auth0 optional via REACT_APP_USE_AUTH0 environment variable

Data Flow Architecture

Document Processing:

  1. Upload → Parser Selection (Docling/LlamaParse/Text)
  2. Parser generates PAWLs JSON (tokens with bounding boxes)
  3. Text layer extracted from PAWLs
  4. Annotations created for structure (headers, sections, etc.)
  5. Relationships detected between elements
  6. Vector embeddings generated for search

GraphQL Permission Flow:

  1. Query resolver filters objects with .visible_to_user(user)
  2. GraphQL types resolve my_permissions via AnnotatePermissionsForReadMixin
  3. Frontend uses permissions to enable/disable UI features
  4. Mutations check permissions and return consistent errors to prevent IDOR

Critical Security Patterns

  1. IDOR Prevention:

    • Query by both ID AND user-owned field: Model.objects.get(pk=pk, recipient=user)
    • Return same error message whether object doesn't exist or belongs to another user
    • Prevents enumeration via timing or different error messages
  2. Permission Checks:

    • NEVER trust frontend - always check server-side
    • Use visible_to_user() manager method for querysets
    • Check user_has_permission_for_obj() for individual objects (in opencontractserver.utils.permissioning)
  3. XSS Prevention:

    • User-generated content in JSON fields must be escaped on frontend
    • GraphQL's GenericScalar handles JSON serialization safely
    • Document this requirement in resolver comments

Critical Concepts

  1. No dead code - when deprecating or replacing code, always try to fully replace older code and, once it's no longer in use, delete it and related texts.
  2. DRY - please always architect code for maximal dryness and always see if you can consolidate related code and remove duplicative code.
  3. Single Responsibility Principle - Generally, ensure that each module / script has a single purpose or related purpose.
  4. No magic numbers - we have constants files in opencontractserver/constants/ (backend) and frontend/src/assets/configurations/constants.ts (frontend). Use them for any hardcoded values.
  5. Don't touch old tests without permission - if pre-existing tests fail after changes, try to identify why and present user with root cause analysis. If the test logic is correct but expectations need updating due to intentional behavior changes, document the change clearly.
  6. Utility functions belong in utility files:
    • Before writing new utilities: Check existing utility files first (frontend/src/utils/, opencontractserver/utils/)
    • When reviewing code: If you find utility functions defined inline in components/views, check if they already exist in utility files. If not, consider whether they should be extracted there for reuse.
    • Frontend utilities: frontend/src/utils/formatters.ts (formatting), frontend/src/utils/files.ts (file operations), etc.
    • Backend utilities: opencontractserver/utils/ contains permissioning, PDF processing, and other shared utilities

Testing Patterns

Manual Test Scripts

Location: docs/test_scripts/

When performing manual testing (e.g., testing migrations, verifying database state, testing API endpoints interactively), always document the test steps in a markdown file under docs/test_scripts/. These scripts will later be used to build automated integration tests.

Format:

# Test: [Brief description]

## Purpose
What this test verifies.

## Prerequisites
- Required state (e.g., "migration at 0058")
- Required data (e.g., "at least one document exists")

## Steps
1. Step one with exact command
   ```bash
   docker compose -f local.yml run --rm django python manage.py shell -c "..."
  1. Step two...

Expected Results

  • What should happen after each step
  • Success criteria

Cleanup

Commands to restore original state if needed.


**When to document**:
- Migration testing (rollback, create test data, migrate forward)
- Database constraint validation
- Race condition verification
- Any manual verification requested during code review

### Backend Tests

**Location**: `opencontractserver/tests/`

**Parallel Testing** (pytest-xdist):
- Run with `-n 4` (or `-n auto`) for parallel execution across workers
- Use `--dist loadscope` to keep tests from the same class on the same worker (respects `setUpClass`)
- Each worker gets its own database (test_db_gw0, test_db_gw1, etc.)
- Use `@pytest.mark.serial` to mark tests that cannot run in parallel
- First run or after DB schema changes: add `--create-db` flag

**Patterns**:
- Use `TransactionTestCase` for tests with signals/asynchronous behavior
- Use `TestCase` for faster tests without transaction isolation
- Clear auto-created notifications when testing moderation: `Notification.objects.filter(recipient=user).delete()`
- Use `_skip_signals` attribute on instances to prevent signal handlers during fixtures

### Frontend Component Tests

**Location**: `frontend/tests/`

**Critical Requirements**:
- Mount through test wrappers that provide all required context
- GraphQL mocks must match query variables exactly
- Include mocks for empty-string variants (unexpected boot calls)
- Wait for visible evidence, not just network-idle
- Use `page.mouse` for PDF canvas interactions (NOT `locator.dragTo`)
- Add settle time after drag operations (500ms UI, 1000ms Apollo cache)

**Test Wrapper Pattern**:
```typescript
// ALWAYS use wrappers, never mount components directly
const component = await mount(
  <DocumentKnowledgeBaseTestWrapper corpusId="corpus-1" documentId="doc-1">
    <DocumentKnowledgeBase />
  </DocumentKnowledgeBaseTestWrapper>
);

Automated Documentation Screenshots

Location: docs/assets/images/screenshots/auto/ (output) | frontend/tests/utils/docScreenshot.ts (utility)

Screenshots for documentation are captured during Playwright component tests and pushed back to a PR branch by the screenshots.yml workflow. The workflow is manual / on-demand only (workflow_dispatch) — it does not auto-trigger on PR push or merge. Run it from the Actions tab or via gh workflow run "Update Documentation Screenshots" -f pr_number=<N> when you want to refresh the screenshots on a PR.

How it works:

  1. Import docScreenshot from ./utils/docScreenshot in any .ct.tsx test file
  2. Call await docScreenshot(page, "area--component--state") after the component reaches the desired visual state
  3. Reference the image in markdown: ![Alt text](../assets/images/screenshots/auto/area--component--state.png)
  4. Trigger the screenshot workflow on demand for the PR; it captures screenshots and commits any changes back to the PR branch

Naming convention (-- separates segments, - within words):

Segment Purpose Examples
area Feature area landing, badges, corpus, versioning, annotations
component Specific view or component hero-section, celebration-modal, list-view
state Visual state captured anonymous, with-data, empty, auto-award

At least 2 segments required, 3 recommended. All lowercase alphanumeric with single hyphens.

Example:

import { docScreenshot } from "./utils/docScreenshot";

// After the component renders and assertions pass:
await docScreenshot(page, "badges--celebration-modal--auto-award");

Rules:

  • Place docScreenshot() calls AFTER assertions that confirm the desired visual state
  • The filename IS the contract between tests and docs — keep names stable
  • Never manually edit files in docs/assets/images/screenshots/auto/ — they are overwritten by CI
  • Manually curated screenshots stay in docs/assets/images/screenshots/ (parent directory)

Release Screenshots (Point-in-Time)

For release notes, use releaseScreenshot to capture screenshots that are locked in amber — they show the UI at a specific release and never change.

Location: docs/assets/images/screenshots/releases/{version}/ (output)

import { releaseScreenshot } from "./utils/docScreenshot";

await releaseScreenshot(page, "v3.0.0.b3", "landing-page", { fullPage: true });

Key differences from docScreenshot:

  • Output: docs/assets/images/screenshots/releases/{version}/{name}.png
  • Write-once: If the file already exists, the function is a no-op (won't overwrite)
  • CI never touches the releases/ directory
  • Name is a simple kebab-case string (no -- segment convention)
  • Version must match v{major}.{minor}.{patch} format (with optional suffix)

When to use which:

  • docScreenshot → README, quickstart, guides (always fresh)
  • releaseScreenshot → Release notes (frozen at release time)

Authenticated Playwright Testing (Live Frontend Debugging)

When you need to interact with the running frontend as an authenticated user (e.g., debugging why a query returns empty results), use Django admin session cookies to authenticate GraphQL requests.

Architecture context: The frontend uses Auth0 for authentication, but the Django backend also accepts session cookie auth. Apollo Client sends GraphQL requests directly to http://localhost:8000/graphql/ (cross-origin from the Vite dev server at localhost:5173). Since fetch defaults to credentials: 'same-origin', browser cookies aren't sent cross-origin. The workaround is to inject the session cookie into request headers via Playwright's route interception.

Step 1: Set a password for the superuser (one-time setup):

docker compose -f local.yml exec django python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.filter(is_superuser=True).first()
u.set_password('testpass123')
u.save()
print(f'Password set for {u.username}')
"

Step 2: Playwright script pattern:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const context = await browser.newContext();
  const page = await context.newPage();

  // Collect console messages for debugging
  const consoleMsgs = [];
  page.on('console', msg => consoleMsgs.push('[' + msg.type() + '] ' + msg.text()));

  // 1. Login to Django admin to get session cookie
  await page.goto('http://localhost:8000/admin/login/');
  await page.fill('#id_username', '<superuser-username>');
  await page.fill('#id_password', 'testpass123');
  await page.click('input[type=submit]');
  await page.waitForTimeout(2000);

  // 2. Extract the session cookie
  const cookies = await context.cookies();
  const sessionCookie = cookies.find(c => c.name === 'sessionid');

  // 3. Intercept GraphQL requests to inject the session cookie
  //    (needed because Apollo sends cross-origin requests to :8000)
  await page.route('**/graphql/**', async (route) => {
    const headers = {
      ...route.request().headers(),
      'Cookie': 'sessionid=' + sessionCookie.value,
    };
    await route.continue({ headers });
  });

  // 4. Navigate to the frontend page under test
  await page.goto('http://localhost:5173/extracts');
  await page.waitForTimeout(5000);

  // 5. Inspect results
  const bodyText = await page.textContent('body');
  console.log(bodyText);

  await browser.close();
})();

Run from the frontend directory (where playwright is a dependency):

cd frontend && node /path/to/script.js

Key details:

  • The Django admin login sets a sessionid cookie for localhost
  • page.route('**/graphql/**') intercepts Apollo's requests to localhost:8000/graphql/ and injects the cookie header
  • The AuthGate will still show anonymous state (no Auth0 session), but GraphQL queries execute as the authenticated Django user
  • This is useful for verifying backend query results, permission filtering, and data flow through the real frontend
  • For verifying just the GraphQL response without the full frontend, curl with the session cookie also works:
    curl -s -X POST http://localhost:8000/graphql/ \
      -H "Content-Type: application/json" \
      -H "Cookie: sessionid=<session-key>" \
      -d '{"query":"query { extracts { edges { node { id name } } } }"}' | python3 -m json.tool

Alternative — create a session programmatically (no admin login needed):

docker compose -f local.yml exec django python manage.py shell -c "
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.filter(is_superuser=True).first()
session = SessionStore()
session['_auth_user_id'] = str(user.pk)
session['_auth_user_backend'] = 'django.contrib.auth.backends.ModelBackend'
session['_auth_user_hash'] = user.get_session_auth_hash()
session.save()
print(f'Session key: {session.session_key}')
"

Then use the printed session key directly in curl or Playwright route interception.

Documentation Locations

  • Permissioning: docs/permissioning/consolidated_permissioning_guide.md
  • Frontend Routing: docs/frontend/routing_system.md
  • Frontend Auth Flow: docs/frontend/auth_flow.md
  • PDF Data Layer: docs/architecture/PDF-data-layer.md
  • PAWLs Format (v1/v2): docs/architecture/pawls-format.md
  • Parser Pipeline: docs/pipelines/pipeline_overview.md
  • LLM Framework: docs/architecture/llms/README.md
  • Collaboration System: docs/commenting_system/README.md
  • Auth Pattern (detailed): frontend/src/docs/AUTHENTICATION_PATTERN.md
  • Documentation Screenshots: docs/development/screenshots.md
  • Query Permission Patterns: docs/architecture/query_permission_patterns.md
  • OS-Legal-Style Migration Guide: docs/frontend/os-legal-style-migration-guide.md

Branch Strategy

This project follows trunk-based development:

  • Work directly on main branch or use short-lived feature branches
  • Feature branches: feature/description-issue-number
  • Merge feature branches quickly (within a day or two)
  • Commit message format: Descriptive with issue references (e.g., "Closes Epic: Notification System #562")

Changelog Maintenance

IMPORTANT: Always update CHANGELOG.md when making significant changes to the codebase.

The changelog follows Keep a Changelog format:

## [Unreleased] - YYYY-MM-DD

### Added
- New features

### Fixed
- Bug fixes with file locations and line numbers

### Changed
- Changes to existing functionality

### Technical Details
- Implementation specifics, architectural notes

When to update:

  • New features or models added
  • Production code bugs fixed (document file location, line numbers, and impact)
  • Breaking changes to APIs or data models
  • Test suite fixes that reveal production issues
  • Database migrations
  • Architecture changes

What to include:

  • File paths and line numbers for code changes
  • Clear description of the issue and fix
  • Impact on system behavior
  • Migration notes if applicable

Pre-commit Hooks

Automatically run on commit:

  • black (Python formatting)
  • isort (import sorting)
  • flake8 (linting)
  • prettier (frontend formatting)
  • pyupgrade (Python syntax modernization)

Run manually: pre-commit run --all-files

Common Pitfalls

  1. Frontend tests hanging: Always use --reporter=list flag
  2. Permission N+1 queries: Use .visible_to_user() NOT individual permission checks. For Conversation list queries in GraphQL resolvers, use ConversationQueryOptimizer from opencontractserver.conversations.query_optimizer - it provides request-level caching to avoid repeated corpus/document visibility subqueries
  3. Missing GraphQL mocks: Check variables match exactly (null vs undefined matters), add duplicates for refetches
  4. Notification duplication in tests: Moderation methods auto-create ModerationAction records
  5. Structural annotation editing: Always read-only except for superusers
  6. Missing signal imports: Import signal handlers in apps.py ready() method
  7. PDF rendering slow in tests: Increase timeouts to 20s+ for Chromium
  8. Cache serialization crashes: Keep InMemoryCache definition inside wrapper, not test file
  9. Backend Tests Waiting > 10 seconds on Postgres to be Ready: Docker network issue. Fix with: docker compose -f test.yml down && docker kill $(docker ps -q) && docker compose -f test.yml down
  10. Empty lists on direct navigation: AuthGate pattern solves this (don't check auth status, it's always ready)
  11. URL desynchronization: Use CentralRouteManager, don't bypass routing system
  12. Jotai state not updating: Ensure atoms are properly imported and used with useAtom hook
  13. Writing sync agent tools: All agent tools must be async. The PydanticAIToolWrapper accepts sync functions but calls them directly (no thread pool) — sync Django ORM calls will raise SynchronousOnlyOperation. Use the a-prefixed async versions in core_tools.py.
  14. PydanticAI system_prompt silently dropped: When creating PydanticAIAgent, use instructions= NOT system_prompt=. The system_prompt parameter is only included when message_history is None, but OpenContracts' chat() persists a HUMAN message before calling pydantic-ai's run(), so history is always non-empty. This causes the system prompt to be silently dropped. See docs/architecture/llms/README.md for full details.
  15. Apollo cache keyArgs must use field argument names, not variable names: In cache.ts, relayStylePagination(["corpus", "name_Contains"]) uses the GraphQL field argument names (e.g., extracts(corpus: $id, name_Contains: $text)), NOT the variable names ($id, $text). Mismatched keyArgs silently fail to isolate cache entries, causing queries with different filters to share stale cached results.
  16. Playwright CT split-import rule: In frontend/tests/*.ct.tsx, keep JSX-component imports in their own import { Wrapper } from "./Wrapper" statement, separate from any helper/constant imports from the same file. Playwright CT's babel transform only rewrites component references into importRefs when every specifier in the statement is a JSX component; mixing a component with helpers leaves the component unrewritten and mount() throws. Do not let auto-import organisers merge them.
  17. Corrupted Docker iptables chains (RARE): If you see Chain 'DOCKER-ISOLATION-STAGE-2' does not exist errors, Docker's iptables chains have been corrupted during docker cycling. Run this nuclear fix:
    sudo systemctl stop docker && sudo systemctl stop docker.socket && sudo ip link delete docker0 2>/dev/null || true && sudo iptables -t nat -F && sudo iptables -t nat -X && sudo iptables -t filter -F && sudo iptables -t filter -X 2>/dev/null || true && sudo iptables -t mangle -F && sudo iptables -t mangle -X && sudo iptables -t filter -N INPUT 2>/dev/null || true && sudo iptables -t filter -N FORWARD 2>/dev/null || true && sudo iptables -t filter -N OUTPUT 2>/dev/null || true && sudo iptables -P INPUT ACCEPT && sudo iptables -P FORWARD ACCEPT && sudo iptables -P OUTPUT ACCEPT && sudo systemctl start docker
    This completely resets Docker's networking and iptables state. Docker will recreate all required chains on startup.

@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review: OC_URL Link Annotations

Overall this is a well-designed feature with solid security foundations. The defence-in-depth approach is exactly right for a click-to-open flow. A few issues need addressing before merge.

Bugs

1. OC_URL annotations are silently hidden in the UnifiedContentFeed (critical)

File: frontend/src/components/knowledge_base/document/unified_feed/UnifiedContentFeed.tsx:260

STRUCTURAL_LABEL_PREFIX = "OC_" and OC_URL starts with OC_, so every URL annotation is filtered out of the feed entirely. OC_URL is a user-authored annotation that should appear in the feed. This needs either a specific exclusion list (OC_SECTION, OC_EXTRACT_SOURCE) instead of a prefix check, or an explicit carve-out for OC_URL.

2. window.location.assign() for site-relative paths causes a full page reload (medium)

File: frontend/src/components/annotator/utils/urlAnnotation.ts:64

window.location.assign() is a hard navigation that discards the Apollo cache and React Router history state. Every other navigation in the annotator uses useNavigate from react-router-dom. The cleanest fix is to accept an optional navigate callback from call sites; Selection.tsx and TxtAnnotator.tsx both already have access to a useNavigate reference they could pass through.

Code Quality

3. Duplicate lucide-react imports in TxtAnnotator.tsx

Lines 37 and 40 both import from lucide-react. Link2 should be merged into the first import statement.

4. Hardcoded values in the SelectionLayer placeholder label

Inside handleConfirmCreateLink in SelectionLayer.tsx the text "OC_URL" and color "#2563EB" are hardcoded as literals. Both values are already defined as constants in frontend/src/assets/configurations/constants.ts and opencontractserver/constants/annotations.py. Using literals creates drift risk.

5. Inconsistent label check in Selection.tsx

The pencil icon click handler checks annotation.annotationLabel?.text === OC_URL_LABEL directly rather than using isUrlAnnotation(). Unlike the isLinkAnnotation variable (which correctly requires both the label AND a non-empty linkUrl), this branch opens the URL edit modal even when linkUrl is absent. That is arguably correct for the edit flow, but the inconsistency is confusing — a comment explaining the intentional difference would help future readers.

6. Inline styles in CreateUrlAnnotationModal.tsx

The rest of the codebase uses styled-components; this modal uses raw inline style object props throughout. Minor inconsistency worth noting.

Potential Issues

7. URL normalization gap in validate_link_url

opencontractserver/annotations/models.py:110: the function validates the stripped form but the caller stores the original self.link_url (possibly with leading/trailing whitespace). The frontend modal pre-trims before sending, so this is safe in practice, but a direct API call or serializer path could persist a URL with leading whitespace that renders as a broken link. Consider stripping in save() before calling validate_link_url.

8. mypy.ini blanket suppression for the new test file

Several existing test files use this pattern. Worth a quick pass to see if the suppressed errors are real type gaps that should be fixed rather than ignored.

What's Done Well

  • Security layering: validate_link_url runs unconditionally in save() regardless of VALIDATE_ANNOTATION_JSON; the serializer normalises empty-string clears; the renderer has a isSafeUrl mirror. That is the right design for a feature that calls window.open.
  • Modifier-key fallback: Shift/Ctrl/Cmd preserving normal selection behaviour is a thoughtful UX detail that lets authors edit/delete their own link annotations without accidentally navigating away.
  • Prop-injection over hook-in-renderer: threading createUrlAnnotation as a prop keeps SelectionLayer and TxtAnnotator decoupled from Apollo and unit-testable.
  • ensure_label_and_labelset reuse: mirroring the OC_SECTION pattern for auto-creating the OC_URL label per-corpus is clean and consistent.
  • Test breadth: backend model validation, GraphQL mutations, frontend hook, and Playwright modal tests all land together.

@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

const x = {
  id: foo,
};

…med sentinel id

- Export isSafeUrl from urlAnnotation.ts and use it in
  CreateUrlAnnotationModal so the http(s)/relative allow-list lives in a
  single place on the frontend (renderer click handler + author modal
  share the same predicate).
- Tighten NewUrlAnnotationOutputType.annotation.linkUrl to
  string | null to match the GraphQL schema (server field is
  nullable). Aligns with RawServerAnnotationType.linkUrl.
- Extract the placeholder OC_URL label sentinel id into
  PENDING_OC_URL_LABEL_ID in constants.ts (was hard-coded as
  "__pending_oc_url__" in SelectionLayer.tsx). Also use
  OC_URL_LABEL for the placeholder's text instead of the raw
  string.
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review: OC_URL Link Annotations (#1648)

Overall this is a well-designed feature with solid security foundations. The defence-in-depth approach (backend validate_link_url + serializer + frontend isSafeUrl) is exactly right for a click-to-open flow. The test coverage across all three layers (backend Django, Vitest unit, Playwright CT) is commendable. A few issues need addressing before merge.

Bugs

1. OC_URL annotations are silently hidden in the UnifiedContentFeed (critical)

frontend/src/components/knowledge_base/document/unified_feed/UnifiedContentFeed.tsx:260

The existing filter reads:

// Always hide OC_* prefixed annotations (platform-generated structural labels)
if (ann.annotationLabel.text?.startsWith(STRUCTURAL_LABEL_PREFIX)) {
  return;
}

STRUCTURAL_LABEL_PREFIX = "OC_" and OC_URL starts with OC_, so every URL annotation created by this feature is filtered out of the feed entirely. The intent of that comment is clearly "hide structural/system annotations", but OC_URL is a user-authored annotation that should appear in the feed like any regular annotation. This needs either a specific exclusion list (OC_SECTION, OC_EXTRACT_SOURCE) instead of a prefix check, or an explicit carve-out for OC_URL.

2. window.location.assign() for site-relative paths causes a full page reload (medium)

frontend/src/components/annotator/utils/urlAnnotation.ts:64

The PR description says site-relative paths "navigate in the current tab so the SPA router can resolve them", but window.location.assign() is a hard navigation that discards the Apollo cache and React Router history state. Every other navigation in the annotator uses useNavigate from react-router-dom (UISettingsAtom.tsx:449, AnnotationControls.tsx:123, HighlightItem.tsx:167, etc.). Since urlAnnotation.ts is a plain utility (not a hook), the cleanest fix is to accept an optional navigate callback from call sites, e.g.:

export function openAnnotationUrl(
  annotation: ServerTokenAnnotation | ServerSpanAnnotation,
  navigate?: NavigateFn
): boolean {
  // ...
  if (isRelativePath) {
    if (navigate) navigate(normalized);
    else window.location.assign(normalized);
  }
  // ...
}

Selection.tsx and TxtAnnotator.tsx both already have access to a useNavigate reference they could pass through.

Code Quality

3. Duplicate lucide-react imports in TxtAnnotator.tsx

Lines 37 and 40 both import from lucide-react. Link2 should be merged into the first import statement.

4. Hardcoded values in the SelectionLayer placeholder label

Inside handleConfirmCreateLink in SelectionLayer.tsx, the text and color fields of the placeholder AnnotationLabelType object are hardcoded as string literals ("OC_URL" and "#2563EB") rather than using the constants already defined in frontend/src/assets/configurations/constants.ts and opencontractserver/constants/annotations.py. Using literals here creates drift risk.

5. Inconsistent label check in Selection.tsx

The pencil icon click handler uses:

if (annotation.annotationLabel?.text === OC_URL_LABEL) {
  setIsEditUrlModalVisible(true);
}

This is a raw label text comparison rather than isUrlAnnotation(). Unlike the isLinkAnnotation variable (which correctly requires both the label AND a non-empty linkUrl), this branch opens the URL edit modal even when linkUrl is absent. That is arguably correct for the edit flow, but the inconsistency is confusing — a comment explaining the intentional difference would help future readers.

6. Inline styles in CreateUrlAnnotationModal.tsx

The rest of the codebase uses styled-components; this modal uses raw style={{...}} throughout. Minor inconsistency worth noting.

Potential Issues

7. URL normalization gap in validate_link_url

opencontractserver/annotations/models.py:110: the function validates the stripped form but the caller stores the original self.link_url (possibly with leading/trailing whitespace). The frontend modal pre-trims before sending, so this is safe in practice, but a direct API call or serializer path could persist a URL with leading whitespace that renders as a broken link. Consider stripping before persisting:

if self.link_url:
    self.link_url = self.link_url.strip()
    validate_link_url(self.link_url)

8. mypy.ini blanket suppression for the new test file

Several existing test files use this pattern. Worth a quick pass to see if the suppressed errors are real type gaps that should be fixed rather than ignored.

What's Done Well

  • Security layering: validate_link_url runs unconditionally in save() regardless of VALIDATE_ANNOTATION_JSON; the serializer normalises empty-string clears; the renderer has a isSafeUrl mirror. That is the right design for a feature that calls window.open.
  • Modifier-key fallback: Shift/Ctrl/Cmd preserving normal selection behaviour is a thoughtful UX detail that lets authors edit/delete their own link annotations without accidentally navigating away.
  • Prop-injection over hook-in-renderer: threading createUrlAnnotation as a prop keeps SelectionLayer and TxtAnnotator decoupled from Apollo and unit-testable.
  • ensure_label_and_labelset reuse: mirroring the OC_SECTION pattern for auto-creating the OC_URL label per-corpus is clean and consistent.
  • Test breadth: backend model validation, GraphQL mutations, frontend hook, and Playwright modal tests all land together.

@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review — PR #1648: OC_URL Link Annotations

Overview

This PR adds clickable hyperlink annotations to both the PDF and text viewers. The implementation is well-structured: a link_url URLField on the Annotation model, a new addUrlAnnotation GraphQL mutation that auto-creates the OC_URL label, a shared validate_link_url() helper applied at every layer (model clean(), model save(), mutation, serializer), and a matching frontend-side isSafeUrl mirror so stale cached values cannot reach window.open. Test coverage is solid. A few issues below range from a real bug to minor nits.


Issues

Bug — TxtAnnotator edit path cannot reach the URL editor for OC_URL annotations without a set URL

In TxtAnnotator.tsx, the pencil-icon handler uses isUrlAnnotation(annotation) to decide whether to open the URL modal or the label modal. isUrlAnnotation requires both the OC_URL label and a non-empty linkUrl. If an OC_URL annotation has a null/empty linkUrl (e.g., created via the generic addAnnotation mutation without a link_url), the condition is false and the user gets the label editor instead of the URL editor — they have no way to attach a URL through the UI.

In Selection.tsx (PDF), the equivalent check is correctly done against the label text directly with annotation.annotationLabel?.text === OC_URL_LABEL, which fires regardless of whether linkUrl is set.

Suggestion: Align the TxtAnnotator check with the PDF one — check the label text rather than isUrlAnnotation when deciding which modal to open.


Gap — link_url is missing from the V2 export/import schema

The new link_url field is not included in the V2 export pipeline (opencontractserver/utils/export_v2.py) or the corresponding import pipeline. Any corpus containing OC_URL annotations exported then re-imported (or forked via the newly-unified fork pipeline) will silently lose all link targets. A test analogous to TestV2ThreeRoundTripDataIntegrity that includes a URL annotation would catch this.


Minor — Error message format for validate_link_url ValidationError

In both mutation mutate() methods, str(exc) on a ValidationError() produces a raw Python dict repr that toast.error displays verbatim. Catching DjangoValidationError specifically and joining exc.message_dict.get("link_url", exc.messages) would produce a cleaner user-facing string.


Minor — Redundant .toLowerCase() calls in isSafeUrl

urlAnnotation.ts calls normalized.toLowerCase() twice in the same expression. Extract to a local lower variable to avoid the double work.


Minor — useUpdateAnnotation sends linkUrl: null for every update

The hook unconditionally appends linkUrl: annotation.linkUrl ?? null to every update mutation. This is safe in practice (non-link annotations have linkUrl=null, OC_URL annotations send their current value back unchanged), but a test that updates an OC_URL annotation for an unrelated field and asserts linkUrl is preserved would make this contract explicit.


Positive Highlights

  • Defense in depth on URL validation is well-executed: model clean(), save() (with the eif self.link_url: guard for the skipped-clean path), mutation layer, serializer, frontend modal, and openAnnotationUrl at render time — all enforce the same allow-list.
  • isUrlAnnotation + openAnnotationUrl utilities centralise click behaviour so both PDF and text renderers stay in lockstep.
  • OC_URL auto-label creation correctly mirrors the OC_SECTION pattern and is idempotent across annotation types (TOKEN_LABEL vs SPAN_LABEL).
  • Modifier-key fallback (Shift/Ctrl/Cmd) restoring normal selection semantics on link annotations is well-implemented at both the SelectionBoundary and handleLabelClick layers.
  • Prop drilling is intentional and documented — keeps renderers testable without Apollo, consistent with the existing createAnnotationHandler pattern.
  • Test suite is comprehensive: unit tests for validate_link_url, mutation-level IDOR/unsafe-scheme tests, hook tests for happy path and guard conditions, and Playwright component tests for the modal.

JSv4 added 2 commits May 13, 2026 21:45
…_url

The pencil-icon handler in TxtAnnotator routed by isUrlAnnotation,
which requires both the OC_URL label AND a non-empty linkUrl. That
left OC_URL annotations with an unset linkUrl (e.g. created via the
generic addAnnotation path) trapped in the label editor with no way
to attach a URL.

Aligns the TxtAnnotator check with the PDF Selection check — route on
the label text directly so the URL editor opens whenever the user is
editing an OC_URL annotation, regardless of current linkUrl value.

Also drops a redundant .toLowerCase() call in isSafeUrl by
caching the lowered string once.
OC_URL annotations carry their target URL in Annotation.link_url.
Without round-trip support, forking a corpus or exporting + re-importing
silently drops every link target.

This commit adds:
- OpenContractsAnnotationPythonType.link_url (NotRequired Optional)
  to the export type contract.
- Export side: structural V2 (export_v2.build_corpus_v2_zip) and
  legacy / non-structural (etl.build_document_export) both emit
  link_url when it is non-NULL.
- Import side: structural V2 (import_v2.import_corpus_v2_from_bytes)
  and the bulk import path (importing.import_annotations) both
  restore link_url on the freshly-created Annotation rows.

Falsy / missing values fall through as NULL, so existing corpora
without link annotations are unaffected.
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review — PR #1648: OC_URL Link Annotations

Overall this is a well-scoped, well-described feature with good layering across backend and frontend. The security-first URL validation design is commendable. A few issues need attention before merge — one critical, a few majors, and several smaller items.


🔴 Security

[CRITICAL] S1 — Protocol-relative URL //evil.com bypasses the safe-URL allow-list (open redirect)

Both the backend and frontend allow-lists check startsWith("/") to allow site-relative paths, but //evil.com satisfies that check. A browser resolves //evil.com as https://evil.com, so an attacker can store //evil.com and have window.location.assign / window.open navigate the user there.

  • Backend: opencontractserver/annotations/models.pyvalidate_link_url() — the normalized.startswith("/") branch
  • Frontend: frontend/src/components/annotator/utils/urlAnnotation.tsisSafeUrl() and openAnnotationUrl() — same pattern

Fix: tighten the path check in both places:

# backend
if normalized.startswith("/") and not normalized.startswith("//"):
    return  # site-relative, OK
// frontend
if (url.startsWith("/") && !url.startsWith("//")) return true;

Also add //evil.com as a rejected test-case in ValidateLinkUrlTests and urlAnnotation.test.ts.


[MAJOR] S2 — DRF serializer raises django.core.exceptions.ValidationError instead of rest_framework.serializers.ValidationError

config/graphql/serializers.py — the validate_link_url field validator catches _validate(value) which raises a Django ValidationError. The DRF mutation base catches only rest_framework.serializers.ValidationError, so an unsafe URL submitted via updateAnnotation bubbles up as a 500 with "Mutation failed due to an internal error." and a backend traceback — confusing UX and noisy logs (the URL is still not persisted, so security is maintained).

Fix:

from rest_framework import serializers as drf_serializers
from django.core.exceptions import ValidationError as DjangoValidationError

try:
    _validate(value)
except DjangoValidationError as exc:
    msgs = list(exc.messages)
    raise drf_serializers.ValidationError(msgs[0] if msgs else "Invalid link URL.")

[MINOR] S3 — link_url skips Annotation.save() validation during bulk-import

opencontractserver/utils/importing.pyAnnotation.objects.bulk_create(instances) bypasses save(), and therefore the validate_link_url() guard. A crafted import ZIP could persist javascript:alert(1) in the database.

Fix: validate ann.link_url before the bulk_create call:

for ann in instances:
    if ann.link_url:
        validate_link_url(ann.link_url)  # raises ValidationError early
Annotation.objects.bulk_create(instances)

🟠 Bugs

[MAJOR] B1 — OC_URL annotations are invisibly filtered out of UnifiedContentFeed

frontend/src/components/knowledge_base/document/unified_feed/UnifiedContentFeed.tsx — the existing filter hides any annotation whose label starts with "OC_" (the structural-label prefix). OC_URL matches, so users can create link annotations but they never appear in the annotation list panel — no error, just silent disappearance.

Fix: key the rule on the structural flag (which OC_URL annotations should not have) rather than the label prefix:

if (ann.annotationLabel.text?.startsWith(STRUCTURAL_LABEL_PREFIX)
    && ann.annotationLabel.text !== OC_URL_LABEL) {
  return;
}

Or, more robustly, rely solely on ann.structural === true.

[MINOR] B2 — handleConfirmCreateLink in SelectionLayer.tsx can silently call the mutation with json: {}

If every pageInfo.getPageAnnotationJson() call returns null, the function still calls createUrlAnnotation with json: {}. The backend rejects this, but the user receives a generic failure. Add an early guard:

if (!Object.keys(json).length) {
  toast.error("Could not determine annotation bounds — please try again.");
  return;
}

[MINOR] B3 — Misleading name: onShiftClick now handles all clicks on link annotations

frontend/src/components/annotator/display/components/Selection.tsx — the prop/function is called onShiftClick but it now also handles normal clicks when the annotation is an OC_URL. Rename to handleAnnotationClick to avoid misleading future readers.


🟡 Code Quality

[MODERATE] Q1 — Deferred local import in serializers.py

# config/graphql/serializers.py
def validate_link_url(self, value):
    from opencontractserver.annotations.models import validate_link_url as _validate
    ...

There is no circular-import reason for this to be a deferred local import. Move it to the module-level imports block per standard Django convention.

[MINOR] Q2 — Magic color "#2563EB" in SelectionLayer.tsx

The placeholder label color for OC_URL annotations is hardcoded as "#2563EB". Per CLAUDE.md, magic values belong in constants files. Add OC_URL_LABEL_COLOR = "#2563EB" to frontend/src/assets/configurations/constants.ts (mirroring the backend constant) and reference it.

[MINOR] Q3 — _format_link_url_error in annotation_mutations.py should live in a utility module

Per CLAUDE.md: "Utility functions belong in utility files." This Django ValidationError→string formatter is a general helper; it should be in opencontractserver/utils/ rather than buried under a private name in the mutations file.


🟡 Tests

[CRITICAL given S1] T1 — No test for //evil.com protocol-relative bypass

Neither test_url_annotation.py:ValidateLinkUrlTests nor urlAnnotation.test.ts has a //evil.com case. Add it as a rejected input in both suites.

[MAJOR given B1] T2 — No test verifying OC_URL annotations surface in UnifiedContentFeed

Bug B1 exists entirely undetected because there is no test asserting that an OC_URL-labelled annotation appears in the feed. A simple unit test with a mocked annotation list would catch the regression.

[MODERATE given S3] T3 — No test for importing.py bulk-create with a malicious link_url

A direct test of import_annotations() passing link_url="javascript:alert(1)" would expose the S3 gap and serve as a regression guard.

[MINOR] T4 — Weak conditional assertion in test_update_rejects_unsafe_link_url

if "data" in result:
    ...

This silently passes when the server returns a GraphQL-level error object. The database assertion below is the real safety net, but the intent should be made unconditional so a future refactor can't silently drop the guard.


✅ What's Good

  • The two-layer validation strategy (backend save() + mutation layer + DRF serializer) is well-designed — defense in depth.
  • isSafeUrl() mirroring the backend allow-list in the renderer is exactly the right defensive pattern for stale cached data.
  • noopener,noreferrer on external window.open calls — correct.
  • ensure_label_and_labelset reuse mirrors the OC_SECTION pattern cleanly.
  • Prop-threading architecture keeps PDF/text renderers decoupled from Apollo and unit-testable.
  • CHANGELOG entry is thorough and well-formatted.

Priority order for fixes before merge:

  1. S1 (protocol-relative open redirect) + T1
  2. B1 (OC_URL annotations invisible in feed) + T2
  3. S2 (serializer exception type mismatch)
  4. S3 (bulk-import bypass) + T3
  5. Remaining minor items

… whitespace trim

Bugs:
- UnifiedContentFeed previously filtered out *every* annotation whose
  label started with the OC_ prefix to hide structural / system
  annotations. OC_URL is user-authored and SHOULD appear, so this
  silently removed every link annotation from the feed. Add an explicit
  carve-out for OC_URL_LABEL.
- openAnnotationUrl now accepts an optional navigate callback so
  call sites can route site-relative paths through useNavigate()
  instead of window.location.assign (a hard reload that discards
  Apollo cache + router state). PDF (Selection.tsx) and text
  (TxtAnnotator.tsx) call sites threaded through. The no-arg overload
  still falls back to window.location.assign for non-router
  contexts.

Hygiene:
- Extract OC_URL_LABEL_COLOR constant and use it in SelectionLayer
  placeholder. Was a hard-coded "#2563EB" literal that could
  drift from the backend OC_URL_LABEL_COLOR Python constant.
- Annotation.clean() and Annotation.save() now .strip()
  link_url before validating + persisting, so a direct API call
  that submits leading/trailing whitespace doesn't store a broken URL.
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review — PR #1648: OC_URL Link Annotations

Overview

This PR adds clickable hyperlink annotations anchored to highlighted text in both PDF and text/markdown viewers. The feature is well-architected with solid defense-in-depth for security (scheme allow-listing at model, serializer, mutation, and frontend renderer layers). The test coverage is comprehensive. Overall this is a high-quality PR.


Security — Strong

The multi-layer URL validation is a good pattern:

  1. Model-level (Annotation.save() + clean()): always strips whitespace and validates scheme regardless of VALIDATE_ANNOTATION_JSON
  2. GraphQL layer (AddAnnotation, AddUrlAnnotation): validates before the parent lookup so unsafe URLs are rejected before any DB reads
  3. Serializer layer (validate_link_url): normalises empty string → None and validates
  4. Frontend renderer (openAnnotationUrl): mirrors the backend allow-list so a stale cached annotation cannot reach window.open

The noopener,noreferrer on external links and the SPA-aware navigate fallback for site-relative paths are both correct.


Potential Bug — Export / Import Loses link_url

OpenContractsAnnotationPythonType (in opencontractserver/types/dicts.py) gains the link_url: NotRequired[Optional[str]] field, but there are no corresponding changes to the V2 export builder (export_tasks_v2.py) or importer (import_v2.py). When a corpus containing OC_URL annotations is exported and re-imported (or forked, which drives the V2 pipeline), link_url will silently be dropped from every annotation.

The PR description mentions round-trip concerns for the fork-tasks change in the changelog, but the actual export/import serialization of this new field does not appear to be in the diff. Worth verifying before merge.


Test Weakness — test_update_rejects_unsafe_link_url

In UpdateAnnotationLinkUrlTests:

if "data" in result and result["data"]:
    payload = result["data"].get("updateAnnotation") or {}
    self.assertFalse(payload.get("ok", False))

The outer if guard means the test silently passes if the mutation raises a GraphQL error (i.e. result["data"] is None or absent) rather than returning ok=False. The DB-row check earlier is the real safety net, but the ok=False assertion should be unconditional:

payload = result.get("data", {}).get("updateAnnotation", {}) or {}
self.assertFalse(payload.get("ok", True))  # default True forces failure if key absent

Minor Issues

1. Constants comments are too longconstants.ts and constants.py use multi-line prose for OC_URL_LABEL, PENDING_OC_URL_LABEL_ID, and OC_URL_LABEL_COLOR. Project conventions say comments should only explain the non-obvious "why"; the one-liner # Keep in sync with ...py on OC_SECTION_LABEL is the right model.

2. Migration field type vs PR description — The description says "new Annotation.link_url URLField" but the migration correctly uses CharField. The code comment in models.py explains why (URLValidator rejects site-relative paths). The implementation is right; the PR description is slightly misleading.

3. Minor redundancy in openAnnotationUrl — Both isSafeUrl and openAnnotationUrl call url.trim(). Harmless, but openAnnotationUrl could reuse the normalised value from isSafeUrl rather than trimming twice.

4. isSafeUrl does not validate URL structure beyond schemehttps:// (empty host) passes the allow-list. The browser fails gracefully, but worth noting for future hardening.


What is Well Done

  • The prop-drilling choice for createUrlAnnotation (rather than calling the hook directly inside SelectionLayer/TxtAnnotator) is the right call for testability and is well-documented in the prop JSDoc.
  • isUrlAnnotation requiring both the OC_URL label and a non-empty linkUrl prevents half-created annotations from behaving as links — good UX and a subtle correctness guard.
  • The Shift/Ctrl/Cmd modifier key fallback to selection behaviour is a thoughtful author UX detail.
  • transaction.atomic() wrapping ensure_label_and_labelset + annotation.save() in AddUrlAnnotation is correct.
  • The UnifiedContentFeed exception (show OC_URL despite the OC_ prefix filter) with an explicit comment is clean and maintainable.
  • PENDING_OC_URL_LABEL_ID as a named sentinel instead of a raw string comparison is good practice.
  • Comprehensive backend test suite covering IDOR, scheme rejection, whitespace-prefix bypass, case-insensitive scheme, and serializer normalisation.

Summary: One concrete bug to resolve (export/import round-trip for link_url), one test to tighten, and minor nits. The security design is solid and the feature is well integrated across both viewers.

…nAnnotationUrl

Test:
- test_update_rejects_unsafe_link_url default-True on .get("ok") so a
  missing/absent updateAnnotation payload still fails the assertion.
  Silent absence is no longer treated as success. Drop the outer
  data-presence guard.

Hygiene:
- openAnnotationUrl trims the URL once and reuses the normalised form
  for both the safety check and navigation, instead of letting isSafeUrl
  trim again internally.
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review — PR #1648: OC_URL Link Annotations

Overall this is a well-scoped, carefully layered feature. The defence-in-depth approach to URL validation (model validator → GraphQL layer → DRF serializer → frontend renderer) is exactly the right pattern for a security-sensitive field. Test coverage for the new code is high. The issues below are ordered by severity.


High — Potential Bugs

1. Protocol-relative URL bypass in isSafeUrl / validate_link_url

Both the frontend isSafeUrl and the backend validate_link_url check whether the URL starts with / to allow site-relative paths. A protocol-relative URL like //evil.com/path also starts with / and passes this check, but:

  • The backend would store it as-is.
  • In openAnnotationUrl, it hits the "site-relative" branch and calls navigate("//evil.com/path") or window.location.assign("//evil.com/path") — both of which navigate the browser externally to evil.com.

Fix: Add a !normalized.startsWith("//") guard to the /-relative branch in both places.

# opencontractserver/annotations/models.py — validate_link_url
if normalized.startswith("/") and not normalized.startswith("//"):
    return value
// frontend/src/components/annotator/utils/urlAnnotation.ts — isSafeUrl
if (normalized.startsWith("/") && !normalized.startsWith("//")) return true;

Test cases to add: "//evil.com", "//evil.com/path" — both should be rejected.

2. Whitespace-only link_url stored as "" instead of None

In Annotation.save(), the guard if self.link_url: is truthy for " " (whitespace-only string). The string is stripped to "", and then validate_link_url("") returns None without raising — so the empty string is persisted rather than None.

Fix in Annotation.save() (and clean()):

if self.link_url is not None:
    self.link_url = self.link_url.strip() or None  # normalise "" → None
    if self.link_url:
        validate_link_url(self.link_url)

Medium — Data Integrity / Latent Issues

3. Race condition in ensure_label_and_labelset

The filter(...).first() + create(...) pattern is not atomic. Two concurrent first-URL-annotation requests in the same corpus will both see label=None and both attempt to create() the OC_URL label, raising an IntegrityError. This is pre-existing for OC_SECTION, but it's worth fixing now that this feature ships.

Fix: Use get_or_create with defaults={}:

label, _ = AnnotationLabel.objects.get_or_create(
    text=OC_URL_LABEL,
    label_type=LabelType.TOKEN_LABEL,
    corpus_id=self.pk,
    defaults={"color": OC_URL_LABEL_COLOR, ...},
)

4. Variable shadowing in SelectionLayer.tsx

Inside handleConfirmCreateLink, const pages = Object.keys(selections).map(Number) shadows the outer pages from usePages(). This is harmless today but will cause a confusing bug if a future edit accidentally uses the outer pages instead.

Fix: Rename the local: const selectionPageNums = Object.keys(selections).map(Number).


Low — Code Quality / Convention

5. Inline styles in CreateUrlAnnotationModal.tsx

The modal interior (input, label, error div) uses raw style={{...}} objects rather than styled-components, which is the pattern used everywhere else in the annotator UI. The OS-Legal UI design system already provides form input primitives.

6. validate_link_url belongs in a utils module

Per CLAUDE.md, utility functions belong in opencontractserver/utils/. The current location in models.py works, but it forces a circular local import in serializers.py (from opencontractserver.annotations.models import validate_link_url as _validate). Moving it to e.g. opencontractserver/utils/annotations.py resolves both issues.

7. CHANGELOG / PR description say "URLField"

Both the CHANGELOG entry and the PR description say Annotation.link_url URLField, but the implementation correctly uses CharField (with a code comment explaining why URLField was rejected). Minor documentation inaccuracy worth correcting so future readers aren't confused.

8. Missing isOcUrlLabel predicate in urlAnnotation.ts

TxtAnnotator.tsx uses both isUrlAnnotation() (from urlAnnotation.ts, which checks label + non-empty URL) and a raw annotation.annotationLabel?.text === OC_URL_LABEL check (for the edit-pencil case, which intentionally needs only the label). A named predicate isOcUrlLabel(annotation) exported from urlAnnotation.ts would make the distinction explicit and eliminate the magic string at the call site.


Test Coverage Gaps

The existing test suite is strong. These are the remaining gaps:

Gap Files
//evil.com bypass (see #1 above) test_url_annotation.py, urlAnnotation.test.ts
Whitespace-only link_url normalisation (see #2) test_url_annotation.py
Export/import round-trip with link_url Currently no test covers etl.py, export_v2.py, import_v2.py changes
Concurrent ensure_label_and_labelset (see #3) Would require a TransactionTestCase with threads

Minor Nits

  • isPublic is hardcoded to false in useCreateUrlAnnotation's local-state construction (pre-existing pattern from useCreateAnnotation). The server returns the real value; the client should read created.isPublic instead.
  • OC_URL_LABEL_ICON is exported from the backend constants but not from frontend/src/assets/configurations/constants.ts, breaking the "mirrors the backend constants" comment in that file. Since the icon isn't used in the frontend, just remove the comment or add the constant.

Summary

The security model is solid and the core implementation is correct. The highest-priority fix is the //evil.com protocol-relative bypass (#1), which is a real open-redirect / safe-URL bypass that affects both the backend validator and the frontend isSafeUrl check. The whitespace normalisation gap (#2) is also worth closing before merge. Everything else is code-quality cleanup that could land in a follow-up.

…only normalisation

Critical security fix:
- //evil.com is a protocol-relative URL — browsers resolve it as
  https://evil.com. It starts with / so the previous site-relative
  branch in validate_link_url (backend) and isSafeUrl (frontend)
  would accept it as safe, then navigate/window.location.assign
  would happily redirect to evil.com. Both now explicitly reject any
  string starting with // before falling through to the allow-list.

Test gap:
- Whitespace-only link_url (e.g. "   ") was truthy as a Python
  string. Annotation.clean()/save() would strip it to "" and
  then validate_link_url("") returns clean — so the column ended up
  with an empty string instead of NULL. Both now collapse
  whitespace-only to None via self.link_url.strip() or None.

Tests:
- test_url_annotation.py: new tests for protocol-relative rejection and
  whitespace-only normalisation.
- urlAnnotation.test.ts: new test for protocol-relative rejection.
@claude
Copy link
Copy Markdown

claude Bot commented May 14, 2026

Code Review: OC_URL Link Annotations (#1648)

Overview

This PR adds clickable hyperlink annotations (OC_URL) anchored to highlighted text in both the PDF viewer and text/markdown viewer. The scope is substantial (~2500 lines) but well-scoped: a new Annotation.link_url CharField, a new addUrlAnnotation GraphQL mutation, a shared urlAnnotation.ts utility, a new CreateUrlAnnotationModal, and a new useCreateUrlAnnotation hook. The security-first approach (multi-layer URL validation, noopener,noreferrer, protocol-relative URL guard) is excellent.


Bugs

1. update({ linkUrl: null }) cannot clear an existing URL

In annotations.ts, both ServerSpanAnnotation.update() and ServerTokenAnnotation.update() use nullish coalescing (??) to merge linkUrl:

// ServerSpanAnnotation.update()
delta.linkUrl ?? this.linkUrl

null ?? this.linkUrl evaluates to this.linkUrl, so passing { linkUrl: null } silently keeps the old URL instead of clearing it. The UI never exercises this path today (the modal only sets new values), but useUpdateAnnotation already plumbs linkUrl: annotation.linkUrl ?? null to the server. If a future caller tries annotation.update({ linkUrl: null }) expecting a clear, they'll get a silent no-op on the client while the server correctly clears. Use a sentinel-aware merge instead:

// in update():
linkUrl: 'linkUrl' in delta ? delta.linkUrl : this.linkUrl

2. max_length=2048 is not enforced in save() when VALIDATE_ANNOTATION_JSON=False

In production (VALIDATE_ANNOTATION_JSON=False), save() skips self.clean() and only calls validate_link_url(). The CharField.max_length validator is not in validate_link_url(), so a URL >2048 bytes bypasses the length cap and lands in the DB (where Postgres will raise a DataError rather than a clean ValidationError). Either add a length check inside validate_link_url(), or always call self.clean_fields(exclude=["json"]) for link_url regardless of the VALIDATE_ANNOTATION_JSON flag.


Code Quality

3. Inconsistent isUrlAnnotation vs label-text check in Selection.tsx

isLinkAnnotation uses isUrlAnnotation(annotation) (requires label + non-empty linkUrl) while the pencil-icon condition uses the raw string compare:

if (annotation.annotationLabel?.text === OC_URL_LABEL) {
  setIsEditUrlModalVisible(true);
}

TxtAnnotator.tsx includes a comment explaining this intentional difference ("the URL editor opens even when link_url is null/empty"). Selection.tsx has no such comment, making it look like an accidental inconsistency. Add a one-liner explaining the intent.

4. AddUrlAnnotation duplicates ~60% of AddAnnotation's logic

AddUrlAnnotation.mutate re-implements _resolve_annotation_parents, permission checks, Annotation(...) construction, set_permissions_for_obj_to_user, and the final return shape. The only novel parts are the ensure_label_and_labelset call and the mandatory link_url. Consider extracting a _create_annotation_record(...) helper that both mutations share, similar to how _resolve_annotation_parents was already extracted. (Not blocking, but will matter if either mutation needs changes later.)

5. handleSpanClick re-created inside the render loop in TxtAnnotator

const handleSpanClick = linkAnnotation
  ? (event: React.MouseEvent) => { ... }
  : undefined;

return (
  <AnnotatedSpan ... onClick={handleSpanClick} />
);

This creates a new function reference for every annotated span on every render. Wrap it with useCallback keyed on linkAnnotation?.linkUrl or hoist it outside the span loop.


Minor Notes

6. combinedRawText accumulates a leading space

In SelectionLayer.tsx → handleConfirmCreateLink:

combinedRawText += " " + pageAnnotation.rawText;

The leading space is trimmed at the end (combinedRawText.trim()), which is fine, but a cleaner pattern is parts.push(rawText)parts.join(" ").trim().

7. Unrelated type-annotation fixes in pipeline files

Several pipeline files (docling_parser_rest.py, multimodal_microservice.py, etc.) change dict[str, str]dict[str, str | bytes] for headers. These are correct but unrelated to OC_URL. Worth splitting into a separate commit for cleaner history.

8. mypy.ini exempts the new test file

[mypy-opencontractserver.tests.test_url_annotation]
ignore_errors = True

All other new test files in this repo are also exempted, so this is consistent. Worth fixing at some point but not a blocker for this PR.


Security

The multi-layer validation approach is solid:

  • validate_link_url() runs in Annotation.save() unconditionally (separate from VALIDATE_ANNOTATION_JSON).
  • GraphQL mutation and DRF serializer also call validate_link_url() eagerly so the error message is user-facing rather than a DB exception.
  • Frontend isSafeUrl() mirrors the backend allow-list.
  • Protocol-relative URL (//evil.com) is explicitly blocked on both sides.
  • External links use noopener,noreferrer.
  • window.open is guarded by a second isSafeUrl call at click time so stale cached annotations can't bypass the scheme check.

One suggestion: the validate_link_url function lives in opencontractserver/annotations/models.py but its purpose is really a domain-level utility. If you later want to reuse it from, say, a pipeline or an import task, you'd need to import from models.py. Consider moving it to opencontractserver/utils/ with a re-export from models.py for backwards compat.


Test Coverage

  • urlAnnotation.test.ts: thorough coverage of isSafeUrl and openAnnotationUrl, including the protocol-relative guard.
  • AnnotationHooks.test.tsx: all four cases for useCreateUrlAnnotation (missing corpus, blank URL, success, network error, server ok=false) look correct. Note the useUpdateAnnotation mock was also correctly updated to include linkUrl: null.
  • test_url_annotation.py: covers validate_link_url edge cases, the mutation happy path, the idempotent label-creation, permission gating, and scheme rejection.
  • Missing: no test for clearing an existing link_url via updateAnnotation (passing empty string → server should set it to null). Given bug Bump postgres from 14.5 to 15.0 in /compose/production/postgres #1 above, this path is also untested on the client.

Summary

The feature is well-designed and production-ready with the two bug fixes above (the ??/null-clearing issue and the max_length enforcement gap). Everything else is polish. The security handling is genuinely careful. Bugs #1 and #2 should be resolved before merge; the rest are non-blocking suggestions.

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.

2 participants