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.
4.8 KiB
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
alembic/versions/0009_saved_chat_share_token.py—revision = "0009",down_revision = "0008"(verify withuv 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, thegit_sources.pathhouse precedent, phase 38); the down reverses both.app/models.py— theSavedChat.share_token: Mapped[uuid.UUID | None]column (nullable unique,index=True… expressed asunique=True, nullable=Trueon themapped_columnto match the migration) + the docstring line (the phase-38pathcolumn's comment style). Apply:uv run alembic upgrade head.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).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 isuuid.uuid4(), persisted, andupdated_atis not bumped (sharing is not a content edit — write the token with a Coresession.execute(update(SavedChat).where(...).values(share_token=…)), which skips the ORMonupdate; 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-levelpublic_router = APIRouter(tags=["chats"])in the same file, registered inmain.pywithprefix="/api", so the JSON endpoint isGET /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).
- The page route —
app/main.py:GET /shared/{token}(a small router inapp/api/chats.pyor 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). app/core/caching.py— extend the middleware's known-page dispatch: a path starting with"/shared/"gets the same treatment asHTML_PAGES(no-cache +?v=asset-rewrite on thetext/htmlbody — theFileResponsebody is drained by the existing_read_bodypath). 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.tests/integration/test_chats_api.py— extend the house file: share (200 +share_urlmatching/^\/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 noid/created_at/updated_at/share_tokenkeys in the body; a wrong token 404s with the "unknown or revoked" detail; a revoked token 404s with the same detail), theupdated_at-unchanged pin for share/unshare, and 404s for share/unshare on unknown ids.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>/shareis idempotent and leavesupdated_atalone;unsharerevokes;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 pytestgreen;uv run ruff check . && uv run pyrightclean.