Files
brain-of-reese/.agent/phases/complete/51_share_chat/01_share_token.md
T
ducoterra 4971e2859d
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s
chore(agent): track .agent/ planning tree in git
Remove the blanket .agent/ gitignore so the phase roadmap, user
stories, reports, and PLAN.md are versioned with the code. Only
runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log)
remain ignored. Update AGENTS.md git protocol rule to match.
2026-09-01 10:18:22 -04:00

4.8 KiB

Task 01 — The share token + public read API + page route

Phase: 51_share_chat · Source: TODO.md:6 — "Need a way to share a chat with a link so others can see it anonymously." Story: n/a (TODO-derived)

Objective

A saved chat can be shared (a token), unshared (revoked), and read publicly by token; the /shared/<token> URL serves the shared page (a real route ahead of the static mount) with the cache-busting contract.

Work

  1. alembic/versions/0009_saved_chat_share_token.py — revision = "0009", down_revision = "0008" (verify with uv run alembic heads): op.add_column("saved_chats", sa.Column("share_token", postgresql.UUID(as_uuid=True), nullable=True)) + op.create_index("ix_saved_chats_share_token", "saved_chats", ["share_token"], unique=True) (a unique index on a nullable column — Postgres treats NULLs as distinct, the git_sources.path house precedent, phase 38); the down reverses both. app/models.py — the SavedChat.share_token: Mapped[uuid.UUID | None] column (nullable unique, index=True… expressed as unique=True, nullable=True on the mapped_column to match the migration) + the docstring line (the phase-38 path column's comment style). Apply: uv run alembic upgrade head.
  2. app/schemas.py — SharedChatOut — title: str, messages: list[ChatMessage] (the public read shape: no id, no timestamps, no token — a shared chat is a content snapshot, not a handle).
  3. app/api/chats.py:
    • POST "/{chat_id}/share" (admin router) → {"chat_id": …, "share_url": "/shared/<token>"} — 200, idempotent: an existing token is returned unchanged; a new token is uuid.uuid4(), persisted, and updated_at is not bumped (sharing is not a content edit — write the token with a Core session.execute(update(SavedChat).where(...).values(share_token=…)), which skips the ORM onupdate; pin this in the tests); 404 on unknown chat.
    • POST "/{chat_id}/unshare" (admin router) → {"chat_id": …, "shared": false} — the token set NULL (the same Core-update pattern), idempotent (an unshared chat unshares cleanly); 404 on unknown.
    • GET "/shared/{token}" (public — no admin dependency; put it on a second module-level public_router = APIRouter(tags=["chats"]) in the same file, registered in main.py with prefix="/api", so the JSON endpoint is GET /api/shared/<token>) → SharedChatOut; 404 {"detail": "unknown or revoked share link"} for a wrong or revoked token (one message — no enumeration between the two cases).
  4. The page route — app/main.py: GET /shared/{token} (a small router in app/api/chats.py or an inline route, registered without a prefix and before the static mount — the API-routes-first convention; /shared/<uuid> is not a static file, so without this route the mount would 404 it) → FileResponse(static_dir / "shared.html") (the page lands in task 03 — guard the missing file with an explicit check returning the same 404 JSON as the API, so a stale deploy never 500s).
  5. app/core/caching.py — extend the middleware's known-page dispatch: a path starting with "/shared/" gets the same treatment as HTML_PAGES (no-cache + ?v= asset-rewrite on the text/html body — the FileResponse body is drained by the existing _read_body path). Comment it (phase 51: the dynamic share page). tests/unit/test_caching.py — pins: a /shared/<uuid> path is treated as a known HTML page (no-cache + rewrite), an /api/shared/<uuid> path passes through untouched, and an unknown path is untouched.
  6. tests/integration/test_chats_api.py — extend the house file: share (200 + share_url matching /^\/shared\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; a second call returns the same token), unshare (token NULL; the public read 404s; unshare on an unshared chat → 200 idempotent), the public read (a fresh anonymous client: 200 with title + messages round-tripping and no id/created_at/updated_at/share_token keys in the body; a wrong token 404s with the "unknown or revoked" detail; a revoked token 404s with the same detail), the updated_at-unchanged pin for share/unshare, and 404s for share/unshare on unknown ids.
  7. tests/integration/test_migration_0009.py (new) — the house pattern: upgrade/downgrade round-trip; the unique index exists; the NULLs-distinct behavior (two rows may both carry NULL; two identical non-NULL tokens are rejected).

Testing & Quality

  • Integration: as above; full suite green.
  • Coverage: >90% on app/.

Completion Criteria

  • POST /api/chats/<id>/share is idempotent and leaves updated_at alone; unshare revokes; GET /api/shared/<token> is public + 404-safe; GET /shared/<token> serves the page route (404-JSON guard when the file is missing).
  • uv run pytest green; uv run ruff check . && uv run pyright clean.