diff --git a/Containerfile b/Containerfile index 4291abc..c2ba681 100644 --- a/Containerfile +++ b/Containerfile @@ -21,10 +21,11 @@ RUN mkdir -p /out/assets \ && esbuild ./assets/tuning.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/tuning.js \ && esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \ && esbuild ./assets/history.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/history.js \ + && esbuild ./assets/shared.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/shared.js \ && esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \ && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \ && esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \ - && cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html /out/ + && cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html /out/ # ---------- Stage 2: python dependencies ---------- FROM docker.io/python:3.12-slim AS python diff --git a/alembic/versions/0009_saved_chat_share_token.py b/alembic/versions/0009_saved_chat_share_token.py new file mode 100644 index 0000000..787c432 --- /dev/null +++ b/alembic/versions/0009_saved_chat_share_token.py @@ -0,0 +1,45 @@ +"""saved_chats.share_token: the anonymous share link (phase 51) + +Revision ID: 0009 +Revises: 0008 +Create Date: 2026-08-29 + +Phase 51 (share-a-chat-by-link, owner-locked 2026-08-29): a saved chat +can be turned into a public link — ``/shared/`` — that anyone +with the URL can read anonymously (no admin session); unsharing sets +the token back to NULL and revokes the link. + +* ``saved_chats.share_token`` — ``UUID`` (a 128-bit ``uuid4``), NULL = + not shared. One column, no new table (A10 extension unchanged — + sharing reuses the already-stored row). +* ``ix_saved_chats_share_token`` — UNIQUE index on the column. A unique + index on a NULLable column: Postgres treats NULLs as distinct, so any + number of unshared chats coexist while two identical non-NULL tokens + can never exist (the ``git_sources.path`` house precedent, phase 38). +""" +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0009" +down_revision = "0008" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + 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 + ) + + +def downgrade() -> None: + op.drop_index("ix_saved_chats_share_token", table_name="saved_chats") + op.drop_column("saved_chats", "share_token") diff --git a/app/api/chats.py b/app/api/chats.py index ffd23e4..d7a43fe 100644 --- a/app/api/chats.py +++ b/app/api/chats.py @@ -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/`` — 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/``): 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 "``. + + ``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/"`` (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/``. +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/`` 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/``. + + The page (``frontend/shared.html``, task 03) fetches + ``GET /api/shared/`` 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) diff --git a/app/core/caching.py b/app/core/caching.py index f2ad75b..7eef10f 100644 --- a/app/core/caching.py +++ b/app/core/caching.py @@ -6,9 +6,10 @@ Two layers, one module: append to their asset URLs (``?v=``). * **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=``; - ``/assets/*`` is + HTML pages (``HTML_PAGES``) and the dynamic share page + ``/shared/`` (phase 51) are always revalidated (``no-cache``) + and their local asset references are rewritten to carry + ``?v=``; ``/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/ — 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=``. + * the known HTML pages (``HTML_PAGES``) plus the dynamic share page + ``/shared/`` (phase 51, by path prefix) — + ``Cache-Control: no-cache``, and (for ``text/html`` bodies) every + local asset reference gains ``?v=``. 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/`` 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/`` — 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 diff --git a/app/main.py b/app/main.py index 487a282..03215e7 100644 --- a/app/main.py +++ b/app/main.py @@ -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/ is the JSON snapshot; /shared/ (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/ # Cache busting (phase 33): the five HTML pages revalidate (no-cache) # with ?v= asset refs; /assets/* becomes immutable for a year. diff --git a/app/models.py b/app/models.py index 8d9c746..2d7c560 100644 --- a/app/models.py +++ b/app/models.py @@ -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/``) — 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/`` 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() diff --git a/app/schemas.py b/app/schemas.py index ac47aa1..9271800 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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/"`` 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/``) 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 diff --git a/frontend/assets/app.js b/frontend/assets/app.js index f1e5a3e..6893bdf 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -155,6 +155,28 @@ * the error banner. Phase 14's local persistence is untouched: saving is * an additional, explicit action. * + * Share the conversation (phase 51, owner-locked 2026-08-29, TODO.md + * L6): the "Share" pill (#share-chat-btn — admin-only, SHIPS HIDDEN, + * revealed at boot in the SAME admin-reveal block as Save) turns the + * CURRENT conversation into a public read-only link (/shared/, + * a 128-bit uuid4 on the saved_chats row). The save-then-share + * contract: the SAME empty-conversation no-op guard as Save (live + * region, no request); linked (currentChatId set) → POST + * /api/chats//share (idempotent — the existing token comes back + * unchanged); unlinked → POST /api/chats with { messages: conversation, + * share: true } and link currentChatId to the created id — one action + * saves AND shares (owner-locked). On success the ABSOLUTE share URL + * (share_url resolved against the page origin) is copied: + * navigator.clipboard.writeText in a try — a non-secure (http) homelab + * origin rejects the clipboard, so the failure path renders the inline + * fallback: a transient link field near the status line (an styled + * input-like that selects its full URL on focus, .share-link-fallback) + * and the live region reads "Share link ready — copy it from the + * field." (the owner-locked fallback). Success (clipboard) reads + * "Share link copied." 403/5xx → the actionable error banner (signed- + * in hint, like Save); a network failure → the "is the app reachable?" + * banner. + * * All DOM ids match frontend/index.html. */ @@ -178,6 +200,7 @@ const banner = document.querySelector("#kb-banner"); const bannerText = document.querySelector("#kb-banner-text"); const versionEl = document.querySelector("#app-version"); const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden) +const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden) /* Phase 39: the display name resolves from one place — window.BOR_BRAND * (the classic assets/brand.js sets it at parse time; its /api/config @@ -1135,6 +1158,116 @@ async function saveCurrentChat() { } } +/* ---------- share the conversation (phase 51, owner-locked 2026-08-29) ---------- */ + +/* The share link's ABSOLUTE URL: the API reports the PATH + * (/shared/); the owner's own origin supplies the scheme/host — + * a homelab http origin stays http (never assume https). */ +function absoluteShareUrl(shareUrl) { + return new URL(shareUrl, window.location.origin).toString(); +} + +/* Select every text node in an element — the link field's + * select-on-focus (an has no .select(); a range does the job). + * Best-effort: selection failure only means the user copies by hand. */ +function selectAllInField(el) { + try { + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } catch { + /* selection is best-effort — the field still shows the full URL */ + } +} + +/* Clipboard copy with the owner-locked inline-link fallback: a + * non-secure (http) homelab origin rejects navigator.clipboard, so the + * failure path renders a TRANSIENT link field near the status line + * (appended to the composer, beside the send button that carries + * #send-status) — input-like, it selects its full URL on focus (click + * or Tab, then Ctrl/Cmd+C). One field at a time (a new offer replaces + * the old). Returns true when the clipboard took it. */ +async function copyShareLinkWithFallback(absoluteUrl) { + document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove()); + try { + await navigator.clipboard.writeText(absoluteUrl); + return true; + } catch { + const field = document.createElement("a"); + field.className = "share-link-fallback"; + field.href = absoluteUrl; // carries the full URL (copy link address works too) + field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML + field.title = "Share link — click, then copy (Ctrl/Cmd+C)"; + field.addEventListener("focus", () => selectAllInField(field)); + composer.appendChild(field); // near the status line (inside the send button) + field.focus({ preventScroll: true }); // selects the URL — ready to copy + return false; + } +} + +/* Share the current conversation — the #share-chat-btn handler + * (phase 51, owner-locked 2026-08-29, TODO.md L6). No-op with a live- + * region line when there is nothing to share (the same guard as + * Save). The save-then-share branch: linked → POST + * /api/chats//share (idempotent token); unlinked → POST /api/chats + * with { messages, share: true } and link to the created id — one + * action saves AND shares (owner-locked). Success copies the absolute + * URL (clipboard → inline-field fallback); the live region reads + * "Share link copied." or "Share link ready — copy it from the + * field." 403/5xx → the actionable banner (signed-out hint); a network + * failure → the reachable? banner. The double-click guard releases in + * the finally — never stale (PLAN §7.4). */ +async function shareCurrentChat() { + if (!conversation.length) { + sendStatus.textContent = "Nothing to share yet."; + return; + } + if (shareBtn.disabled) return; // one share at a time (double-click guard) + shareBtn.disabled = true; + try { + let shareUrl; + if (currentChatId) { + // Linked (already saved): the idempotent share — an existing + // token comes back unchanged, a new one is minted. + const res = await fetch(`/api/chats/${currentChatId}/share`, { method: "POST" }); + if (!res.ok) { + showErrorBanner( + "Couldn't share the conversation — check you're still signed in and try again." + ); + return; + } + shareUrl = (await res.json()).share_url; + } else { + // Unsaved: save AND share in ONE action (owner-locked) — the + // server sets the 128-bit uuid4 token in the same commit. + const res = await fetch("/api/chats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: conversation, share: true }), + }); + if (!res.ok) { + showErrorBanner( + "Couldn't share the conversation — check you're still signed in and try again." + ); + return; + } + const created = await res.json(); + currentChatId = String(created.id); // one action saved AND shared: link + shareUrl = created.share_url; + } + const copied = await copyShareLinkWithFallback(absoluteShareUrl(shareUrl)); + sendStatus.textContent = copied + ? "Share link copied." + : "Share link ready — copy it from the field."; + } catch { + showErrorBanner("Couldn't share the conversation — is the app reachable?"); + } finally { + shareBtn.disabled = false; // released on EVERY outcome — never stale + } +} + /* Brain message save point (on `done`): raw accumulated text + metadata. Phase 17: meta.thinking and phase 37: meta.tools are optional — `undefined` drops the key from the JSON, so turns without them persist @@ -1550,6 +1683,9 @@ composer.addEventListener("submit", handleSend); * IIFE below reveals it for admin (absent-not-hidden for anonymous, * phase 16). Status-only feedback — the live region, never stale. */ saveBtn?.addEventListener("click", saveCurrentChat); +/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill — + * same ship-hidden/reveal contract as Save (the boot IIFE below). */ +shareBtn?.addEventListener("click", shareCurrentChat); /* Navigate-away save point (phase 20, owner choice 2026-08-24 A1): * leaving the chat mid-turn would otherwise drop the in-flight @@ -1585,6 +1721,7 @@ window.addEventListener("pagehide", () => { isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami applyAuthState(); // chat page: the auth pair (idempotent with header.js) if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16) + if (shareBtn) shareBtn.hidden = !isAdmin; // phase 51: the Share pill joins the same block // Phase 50: /?chat= (valid uuid + admin) boots into the saved // conversation; every other outcome falls through to the local restore. const openedSaved = await restoreSavedChatFromUrl(); diff --git a/frontend/assets/header.js b/frontend/assets/header.js index dd461c2..4cc53fc 100644 --- a/frontend/assets/header.js +++ b/frontend/assets/header.js @@ -106,9 +106,20 @@ export async function initSharedHeader() { // a query-safe "/…" string (never "//"; ? # and spaces stay // percent-encoded in it), so it rides in next= as-is — the same shape // the static fallbacks use (login.js safeNext re-validates it). + // + // Phase 51 (owner-locked 2026-08-29, TODO.md L6): the ONE exception — + // on the NESTED /shared/ page "where you were" is a public + // link, not a place in the app: the rewrite stays at the APP ROOT + // ("/"), so a guest signing in from a shared page lands in the chat + // (the static ?next=/ fallback in shared.html matches — the rewrite + // only ever KEEPS it there). A signed-in admin on the shared page + // never sees the link (hidden = admin), so this only shapes the + // guest experience. + const nextPath = window.location.pathname || "/"; + const signInNext = nextPath.startsWith("/shared/") ? "/" : nextPath; document.querySelectorAll(".sign-in-link").forEach(link => { link.hidden = admin; - link.href = "/login.html?next=" + (window.location.pathname || "/"); + link.href = "/login.html?next=" + signInNext; }); document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; }); const navSources = document.querySelector("#nav-sources"); diff --git a/frontend/assets/history.js b/frontend/assets/history.js index 46bf23f..8b82070 100644 --- a/frontend/assets/history.js +++ b/frontend/assets/history.js @@ -3,21 +3,40 @@ * TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat * history in a new page, then return to that history with a click." * - * Wires the admin-only `GET /api/chats` + `DELETE /api/chats/` - * endpoints (phase 50 task 02) into the page's full-width table: + * Wires the admin-only `GET /api/chats` + `POST /api/chats//share` + * + `POST /api/chats//unshare` + `DELETE /api/chats/` + * endpoints (phase 50 task 02; phase 51 task 01+02) into the page's + * full-width table: * * • Title — an ``: Open IS the title link * ("return to that history with a click") — the chat page boots * into the saved conversation through ?chat= (task 03); * • Messages — the row's message_count; * • Updated — locale date+time, the full ISO in the title attribute; - * • Actions — Delete ONLY (phase 51 adds the share column), inline - * TWO-STEP confirm (owner-locked 2026-08-29: no native confirm - * dialog anywhere in this file) — the first click swaps the button for + * • Share — phase 51 (owner-locked 2026-08-29, TODO.md L6): the + * row's share state, rendered from the list's OWN share_url (the + * GET /api/chats endpoint populates it — no second fetch per row). + * Three states: unshared → [Create link] (POST share → the cell + * re-renders shared + the link is offered for copying); shared → + * [Copy] [Unshare]; confirming → the inline two-step "Unshare? + * [Yes] [No]" (the phase-50 Delete-confirm pattern + CSS — no + * native dialog) — Yes POSTs /api/chats//unshare (revokes), + * the cell re-renders unshared + the live region; + * • Actions — Delete, inline TWO-STEP confirm (owner-locked + * 2026-08-29: no native confirm dialog anywhere in this file) — + * the first click swaps the button for * "Delete? [Yes] [No]" (focus moves to Yes, so the confirm is * keyboard-reachable), Yes fires the DELETE and removes the row, * No (or a failed request) keeps it. * + * The share copy has the owner-locked inline-link fallback: a + * non-secure (http) homelab origin rejects navigator.clipboard, so the + * offer renders a transient .share-link-fallback field (input-like, + * selects its full URL on focus) in the row's share cell — this file + * keeps its OWN copy of the ~10-line helper (the per-page duplication + * house style; app.js keeps the chat page's) rather than a new shared + * module. + * * Every cell is built with the DOM APIs (textContent) — the title is * user-derived (the auto-title is the first question), so it NEVER * touches innerHTML (XSS-safe by construction, the sources.js house @@ -86,6 +105,13 @@ function makeRow(chat) { updatedTd.textContent = fmtDate(chat.updated_at); tr.appendChild(updatedTd); + // Phase 51: the Share cell (between Updated and Actions) — the + // three-state share control (unshared / shared / confirming-unshare). + const shareTd = document.createElement("td"); + shareTd.className = "history-share-cell"; + shareTd.appendChild(makeShareControl(chat)); + tr.appendChild(shareTd); + const actionsTd = document.createElement("td"); actionsTd.className = "history-actions-cell"; actionsTd.appendChild(makeDeleteControl(chat, tr)); @@ -169,6 +195,186 @@ async function confirmDelete(chat, row, yesBtn, restoreDelete) { announce(`Deleted "${chat.title}".`); } +/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */ + +/* Select every text node in the link field (an has no .select(); + a range does the job) — best-effort: a selection failure only means + the user copies by hand. */ +function selectAllInField(el) { + try { + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } catch { + /* selection is best-effort — the field still shows the full URL */ + } +} + +/* Clipboard + the owner-locked inline-link fallback: a non-secure + (http) homelab origin rejects navigator.clipboard, so the failure + path renders a TRANSIENT link field in the row's share cell — + input-like, it selects its full URL on focus (click or Tab, then + Ctrl/Cmd+C). One field at a time (a new offer replaces the old). + Returns true when the clipboard took it. (The per-page duplication + house style — this is history.js's OWN copy of the ~10-line helper; + app.js keeps the chat page's, no new shared module.) */ +async function copyShareLink(cell, absoluteUrl) { + cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove()); + try { + await navigator.clipboard.writeText(absoluteUrl); + return true; + } catch { + const field = document.createElement("a"); + field.className = "share-link-fallback"; + field.href = absoluteUrl; // carries the full URL (copy link address works too) + field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML + field.title = "Share link — click, then copy (Ctrl/Cmd+C)"; + field.addEventListener("focus", () => selectAllInField(field)); + cell.appendChild(field); + field.focus({ preventScroll: true }); // selects the URL — ready to copy + return false; + } +} + +/* The unshared state: the [Create link] button (a failed share keeps + the cell here — Create link is retryable). */ +function renderShareUnshared(chat, cell) { + const create = document.createElement("button"); + create.type = "button"; + create.className = "history-share-create"; + create.setAttribute("aria-label", `Create share link: ${chat.title}`); + create.textContent = "Create link"; + create.addEventListener("click", () => void createShareLink(chat, cell, create)); + cell.replaceChildren(create); +} + +/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step + (the phase-50 Delete-confirm pattern — same .history-confirm-* CSS, + focus moves to Yes so the confirm is keyboard-reachable); No or a + failed request restores this state (retryable). */ +function renderShareShared(chat, cell) { + const copy = document.createElement("button"); + copy.type = "button"; + copy.className = "history-share-copy"; + copy.setAttribute("aria-label", `Copy share link: ${chat.title}`); + copy.textContent = "Copy"; + copy.addEventListener("click", () => void copyRowShareLink(chat, cell)); + const unshare = document.createElement("button"); + unshare.type = "button"; + unshare.className = "history-unshare"; + unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`); + unshare.textContent = "Unshare"; + function restoreShared() { + cell.replaceChildren(copy, unshare); + unshare.focus({ preventScroll: true }); // focus returns to the (restored) control + } + unshare.addEventListener("click", () => { + const label = document.createElement("span"); + label.className = "history-confirm-text"; + label.textContent = "Unshare?"; + const yes = document.createElement("button"); + yes.type = "button"; + yes.className = "history-confirm-yes"; + yes.textContent = "Yes"; + const no = document.createElement("button"); + no.type = "button"; + no.className = "history-confirm-no"; + no.textContent = "No"; + yes.addEventListener("click", () => void confirmUnshare(chat, cell, yes, restoreShared)); + no.addEventListener("click", restoreShared); + cell.replaceChildren(label, yes, no); + yes.focus({ preventScroll: true }); // the confirm pair takes over the focus + }); + cell.replaceChildren(copy, unshare); +} + +/* The Share cell (phase 51): the span the row's Share carries. + The shipped state comes from the row's share_url (the list endpoint + populates it — no second fetch): shared → Copy + Unshare, unshared → + Create link. No share action ever removes the ROW (only Delete + does) — the cell just re-renders between its states. */ +function makeShareControl(chat) { + const cell = document.createElement("span"); + cell.className = "history-share"; + if (chat.share_url) { + renderShareShared(chat, cell); + } else { + renderShareUnshared(chat, cell); + } + return cell; +} + +/* Create the link: POST /api/chats//share → the response's + share_url becomes the row's data (chat.share_url — the later Copy + uses it), the cell re-renders to the shared state, and the ABSOLUTE + link (the row's own origin supplies the scheme/host) is offered for + copying — clipboard → the inline-field fallback in the cell. A + non-2xx (a 404 — the row was deleted behind our back — or 5xx) or a + network error keeps the unshared state (Create link re-enabled, + retryable) and lands the error line. */ +async function createShareLink(chat, cell, createBtn) { + createBtn.disabled = true; // no double-fire while the request is in flight + let r; + try { + r = await fetch(`/api/chats/${chat.id}/share`, { method: "POST" }); + } catch { + announce(`Couldn't share "${chat.title}" — is the app reachable?`); + createBtn.disabled = false; + return; + } + if (!r.ok) { + announce(`Couldn't share "${chat.title}" — try again.`); + createBtn.disabled = false; + return; + } + const { share_url } = await r.json(); + chat.share_url = share_url; // the row is shared from now on + renderShareShared(chat, cell); + const copied = await copyShareLink( + cell, + new URL(share_url, window.location.origin).toString(), + ); + announce(copied ? "Share link copied." : "Share link ready — copy it from the field."); +} + +/* Copy (shared state): re-copy the row's share_url — the per-page + clipboard + fallback helper; the live region lands the outcome. */ +async function copyRowShareLink(chat, cell) { + const copied = await copyShareLink( + cell, + new URL(chat.share_url, window.location.origin).toString(), + ); + announce(copied ? "Share link copied." : "Share link ready — copy it from the field."); +} + +/* The confirmed unshare: POST /api/chats//unshare → the token is + NULL (revoked — the public link 404s from now on), the cell + re-renders to the unshared state (Create link) and the live region + gets `Unshared "".` A non-2xx / a network error keeps the + shared state (restoreShared — Copy + Unshare, retryable) and lands + the error line. */ +async function confirmUnshare(chat, cell, yesBtn, restoreShared) { + yesBtn.disabled = true; // no double-fire while the request is in flight + let r; + try { + r = await fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" }); + } catch { + announce(`Couldn't unshare "${chat.title}" — is the app reachable?`); + restoreShared(); + return; + } + if (!r.ok) { + announce(`Couldn't unshare "${chat.title}" — try again.`); + restoreShared(); + return; + } + chat.share_url = null; // revoked: the row is unshared again + renderShareUnshared(chat, cell); + announce(`Unshared "${chat.title}".`); +} + /* The empty-state row reappears exactly when the last data row was removed (the empty row itself ships in the tbody, hidden). */ function showEmptyIfLast() { diff --git a/frontend/assets/shared.js b/frontend/assets/shared.js new file mode 100644 index 0000000..2e49740 --- /dev/null +++ b/frontend/assets/shared.js @@ -0,0 +1,347 @@ +/* Brain of Reese — the anonymous shared conversation page (phase 51, + * task 03). + * + * /shared/<token> (owner-locked 2026-08-29, TODO.md L6): anyone with + * the link sees the conversation READ-ONLY — zero interactive controls + * (no composer, no Save/Share/Tune/Retry, no document access). The + * page fetches GET /api/shared/<token> (public — the token IS the + * credential, no admin dependency) and renders the SAME record shape + * the chat page uses (phase 14 bor.chat.v1 — { who, text, sources?, + * deflected?, suggestions?, thinking?, tools?, stopped? }): + * + * • user → the .msg.user bubble (markdown, escape-first); + * • brain → the .msg.brain bubble: the optional thinking block + * restored COLLAPSED (the phase-17 restore convention — a guest + * can still expand it; reading is not mutating), the tool lines + * in saved order (phase 37), the is-deflected treatment + the + * "Maybe try" chips as PLAIN SPAN text (a guest tapping a chip + * has nowhere to go — owner-locked zero controls), the source + * chips as PLAIN TEXT spans (no href, no modal wiring — the + * documents API is admin-only, so a guest cannot open documents), + * and the stopped note (phase 48) when the turn was user-stopped. + * + * Per-page duplication house style (history.js keeps its own clipboard + * helper, the chat page's stays in app.js): this file carries its own + * small copies of the chat page's message-fragment builders — the + * thinking block, the tool lines, the stopped note, the chip rows. + * Nothing is imported from app.js (a page script is never imported by + * another page; header.js is the only cross-page module). + * + * Failure contract: a malformed or missing token in the URL → the + * invalid state shows immediately with NO fetch of any kind (not even + * whoami — the header ships in its guest state, which is already + * correct); a 404 (wrong or revoked token), a network failure, or a + * malformed body → the invalid state (the h1 keeps its fallback), no + * data is rendered, and there is no error banner on this page (zero + * controls — the muted invalid card is the whole failure UI). + * + * The header works for guests: initSharedHeader() runs the cached + * whoami (anonymous → the admin-only links stay hidden, the Sign in + * link is shown) and rewrites ?next= to the current pathname for a + * signed-in admin; the guest's static fallback is ?next=/ — a guest + * signing in from a shared page returns to the app root (see + * shared.html). + * + * All DOM ids match frontend/shared.html. + */ + +import { initSharedHeader } from "./header.js"; + +const titleEl = document.querySelector("#shared-title"); +const noteEl = document.querySelector(".shared-note"); +const messagesEl = document.querySelector("#messages"); +const invalidEl = document.querySelector("#shared-invalid"); + +/* Phase 39: the display name resolves from one place — window.BOR_BRAND + * (the classic assets/brand.js sets it at parse time; its /api/config + * fetch refreshes it). Read LAZILY (a function, not a const string): + * the note set after the fetch lands carries the configured name; the + * literal is only the no-config fallback. */ +const brand = () => window.BOR_BRAND || "Brain of Reese"; + +/* ---------- the token (from the URL) ---------- + * The last path segment of /shared/<token>. A malformed or missing + * token (a path without a final segment, or a non-uuid segment) → + * null: the boot shows the invalid state immediately and makes NO + * fetch of any kind (the server's page route 404s a hand-typed + * /shared/garbage anyway — the client gate keeps the no-fetch rule + * and renders the page's own invalid state instead of a JSON error). */ +const TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function parseSharedToken() { + const segments = window.location.pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1] || ""; + return TOKEN_RE.test(last) ? last : null; +} + +/* ---------- the invalid / revoked state ---------- + * The one failure UI on the page: the centered muted card (ship- + * hidden in the markup, revealed here). The h1 keeps its static + * fallback, the conversation section stays empty — no data rendered, + * no banner. */ +export function showInvalid() { + if (invalidEl) invalidEl.hidden = false; +} + +/* ---------- avatar glyphs (duplicated from app.js, phase 08) ---------- + * Inline SVG as string constants so the message renderer shares the + * exact marks the chat page uses. currentColor lets the CSS theme the + * stroke (brand-ink for Brain, ink-soft for the user). */ +const BRAIN_AVATAR = + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/><circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none"/><path d="M9.5 6.5V3.8M14.5 6.5V3.8M9.5 20.2v-2.7M14.5 20.2v-2.7M6.5 9.5H3.8M6.5 14.5H3.8M20.2 9.5h-2.7M20.2 14.5h-2.7"/></svg>'; + +const USER_AVATAR = + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>'; + +/* ---------- messages (read-only) ---------- + * The SAME .msg/.msg-body/.bubble structure the chat page renders, so + * the existing CSS applies unchanged. No scroll intent (a guest lands + * where the browser puts them — no composer to reveal), no empty + * state to hide (the section is empty by construction until records + * are appended). */ +function addSharedMessage(who, html) { + const wrap = document.createElement("div"); + wrap.className = `msg ${who}`; + wrap.innerHTML = ` + <span class="avatar" aria-hidden="true">${who === "brain" ? BRAIN_AVATAR : USER_AVATAR}</span> + <div class="msg-body"> + <div class="bubble">${html}</div> + </div>`; + messagesEl.appendChild(wrap); + return wrap; +} + +/* The thinking block (phase 17) — the local copy of the chat page's + * restore path: the model's reasoning ABOVE the answer bubble, + * restored COLLAPSED (the phase-17 restore convention — the live path + * opens it while streaming; a shared chat is a finished conversation, + * so it lands closed). Native details/summary — expanding is reading, + * not mutating. The reasoning text is stored RAW, so it goes through + * the same escape-first global renderMarkdown as the answer. */ +function addThinkingBlock(wrap, thinking) { + const body = wrap.querySelector(".msg-body"); + if (!body) return; + const block = document.createElement("details"); + block.className = "thinking"; + block.open = false; // restored COLLAPSED + const summary = document.createElement("summary"); + summary.textContent = "Thinking"; + const textEl = document.createElement("div"); + textEl.className = "thinking-text"; + textEl.innerHTML = renderMarkdown(thinking); // escape-first, XSS-safe + block.append(summary, textEl); + body.insertBefore(block, body.querySelector(".bubble")); +} + +/* Tool-call lines (phase 37) — the local copy of the chat page's + * appendToolLine: one visible "calling tool" row per saved + * {name, argument} record, in saved order, above the answer. The + * path argument goes through textContent, so nothing HTML-shaped can + * come from storage. Lines are not interactive (no focus targets). + * The two content marks (the read glyph / the list glyph) are the + * exact app.js template strings — the frontend emoji guard strips + * precisely those two literals in this file, as in app.js. */ +function addToolLines(wrap, tools) { + if (!Array.isArray(tools) || !tools.length) return; + const body = wrap.querySelector(".msg-body"); + if (!body) return; + const container = document.createElement("div"); + container.className = "tool-calls"; + container.setAttribute("role", "list"); + container.setAttribute("aria-label", "Tool calls"); + for (const t of tools) { + if (!t || typeof t.name !== "string") continue; + const line = document.createElement("span"); + line.className = "tool-call"; + line.setAttribute("role", "listitem"); + const argument = + typeof t.argument === "string" && t.argument ? t.argument : null; + if (t.name === "read_document" && argument) { + line.textContent = "📄 Reading "; + const code = document.createElement("code"); + code.textContent = argument; // the path is data, never markup + line.appendChild(code); + } else { + line.textContent = "🔎 Listing documents"; + } + container.appendChild(line); + } + body.insertBefore(container, body.querySelector(".bubble")); +} + +/* "Maybe try:" chips under a deflected bubble (honesty gate, phase + * 04) — PLAIN SPAN text, not buttons (owner-locked 2026-08-29: a + * guest tapping a chip has nowhere to go — zero interactive + * controls). Same .suggestion-chip pill look as the chat page; the + * shared-page CSS kills the pointer (pointer-events: none, scoped to + * .shared-shell — the chat page's interactive chips are untouched). */ +function addMaybeTry(wrap, suggestions) { + if (!Array.isArray(suggestions) || !suggestions.length) return; + const body = wrap.querySelector(".msg-body"); + if (!body) return; + const group = document.createElement("div"); + group.className = "maybe-try"; + group.setAttribute("role", "list"); + group.setAttribute("aria-label", "Maybe try"); + const label = document.createElement("span"); + label.className = "visually-hidden"; + label.textContent = "Maybe try:"; + group.appendChild(label); + for (const item of suggestions) { + const text = String(item || "").trim(); + if (!text) continue; + const chip = document.createElement("span"); + chip.className = "suggestion-chip"; + chip.setAttribute("role", "listitem"); + chip.textContent = text; // raw text — never markup + group.appendChild(chip); + } + body.appendChild(group); +} + +/* Source chips (mono, source/path) under a brain bubble — PLAIN TEXT + * spans: no href, no modal wiring, no click handler (owner-locked: + * guests cannot open documents — the documents API is admin-only, + * phase 16). Same .source-chip pill look as the chat page; the + * shared-page CSS kills the pointer, scoped to .shared-shell. */ +function addSources(wrap, sources) { + if (!Array.isArray(sources) || !sources.length) return; + const body = wrap.querySelector(".msg-body"); + if (!body) return; + const meta = document.createElement("div"); + meta.className = "msg-meta"; + meta.setAttribute("role", "list"); + meta.setAttribute("aria-label", "Sources"); + for (const s of sources) { + if (!s || typeof s.source !== "string" || typeof s.path !== "string") continue; + const label = `${s.source}/${s.path}`; + const chip = document.createElement("span"); + chip.className = "source-chip"; + chip.setAttribute("role", "listitem"); + chip.textContent = label; // the path is data — never markup + chip.title = label; + meta.appendChild(chip); + } + body.appendChild(meta); +} + +/* The "Stopped" note (phase 48) — the local copy of the chat page's + * appendStoppedNote: the non-interactive meta-row mark on a + * user-stopped brain bubble (the partial answer is what the owner + * shared). Reuses the .msg-meta row when one exists (the source + * chips' row) so the note joins it as a listitem, ARIA-valid. */ +function addStoppedNote(wrap) { + const body = wrap?.querySelector?.(".msg-body"); + if (!body) return; + let meta = body.querySelector(".msg-meta"); + if (!meta) { + meta = document.createElement("div"); + meta.className = "msg-meta"; + body.appendChild(meta); + } + if (meta.querySelector(".stopped-note")) return; // one per bubble + const note = document.createElement("span"); + note.className = "stopped-note"; + if (meta.getAttribute("role") === "list") note.setAttribute("role", "listitem"); + note.innerHTML = + '<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><rect x="6.5" y="6.5" width="11" height="11" rx="2"/></svg>'; + const label = document.createElement("span"); + label.textContent = "Stopped"; + note.appendChild(label); + meta.appendChild(note); +} + +/* One stored record through the SAME .msg structure the chat page + * uses (pixel-parity with the chat page's restore path): user → the + * .msg.user bubble; brain → the .msg.brain bubble with the optional + * thinking block (restored COLLAPSED — phase 17), the tool lines, + * the deflection treatment + the plain-text "Maybe try" chips, the + * plain-text source chips, and the stopped note. NO interactive + * markup is ever created here — no buttons, no forms, no links, no + * click handlers (owner-locked: zero controls). Markdown goes + * through the global escape-first renderMarkdown (markdown.js): the + * stored payloads are raw text, so the renderer's XSS safety applies + * unchanged. */ +function renderSharedMessage(m) { + if (m.who === "user") { + addSharedMessage("user", renderMarkdown(m.text)); + return; + } + const wrap = addSharedMessage("brain", renderMarkdown(m.text)); + if (typeof m.thinking === "string" && m.thinking) { + addThinkingBlock(wrap, m.thinking); + } + addToolLines(wrap, m.tools); + if (m.deflected) { + wrap.classList.add("is-deflected"); + addMaybeTry(wrap, m.suggestions); + } + addSources(wrap, m.sources); + if (m.stopped) addStoppedNote(wrap); +} + +/* ---------- the public read ---------- + * GET /api/shared/<token> — no admin dependency (the token IS the + * credential). Returns the SharedChatOut snapshot (title + messages) + * or null: a 404 (wrong or revoked token — one message server-side, + * no enumeration), a network failure, or a malformed body all + * collapse to null → the invalid state. */ +async function fetchSharedChat(token) { + let res; + try { + res = await fetch(`/api/shared/${token}`); + } catch { + return null; // network failure + } + if (!res.ok) return null; // 404 (wrong/revoked) / 5xx + let data; + try { + data = await res.json(); + } catch { + return null; // malformed body + } + return data && Array.isArray(data.messages) ? data : null; +} + +/* The 200 path: the h1 gets the shared chat's title (the static + * fallback stays when the title is missing/blank) and every record + * renders through renderSharedMessage. The same defensive filter as + * the chat page's restore keeps a corrupted stored row from + * poisoning the render (nothing HTML-shaped, ever). */ +function renderSharedChat(data) { + const title = typeof data.title === "string" ? data.title.trim() : ""; + if (title) titleEl.textContent = title; + const messages = data.messages.filter( + (m) => + m && + (m.who === "user" || m.who === "brain") && + typeof m.text === "string" && + m.text.length > 0 + ); + for (const m of messages) renderSharedMessage(m); +} + +/* Boot: the brand note first (call-time resolution — the classic + * brand.js already set the synchronous default), then the token. A + * malformed or missing token shows the invalid state immediately — + * NO fetch of any kind (not even whoami: the header ships in its + * guest state, which is already correct for a bad URL). A well- + * formed token runs the shared header init (guests: whoami + * anonymous, the admin-only links stay hidden) and then the public + * read; a null read (404 / network / malformed) shows the invalid + * state, and a 200 renders the conversation read-only. */ +(async () => { + if (noteEl) noteEl.textContent = `Shared via ${brand()} — read-only.`; + const token = parseSharedToken(); + if (!token) { + showInvalid(); + return; + } + await initSharedHeader(); + const data = await fetchSharedChat(token); + if (!data) { + showInvalid(); + return; + } + renderSharedChat(data); +})(); diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 43edff0..d745d8f 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -338,6 +338,37 @@ html::after { the whole control below 640px (mirrored in the ≤640 block below). */ .save-chat-btn svg { width: 16px; height: 16px; display: none; } +/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill — + the EXACT visual family of .save-chat-btn (same declarations, so the + chat-shell actions always read as a pair): solid brand pill, --bg + text on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover + lightens the brand fill, focus-visible via the global 3px rule. + Shipped hidden in index.html — app.js reveals it for admin only + (phase 16 absent-not-hidden); ≤640px overrides below mirror the + Save ones (label stays visible in .chat-shell, icon hidden there). */ +.share-chat-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-height: 44px; + padding: 0.5rem 0.9rem; + border-radius: 999px; + border: 0; + background: var(--brand); + color: var(--bg); + font: inherit; + font-weight: 700; + font-size: 0.95rem; + white-space: nowrap; + cursor: pointer; +} +.share-chat-btn:hover { background: #f55a72; color: var(--bg); } +/* The link mark is hidden on desktop (the label carries the pill); it + is the whole control below 640px (mirrored in the ≤640 block + below). */ +.share-chat-btn svg { width: 16px; height: 16px; display: none; } + /* Phase 16: header auth controls (Sign in link / Sign out button) — the same ghost pill as New chat, so the bar keeps one visual language. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1. @@ -1897,6 +1928,60 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } cursor: pointer; } .history-confirm-no:hover { background: var(--brand-soft); color: var(--brand-ink); } +/* Share column (phase 51, owner-locked 2026-08-29): the Tune/Retry- + family ghost buttons — Create link (unshared) and Copy (shared); + Unshare is the .history-unshare ghost and its two-step confirm reuses + the .history-confirm-* pair CSS above (the phase-50 pattern). The + row-action language of .history-delete: --line border, transparent + fill, ink-soft (>=5.1:1), ≥44px comfortable target, focus-visible + via the global 3px rule; hover takes the brand pair (6.9:1). */ +.history-share { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; } +.history-share-cell { white-space: nowrap; } +.history-share-create, +.history-share-copy, +.history-unshare { + min-height: 44px; + padding: 0.35rem 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--ink-soft); + font: inherit; + font-weight: 600; + font-size: 0.82rem; + white-space: nowrap; + cursor: pointer; +} +.history-share-create:hover:not(:disabled), +.history-share-copy:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); } +.history-unshare:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); } +.history-share-create:disabled, +.history-share-copy:disabled, +.history-unshare:disabled { opacity: 0.5; cursor: wait; } +/* The share link's inline fallback field (phase 51, owner-locked): a + non-secure (http) origin rejects the clipboard, so the full URL is + offered as an input-like <a> that selects itself on focus — click or + Tab, then Ctrl/Cmd+C. Mono (the URL is data), surface fill, --line + border; truncates with an ellipsis at narrow widths (the full URL is + the title + the text selection). Ink on surface ≈13.8:1. */ +.share-link-fallback { + display: inline-block; + max-width: 14rem; + padding: 0.35rem 0.55rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + font-family: var(--mono); + font-size: 0.78rem; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} +.share-link-fallback:hover { border-color: var(--brand); } +.share-link-fallback:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; } /* Empty-state row: the muted centered message at full table width (the .git-sources-empty language, inline in the table). */ .history-empty-row td { @@ -1906,6 +1991,61 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } font-style: italic; } +/* ---------- Shared page (phase 51, task 03) ---------- + /shared/<token>: the anonymous read-only conversation (owner-locked + 2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered + 46rem chat column — the conversation reads exactly like the chat + page (the .msg/.bubble/.thinking/.tool-calls/.msg-meta rules apply + unchanged) with NO composer, so the column contract holds for a + guest. Zero interactive controls (owner-locked): the chips are + plain text, so the pill families' pointer treatments are switched + off IN THIS SCOPE ONLY — the chat page's interactive chips keep + their styles untouched. Every pair reuses the Phase-08 AA palette + (ink-soft ≥6.9:1 on surface/bg, brand-ink on brand-soft 6.9:1); + :focus-visible via the global 3px outline rule. No CDN, system + fonts. */ +.shared-shell { + width: 100%; + max-width: 46rem; /* the PLAN §7 centered chat column */ + margin-inline: auto; + display: flex; + flex-direction: column; + gap: 1rem; + flex: 1; +} +/* Page title (JS-filled with the shared chat's title; the static + fallback is "Shared conversation") — the page-head h1 size. */ +#shared-title { margin: 0; font-size: 1.7rem; } +/* The muted meta line under the h1 ("Shared via … — read-only."): + ink-soft on the page bg ≥8.6:1, the page-sub language. */ +.shared-note { + margin: 0; + color: var(--ink-soft); + font-size: 0.92rem; +} +/* The invalid / revoked state: a centered muted card (the not-found + language — surface fill, --line border, italic ink-soft). Revealed + by shared.js for a malformed token (no fetch) or a failed read. */ +#shared-invalid { + padding: 1.4rem 1rem; + text-align: center; + color: var(--ink-soft); + font-style: italic; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +/* Static chips: a guest's "Maybe try" and source chips are TEXT — no + pointer, no hover, no cursor (owner-locked zero controls). The pill + look is kept; the interactivity is scoped off here and only here + (pointer-events: none also makes the :hover rules unreachable). */ +.shared-shell .suggestion-chip, +.shared-shell .source-chip { + pointer-events: none; + cursor: default; +} + /* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */ /* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO rows in one sticky <header> — row 1 reuses the standard .app-header / @@ -2445,12 +2585,19 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } .save-chat-btn { padding: 0.4rem 0.3rem; } .save-chat-label { display: none; } .save-chat-btn svg { display: block; } + /* Phase 51: the Share pill squeezes with Save (same family, same + rules — the chat-shell overrides below keep both labels visible). */ + .share-chat-btn { padding: 0.4rem 0.3rem; } + .share-chat-label { display: none; } + .share-chat-btn svg { display: block; } /* But on the chat page there is room — keep the label visible and hide the icon (the button lives inside .chat-shell, not the navbar). */ .chat-shell .new-chat-label { display: inline; } .chat-shell .new-chat-btn svg { display: none; } .chat-shell .save-chat-label { display: inline; } .chat-shell .save-chat-btn svg { display: none; } + .chat-shell .share-chat-label { display: inline; } + .chat-shell .share-chat-btn svg { display: none; } /* Phase 16: the auth pill goes icon-only like New chat — brand text ellipsizes as the designated squeeze target, no bar overflow. */ .auth-link { padding: 0.4rem 0.3rem; } @@ -2564,6 +2711,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } the two-step confirm pair fits the phone width. */ .history-actions-cell { white-space: normal; } .history-actions { flex-wrap: wrap; } + /* Phase 51: the shared page squeezes like the chat column — the + title and the note step down (the empty-state-title family); the + shell keeps its 46rem column (it is already the narrowest box on + the page) and .msg-body's 92% override above applies. */ + #shared-title { font-size: 1.35rem; } + .shared-note { font-size: 0.88rem; } .footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; } main { padding-bottom: env(safe-area-inset-bottom, 0); } /* Sync button goes icon-only on mobile; the label hides, aria-label diff --git a/frontend/history.html b/frontend/history.html index 7c8a37a..dab8054 100644 --- a/frontend/history.html +++ b/frontend/history.html @@ -135,7 +135,9 @@ <!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny list): Title (the Open link → /?chat=<id>) | Messages | - Updated | Actions (Delete, inline two-step confirm). + Updated | Share (phase 51: Create link / Copy / Unshare — + the row's share_url comes from GET /api/chats itself, no + second fetch) | Actions (Delete, inline two-step confirm). history.js fills #history-tbody; #history-empty-row ships hidden and is revealed by a 0-row fetch. The Actions column header is visually-hidden — the row buttons carry their own @@ -148,12 +150,13 @@ <th scope="col">Title</th> <th scope="col">Messages</th> <th scope="col">Updated</th> + <th scope="col">Share</th> <th scope="col"><span class="visually-hidden">Actions</span></th> </tr> </thead> <tbody id="history-tbody"> <tr class="history-empty-row" id="history-empty-row" hidden> - <td colspan="4">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td> + <td colspan="5">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td> </tr> </tbody> </table> diff --git a/frontend/index.html b/frontend/index.html index 8c00b0b..81d0296 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -134,6 +134,29 @@ <span class="save-chat-label">Save</span> </button> + <!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share" + turns the current conversation into a PUBLIC read-only link — + /shared/<token> (a 128-bit uuid4 on the saved_chats row, + migration 0009; the anonymous page is task 03). The + save-then-share contract: an UNSAVED (unlinked) conversation + is saved AND shared in ONE action — app.js POSTs /api/chats + with { messages, share: true } (the server sets the token in + the same commit) and links the conversation to the created + row; a saved (linked) one just POSTs /api/chats/<id>/share + (idempotent — the existing token comes back unchanged). On + success the ABSOLUTE link is copied to the clipboard; a + non-secure (http) homelab origin that rejects the clipboard + gets the inline link-field fallback instead (owner-locked — + app.js renders .share-link-fallback near the status line). + Admin-only — ships HIDDEN exactly like Save (absent-not- + hidden, phase 16); app.js reveals it at boot (the same + admin-reveal block) and binds the click to shareCurrentChat. + Unsharing lives on the History page's Share column (task 04). --> + <button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat" hidden> + <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> + <span class="share-chat-label">Share</span> + </button> + <!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain bubble can carry JS-injected actions (app.js) — Tune (admin only, phase 15) and Retry (every visitor; the LAST brain diff --git a/frontend/shared.html b/frontend/shared.html new file mode 100644 index 0000000..5a0c5b3 --- /dev/null +++ b/frontend/shared.html @@ -0,0 +1,175 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> + <meta name="description" content="A shared Brain of Reese conversation — read-only."> + <title>Shared conversation · Brain of Reese + + + + + + +
+
+ + + Brain of Reese + + + + + + + + + +
+
+ +
+ + +

+ + +
+

Shared conversation

+

Shared via Brain of Reese — read-only.

+ + + + +
+ +
+
+
+ +
+ +
+ + + + + + + + diff --git a/tests/e2e/test_cache_busting.py b/tests/e2e/test_cache_busting.py index 2a11990..34a24da 100644 --- a/tests/e2e/test_cache_busting.py +++ b/tests/e2e/test_cache_busting.py @@ -16,14 +16,18 @@ mock LLM keeps the SSE check deterministic (no live aipi). from __future__ import annotations import json +import re from pathlib import Path from typing import Any import httpx from playwright.sync_api import Page +from e2e.auth_helpers import login + REPO = Path(__file__).resolve().parents[2] CHAT_QUESTION = "How is my Kubernetes cluster set up?" +SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$") def _expected_token() -> str: @@ -44,6 +48,16 @@ def _version_token(url: str) -> str: return url.rsplit("?v=", 1)[1] +def _admin_cookies(page: Page) -> dict[str, str]: + """The signed session cookies the browser holds after a form login + — used to call the admin API with plain httpx.""" + return { + c["name"]: c["value"] + for c in page.context.cookies() + if "name" in c and "value" in c + } + + def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]: """Minimal SSE chat request (same pattern as ``test_chat_rag.py``): POST /api/chat and collect the ``data:`` frames until the stream ends.""" @@ -123,6 +137,62 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None: assert sources_token == login_token == history_token == token +def test_shared_page_is_no_cache_and_versioned( + page: Page, app_url: str, db_ready: None +) -> None: + """`/shared/` (phase 51, the dynamic share page): the same + contract as the static HTML pages — the document revalidates + (no-cache) and the served HTML's asset refs are `?v=`-tagged (the + middleware's prefix extension, task 01). The chat is created + + shared via the admin API for the test.""" + token = _expected_token() + assert token, "the version token must be non-empty" + + login(page, app_url, next="/") + cookies = _admin_cookies(page) + r = httpx.post( + f"{app_url}/api/chats", + json={ + "messages": [ + {"who": "user", "text": "cache-busting shared-page probe"}, + {"who": "brain", "text": "Shared for the cache contract."}, + ], + "share": True, + }, + timeout=10, + cookies=cookies, + ) + assert r.status_code == 201 + body = r.json() + assert SHARE_URL_RE.fullmatch(body["share_url"]), body["share_url"] + try: + with page.expect_response(lambda r: "/assets/styles.css" in r.url) as css_info: + doc = page.goto(app_url + body["share_url"]) + + # The document: always revalidated, like every HTML page. + assert doc is not None + assert doc.headers["cache-control"] == "no-cache" + + # The CSS request the browser actually makes carries the + # process token… + assert _version_token(css_info.value.url) == token + + # …and the served HTML references its assets versioned (the + # page uses ABSOLUTE /assets refs — required for the nested + # /shared/ path). + html = page.content() + assert f'href="/assets/styles.css?v={token}"' in html + assert f'src="/assets/brand.js?v={token}"' in html + assert f'src="/assets/markdown.js?v={token}"' in html + assert f'src="/assets/shared.js?v={token}"' in html + + # No unversioned reference survives the rewrite. + assert 'href="/assets/styles.css"' not in html + assert 'src="/assets/shared.js"' not in html + finally: + httpx.delete(f"{app_url}/api/chats/{body['id']}", timeout=10, cookies=cookies) + + def test_api_responses_unaffected(page: Page, app_url: str, db_ready: None) -> None: """`/api/*` passes through untouched: no injected Cache-Control on the health endpoint, and the SSE chat stream still streams to done.""" diff --git a/tests/e2e/test_share_chat.py b/tests/e2e/test_share_chat.py new file mode 100644 index 0000000..e93507e --- /dev/null +++ b/tests/e2e/test_share_chat.py @@ -0,0 +1,498 @@ +"""Phase 51 E2E (Playwright): share a chat by link — anonymous view. + +TODO.md L6 (owner 2026-08-29): "Need a way to share a chat with a link +so others can see it anonymously." +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_share_chat.py -v --no-cov + +The owner-locked loop under test (2026-08-29, roadmap confirmation): + +* **Share from the chat page** — the Share pill (admin-only, ships + hidden) on an UNSAVED conversation saves AND shares in ONE action + (``POST /api/chats`` with ``share: true`` — the row appears in + ``GET /api/chats`` with a non-null ``share_url`` of the shape + ``/shared/``); the absolute URL is copied to the clipboard, + with the inline-link fallback on a non-secure origin (the assertion + branches on ``navigator.clipboard`` availability); +* **Anonymous view** — a FRESH browser context (a separate session, no + cookies) opening ``/shared/`` sees the full conversation + read-only through the same record shape: title = the auto-title, + user + brain bubbles (the same deterministic answer text the admin + session saw), the thinking block RESTORED COLLAPSED, the source + chips as PLAIN TEXT (zero ``a.source-chip`` — guests cannot open + documents, the documents API is admin-only), and ZERO interactive + controls anywhere (no composer, no Save/Share pills, no Tune/Retry, + no button chips); the nav's admin-only links stay hidden for a + guest; +* **Share from History + unshare** — the History row's Share column: + "Create link" → Copy + Unshare; Unshare is the inline two-step + (no native dialog); after Yes the cell returns to "Create link", + the SAME URL now shows the "invalid or was revoked" state in a + fresh anonymous context, and ``GET /api/chats/`` no longer + carries ``share_url`` (the omission rule — the key is absent, not + null); +* **Bad token** — a well-formed but unknown token renders the invalid + state with no JS crash and the guest header (the page route serves + the HTML for any well-formed token; the client's 404 read drives + the invalid card). + +DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows +across suites, so every test here uses a DISTINCTIVE question text +(its auto-title is therefore unique), never asserts on absolute row +counts, and deletes the rows it creates in a ``finally`` (admin +cookie). The KB tables are truncated + re-seeded the house way +(deterministic mock embeddings); ``saved_chats`` is never touched by +the reset. +""" +from __future__ import annotations + +import asyncio +import re +from pathlib import Path +from threading import Thread +from typing import Any + +import httpx +from playwright.sync_api import Browser, BrowserContext, Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient +from e2e.auth_helpers import login + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" +MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" +SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$") +#: The invalid/revoked card's line (frontend/shared.html, task 03). +INVALID_TEXT = "This share link is invalid or was revoked." +BAD_TOKEN = "00000000-0000-4000-8000-000000000000" + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread. + + Playwright's sync API keeps an asyncio loop running on the test + thread, so ``asyncio.run`` cannot be called directly from a test + body. + """ + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: + """Truncate the KB (and query log + steering notes — deterministic + mock answers), then optionally re-import fixtures. ``saved_chats`` + is deliberately NOT touched: rows persist across suites and every + test here cleans up after itself.""" + with SessionLocal() as db: + db.execute( + text("TRUNCATE chunks, documents, query_log, steering_notes") + ) + db.commit() + if not seed: + return None + return _run_in_thread(_import_fixtures(mock_port)) + + +def _ask(page: Page, question: str) -> None: + """Send one turn and wait until the grounded answer has fully + landed (the ``done`` event restored the Send button).""" + page.fill("#message-input", question) + page.click("#send-btn") + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + expect(page.locator(".msg.brain .bubble").last).to_contain_text( + MOCK_ANSWER_MARKER, timeout=30_000 + ) + expect(page.locator("#send-btn")).to_be_enabled() + expect(page.locator("#send-label")).to_have_text("Send") + + +def _admin_cookies(page: Page) -> dict[str, str]: + """The signed session cookies the browser holds after a form login — + used to call the admin API with plain httpx (the test's API side + sees exactly what the signed-in browser sees).""" + return { + c["name"]: c["value"] + for c in page.context.cookies() + if "name" in c and "value" in c + } + + +def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]: + r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies) + assert r.status_code == 200 + return r.json()["chats"] + + +def _auto_title(question: str) -> str: + """The phase-50 auto-title convention: the first question, + whitespace-collapsed, capped at 120 chars.""" + return " ".join(question.split())[:120] + + +def _find_row( + rows: list[dict[str, Any]], title: str +) -> dict[str, Any] | None: + return next((c for c in rows if c["title"] == title), None) + + +def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None: + """Best-effort row cleanup (a 404 — already deleted — is fine).""" + httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) + + +def _grant_clipboard(page: Page, app_url: str) -> None: + """Grant the async-clipboard permissions on the admin context. + + ``http://127.0.0.1`` is a secure context, so ``navigator.clipboard`` + exists — but headless Chromium still requires the permission grant + before ``writeText`` resolves (without it the owner-locked + inline-link fallback fires). The assertion below branches on the + API's availability, so a non-secure origin still passes through + the fallback branch deterministically. + """ + page.context.grant_permissions( + ["clipboard-read", "clipboard-write"], origin=app_url + ) + + +def _click_share_and_assert_status(page: Page, app_url: str) -> None: + """Press the chat page's Share pill and pin the owner-locked + outcome on the live region: "Share link copied." when + ``navigator.clipboard`` is available in the context, else the + inline fallback link field carrying the ``/shared/`` URL. + The branch is on the API's availability (task spec).""" + page.locator("#share-chat-btn").click() + if page.evaluate("() => !!navigator.clipboard"): + expect(page.locator("#send-status")).to_have_text( + "Share link copied.", timeout=15_000 + ) + expect(page.locator(".share-link-fallback")).to_have_count(0) + else: + expect(page.locator("#send-status")).to_have_text( + "Share link ready — copy it from the field.", timeout=15_000 + ) + field = page.locator(".share-link-fallback") + expect(field).to_be_visible() + href = field.get_attribute("href") + assert href is not None + assert href.startswith(app_url) + assert SHARE_URL_RE.fullmatch(href.removeprefix(app_url)) + + +# --------------------------------------------------------------------------- +# 1. Share from the chat page: an UNSAVED conversation is saved + shared +# in one action; the API row carries the /shared/ link +# --------------------------------------------------------------------------- + + +def test_share_from_chat_page( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + + login(page, app_url, next="/") + expect(page).to_have_url(app_url + "/", timeout=30_000) + + q = "How is my Kubernetes cluster set up? (share-chat)" + _ask(page, q) + + # Admin: the Share pill is revealed (ships hidden, whoami reveals + # it — the same block as Save). The conversation is UNSAVED at this + # point: no row exists yet under the auto-title. + share = page.locator("#share-chat-btn") + expect(share).to_be_visible() + expect(share).to_have_attribute("aria-label", "Share chat") + cookies = _admin_cookies(page) + assert ( + _find_row(_chats(app_url, cookies), _auto_title(q)) is None + ), "the conversation is unsaved before the Share click" + + _grant_clipboard(page, app_url) + _click_share_and_assert_status(page, app_url) + + created: str | None = None + try: + # The API agrees: ONE action saved AND shared — the new row + # exists with a non-null share_url of the token shape. + row = _find_row(_chats(app_url, cookies), _auto_title(q)) + assert row is not None, "the Share click must have saved the conversation" + assert row["message_count"] == 2, "the saved conversation holds both messages" + share_url = row.get("share_url") + assert share_url is not None, "the share_url must be present (non-null)" + assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}" + created = row["id"] + finally: + if created is not None: + _delete_chat(app_url, cookies, created) + + +# --------------------------------------------------------------------------- +# 2. The anonymous shared view: a FRESH context (no cookies) sees the +# full conversation read-only — thinking collapsed, plain-text chips, +# zero controls, the guest header +# --------------------------------------------------------------------------- + + +def test_anonymous_shared_view( + page: Page, + browser: Browser, + app_url: str, + mock_llm: int, + db_ready: None, +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + + login(page, app_url, next="/") + expect(page).to_have_url(app_url + "/", timeout=30_000) + + # "think out loud" → the mock streams reasoning first, so the saved + # record (and the shared view) carries a thinking block. + q = "think out loud — how is my kubernetes cluster set up? (share-view)" + _ask(page, q) + # The answer text the admin session saw — the shared view must + # render the SAME deterministic text (same record, same renderer). + answer = page.locator(".msg.brain .bubble").first.inner_text() + + _grant_clipboard(page, app_url) + _click_share_and_assert_status(page, app_url) + + cookies = _admin_cookies(page) + row = _find_row(_chats(app_url, cookies), _auto_title(q)) + assert row is not None and row.get("share_url") + share_url: str = row["share_url"] + created: str | None = row["id"] + anon_ctx: BrowserContext | None = None + try: + # A FRESH context: a separate session with no cookies — the + # guest's only credential is the token in the URL. + anon_ctx = browser.new_context() + anon = anon_ctx.new_page() + anon.set_default_timeout(30_000) + anon.goto(app_url + share_url) + + # Title = the auto-title (the shared chat's h1). + expect(anon.locator("#shared-title")).to_have_text(_auto_title(q)) + + # The conversation rendered: the user question bubble + the + # brain answer with the SAME deterministic text the admin saw. + expect(anon.locator(".msg.user .bubble")).to_have_count(1) + expect(anon.locator(".msg.user .bubble")).to_contain_text(q) + bubble = anon.locator(".msg.brain .bubble").first + expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000) + assert bubble.inner_text() == answer, "the shared answer must match the admin's" + + # The thinking block exists and is RESTORED COLLAPSED (the + # phase-17 restore convention — a closed
has no + # `open` attribute). + think = anon.locator("details.thinking") + expect(think).to_have_count(1) + expect(think.first).not_to_have_attribute("open") + + # Source chips are PLAIN TEXT: the on-topic turn carries its + # source chips (top-2 docs — the hybrid retrieval), but every + # one as a : zero anywhere (a guest + # cannot open documents; the documents API is admin-only). + assert ( + anon.locator(".msg.brain .source-chip").count() >= 1 + ), "the grounded turn must carry its source chips" + expect( + anon.locator(".msg.brain .source-chip", has_text="kubernetes.md") + ).to_have_count(1) + expect(anon.locator("a.source-chip")).to_have_count(0) + + # ZERO interactive controls anywhere in the conversation: no + # composer, no Save/Share pills, no Tune/Retry, and (were the + # turn deflected) the "Maybe try" chips would be spans — never + # buttons. + expect(anon.locator("#composer")).to_have_count(0) + expect(anon.locator("#save-chat-btn, #share-chat-btn")).to_have_count(0) + expect(anon.locator(".tune-btn")).to_have_count(0) + expect(anon.locator(".retry-btn")).to_have_count(0) + expect(anon.locator("button.suggestion-chip")).to_have_count(0) + + # The nav's admin-only links stay hidden for a guest (and the + # Sign in link is what the guest gets instead). + for admin_link in ("#nav-sources", "#nav-git-sources", "#nav-tuning", "#nav-history"): + expect(anon.locator(admin_link)).to_be_hidden() + expect(anon.locator("#sign-in-link")).to_be_visible(timeout=15_000) + finally: + if anon_ctx is not None: + anon_ctx.close() + if created is not None: + _delete_chat(app_url, cookies, created) + + +# --------------------------------------------------------------------------- +# 3. Share from the History row + unshare: Create link → Copy/Unshare → +# the two-step confirm revokes — the same URL goes invalid and the +# API drops share_url +# --------------------------------------------------------------------------- + + +def test_share_from_history_and_unshare( + page: Page, + browser: Browser, + app_url: str, + mock_llm: int, + db_ready: None, +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + + login(page, app_url, next="/") + expect(page).to_have_url(app_url + "/", timeout=30_000) + + q = "How is my Kubernetes cluster set up? (share-history)" + _ask(page, q) + + # Save first (this test drives the History column, not the + # save-then-share one-action path — test 1 covers that). + page.locator("#save-chat-btn").click() + expect(page.locator("#send-status")).to_have_text("Conversation saved.") + + cookies = _admin_cookies(page) + _grant_clipboard(page, app_url) + row = _find_row(_chats(app_url, cookies), _auto_title(q)) + assert row is not None + chat_id: str = row["id"] + anon_ctx: BrowserContext | None = None + try: + # History: the row's Share cell ships in the unshared state — + # the single "Create link" button. + page.goto(app_url + "/history.html") + tr = page.locator( + "#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']") + ) + create = tr.locator("button.history-share-create") + expect(create).to_be_visible(timeout=15_000) + expect(create).to_have_text("Create link") + + # Create link → the cell re-renders to the shared state + # (Copy + Unshare) and the outcome lands on the live region + # (clipboard or the inline field — either success line). + create.click() + expect(tr.locator("button.history-share-copy")).to_be_visible(timeout=15_000) + expect(tr.locator("button.history-unshare")).to_be_visible() + expect(page.locator("#history-status")).to_contain_text("Share link") + + # The API agrees: the row now carries the share link… + r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) + assert r.status_code == 200 + share_url = r.json().get("share_url") + assert share_url is not None + assert SHARE_URL_RE.fullmatch(share_url) + + # …and the FRESH anonymous context renders the conversation at + # the URL. + anon_ctx = browser.new_context() + anon = anon_ctx.new_page() + anon.set_default_timeout(30_000) + anon.goto(app_url + share_url) + expect(anon.locator("#shared-title")).to_have_text(_auto_title(q)) + expect(anon.locator(".msg.user .bubble")).to_contain_text(q) + expect(anon.locator(".msg.brain .bubble").first).to_contain_text( + MOCK_ANSWER_MARKER, timeout=30_000 + ) + anon_ctx.close() + anon_ctx = None + + # Unshare: the inline two-step (the phase-50 confirm pattern — + # no native dialog; the pair appearing is the pinned contract). + tr.locator("button.history-unshare").click() + expect(tr.locator(".history-confirm-text")).to_have_text("Unshare?") + expect(tr.locator(".history-confirm-yes")).to_be_visible() + expect(tr.locator(".history-confirm-no")).to_be_visible() + tr.locator(".history-confirm-yes").click() + + # The cell returns to the unshared state + the live region. + expect(tr.locator("button.history-share-create")).to_be_visible(timeout=15_000) + expect(tr.locator("button.history-share-copy")).to_have_count(0) + expect(page.locator("#history-status")).to_have_text(f'Unshared "{q}".') + + # The SAME URL is revoked now: a fresh anonymous context sees + # the invalid state (no data rendered). + anon_ctx = browser.new_context() + anon = anon_ctx.new_page() + anon.set_default_timeout(30_000) + anon.goto(app_url + share_url) + expect(anon.locator("#shared-invalid")).to_be_visible(timeout=15_000) + expect(anon.locator("#shared-invalid")).to_contain_text(INVALID_TEXT) + expect(anon.locator(".msg")).to_have_count(0) + anon_ctx.close() + anon_ctx = None + + # The API agrees: the row is unshared — share_url is ABSENT + # (the omission rule: no null in the wire shape). + r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) + assert r.status_code == 200 + assert "share_url" not in r.json(), "unshared → share_url must be absent" + finally: + if anon_ctx is not None: + anon_ctx.close() + _delete_chat(app_url, cookies, chat_id) + + +# --------------------------------------------------------------------------- +# 4. A well-formed but unknown token: the invalid state, no JS crash, +# the guest header renders +# --------------------------------------------------------------------------- + + +def test_bad_token_invalid_state( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + # No DB reset / no login: the test is anonymous by construction + # (a fresh context from the page fixture) and the page route serves + # the HTML for any well-formed token — the 404 comes from the + # public API read, which needs the DB up (db_ready). + page.set_default_timeout(30_000) + + js_errors: list[str] = [] + page.on("pageerror", lambda e: js_errors.append(str(e))) + + # The page route serves the page (200 HTML) for the well-formed + # token — the invalid state is rendered by the client after its + # 404 API read, not a server error page. + doc = page.goto(app_url + f"/shared/{BAD_TOKEN}") + assert doc is not None + assert doc.status == 200 + + expect(page.locator("#shared-invalid")).to_be_visible(timeout=15_000) + expect(page.locator("#shared-invalid")).to_contain_text(INVALID_TEXT) + # The title keeps its static fallback and nothing rendered. + expect(page.locator("#shared-title")).to_have_text("Shared conversation") + expect(page.locator(".msg")).to_have_count(0) + + # The guest header rendered: Sign in visible, the admin-only nav + # links hidden. + expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000) + for admin_link in ("#nav-sources", "#nav-git-sources", "#nav-tuning", "#nav-history"): + expect(page.locator(admin_link)).to_be_hidden() + + # No crash: no uncaught page errors. + assert not js_errors, f"the invalid-state render must not throw: {js_errors}" diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index d37b7b1..1a5ee28 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -89,6 +89,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None: ("/tuning.html", "Global Tuning"), # phase 27: global tuning page ("/git-sources.html", "Git sources"), # phase 35: admin git sources page ("/history.html", "Saved chats"), # phase 50: admin saved-chats page + ("/shared.html", "Shared conversation"), # phase 51: anonymous shared page ], ) def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None: @@ -126,7 +127,8 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None: @pytest.mark.parametrize( "path", ["/sources.html", "/document.html", "/login.html", "/tuning.html", - "/git-sources.html", "/history.html"], # phase 50: + the History page + "/git-sources.html", "/history.html", # phase 50: + the History page + "/shared.html"], # phase 51: + the anonymous shared page ) def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None: """Each of the other four pages revalidates and carries at least one @@ -179,6 +181,7 @@ def test_styles_and_js_served(client) -> None: assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module assert client.get("/assets/tuning.js").status_code == 200 # phase 27: tuning page assert client.get("/assets/git-sources.js").status_code == 200 # phase 35: git sources page + assert client.get("/assets/shared.js").status_code == 200 # phase 51: shared page module # Emoji code points banned from UI chrome (phase 08): the pictograph @@ -218,6 +221,8 @@ def _find_emoji(text: str) -> list[str]: "/assets/login.js", # phase 16 "/assets/document-modal.js", # phase 26: the document modal module "/assets/git-sources.js", # phase 35: the git sources page module + "/shared.html", # phase 51: the anonymous shared page + "/assets/shared.js", # phase 51: the shared page module "/assets/styles.css", ], ) @@ -234,7 +239,7 @@ def test_ui_chrome_has_no_emoji(client, path: str) -> None: r = client.get(path) assert r.status_code == 200 text = r.text - if path == "/assets/app.js": + if path in ("/assets/app.js", "/assets/shared.js"): text = text.replace('"🔎 Listing documents"', "") text = text.replace('"📄 Reading "', "") assert _find_emoji(text) == [], f"emoji found in {path}: {_find_emoji(text)!r}" diff --git a/tests/integration/test_chats_api.py b/tests/integration/test_chats_api.py index 1599073..1fe7b82 100644 --- a/tests/integration/test_chats_api.py +++ b/tests/integration/test_chats_api.py @@ -16,10 +16,12 @@ Requires: podman compose up -d db """ from __future__ import annotations +import re import time import uuid from collections.abc import Iterator from datetime import UTC, datetime, timedelta +from pathlib import Path from typing import Any import pytest @@ -27,12 +29,21 @@ from fastapi.testclient import TestClient from sqlalchemy import select, text from sqlalchemy.orm import Session +from app.config import Settings from app.main import app as fastapi_app from app.models import SavedChat FIRST_QUESTION = "How did I install gitlab?" EXPLICIT_TITLE = "My backup notes" +#: The share link's shape: the page path + a canonical (lowercase) UUID. +SHARE_URL_RE = re.compile( + r"^/shared/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" +) + +#: The PUBLIC read's exact key set — no id, no timestamps, no token. +SHARED_OUT_KEYS = {"title", "messages"} + #: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional #: key present; the round-trip test asserts it survives byte-identical. FULL_BRAIN: dict[str, Any] = { @@ -110,7 +121,11 @@ def test_anonymous_every_route_returns_403(client: TestClient) -> None: ("GET", f"/api/chats/{unknown}", None), ("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}), ("DELETE", f"/api/chats/{unknown}", None), + ("POST", f"/api/chats/{unknown}/share", None), + ("POST", f"/api/chats/{unknown}/unshare", None), ] + # The public read is NOT in this list — it is anonymous by design + # (a wrong token 404s there, it never 403s). for method, path, body in cases: r = anon.request(method, path, json=body) assert r.status_code == 403, f"{method} {path} must be 403 for anonymous" @@ -444,3 +459,291 @@ def test_delete_unknown_chat_returns_404(admin_client: TestClient) -> None: def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None: assert admin_client.delete("/api/chats/not-a-uuid").status_code == 422 + + +# ---------- share / unshare / public read (phase 51, task 01) ---------- + + +def _share(admin_client: TestClient, chat_id: str) -> dict[str, Any]: + r = admin_client.post(f"/api/chats/{chat_id}/share") + assert r.status_code == 200 + return r.json() + + +def _stored_token(db: Session, chat_id: str) -> uuid.UUID | None: + """The row's ``share_token`` as seen by a fresh DB read.""" + row = db.get(SavedChat, uuid.UUID(chat_id)) + assert row is not None, "the chat row must exist" + return row.share_token + + +def test_share_returns_200_with_share_url_and_is_idempotent( + admin_client: TestClient, db: Session +) -> None: + created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() + + body1 = _share(admin_client, created["id"]) + assert set(body1) == {"chat_id", "share_url"} + assert body1["chat_id"] == created["id"] + assert SHARE_URL_RE.fullmatch(body1["share_url"]), ( + f"share_url must be /shared/: {body1['share_url']}" + ) + token = uuid.UUID(body1["share_url"].removeprefix("/shared/")) + # Persisted on the row (the A10 extension unchanged: same row, one + # new column — no new table). + assert _stored_token(db, created["id"]) == token + + # Idempotent: a re-share returns the SAME token, unchanged. + body2 = _share(admin_client, created["id"]) + assert body2 == body1 + assert _stored_token(db, created["id"]) == token + + +def test_share_leaves_updated_at_unchanged(admin_client: TestClient) -> None: + """Sharing is not a content edit — the token is written with a Core + ``update()`` that skips the ORM ``onupdate``, so the History page's + "latest activity first" order follows content edits only.""" + created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() + updated_before = created["updated_at"] + + time.sleep(0.1) # now() has µs resolution — make a bump observable + _share(admin_client, created["id"]) + + r = admin_client.get(f"/api/chats/{created['id']}") + assert r.json()["updated_at"] == updated_before, ( + "share must not bump updated_at" + ) + + +def test_unshare_revokes_the_link_and_is_idempotent( + admin_client: TestClient, db: Session +) -> None: + created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() + share_url = _share(admin_client, created["id"])["share_url"] + anon = TestClient(fastapi_app) # fresh jar: truly anonymous + assert anon.get(f"/api{share_url}").status_code == 200 # live, pre-revoke + + r = admin_client.post(f"/api/chats/{created['id']}/unshare") + assert r.status_code == 200 + assert r.json() == {"chat_id": created["id"], "shared": False} + + # The token is NULL in the DB and the public read now 404s. + assert _stored_token(db, created["id"]) is None + revoked = anon.get(f"/api{share_url}") + assert revoked.status_code == 404 + assert revoked.json() == {"detail": "unknown or revoked share link"} + + # Idempotent: unsharing an unshared chat is a clean 200 (no write). + r2 = admin_client.post(f"/api/chats/{created['id']}/unshare") + assert r2.status_code == 200 + assert r2.json() == {"chat_id": created["id"], "shared": False} + assert _stored_token(db, created["id"]) is None + + +def test_unshare_leaves_updated_at_unchanged(admin_client: TestClient) -> None: + created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() + updated_before = created["updated_at"] + _share(admin_client, created["id"]) + + time.sleep(0.1) + admin_client.post(f"/api/chats/{created['id']}/unshare") + + r = admin_client.get(f"/api/chats/{created['id']}") + assert r.json()["updated_at"] == updated_before, ( + "unshare must not bump updated_at" + ) + + +def test_public_read_returns_snapshot_without_private_keys( + admin_client: TestClient, +) -> None: + """A fresh anonymous client reads the shared chat: title + messages + round-trip, and the body carries NONE of the admin-surface keys + (no id, no timestamps, no token — a content snapshot, not a handle).""" + created = admin_client.post( + "/api/chats", + json={ + "title": EXPLICIT_TITLE, + "messages": [_user(FIRST_QUESTION), FULL_BRAIN], + }, + ).json() + share_url = _share(admin_client, created["id"])["share_url"] + + anon = TestClient(fastapi_app) # fresh jar: truly anonymous + r = anon.get(f"/api{share_url}") + assert r.status_code == 200 + body = r.json() + assert set(body) == SHARED_OUT_KEYS + assert body["title"] == EXPLICIT_TITLE + assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN]) + + +def test_public_read_wrong_and_revoked_tokens_404_with_one_detail( + admin_client: TestClient, +) -> None: + """Wrong (never issued) and revoked (unshared) tokens 404 with the + SAME detail — no enumeration between the two cases.""" + anon = TestClient(fastapi_app) + + wrong = anon.get(f"/api/shared/{uuid.uuid4()}") + assert wrong.status_code == 404 + assert wrong.json() == {"detail": "unknown or revoked share link"} + + created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() + share_url = _share(admin_client, created["id"])["share_url"] + assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200 + revoked = anon.get(f"/api{share_url}") + assert revoked.status_code == 404 + assert revoked.json() == wrong.json() # one message, both cases + + +def test_public_read_malformed_token_returns_422() -> None: + anon = TestClient(fastapi_app) + assert anon.get("/api/shared/not-a-uuid").status_code == 422 + + +def test_share_and_unshare_unknown_chat_return_404(admin_client: TestClient) -> None: + unknown = uuid.uuid4() + r = admin_client.post(f"/api/chats/{unknown}/share") + assert r.status_code == 404 + assert r.json() == {"detail": "unknown chat"} + r = admin_client.post(f"/api/chats/{unknown}/unshare") + assert r.status_code == 404 + assert r.json() == {"detail": "unknown chat"} + + +# ---------- create-with-share (phase 51, task 02 — the save-then-share +# contract: one request saves AND shares; unshared shapes carry NO +# ``share_url`` key at all — absent, not null) ---------- + + +def test_create_with_share_sets_token_in_the_same_commit( + admin_client: TestClient, db: Session +) -> None: + """``POST /api/chats`` with ``share: true``: the 201 body carries + ``share_url`` (the ONLY extra key — the shape is OUT_KEYS + + ``share_url``), matching the token shape, and the row's + ``share_token`` is persisted in the SAME commit (one INSERT — no + second request, no window where the row is saved but unshared).""" + r = admin_client.post( + "/api/chats", json={"messages": _simple_conversation(), "share": True} + ) + assert r.status_code == 201 + body = r.json() + assert set(body) == OUT_KEYS | {"share_url"} + assert SHARE_URL_RE.fullmatch(body["share_url"]), ( + f"share_url must be /shared/: {body['share_url']}" + ) + token = uuid.UUID(body["share_url"].removeprefix("/shared/")) + assert _stored_token(db, body["id"]) == token + + +def test_create_with_share_is_immediately_publicly_readable( + admin_client: TestClient, +) -> None: + """The save-then-share contract's payoff: the row is readable + ANONYMOUSLY the moment the 201 lands (no second step).""" + created = admin_client.post( + "/api/chats", json={"messages": _simple_conversation(), "share": True} + ).json() + anon = TestClient(fastapi_app) # fresh jar: truly anonymous + r = anon.get(f"/api{created['share_url']}") + assert r.status_code == 200 + body = r.json() + assert set(body) == SHARED_OUT_KEYS + assert body["messages"] == _expect(_simple_conversation()) + + +def test_create_without_share_has_no_share_url(admin_client: TestClient) -> None: + """The default (``share`` absent or false) is byte-for-byte the + phase-50 shape: NO ``share_url`` key in the create body, the get + body, or the list row — absent, not ``null``.""" + r = admin_client.post("/api/chats", json={"messages": _simple_conversation()}) + assert r.status_code == 201 + created = r.json() + assert "share_url" not in created + assert set(created) == OUT_KEYS + got = admin_client.get(f"/api/chats/{created['id']}").json() + assert "share_url" not in got and set(got) == OUT_KEYS + row = admin_client.get("/api/chats").json()["chats"][0] + assert "share_url" not in row and set(row) == ROW_KEYS + + +def test_create_share_false_is_explicitly_unshared(admin_client: TestClient) -> None: + r = admin_client.post( + "/api/chats", json={"messages": _simple_conversation(), "share": False} + ) + assert r.status_code == 201 + assert "share_url" not in r.json(), "share: false is a plain Save (phase-50 shape)" + + +def test_list_rows_carry_share_url_only_when_shared( + admin_client: TestClient, +) -> None: + """The list endpoint populates ``share_url`` — so the History + column renders straight from ``GET /api/chats`` (no second fetch + per row): shared rows carry it (token shape), unshared rows omit it + (the row shape is exactly ROW_KEYS).""" + shared = admin_client.post( + "/api/chats", + json={"title": "Shared one", "messages": _simple_conversation(), "share": True}, + ).json() + plain = admin_client.post( + "/api/chats", + json={"title": "Plain one", "messages": _simple_conversation()}, + ).json() + rows = {c["id"]: c for c in admin_client.get("/api/chats").json()["chats"]} + assert SHARE_URL_RE.fullmatch(rows[shared["id"]]["share_url"]) + assert set(rows[shared["id"]]) == ROW_KEYS | {"share_url"} + assert "share_url" not in rows[plain["id"]] + assert set(rows[plain["id"]]) == ROW_KEYS + + +def test_get_carry_share_url_and_unshare_drops_it(admin_client: TestClient) -> None: + """``GET /{chat_id}`` carries ``share_url`` while shared (the same + path as the create body) and drops the key after ``unshare`` — the + full-payload shape returns to the phase-50 OUT_KEYS.""" + created = admin_client.post( + "/api/chats", json={"messages": _simple_conversation(), "share": True} + ).json() + got = admin_client.get(f"/api/chats/{created['id']}").json() + assert got["share_url"] == created["share_url"] + assert set(got) == OUT_KEYS | {"share_url"} + + assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200 + got2 = admin_client.get(f"/api/chats/{created['id']}").json() + assert "share_url" not in got2 + assert set(got2) == OUT_KEYS + + +# ---------- the /shared/ page route (phase 51, task 01) ---------- + + +def test_page_route_missing_shared_html_returns_same_404_json( + client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Stale-deploy guard: a static dir WITHOUT ``shared.html`` (the + page lands in task 03) 404s with the SAME JSON as the API — never a + 500, regardless of the token.""" + monkeypatch.setattr( + "app.api.chats.get_settings", lambda: Settings(static_dir=str(tmp_path)) + ) + r = client.get(f"/shared/{uuid.uuid4()}") + assert r.status_code == 404 + assert r.json() == {"detail": "unknown or revoked share link"} + + +def test_page_route_serves_shared_html_when_present( + client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Once the file exists (task 03), the route serves it for any well- + formed token — token validity is the page's own concern (it fetches + the API and renders the "invalid or revoked" state itself).""" + (tmp_path / "shared.html").write_text("shared page", encoding="utf-8") + monkeypatch.setattr( + "app.api.chats.get_settings", lambda: Settings(static_dir=str(tmp_path)) + ) + r = client.get(f"/shared/{uuid.uuid4()}") + assert r.status_code == 200 + assert r.text == "shared page" + assert "text/html" in r.headers["content-type"] diff --git a/tests/integration/test_migration_0009.py b/tests/integration/test_migration_0009.py new file mode 100644 index 0000000..a35d754 --- /dev/null +++ b/tests/integration/test_migration_0009.py @@ -0,0 +1,230 @@ +"""Integration: migration 0009 (saved_chats.share_token) schema contract. + +Drives the **real Alembic engine** against the live dev database +(``podman compose up -d db``), mirroring the house pattern of +``test_migration_0008.py`` (information_schema / pg_indexes assertions +on the state the migration must leave). The tests target revision +``0009`` explicitly so later migrations cannot break them: + +* upgrade 0008 → 0009 → ``saved_chats.share_token`` exists as + ``UUID`` **NULLable** (NULL = not shared) and the UNIQUE index + ``ix_saved_chats_share_token`` exists; pre-0009 rows come back + unshared (NULL); +* the NULLs-distinct behavior (the phase-38 ``git_sources.path`` + precedent): two rows may both carry NULL, while two identical + non-NULL tokens are rejected by the unique index; +* downgrade to 0008 → the column and the index are gone (A13 — + reversible), the rest of the table survives; +* upgrade back to 0009 → both are back (round-trip). + +The ``alembic`` fixture guarantees the DB ends at head even if a test +fails or the process is interrupted. +""" +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from typing import Any + +import pytest +from alembic.config import Config +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from alembic import command +from app.db import db_available + + +@pytest.fixture() +def alembic(db: Session) -> Iterator[Config]: + """Real Alembic config bound to the dev DB (URL from app settings). + + Starts at head (repairs an interrupted earlier run); teardown upgrades + to head no matter what happened, so the dev DB is never left below + head. + """ + if not db_available(): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + cfg = Config() # no alembic.ini file — env.py gets the URL from app config + cfg.set_main_option("script_location", "alembic") + command.upgrade(cfg, "head") + try: + yield cfg + finally: + command.upgrade(cfg, "head") + + +def _version(db: Session) -> str | None: + return db.execute(text("SELECT version_num FROM alembic_version")).scalar() + + +def _column(db: Session, column: str) -> tuple[Any, ...] | None: + """(data_type, is_nullable, column_default) for one saved_chats column.""" + row = db.execute( + text( + "SELECT data_type, is_nullable, column_default" + " FROM information_schema.columns" + " WHERE table_name = 'saved_chats' AND column_name = :c" + ), + {"c": column}, + ).fetchone() + return tuple(row) if row is not None else None + + +def _unique_token_index(db: Session) -> int: + """1 iff ``ix_saved_chats_share_token`` exists as a UNIQUE index.""" + count: Any = db.execute( + text( + "SELECT count(*) FROM pg_indexes" + " WHERE tablename = 'saved_chats'" + " AND indexname = 'ix_saved_chats_share_token'" + " AND indexdef ILIKE 'CREATE UNIQUE%'" + ) + ).scalar() + assert count is not None, "pg_indexes count must be an int" + return int(count) + + +def _insert(db: Session, token: uuid.UUID | None) -> uuid.UUID: + """Insert one saved_chats row with an explicit ``share_token``.""" + chat_id: uuid.UUID = db.execute( + text( + "INSERT INTO saved_chats (id, title, messages, share_token)" + " VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb), :tok)" + " RETURNING id" + ), + { + "t": "Mig 0009", + "m": '[{"who": "user", "text": "How did I install gitlab?"}]', + "tok": token, + }, + ).scalar_one() + db.commit() + return chat_id + + +def _legacy_insert(db: Session) -> uuid.UUID: + """Insert one row WITHOUT the ``share_token`` column — the only + possible shape at revision 0008 (the column does not exist yet).""" + chat_id: uuid.UUID = db.execute( + text( + "INSERT INTO saved_chats (id, title, messages)" + " VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))" + " RETURNING id" + ), + { + "t": "Mig 0009", + "m": '[{"who": "user", "text": "How did I install gitlab?"}]', + }, + ).scalar_one() + db.commit() + return chat_id + + +def _delete(db: Session, chat_id: uuid.UUID) -> None: + db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat_id}) + db.commit() + + +def test_upgrade_to_0009_adds_share_token(db: Session, alembic: Config) -> None: + """Upgrade 0008 → 0009: the column is UUID + NULLable, the unique + index exists, and a pre-0009 row comes back unshared (NULL).""" + command.downgrade(alembic, "0008") # start from the pre-0009 state + assert _version(db) == "0008" + assert _column(db, "share_token") is None, "share_token must be absent at 0008" + assert _unique_token_index(db) == 0, "the index must be absent at 0008" + + # A pre-0009 row (no share_token in the INSERT — the column does + # not exist at 0008): its data must survive the additive migration. + legacy = _legacy_insert(db) + try: + command.upgrade(alembic, "0009") + assert _version(db) == "0009", "alembic_version must be at 0009" + + col = _column(db, "share_token") + assert col is not None, "saved_chats.share_token is missing" + assert col[0] == "uuid", "share_token must be UUID" + assert col[1] == "YES", "share_token must be NULLable (NULL = not shared)" + assert _unique_token_index(db) == 1, "the unique token index is missing" + + token = db.execute( + text("SELECT share_token FROM saved_chats WHERE id = :i"), {"i": legacy} + ).scalar_one() + assert token is None, "a pre-0009 row must upgrade as unshared (NULL)" + finally: + _delete(db, legacy) + + +def test_unique_index_treats_nulls_as_distinct(db: Session, alembic: Config) -> None: + """NULLs are distinct under the unique index (the phase-38 + ``git_sources.path`` precedent): any number of unshared chats + coexist.""" + command.upgrade(alembic, "head") + a = _insert(db, None) + b = _insert(db, None) + try: + count = db.execute( + text( + "SELECT count(*) FROM saved_chats" + " WHERE id IN (:a, :b) AND share_token IS NULL" + ), + {"a": a, "b": b}, + ).scalar_one() + assert count == 2, "two NULL tokens must coexist (NULLs are distinct)" + finally: + _delete(db, a) + _delete(db, b) + + +def test_unique_index_rejects_duplicate_tokens(db: Session, alembic: Config) -> None: + """Two identical non-NULL tokens are rejected by the unique index — + the share link is a unique handle (and a distinct token still lands). + """ + command.upgrade(alembic, "head") + token = uuid.uuid4() + a = _insert(db, token) + b: uuid.UUID | None = None + try: + try: + _insert(db, token) + except IntegrityError: + db.rollback() # the aborted transaction must not leak + else: + pytest.fail("a duplicate non-NULL share_token must be rejected") + + # A different token is fine — only the exact duplicate is unique. + b = _insert(db, uuid.uuid4()) + finally: + _delete(db, a) + if b is not None: + _delete(db, b) + + +def test_downgrade_to_0008_drops_share_token(db: Session, alembic: Config) -> None: + """Downgrade to 0008: the column and the index are gone (A13 — + reversible) while the rest of the table survives.""" + command.downgrade(alembic, "0008") + assert _version(db) == "0008" + assert _column(db, "share_token") is None, "share_token must be dropped" + assert _unique_token_index(db) == 0, "the unique index must be dropped" + + id_col = _column(db, "id") + assert id_col is not None and id_col[0] == "uuid", ( + "saved_chats.id must survive the downgrade" + ) + + +def test_upgrade_round_trip_restores_share_token(db: Session, alembic: Config) -> None: + """Downgrade to 0008, then upgrade back to 0009: the column and the + unique index are back.""" + command.downgrade(alembic, "0008") + command.upgrade(alembic, "0009") + assert _version(db) == "0009", "round-trip upgrade must land at 0009" + + col = _column(db, "share_token") + assert col is not None, "share_token must be back after the round-trip" + assert col[0] == "uuid" and col[1] == "YES", ( + "share_token must be UUID + NULLable after the round-trip" + ) + assert _unique_token_index(db) == 1, "the unique index must be back" diff --git a/tests/unit/test_caching.py b/tests/unit/test_caching.py index 7761d02..d64dc41 100644 --- a/tests/unit/test_caching.py +++ b/tests/unit/test_caching.py @@ -17,6 +17,7 @@ import asyncio import os import re import subprocess +import uuid from collections.abc import AsyncIterator, Iterator from pathlib import Path @@ -205,6 +206,7 @@ def test_html_pages_include_history() -> None: "/tuning.html", "/git-sources.html", "/history.html", + "/shared.html", # phase 51: the shared page's static path ): assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES" @@ -386,3 +388,67 @@ def test_read_body_drains_streaming_response() -> None: resp = StreamingResponse(content=gen(), media_type="text/html") assert asyncio.run(caching._read_body(resp)) == b"" + + +# --------------------------------------------------------------------------- +# Phase 51: the dynamic share page — the ``/shared/`` prefix contract +# --------------------------------------------------------------------------- + + +def _shared_page_app() -> FastAPI: + """A bare app with the phase-51 route pair + the middleware: + ``/shared/`` (``text/html`` — the page the route serves) and + ``/api/shared/`` (the JSON read), plus an unknown path.""" + app = FastAPI() + + @app.get("/shared/{token}", response_class=HTMLResponse) + def shared_page(token: str) -> str: + return ( + "" + '' + "shared" + ) + + @app.get("/api/shared/{token}") + def shared_api(token: str) -> JSONResponse: + return JSONResponse({"title": "Shared", "messages": []}) + + @app.get("/some/unknown/path") + def unknown() -> JSONResponse: + return JSONResponse({"ok": True}) + + caching.configure_caching(app) + return app + + +def test_middleware_treats_shared_page_path_as_known_html_page() -> None: + """Phase 51: ``/shared/`` joins the HTML_PAGES contract — + ``no-cache`` + ``?v=`` asset rewrite on the ``text/html`` body (the + FileResponse body is drained by the existing ``_read_body`` path).""" + client = TestClient(_shared_page_app()) + r = client.get(f"/shared/{uuid.uuid4()}") + assert r.status_code == 200 + assert r.headers["cache-control"] == "no-cache" + token = caching.asset_version() + assert f'href="/assets/styles.css?v={token}"' in r.text + assert 'href="/assets/styles.css">' not in r.text + + +def test_middleware_leaves_api_shared_read_untouched() -> None: + """``/api/shared/`` — the JSON read — starts with ``/api/``, + not ``/shared/``: byte-identical pass-through, no injected headers.""" + client = TestClient(_shared_page_app()) + r = client.get(f"/api/shared/{uuid.uuid4()}") + assert r.status_code == 200 + assert "cache-control" not in r.headers + assert r.json() == {"title": "Shared", "messages": []} + + +def test_middleware_leaves_unknown_paths_untouched() -> None: + """A path that is neither a known page, ``/shared/*``, nor + ``/assets/*`` passes through byte-identical, no headers.""" + client = TestClient(_shared_page_app()) + r = client.get("/some/unknown/path") + assert r.status_code == 200 + assert "cache-control" not in r.headers + assert r.json() == {"ok": True} diff --git a/tests/unit/test_frontend_brand.py b/tests/unit/test_frontend_brand.py index 6d91439..788e83c 100644 --- a/tests/unit/test_frontend_brand.py +++ b/tests/unit/test_frontend_brand.py @@ -29,6 +29,7 @@ HTML_PAGES = ( "login.html", "git-sources.html", "history.html", # phase 50: the admin saved-chats page + "shared.html", # phase 51: the anonymous shared-conversation page ) @@ -138,9 +139,13 @@ def test_every_page_loads_brand_js_before_its_module_script() -> None: default must be set first).""" for page in HTML_PAGES: html = _text(FRONTEND / page) - brand_idx = html.find('') + # Optional leading slash: root-level pages use "assets/brand.js", + # but the shared page is served from the NESTED /shared/ + # route, where a relative ref would 404 — it uses "/assets/…". + m = re.search(r'', html) + assert m, f"{page}: brand.js must load" + brand_idx = m.start() module_idx = html.find('