All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green. **Phase 175 final verification pass — report** - Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed. - Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate). - `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings. - Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN). - Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins). - Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's). - Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.** - Deviations: none. No defects found in previously completed work. - Next pending phase: none — 175 is the only entry in `todo/`.
489 lines
20 KiB
Python
489 lines
20 KiB
Python
"""Saved-chat API — save and view chat history (phase 50, task 02).
|
||
|
||
Split gate (phase 55, task 01 — owner-locked A1, superseding the
|
||
phase-50 "save/history is admin-only" lock): under ``/api/chats`` the
|
||
WRITE surface is **public** (no session) — ``POST`` (create, including
|
||
the save-then-share ``share: true`` branch), ``PUT /{chat_id}`` (the
|
||
re-Save upsert), ``POST /{chat_id}/share``. WHY: a save is the
|
||
visitor's OWN conversation; the row id is an unguessable ``uuid4``, the
|
||
same trust model as the phase-51 share token — the id/token IS the
|
||
credential (a guest holds the handle to what they just saved, exactly
|
||
as a link-holder holds a token). The MANAGEMENT surface is
|
||
**admin-only** and keeps the phase-16 :func:`app.core.auth.require_admin`
|
||
gate — applied per-route on exactly those four decorators (list,
|
||
detail, delete, unshare — the owner's History surface; the
|
||
:mod:`app.api.steering` router-wide pattern is untouched). Guest chats
|
||
appear in the admin's History (saved by default — phase 55).
|
||
|
||
Conversations are stored in Postgres (``saved_chats``, migration
|
||
0008).
|
||
|
||
A10 extension (owner permission 2026-08-29, recorded per AGENTS.md
|
||
rule 3 — a recorded revision, not a silent deviation): ``/api/chat``
|
||
itself stays stateless; nothing is stored about a conversation that was
|
||
not saved, and phase 14's browser-local persistence is untouched
|
||
(saving is an additional, explicit action). The stored ``messages``
|
||
payload is the exact ``bor.chat.v1`` localStorage record shape
|
||
(phase 14 — raw text, never HTML), so a saved chat restores
|
||
pixel-identical through the existing ``renderStoredMessage`` path.
|
||
|
||
Routes: ``GET`` (list — paginated, phase 175 task 01: ``limit``
|
||
(default 25, clamped 1–100) + ``offset`` (default 0); out-of-range
|
||
values 422 via FastAPI's field-message validation — no hand-rolled
|
||
checks — and the body reports the full ``total`` (one small count
|
||
query per request) so a client can walk the pages; rows are one
|
||
PAGE in the History page's table order, latest activity first
|
||
(``updated_at desc, id desc``); no payloads, but the phase-51
|
||
``share_url`` and the phase-53 ``stale`` flag so the History's
|
||
Share and Stale columns render without a second fetch),
|
||
``POST`` (create — auto-title from the first question
|
||
when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets
|
||
the token in the SAME commit — the save-then-share contract;
|
||
(phase 53, task 03) stamps the row's ``sources_version`` on the
|
||
PENDING row so it ships in the same INSERT),
|
||
``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
|
||
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
|
||
only when supplied; re-stamps ``sources_version`` to the current
|
||
generation — a Re-Save is the owner affirming this content against
|
||
the current KB), ``DELETE /{chat_id}``, and (phase 51, task 01)
|
||
``POST /{chat_id}/share`` (public, phase 55) /
|
||
``POST /{chat_id}/unshare`` (admin-only — the owner's History
|
||
action).
|
||
|
||
Staleness (phase 53, task 03): every saved row carries the
|
||
``sources_meta`` generation it was saved against (``sources_version``,
|
||
migration 0010). ``GET``/list/detail expose ``stale`` — true iff the
|
||
row's stamp is behind the current generation, computed server-side
|
||
from ONE ``current_sources_version`` read per request (the client
|
||
never does staleness math). Share/unshare stay version-immune (raw SQL
|
||
on ``share_token`` only — the version, like ``updated_at``, is
|
||
untouched); ``SharedChatOut`` (the public snapshot) carries no
|
||
staleness surface at all — it is frozen by design (phase 51).
|
||
|
||
Sharing (phase 51, owner-locked 2026-08-29): a saved chat's
|
||
``share_token`` (a 128-bit ``uuid4``, migration 0009) makes it
|
||
publicly readable at ``/shared/<token>`` — two more routers in this
|
||
file, registered in ``app.main`` with **no** admin dependency:
|
||
|
||
* ``public_router`` — ``GET /shared/{token}`` (mounted under ``/api``
|
||
→ ``GET /api/shared/<token>``): the anonymous read, a minimal
|
||
``SharedChatOut`` snapshot (no id/timestamps/token); wrong or
|
||
revoked tokens 404 with one message (no enumeration).
|
||
* ``shared_page_router`` — ``GET /shared/{token}`` (mounted with **no**
|
||
prefix, before the static catch-all): serves
|
||
``frontend/shared.html`` (the page lands in phase 51, task 03). A
|
||
stale deploy without the file 404s with the SAME JSON as the API —
|
||
never a 500.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||
from fastapi.responses import FileResponse, JSONResponse
|
||
from sqlalchemy import func, select, text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.config import get_settings
|
||
from app.core.auth import require_admin
|
||
from app.db import get_db
|
||
from app.models import SavedChat
|
||
from app.rag.sources_meta import current_sources_version
|
||
from app.schemas import (
|
||
ChatMessage,
|
||
SavedChatCreate,
|
||
SavedChatList,
|
||
SavedChatOut,
|
||
SavedChatRow,
|
||
SavedChatUpdate,
|
||
SharedChatOut,
|
||
ShareOut,
|
||
UnshareOut,
|
||
)
|
||
|
||
# Phase 55, task 01 (owner-locked A1): NO router-wide gate here — the
|
||
# write surface (create / re-Save / share) is public; exactly the four
|
||
# management routes below carry ``dependencies=[Depends(require_admin)]``.
|
||
router = APIRouter(
|
||
prefix="/chats",
|
||
tags=["chats"],
|
||
)
|
||
|
||
#: Auto-title cap (owner-locked convention, phase 50): the first user
|
||
#: message's text, whitespace-collapsed, truncated to 120 chars.
|
||
_AUTO_TITLE_MAX = 120
|
||
|
||
|
||
def _auto_title(messages: list[ChatMessage]) -> str:
|
||
"""The auto-title (owner-locked, phase 50): the **first user
|
||
message**'s text, whitespace-collapsed, truncated to 120 chars.
|
||
|
||
Returns "" when the conversation has no user message (defensive —
|
||
the UI cannot produce one; the route then falls back to
|
||
``"Chat <id-hex8>"``).
|
||
"""
|
||
first_user = next((m.text for m in messages if m.who == "user"), None)
|
||
if first_user is None:
|
||
return ""
|
||
return " ".join(first_user.split())[:_AUTO_TITLE_MAX]
|
||
|
||
|
||
def _share_url(row: SavedChat) -> str | None:
|
||
"""The row's share link path (phase 51) — ``None`` when unshared.
|
||
|
||
``None`` is ABSENT from the JSON (the schemas' omission rule), so an
|
||
unshared chat exposes no share surface at all.
|
||
"""
|
||
return f"/shared/{row.share_token}" if row.share_token else None
|
||
|
||
|
||
def _to_out(row: SavedChat, current_version: int) -> SavedChatOut:
|
||
"""The full-payload response shape (create/get/put).
|
||
|
||
``current_version`` is the caller's ONE per-request
|
||
``current_sources_version`` read — the helpers stay pure (no session
|
||
argument, no second query); ``stale`` is true iff the row's stamp
|
||
is behind that generation.
|
||
"""
|
||
return SavedChatOut(
|
||
id=row.id,
|
||
title=row.title,
|
||
created_at=row.created_at,
|
||
updated_at=row.updated_at,
|
||
message_count=len(row.messages),
|
||
messages=[ChatMessage.model_validate(m) for m in row.messages],
|
||
share_url=_share_url(row),
|
||
stale=row.sources_version < current_version,
|
||
)
|
||
|
||
|
||
def _to_row(row: SavedChat, current_version: int) -> SavedChatRow:
|
||
"""The list-page row shape (no payloads in the list; ``share_url``
|
||
and ``stale`` are not payloads — they are the History Share and
|
||
Stale columns' data). ``current_version`` is the caller's ONE
|
||
per-request read (see :func:`_to_out`)."""
|
||
return SavedChatRow(
|
||
id=row.id,
|
||
title=row.title,
|
||
updated_at=row.updated_at,
|
||
message_count=len(row.messages),
|
||
share_url=_share_url(row),
|
||
stale=row.sources_version < current_version,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"",
|
||
response_model=SavedChatList,
|
||
dependencies=[Depends(require_admin)], # management surface (phase 55)
|
||
)
|
||
def list_chats(
|
||
limit: int = Query(25, ge=1, le=100), # noqa: B008
|
||
offset: int = Query(0, ge=0), # noqa: B008
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> SavedChatList:
|
||
"""One PAGE of the saved chats (phase 175, task 01), latest
|
||
activity first (``updated_at desc, id desc``) — the History
|
||
page's table order, admin-only (the owner's History surface;
|
||
phase 55 moved the gate per-route).
|
||
|
||
``limit`` (default 25, clamped 1–100) and ``offset`` (default 0)
|
||
are FastAPI-validated: ``limit=0`` / ``limit=101`` / ``offset=-1``
|
||
422 with the house field-message contract (no hand-rolled checks).
|
||
A bare ``GET`` is the first 25 rows; an offset beyond the end is
|
||
honest — an empty ``chats`` list with the real ``total``.
|
||
|
||
``total`` is the FULL count across all saved chats: one small
|
||
``count`` query per request (two small queries total; the
|
||
once-per-request ``current_sources_version`` read is untouched),
|
||
so a client can walk the pages and know when it has seen them
|
||
all. Each row carries the phase-53 ``stale`` flag: the current
|
||
generation is read ONCE per request (one PK read of the seeded
|
||
row) and compared against every row's stamp in the serializer
|
||
helpers — no per-row queries."""
|
||
current_version = current_sources_version(db)
|
||
rows = db.scalars(
|
||
select(SavedChat)
|
||
.order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
|
||
.offset(offset)
|
||
.limit(limit)
|
||
).all()
|
||
total = db.scalar(select(func.count(SavedChat.id))) or 0
|
||
return SavedChatList(
|
||
chats=[_to_row(row, current_version) for row in rows],
|
||
total=total,
|
||
)
|
||
|
||
|
||
@router.post("", response_model=SavedChatOut, status_code=201)
|
||
def create_chat(
|
||
payload: SavedChatCreate,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> SavedChatOut:
|
||
"""Store one explicitly saved conversation (201).
|
||
|
||
PUBLIC (phase 55, task 01) — no session required: the save is the
|
||
visitor's own conversation; the unguessable ``uuid4`` row id IS the
|
||
credential (the phase-51 token's trust model).
|
||
|
||
Auto-title when ``title`` is absent/blank: the first user message's
|
||
text, whitespace-collapsed, truncated to 120 chars (owner-locked
|
||
convention); a conversation with no user message (defensive) falls
|
||
back to ``"Chat <id-hex8>"``.
|
||
|
||
``share: true`` (phase 51, task 02 — the save-then-share contract):
|
||
the fresh row carries ``share_token = uuid.uuid4()`` in the SAME
|
||
INSERT/commit — one request saves AND shares, and the 201 body
|
||
carries ``share_url`` (the chat page copies it in one action).
|
||
|
||
``sources_version`` (phase 53, task 03): stamped on the PENDING
|
||
row with the current generation — it ships in the SAME INSERT (the
|
||
``share_token`` precedent), so a save can never be committed
|
||
unstamped, and the 201 body's ``stale`` flag (freshly stamped →
|
||
always false) is honest by construction.
|
||
"""
|
||
title = (payload.title or "").strip() or _auto_title(payload.messages)
|
||
current_version = current_sources_version(db) # once per request
|
||
row = SavedChat(
|
||
title=title,
|
||
# Plain model_dump (no exclude_none): the stored JSONB keeps
|
||
# every bor.chat.v1 key, explicit null included — the phase-37
|
||
# tool records carry `argument: null` in localStorage, so this
|
||
# is what makes a real payload round-trip byte-identical (the
|
||
# restore path is null-safe for every optional key).
|
||
messages=[m.model_dump() for m in payload.messages],
|
||
# Phase 53: the KB generation this save is made against, set
|
||
# before db.add so it rides the same INSERT (see above).
|
||
sources_version=current_version,
|
||
)
|
||
if payload.share:
|
||
# Phase 51: set on the PENDING row, so the token ships in the
|
||
# same INSERT (one commit — save and share are one action).
|
||
row.share_token = uuid.uuid4()
|
||
db.add(row)
|
||
db.flush() # python-side uuid default lands the id before the fallback
|
||
if not row.title:
|
||
row.title = f"Chat {row.id.hex[:8]}"
|
||
db.commit()
|
||
db.refresh(row)
|
||
return _to_out(row, current_version)
|
||
|
||
|
||
@router.get(
|
||
"/{chat_id}",
|
||
response_model=SavedChatOut,
|
||
dependencies=[Depends(require_admin)], # management surface (phase 55)
|
||
)
|
||
def get_chat(
|
||
chat_id: uuid.UUID,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> SavedChatOut:
|
||
"""One saved chat, full payload (the ``?chat=<id>`` load) —
|
||
admin-only (the ``?chat=<id>`` boot restore is the owner's "Open"
|
||
action, phase 55 A3); 404 when the id is unknown. The ``stale`` flag (phase 53) tells the chat
|
||
page whether to reveal its stale banner (task 05) before the
|
||
messages render."""
|
||
row = db.get(SavedChat, chat_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="unknown chat")
|
||
return _to_out(row, current_sources_version(db))
|
||
|
||
|
||
@router.put("/{chat_id}", response_model=SavedChatOut)
|
||
def update_chat(
|
||
chat_id: uuid.UUID,
|
||
payload: SavedChatUpdate,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> SavedChatOut:
|
||
"""Re-Save upsert: full ``messages`` replacement on the same row.
|
||
|
||
PUBLIC (phase 55, task 01) — no session required (the auto-save
|
||
upsert for the visitor's own conversation; same row-id trust model
|
||
as :func:`create_chat`).
|
||
|
||
``title`` is replaced only when supplied (an absent/blank ``title``
|
||
keeps the current one); 404 when the id is unknown. ``updated_at``
|
||
bumps via the model's ``onupdate=func.now()`` — the attribute
|
||
assignment above is the ORM change that triggers it, so the History
|
||
page's "latest activity first" order follows re-Saves.
|
||
|
||
``sources_version`` is re-stamped to the CURRENT generation
|
||
unconditionally (phase 53, task 03 — ASSUMPTION: a Re-Save ALWAYS
|
||
re-stamps, not "only when stale"): re-Saving is the owner
|
||
affirming this content against the current KB, which makes the
|
||
response's ``stale`` flag false and doubles as the manual escape
|
||
hatch for a false-positive stale row (one fewer code path than a
|
||
conditional re-stamp).
|
||
"""
|
||
row = db.get(SavedChat, chat_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="unknown chat")
|
||
current_version = current_sources_version(db) # once per request
|
||
row.messages = [m.model_dump() for m in payload.messages]
|
||
title = (payload.title or "").strip()
|
||
if title:
|
||
row.title = title
|
||
# Phase 53: the affirmation stamp — see the docstring above.
|
||
row.sources_version = current_version
|
||
db.commit()
|
||
db.refresh(row)
|
||
return _to_out(row, current_version)
|
||
|
||
|
||
@router.delete(
|
||
"/{chat_id}",
|
||
status_code=204,
|
||
dependencies=[Depends(require_admin)], # management surface (phase 55)
|
||
)
|
||
def delete_chat(
|
||
chat_id: uuid.UUID,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> Response:
|
||
"""Remove a saved chat — admin-only (the owner's History action,
|
||
phase 55); 404 when the id is unknown."""""
|
||
row = db.get(SavedChat, chat_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="unknown chat")
|
||
db.delete(row)
|
||
db.commit()
|
||
return Response(status_code=204)
|
||
|
||
|
||
@router.post("/{chat_id}/share", response_model=ShareOut)
|
||
def share_chat(
|
||
chat_id: uuid.UUID,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> ShareOut:
|
||
"""Turn a saved chat into a public link (phase 51, task 01).
|
||
|
||
PUBLIC (phase 55, task 01) — no session required: sharing is what
|
||
the visitor does with their own conversation (the row-id trust
|
||
model of :func:`create_chat`; revoking, by contrast, is the
|
||
owner's History action — :func:`unshare_chat` stays admin-only).
|
||
|
||
Returns ``{"chat_id", "share_url"}`` with ``share_url =
|
||
"/shared/<token>"`` (200, idempotent — an existing token is
|
||
returned unchanged; a new token is a 128-bit ``uuid4``).
|
||
``updated_at`` is **not** bumped: sharing is not a content edit,
|
||
so the token is written with a raw SQL ``UPDATE`` that touches ONLY
|
||
``share_token`` — the column's ``onupdate=func.now()`` default is
|
||
registered on the Table (via ``mapped_column``), so even a Core
|
||
``update(SavedChat)`` DML statement would pick it up; the ORM
|
||
object is never mutated either. 404 when the id is unknown.
|
||
"""
|
||
row = db.get(SavedChat, chat_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="unknown chat")
|
||
token = row.share_token
|
||
if token is None:
|
||
token = uuid.uuid4()
|
||
# Raw SQL on purpose: sets ONLY share_token, so the column's
|
||
# onupdate default for ``updated_at`` never fires (the History
|
||
# page's "latest activity" order must follow content edits only).
|
||
db.execute(
|
||
text("UPDATE saved_chats SET share_token = :tok WHERE id = :id"),
|
||
{"tok": token, "id": chat_id},
|
||
)
|
||
db.commit()
|
||
return ShareOut(chat_id=row.id, share_url=f"/shared/{token}")
|
||
|
||
|
||
@router.post(
|
||
"/{chat_id}/unshare",
|
||
response_model=UnshareOut,
|
||
dependencies=[Depends(require_admin)], # management surface (phase 55)
|
||
)
|
||
def unshare_chat(
|
||
chat_id: uuid.UUID,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> UnshareOut:
|
||
"""Revoke a shared chat (phase 51, task 01): ``share_token`` → NULL.
|
||
|
||
Admin-only (the owner's History action — phase 55 kept the
|
||
revocation off the guest's reach, so a guest cannot un-revoke).
|
||
|
||
Idempotent — an unshared chat unshares cleanly (200, no write).
|
||
``updated_at`` is not bumped (raw SQL ``UPDATE`` touching only
|
||
``share_token``, same reasoning as :func:`share_chat`); 404 when
|
||
the id is unknown.
|
||
"""
|
||
row = db.get(SavedChat, chat_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="unknown chat")
|
||
if row.share_token is not None:
|
||
db.execute(
|
||
text("UPDATE saved_chats SET share_token = NULL WHERE id = :id"),
|
||
{"id": chat_id},
|
||
)
|
||
db.commit()
|
||
return UnshareOut(chat_id=row.id, shared=False)
|
||
|
||
|
||
#: Public read surface (phase 51, task 01) — **no** admin dependency:
|
||
#: a guest with the link reads the shared chat anonymously. Mounted
|
||
#: under ``/api`` in ``app.main`` → ``GET /api/shared/<token>``.
|
||
public_router = APIRouter(tags=["chats"])
|
||
|
||
|
||
def _to_shared_out(row: SavedChat) -> SharedChatOut:
|
||
"""The PUBLIC read shape: title + messages only — no id,
|
||
timestamps, or token (a content snapshot, not a handle)."""
|
||
return SharedChatOut(
|
||
title=row.title,
|
||
messages=[ChatMessage.model_validate(m) for m in row.messages],
|
||
)
|
||
|
||
|
||
@public_router.get("/shared/{token}", response_model=SharedChatOut)
|
||
def read_shared_chat(
|
||
token: uuid.UUID,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> SharedChatOut:
|
||
"""Anonymous read of a shared chat by token (phase 51, task 01).
|
||
|
||
No admin dependency — the token IS the credential. A wrong or a
|
||
revoked (unshared) token 404s with ONE message: ``unknown or
|
||
revoked share link`` — deliberately no enumeration between the two
|
||
cases.
|
||
"""
|
||
row = db.execute(
|
||
select(SavedChat).where(SavedChat.share_token == token).limit(1)
|
||
).scalars().first()
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="unknown or revoked share link")
|
||
return _to_shared_out(row)
|
||
|
||
|
||
#: The share PAGE route (phase 51, task 01) — mounted with **no**
|
||
#: prefix, BEFORE the static catch-all in ``app.main`` (the
|
||
#: API-routes-first convention): ``/shared/<uuid>`` is not a static
|
||
#: file, so without this route the ``StaticFiles`` mount would 404 it.
|
||
shared_page_router = APIRouter(tags=["chats"])
|
||
|
||
#: The page's filename inside the static dir (the file lands in phase
|
||
#: 51, task 03; until then the guard below keeps a stale deploy from
|
||
#: 500'ing).
|
||
_SHARED_PAGE_NAME = "shared.html"
|
||
|
||
|
||
@shared_page_router.get("/shared/{token}", response_model=None)
|
||
def shared_page(token: uuid.UUID) -> FileResponse | JSONResponse:
|
||
"""Serve the anonymous shared-chat page for ``/shared/<token>``.
|
||
|
||
The page (``frontend/shared.html``, task 03) fetches
|
||
``GET /api/shared/<token>`` itself and renders the conversation
|
||
read-only — including the "invalid or revoked" state for a wrong
|
||
or revoked token — so this route serves the page for any well-
|
||
formed token and never 404s on the token's validity. The guard:
|
||
when the page file is missing (a stale deploy — the API is ahead
|
||
of the static bundle), return the SAME 404 JSON as the API rather
|
||
than a 500.
|
||
"""
|
||
page = Path(get_settings().static_dir).expanduser().resolve() / _SHARED_PAGE_NAME
|
||
if not page.is_file():
|
||
return JSONResponse(
|
||
status_code=404, content={"detail": "unknown or revoked share link"}
|
||
)
|
||
return FileResponse(page)
|