# 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/` 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/"}` — 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/`) → `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/` 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/` path is treated as a known HTML page (no-cache + rewrite), an `/api/shared/` 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//share` is idempotent and leaves `updated_at` alone; `unshare` revokes; `GET /api/shared/` is public + 404-safe; `GET /shared/` serves the page route (404-JSON guard when the file is missing). - [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.