---
phase: 04-folders-sharing-quotas-document-ux
plan: 05
type: execute
wave: 4
depends_on:
  - "04-03"
  - "04-04"
files_modified:
  - backend/api/documents.py
  - backend/api/auth.py
autonomous: true
requirements:
  - DOC-01
  - DOC-02
  - FOLD-04

must_haves:
  truths:
    - "GET /api/documents/{id}/content streams document bytes from MinIO through FastAPI"
    - "Range header requests return 206 with correct Content-Range header"
    - "Admin cannot access the streaming proxy — get_regular_user dep returns 403"
    - "No presigned URL is generated or exposed in the proxy response"
    - "Share recipients can access document content via the proxy"
    - "PATCH /api/auth/me/preferences stores and returns pdf_open_mode preference"
  artifacts:
    - path: "backend/api/documents.py"
      provides: "GET /api/documents/{id}/content streaming proxy with Range header support"
    - path: "backend/api/auth.py"
      provides: "PATCH /api/auth/me/preferences endpoint for pdf_open_mode"
  key_links:
    - from: "backend/api/documents.py"
      to: "backend/storage/minio_backend.py"
      via: "get_storage_backend().get_object(doc.object_key) — bytes fetched directly, not via presigned URL"
      pattern: "get_object"
    - from: "backend/api/documents.py"
      to: "backend/db/models.py"
      via: "Share model for _can_access_document() recipient check"
      pattern: "Share.recipient_id"
---

<objective>
Add the PDF streaming proxy to the documents API (DOC-02) and the pdf_open_mode preferences
endpoint to the auth API (D-10, DOC-01). These can be implemented together since they both
modify existing API modules and neither depends on the other.

Purpose: Complete document content access — both the proxy stream and the user preference for
how to open PDFs.
Output: Streaming proxy endpoint in documents.py + preferences PATCH in auth.py.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-RESEARCH.md
@backend/api/documents.py
@backend/api/auth.py
@backend/storage/minio_backend.py
@backend/db/models.py
</context>

<interfaces>
<!-- Key interfaces the executor needs. Extracted from codebase. -->

<!-- From backend/storage/minio_backend.py — get_object signature (read the actual file to confirm):
async def get_object(self, object_key: str) -> bytes:
    # wraps self._client.get_object via asyncio.to_thread; returns raw bytes
-->

<!-- Range header parse pattern (from RESEARCH.md Pattern 3):
def _parse_range(range_header: str, file_size: int) -> tuple[int, int]:
    try:
        h = range_header.replace("bytes=", "").split("-")
        start = int(h[0]) if h[0] != "" else 0
        end = int(h[1]) if h[1] != "" else file_size - 1
    except (ValueError, IndexError):
        raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
    if start > end or start < 0 or end >= file_size:
        raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
    return start, end
-->

<!-- _can_access_document pattern (from RESEARCH.md Pattern 4):
async def _can_access_document(session, doc, current_user_id):
    if doc.user_id == current_user_id: return True
    result = await session.execute(select(Share).where(Share.document_id == doc.id, Share.recipient_id == current_user_id))
    return result.scalar_one_or_none() is not None
-->

<!-- User.pdf_open_mode column (added in migration 0004):
pdf_open_mode: Mapped[str]  # server_default = "in_app"; allowed: "in_app" | "new_tab"
-->
</interfaces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add GET /api/documents/{id}/content streaming proxy to documents.py</name>
  <files>backend/api/documents.py</files>
  <read_first>
    backend/api/documents.py — read the entire file; identify current imports (Request, StreamingResponse if already present; func, Select, etc.); find the endpoint list to determine where to insert the new endpoint; confirm existing UUID parse pattern and get_regular_user usage
    backend/storage/minio_backend.py — read to confirm the exact name and signature of get_object(); confirm it returns bytes (not a stream object)
    backend/db/models.py — search for the Share class definition; confirm Share.document_id and Share.recipient_id column names
  </read_first>
  <behavior>
    GET /api/documents/{id}/content:
    - MUST use get_regular_user dep (admin role → 403 — Pitfall 3, CRITICAL)
    - Parse doc_id as UUID → 404 "Document not found" on ValueError
    - Load document via session.get(Document, uid) → 404 if None
    - Access check: if doc.user_id == current_user.id → proceed; else query Share where Share.document_id == doc.id AND Share.recipient_id == current_user.id; if no share → 404 "Document not found"
    - Fetch bytes: `file_bytes = await get_storage_backend().get_object(doc.object_key)` — this calls MinIO directly, NO presigned URL
    - Build base headers: content-type = doc.content_type, content-disposition = `inline; filename="{doc.filename}"`, accept-ranges = "bytes", content-length = str(file_size)
    - Range header handling: if request.headers.get("range") is truthy, call _parse_range() → on valid range: add content-range header, set content-length to chunk length, return StreamingResponse(iter([chunk]), status_code=206, headers=headers)
    - No range: return StreamingResponse(iter([file_bytes]), status_code=200, headers=headers)
    - _parse_range() is a module-level helper function, not an endpoint (defined above the endpoint)

    DOC-01 note: GET /api/documents/{id} already returns extracted_text — verify this is still included in the existing response; add it to the response if absent (read the actual endpoint to check before modifying).
  </behavior>
  <action>
    Modify backend/api/documents.py.

    Add to imports if not already present: `from fastapi import Request` (alongside existing FastAPI imports), `from fastapi.responses import StreamingResponse`, `from fastapi import status`. Confirm `from db.models import Share` is in imports (added in plan 04-03 for the is_shared subquery — if absent, add it now).

    Define _parse_range() helper function at module level (above the stream_document_content endpoint). Implement exactly as specified in the behavior block and RESEARCH.md Pattern 3: parse "bytes=X-Y" format, handle open-ended ranges (end = file_size - 1 when Y is empty), raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE) on invalid range.

    Add the stream_document_content endpoint after the existing document endpoints. Implement the access check using an inline query for Share (do NOT call a separate helper function — the access check is in the handler body for clarity and to avoid test mocking complexity).

    CRITICAL: endpoint decorator uses `get_regular_user` not `get_current_user`. This is verified by the security agent and by test_content_stream_admin_403.

    CRITICAL: NEVER call presigned_get_url() or any presigned URL method inside this endpoint. Bytes must flow directly from MinIO through FastAPI.
  </action>
  <verify>
    <automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_documents.py -x -v --no-header 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - `GET /api/documents/{id}/content` endpoint exists in documents.py (grep: `async def stream_document_content` or similar)
    - Endpoint uses `get_regular_user` dep, NOT `get_current_user` (grep: `Depends(get_regular_user)` on the content endpoint; no `get_current_user` on content endpoint)
    - _parse_range() helper exists at module level (grep: `def _parse_range`)
    - StreamingResponse imported and used (grep: `StreamingResponse` in documents.py)
    - No presigned URL call in the handler (grep: `presigned` absent from stream_document_content function body)
    - Share access check present (grep: `Share.recipient_id` or `recipient_id` in documents.py)
    - test_content_stream_admin_403 turns green (xpass) or remains xfail — not FAILED
    - test_content_stream_no_presigned_url turns green or remains xfail — not FAILED
    - `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
  </acceptance_criteria>
  <done>Streaming proxy delivers bytes via StreamingResponse; admin blocked; Range headers supported; no presigned URL exposure.</done>
</task>

<task type="auto">
  <name>Task 2: Add PATCH /api/auth/me/preferences endpoint for pdf_open_mode</name>
  <files>backend/api/auth.py</files>
  <read_first>
    backend/api/auth.py — read the entire file; identify the /api/auth/me GET endpoint (existing); find the router prefix and how it is declared; confirm get_current_user (not get_regular_user) is used for /me endpoints (admin can read/update their own preferences); find the existing import block to add pydantic BaseModel if needed
    backend/db/models.py — confirm users.pdf_open_mode column name and that it exists (added by migration 0004)
  </read_first>
  <behavior>
    PATCH /api/auth/me/preferences:
    - Auth: get_current_user (both regular users and admins can set their own PDF preference — D-10)
    - Body: {pdf_open_mode: str} — validated to be one of ["in_app", "new_tab"]
    - If pdf_open_mode not in allowed values → 422 (Pydantic validation handles this via Literal type or custom validator)
    - Update current_user.pdf_open_mode = body.pdf_open_mode
    - session.add(current_user); await session.commit()
    - Return 200: {pdf_open_mode: current_user.pdf_open_mode}

    GET /api/auth/me/preferences (optional but recommended for frontend to load initial value):
    - Auth: get_current_user
    - Return 200: {pdf_open_mode: current_user.pdf_open_mode}
    - If pdf_open_mode column is missing from User model (migration not yet run), return {"pdf_open_mode": "in_app"} as default
  </behavior>
  <action>
    Modify backend/api/auth.py.

    Add a Pydantic request model: PreferencesUpdate with field `pdf_open_mode: Literal["in_app", "new_tab"]`. Import `Literal` from typing.

    Add two new endpoints at the end of the router function list:
    1. GET /api/auth/me/preferences — returns {pdf_open_mode: str}. Uses `get_current_user` dep. Reads current_user.pdf_open_mode. If AttributeError (column not yet in ORM map, e.g., test env without migration), return {"pdf_open_mode": "in_app"}.
    2. PATCH /api/auth/me/preferences — accepts PreferencesUpdate body. Updates current_user.pdf_open_mode. Commits. Returns {pdf_open_mode: updated value}.

    Both endpoints are placed after the existing /api/auth/me endpoint. No new router prefix needed — they use the same auth router.

    Do NOT break any existing auth tests. Do NOT modify any existing endpoint.
  </action>
  <verify>
    <automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from api.auth import router; print([r.path for r in router.routes])" 2>&1</automated>
  </verify>
  <acceptance_criteria>
    - `/api/auth/me/preferences` appears in auth router routes (grep: `me/preferences` in auth.py)
    - PATCH endpoint validates pdf_open_mode via Literal["in_app", "new_tab"] (grep: `Literal` and `pdf_open_mode` in auth.py)
    - `python -c "from api.auth import router"` exits 0
    - Existing auth tests still pass: `cd backend && python -m pytest tests/test_auth_api.py -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing
    - Full suite: `cd backend && python -m pytest -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing
  </acceptance_criteria>
  <done>PATCH /api/auth/me/preferences stores pdf_open_mode; GET returns current value; existing auth tests unaffected.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Client → GET /api/documents/{id}/content | Untrusted doc_id; access checked via ownership or active share; bytes flow from MinIO through FastAPI only |
| Client → Range header | Untrusted byte ranges; _parse_range() validates start/end bounds against file_size; invalid range → 416 |
| Client → PATCH /api/auth/me/preferences | Untrusted pdf_open_mode value; Pydantic Literal validates allowlist |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-05-01 | Broken Access Control | GET /api/documents/{id}/content — admin access | mitigate | MUST use get_regular_user dep: admin role → 403; test_content_stream_admin_403 validates; security agent checks |
| T-04-05-02 | Information Disclosure | Presigned URL exposure in proxy response | mitigate | presigned_get_url() NEVER called in stream_document_content; bytes fetched via get_object() directly; test_content_stream_no_presigned_url validates |
| T-04-05-03 | Information Disclosure | Range header bypass — out-of-bounds access | mitigate | _parse_range() validates: start <= end, start >= 0, end < file_size; → 416 on any violation |
| T-04-05-04 | Information Disclosure | Non-recipient accessing shared document via proxy | mitigate | Access check: doc.user_id == current_user.id OR active Share.recipient_id == current_user.id; neither → 404 |
| T-04-05-05 | Tampering | pdf_open_mode mass assignment | mitigate | Pydantic Literal["in_app", "new_tab"] enforces strict allowlist; no other user fields touched by preferences endpoint |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>

<verification>
1. Security test: `cd backend && python -m pytest tests/test_documents.py::test_content_stream_admin_403 tests/test_documents.py::test_content_stream_no_presigned_url -v`
2. Full suite: `cd backend && python -m pytest tests/ -v --no-header 2>&1 | grep -E "FAILED|ERROR"`
3. Admin dep grep: `grep -A5 "content" backend/api/documents.py | grep "get_regular_user"` — must match
4. Presigned URL grep: `grep -n "presigned" backend/api/documents.py` — confirms absence from stream handler
</verification>

<success_criteria>
- GET /api/documents/{id}/content proxies bytes from MinIO; admin gets 403; Range → 206; no presigned URL
- PATCH /api/auth/me/preferences stores valid pdf_open_mode; GET returns current value
- test_content_stream_* tests turn green or remain xfail; zero FAILED
- Full pytest suite green
</success_criteria>

<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-05-SUMMARY.md` when done.
</output>
