feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+179 -6
View File
@@ -14,20 +14,43 @@ 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, latest activity first — no payloads), ``POST``
(create — auto-title from the first question when no ``title`` is
supplied), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
Routes: ``GET`` (list, latest activity first — no payloads, but the
phase-51 ``share_url`` so the History's Share column renders 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),
``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
only when supplied), ``DELETE /{chat_id}``.
only when supplied), ``DELETE /{chat_id}``, and (phase 51, task 01)
``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this
admin-gated router.
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, Response
from sqlalchemy import select
from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy import 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
@@ -38,6 +61,9 @@ from app.schemas import (
SavedChatOut,
SavedChatRow,
SavedChatUpdate,
SharedChatOut,
ShareOut,
UnshareOut,
)
router = APIRouter(
@@ -65,6 +91,15 @@ def _auto_title(messages: list[ChatMessage]) -> str:
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) -> SavedChatOut:
"""The full-payload response shape (create/get/put)."""
return SavedChatOut(
@@ -74,16 +109,19 @@ def _to_out(row: SavedChat) -> SavedChatOut:
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),
)
def _to_row(row: SavedChat) -> SavedChatRow:
"""The list-page row shape (no payloads in the list)."""
"""The list-page row shape (no payloads in the list; ``share_url``
is not a payload — it is the History Share column's data)."""
return SavedChatRow(
id=row.id,
title=row.title,
updated_at=row.updated_at,
message_count=len(row.messages),
share_url=_share_url(row),
)
@@ -110,6 +148,11 @@ def create_chat(
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).
"""
title = (payload.title or "").strip() or _auto_title(payload.messages)
row = SavedChat(
@@ -121,6 +164,10 @@ def create_chat(
# restore path is null-safe for every optional key).
messages=[m.model_dump() for m in payload.messages],
)
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:
@@ -181,3 +228,129 @@ def delete_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).
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)
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.
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)
+21 -7
View File
@@ -6,9 +6,10 @@ Two layers, one module:
append to their asset URLs (``?v=<token>``).
* **Response middleware** (``CachingMiddleware`` / ``configure_caching``)
— applies the caching behavior at the transport layer: the known
HTML pages (``HTML_PAGES``) are always revalidated (``no-cache``) and
their local asset references are rewritten to carry ``?v=<token>``;
``/assets/*`` is
HTML pages (``HTML_PAGES``) and the dynamic share page
``/shared/<token>`` (phase 51) are always revalidated (``no-cache``)
and their local asset references are rewritten to carry
``?v=<token>``; ``/assets/*`` is
served ``immutable`` for a year; everything else — all of ``/api/*``,
including the SSE chat stream — passes through byte-identical.
@@ -125,6 +126,11 @@ HTML_PAGES: tuple[str, ...] = (
"/tuning.html",
"/git-sources.html", # phase 35: the admin git sources page
"/history.html", # phase 50: the admin saved-chats page
# phase 51: the shared page's STATIC path (the static mount serves
# shared.html at /shared.html as well as the real route serves the
# dynamic /shared/<token> — both must carry the no-cache + ?v=
# contract, so the direct URL can never pin stale assets).
"/shared.html",
)
#: Prefix of the versioned static assets (header-only caching; the body is
@@ -194,9 +200,10 @@ class CachingMiddleware(BaseHTTPMiddleware):
* ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable``
(header only — the body is never read).
* the known HTML pages (``HTML_PAGES``) — ``Cache-Control: no-cache``,
and (for ``text/html`` bodies) every local asset reference gains
``?v=<token>``.
* the known HTML pages (``HTML_PAGES``) plus the dynamic share page
``/shared/<token>`` (phase 51, by path prefix) —
``Cache-Control: no-cache``, and (for ``text/html`` bodies) every
local asset reference gains ``?v=<token>``.
Everything else — all of ``/api/*`` (including the SSE chat stream) —
passes through byte-identical: no header changes, the body stream is
@@ -213,7 +220,14 @@ class CachingMiddleware(BaseHTTPMiddleware):
response.headers["Cache-Control"] = ASSET_CACHE_CONTROL
return response
if path not in HTML_PAGES:
# Phase 51: the dynamic share page — ``/shared/<token>`` is a
# REAL route (not a static file) serving ``shared.html``, so it
# joins the known-page contract by path prefix: no-cache +
# ``?v=`` asset rewrite. (``/api/shared/<token>`` — the JSON
# read — starts with ``/api/`` and passes through below.)
is_known_page = path in HTML_PAGES or path.startswith("/shared/")
if not is_known_page:
# /api/* (incl. SSE), /favicon.ico, unknown paths: untouched.
return response
+14 -1
View File
@@ -21,7 +21,15 @@ from starlette.middleware.sessions import SessionMiddleware
from app.api.auth import router as auth_router
from app.api.chat import router as chat_router
from app.api.chats import router as chats_router
from app.api.chats import (
public_router as chats_public_router,
)
from app.api.chats import (
router as chats_router,
)
from app.api.chats import (
shared_page_router as chats_shared_page_router,
)
from app.api.config import router as config_router
from app.api.docs import router as docs_router
from app.api.git_sources import router as git_sources_router
@@ -73,6 +81,11 @@ def create_app() -> FastAPI:
app.include_router(steering_router, prefix="/api")
app.include_router(sync_router, prefix="/api")
app.include_router(chats_router, prefix="/api")
# Phase 51: the anonymous shared-chat read — NO admin dependency.
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
# page route below, registered without a prefix) is the page.
app.include_router(chats_public_router, prefix="/api")
app.include_router(chats_shared_page_router) # no prefix — /shared/<token>
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
+13 -3
View File
@@ -21,7 +21,9 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
explicitly Saved conversation (auto-``title`` +
the ``bor.chat.v1`` message list as JSONB, phase
14 shape) — phase 50; ``/api/chat`` stays
stateless.
stateless; ``share_token`` (NULL = private,
``uuid4`` = publicly readable at
``/shared/<token>``) — phase 51.
"""
from __future__ import annotations
@@ -178,8 +180,7 @@ class SavedChat(Base):
saved. ``messages`` holds 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. No share-related columns here —
phase 51 adds ``share_token`` in migration 0009.
``renderStoredMessage`` path.
"""
__tablename__ = "saved_chats"
@@ -193,6 +194,15 @@ class SavedChat(Base):
#: tools?, stopped?}``); the API always supplies a list, so no
#: default is needed.
messages: Mapped[list] = mapped_column(JSONB)
#: Anonymous share link (phase 51): a 128-bit ``uuid4`` token; when
#: set, the chat is publicly readable at ``/shared/<token>`` without
#: any admin session, and unsharing (token → NULL) revokes it.
#: Unique — Postgres treats NULLs as distinct under a unique index
#: (the ``git_sources.path`` precedent, phase 38), so any number of
#: unshared chats coexist while two identical tokens can never exist.
share_token: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), unique=True, nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+99 -6
View File
@@ -3,9 +3,16 @@ from __future__ import annotations
import uuid
from datetime import datetime
from typing import Literal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
SerializerFunctionWrapHandler,
field_validator,
model_serializer,
)
class HealthResponse(BaseModel):
@@ -324,16 +331,26 @@ class ChatMessage(BaseModel):
class SavedChatCreate(BaseModel):
"""``POST /api/chats`` body (phase 50, task 02).
"""``POST /api/chats`` body (phase 50, task 02; ``share``, phase 51
task 02).
``title`` is optional: when absent or blank the API auto-titles the
row (the first user message's text, whitespace-collapsed, truncated
to 120 chars — the owner-locked convention). ``messages`` must be
non-empty — a saved chat with nothing to restore is meaningless.
``share`` (phase 51, owner-locked 2026-08-29): when true, the row is
shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on
the fresh row before the INSERT, so one request saves AND shares
(the chat page's Share button on an unsaved conversation, the
save-then-share contract). The response then carries ``share_url``
(see :class:`SavedChatOut`). Default false — a plain Save is
unchanged by phase 51.
"""
title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1)
share: bool = False
class SavedChatUpdate(BaseModel):
@@ -349,11 +366,35 @@ class SavedChatUpdate(BaseModel):
messages: list[ChatMessage] = Field(min_length=1)
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat
exposes no share surface at all, and the History column renders the
unshared state from the key's absence.
A ``mode="wrap"`` model serializer: the default (recursive) dump runs
first, then only the TOP-LEVEL key is dropped when null. The
recursion matters — a route-level ``response_model_exclude_none``
would also drop the nested ``ChatMessage`` nulls (``sources: null``
and friends), which the byte-identical round-trip contract (phase
50) forbids.
"""
data = handler(model)
if data.get("share_url") is None:
data.pop("share_url", None)
return data
class SavedChatOut(BaseModel):
"""One saved chat, full payload (create/get/put response, phase 50).
"""One saved chat, full payload (create/get/put response, phase 50;
``share_url``, phase 51 task 02).
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
— the restore path is pixel-identical by construction.
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
shared, ABSENT from the JSON when unshared (``None`` → dropped by
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
"""
id: uuid.UUID
@@ -362,19 +403,33 @@ class SavedChatOut(BaseModel):
updated_at: datetime
message_count: int
messages: list[ChatMessage]
share_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_absent_share_url(self, handler)
class SavedChatRow(BaseModel):
"""One row of ``GET /api/chats`` (the History page's list shape).
"""One row of ``GET /api/chats`` (the History page's list shape,
phase 50; ``share_url``, phase 51 task 02).
No payloads in the list — the row carries only what the table needs
(Title, Messages count, Updated).
(Title, Messages count, Updated). ``share_url`` is populated here so
the History page's Share column renders straight from ``GET
/api/chats`` — no second fetch per row (``None`` → absent, the same
omission rule as :class:`SavedChatOut`).
"""
id: uuid.UUID
title: str
updated_at: datetime
message_count: int
share_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_absent_share_url(self, handler)
class SavedChatList(BaseModel):
@@ -382,3 +437,41 @@ class SavedChatList(BaseModel):
(``updated_at desc, id desc``)."""
chats: list[SavedChatRow]
class SharedChatOut(BaseModel):
"""``GET /api/shared/{token}`` body (phase 51, task 01) — the PUBLIC
read shape of a shared chat.
Deliberately minimal: ``title`` + ``messages`` only. No id, no
timestamps, no token, no ``message_count`` — a shared chat is a
content snapshot, not a handle: nothing in the body can be turned
back into an admin-surface request, and the token itself never
round-trips (it is the URL, not data).
"""
title: str
messages: list[ChatMessage]
class ShareOut(BaseModel):
"""``POST /api/chats/{chat_id}/share`` response (phase 51, task 01).
``share_url`` is the path (``/shared/<token>``) the UI copies into
the clipboard — the owner's own origin supplies the scheme/host.
Idempotent: a re-share returns the existing, unchanged token.
"""
chat_id: uuid.UUID
share_url: str
class UnshareOut(BaseModel):
"""``POST /api/chats/{chat_id}/unshare`` response (phase 51, task 01).
``shared: false`` is reported unconditionally — the endpoint is
idempotent, so an already-unshared chat unshares cleanly (200).
"""
chat_id: uuid.UUID
shared: bool