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

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+2 -1
View File
@@ -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/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/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/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/brand.js --minify --outfile=/out/assets/brand.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \ && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \ && 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 ---------- # ---------- Stage 2: python dependencies ----------
FROM docker.io/python:3.12-slim AS python FROM docker.io/python:3.12-slim AS python
@@ -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/<token>`` — 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")
+179 -6
View File
@@ -14,20 +14,43 @@ payload is the exact ``bor.chat.v1`` localStorage record shape
(phase 14 — raw text, never HTML), so a saved chat restores (phase 14 — raw text, never HTML), so a saved chat restores
pixel-identical through the existing ``renderStoredMessage`` path. pixel-identical through the existing ``renderStoredMessage`` path.
Routes: ``GET`` (list, latest activity first — no payloads), ``POST`` Routes: ``GET`` (list, latest activity first — no payloads, but the
(create — auto-title from the first question when no ``title`` is phase-51 ``share_url`` so the History's Share column renders without a
supplied), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}`` 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 (re-Save upsert — full ``messages`` replacement, ``title`` replaced
only when supplied), ``DELETE /{chat_id}``. only when supplied), ``DELETE /{chat_id}``, and (phase 51, task 01)
``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this
admin-gated router.
Sharing (phase 51, owner-locked 2026-08-29): a saved chat's
``share_token`` (a 128-bit ``uuid4``, migration 0009) makes it
publicly readable at ``/shared/<token>`` — two more routers in this
file, registered in ``app.main`` with **no** admin dependency:
* ``public_router`` — ``GET /shared/{token}`` (mounted under ``/api``
→ ``GET /api/shared/<token>``): the anonymous read, a minimal
``SharedChatOut`` snapshot (no id/timestamps/token); wrong or
revoked tokens 404 with one message (no enumeration).
* ``shared_page_router`` — ``GET /shared/{token}`` (mounted with **no**
prefix, before the static catch-all): serves
``frontend/shared.html`` (the page lands in phase 51, task 03). A
stale deploy without the file 404s with the SAME JSON as the API —
never a 500.
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response 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 sqlalchemy.orm import Session
from app.config import get_settings
from app.core.auth import require_admin from app.core.auth import require_admin
from app.db import get_db from app.db import get_db
from app.models import SavedChat from app.models import SavedChat
@@ -38,6 +61,9 @@ from app.schemas import (
SavedChatOut, SavedChatOut,
SavedChatRow, SavedChatRow,
SavedChatUpdate, SavedChatUpdate,
SharedChatOut,
ShareOut,
UnshareOut,
) )
router = APIRouter( router = APIRouter(
@@ -65,6 +91,15 @@ def _auto_title(messages: list[ChatMessage]) -> str:
return " ".join(first_user.split())[:_AUTO_TITLE_MAX] 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: def _to_out(row: SavedChat) -> SavedChatOut:
"""The full-payload response shape (create/get/put).""" """The full-payload response shape (create/get/put)."""
return SavedChatOut( return SavedChatOut(
@@ -74,16 +109,19 @@ def _to_out(row: SavedChat) -> SavedChatOut:
updated_at=row.updated_at, updated_at=row.updated_at,
message_count=len(row.messages), message_count=len(row.messages),
messages=[ChatMessage.model_validate(m) for m in row.messages], messages=[ChatMessage.model_validate(m) for m in row.messages],
share_url=_share_url(row),
) )
def _to_row(row: SavedChat) -> SavedChatRow: 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( return SavedChatRow(
id=row.id, id=row.id,
title=row.title, title=row.title,
updated_at=row.updated_at, updated_at=row.updated_at,
message_count=len(row.messages), 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 text, whitespace-collapsed, truncated to 120 chars (owner-locked
convention); a conversation with no user message (defensive) falls convention); a conversation with no user message (defensive) falls
back to ``"Chat <id-hex8>"``. back to ``"Chat <id-hex8>"``.
``share: true`` (phase 51, task 02 — the save-then-share contract):
the fresh row carries ``share_token = uuid.uuid4()`` in the SAME
INSERT/commit — one request saves AND shares, and the 201 body
carries ``share_url`` (the chat page copies it in one action).
""" """
title = (payload.title or "").strip() or _auto_title(payload.messages) title = (payload.title or "").strip() or _auto_title(payload.messages)
row = SavedChat( row = SavedChat(
@@ -121,6 +164,10 @@ def create_chat(
# restore path is null-safe for every optional key). # restore path is null-safe for every optional key).
messages=[m.model_dump() for m in payload.messages], 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.add(row)
db.flush() # python-side uuid default lands the id before the fallback db.flush() # python-side uuid default lands the id before the fallback
if not row.title: if not row.title:
@@ -181,3 +228,129 @@ def delete_chat(
db.delete(row) db.delete(row)
db.commit() db.commit()
return Response(status_code=204) return Response(status_code=204)
@router.post("/{chat_id}/share", response_model=ShareOut)
def share_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> ShareOut:
"""Turn a saved chat into a public link (phase 51, task 01).
Returns ``{"chat_id", "share_url"}`` with ``share_url =
"/shared/<token>"`` (200, idempotent — an existing token is
returned unchanged; a new token is a 128-bit ``uuid4``).
``updated_at`` is **not** bumped: sharing is not a content edit,
so the token is written with a raw SQL ``UPDATE`` that touches ONLY
``share_token`` — the column's ``onupdate=func.now()`` default is
registered on the Table (via ``mapped_column``), so even a Core
``update(SavedChat)`` DML statement would pick it up; the ORM
object is never mutated either. 404 when the id is unknown.
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
token = row.share_token
if token is None:
token = uuid.uuid4()
# Raw SQL on purpose: sets ONLY share_token, so the column's
# onupdate default for ``updated_at`` never fires (the History
# page's "latest activity" order must follow content edits only).
db.execute(
text("UPDATE saved_chats SET share_token = :tok WHERE id = :id"),
{"tok": token, "id": chat_id},
)
db.commit()
return ShareOut(chat_id=row.id, share_url=f"/shared/{token}")
@router.post("/{chat_id}/unshare", response_model=UnshareOut)
def unshare_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> UnshareOut:
"""Revoke a shared chat (phase 51, task 01): ``share_token`` → NULL.
Idempotent — an unshared chat unshares cleanly (200, no write).
``updated_at`` is not bumped (raw SQL ``UPDATE`` touching only
``share_token``, same reasoning as :func:`share_chat`); 404 when
the id is unknown.
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
if row.share_token is not None:
db.execute(
text("UPDATE saved_chats SET share_token = NULL WHERE id = :id"),
{"id": chat_id},
)
db.commit()
return UnshareOut(chat_id=row.id, shared=False)
#: Public read surface (phase 51, task 01) — **no** admin dependency:
#: a guest with the link reads the shared chat anonymously. Mounted
#: under ``/api`` in ``app.main`` → ``GET /api/shared/<token>``.
public_router = APIRouter(tags=["chats"])
def _to_shared_out(row: SavedChat) -> SharedChatOut:
"""The PUBLIC read shape: title + messages only — no id,
timestamps, or token (a content snapshot, not a handle)."""
return SharedChatOut(
title=row.title,
messages=[ChatMessage.model_validate(m) for m in row.messages],
)
@public_router.get("/shared/{token}", response_model=SharedChatOut)
def read_shared_chat(
token: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> SharedChatOut:
"""Anonymous read of a shared chat by token (phase 51, task 01).
No admin dependency — the token IS the credential. A wrong or a
revoked (unshared) token 404s with ONE message: ``unknown or
revoked share link`` — deliberately no enumeration between the two
cases.
"""
row = db.execute(
select(SavedChat).where(SavedChat.share_token == token).limit(1)
).scalars().first()
if row is None:
raise HTTPException(status_code=404, detail="unknown or revoked share link")
return _to_shared_out(row)
#: The share PAGE route (phase 51, task 01) — mounted with **no**
#: prefix, BEFORE the static catch-all in ``app.main`` (the
#: API-routes-first convention): ``/shared/<uuid>`` is not a static
#: file, so without this route the ``StaticFiles`` mount would 404 it.
shared_page_router = APIRouter(tags=["chats"])
#: The page's filename inside the static dir (the file lands in phase
#: 51, task 03; until then the guard below keeps a stale deploy from
#: 500'ing).
_SHARED_PAGE_NAME = "shared.html"
@shared_page_router.get("/shared/{token}", response_model=None)
def shared_page(token: uuid.UUID) -> FileResponse | JSONResponse:
"""Serve the anonymous shared-chat page for ``/shared/<token>``.
The page (``frontend/shared.html``, task 03) fetches
``GET /api/shared/<token>`` itself and renders the conversation
read-only — including the "invalid or revoked" state for a wrong
or revoked token — so this route serves the page for any well-
formed token and never 404s on the token's validity. The guard:
when the page file is missing (a stale deploy — the API is ahead
of the static bundle), return the SAME 404 JSON as the API rather
than a 500.
"""
page = Path(get_settings().static_dir).expanduser().resolve() / _SHARED_PAGE_NAME
if not page.is_file():
return JSONResponse(
status_code=404, content={"detail": "unknown or revoked share link"}
)
return FileResponse(page)
+21 -7
View File
@@ -6,9 +6,10 @@ Two layers, one module:
append to their asset URLs (``?v=<token>``). append to their asset URLs (``?v=<token>``).
* **Response middleware** (``CachingMiddleware`` / ``configure_caching``) * **Response middleware** (``CachingMiddleware`` / ``configure_caching``)
— applies the caching behavior at the transport layer: the known — applies the caching behavior at the transport layer: the known
HTML pages (``HTML_PAGES``) are always revalidated (``no-cache``) and HTML pages (``HTML_PAGES``) and the dynamic share page
their local asset references are rewritten to carry ``?v=<token>``; ``/shared/<token>`` (phase 51) are always revalidated (``no-cache``)
``/assets/*`` is and their local asset references are rewritten to carry
``?v=<token>``; ``/assets/*`` is
served ``immutable`` for a year; everything else — all of ``/api/*``, served ``immutable`` for a year; everything else — all of ``/api/*``,
including the SSE chat stream — passes through byte-identical. including the SSE chat stream — passes through byte-identical.
@@ -125,6 +126,11 @@ HTML_PAGES: tuple[str, ...] = (
"/tuning.html", "/tuning.html",
"/git-sources.html", # phase 35: the admin git sources page "/git-sources.html", # phase 35: the admin git sources page
"/history.html", # phase 50: the admin saved-chats page "/history.html", # phase 50: the admin saved-chats page
# phase 51: the shared page's STATIC path (the static mount serves
# shared.html at /shared.html as well as the real route serves the
# dynamic /shared/<token> — both must carry the no-cache + ?v=
# contract, so the direct URL can never pin stale assets).
"/shared.html",
) )
#: Prefix of the versioned static assets (header-only caching; the body is #: 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`` * ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable``
(header only — the body is never read). (header only — the body is never read).
* the known HTML pages (``HTML_PAGES``) — ``Cache-Control: no-cache``, * the known HTML pages (``HTML_PAGES``) plus the dynamic share page
and (for ``text/html`` bodies) every local asset reference gains ``/shared/<token>`` (phase 51, by path prefix) —
``?v=<token>``. ``Cache-Control: no-cache``, and (for ``text/html`` bodies) every
local asset reference gains ``?v=<token>``.
Everything else — all of ``/api/*`` (including the SSE chat stream) — Everything else — all of ``/api/*`` (including the SSE chat stream) —
passes through byte-identical: no header changes, the body stream is 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 response.headers["Cache-Control"] = ASSET_CACHE_CONTROL
return response return response
if path not in HTML_PAGES: # Phase 51: the dynamic share page — ``/shared/<token>`` is a
# REAL route (not a static file) serving ``shared.html``, so it
# joins the known-page contract by path prefix: no-cache +
# ``?v=`` asset rewrite. (``/api/shared/<token>`` — the JSON
# read — starts with ``/api/`` and passes through below.)
is_known_page = path in HTML_PAGES or path.startswith("/shared/")
if not is_known_page:
# /api/* (incl. SSE), /favicon.ico, unknown paths: untouched. # /api/* (incl. SSE), /favicon.ico, unknown paths: untouched.
return response return response
+14 -1
View File
@@ -21,7 +21,15 @@ from starlette.middleware.sessions import SessionMiddleware
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.chat import router as chat_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.config import router as config_router
from app.api.docs import router as docs_router from app.api.docs import router as docs_router
from app.api.git_sources import router as git_sources_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(steering_router, prefix="/api")
app.include_router(sync_router, prefix="/api") app.include_router(sync_router, prefix="/api")
app.include_router(chats_router, prefix="/api") app.include_router(chats_router, prefix="/api")
# Phase 51: the anonymous shared-chat read — NO admin dependency.
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
# page route below, registered without a prefix) is the page.
app.include_router(chats_public_router, prefix="/api")
app.include_router(chats_shared_page_router) # no prefix — /shared/<token>
# Cache busting (phase 33): the five HTML pages revalidate (no-cache) # Cache busting (phase 33): the five HTML pages revalidate (no-cache)
# with ?v=<token> asset refs; /assets/* becomes immutable for a year. # with ?v=<token> asset refs; /assets/* becomes immutable for a year.
+13 -3
View File
@@ -21,7 +21,9 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
explicitly Saved conversation (auto-``title`` + explicitly Saved conversation (auto-``title`` +
the ``bor.chat.v1`` message list as JSONB, phase the ``bor.chat.v1`` message list as JSONB, phase
14 shape) — phase 50; ``/api/chat`` stays 14 shape) — phase 50; ``/api/chat`` stays
stateless. stateless; ``share_token`` (NULL = private,
``uuid4`` = publicly readable at
``/shared/<token>``) — phase 51.
""" """
from __future__ import annotations from __future__ import annotations
@@ -178,8 +180,7 @@ class SavedChat(Base):
saved. ``messages`` holds the exact ``bor.chat.v1`` localStorage saved. ``messages`` holds the exact ``bor.chat.v1`` localStorage
record shape (phase 14 — raw text, never HTML), so a saved chat record shape (phase 14 — raw text, never HTML), so a saved chat
restores pixel-identical through the existing restores pixel-identical through the existing
``renderStoredMessage`` path. No share-related columns here — ``renderStoredMessage`` path.
phase 51 adds ``share_token`` in migration 0009.
""" """
__tablename__ = "saved_chats" __tablename__ = "saved_chats"
@@ -193,6 +194,15 @@ class SavedChat(Base):
#: tools?, stopped?}``); the API always supplies a list, so no #: tools?, stopped?}``); the API always supplies a list, so no
#: default is needed. #: default is needed.
messages: Mapped[list] = mapped_column(JSONB) messages: Mapped[list] = mapped_column(JSONB)
#: Anonymous share link (phase 51): a 128-bit ``uuid4`` token; when
#: set, the chat is publicly readable at ``/shared/<token>`` without
#: any admin session, and unsharing (token → NULL) revokes it.
#: Unique — Postgres treats NULLs as distinct under a unique index
#: (the ``git_sources.path`` precedent, phase 38), so any number of
#: unshared chats coexist while two identical tokens can never exist.
share_token: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), unique=True, nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+99 -6
View File
@@ -3,9 +3,16 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime 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): class HealthResponse(BaseModel):
@@ -324,16 +331,26 @@ class ChatMessage(BaseModel):
class SavedChatCreate(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 ``title`` is optional: when absent or blank the API auto-titles the
row (the first user message's text, whitespace-collapsed, truncated row (the first user message's text, whitespace-collapsed, truncated
to 120 chars — the owner-locked convention). ``messages`` must be to 120 chars — the owner-locked convention). ``messages`` must be
non-empty — a saved chat with nothing to restore is meaningless. 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) title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1) messages: list[ChatMessage] = Field(min_length=1)
share: bool = False
class SavedChatUpdate(BaseModel): class SavedChatUpdate(BaseModel):
@@ -349,11 +366,35 @@ class SavedChatUpdate(BaseModel):
messages: list[ChatMessage] = Field(min_length=1) 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): 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 ``messages`` round-trips the ``bor.chat.v1`` record list losslessly
— the restore path is pixel-identical by construction. — the restore path is pixel-identical by construction.
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
shared, ABSENT from the JSON when unshared (``None`` → dropped by
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
""" """
id: uuid.UUID id: uuid.UUID
@@ -362,19 +403,33 @@ class SavedChatOut(BaseModel):
updated_at: datetime updated_at: datetime
message_count: int message_count: int
messages: list[ChatMessage] 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): 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 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 id: uuid.UUID
title: str title: str
updated_at: datetime updated_at: datetime
message_count: int 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): class SavedChatList(BaseModel):
@@ -382,3 +437,41 @@ class SavedChatList(BaseModel):
(``updated_at desc, id desc``).""" (``updated_at desc, id desc``)."""
chats: list[SavedChatRow] chats: list[SavedChatRow]
class SharedChatOut(BaseModel):
"""``GET /api/shared/{token}`` body (phase 51, task 01) — the PUBLIC
read shape of a shared chat.
Deliberately minimal: ``title`` + ``messages`` only. No id, no
timestamps, no token, no ``message_count`` — a shared chat is a
content snapshot, not a handle: nothing in the body can be turned
back into an admin-surface request, and the token itself never
round-trips (it is the URL, not data).
"""
title: str
messages: list[ChatMessage]
class ShareOut(BaseModel):
"""``POST /api/chats/{chat_id}/share`` response (phase 51, task 01).
``share_url`` is the path (``/shared/<token>``) the UI copies into
the clipboard — the owner's own origin supplies the scheme/host.
Idempotent: a re-share returns the existing, unchanged token.
"""
chat_id: uuid.UUID
share_url: str
class UnshareOut(BaseModel):
"""``POST /api/chats/{chat_id}/unshare`` response (phase 51, task 01).
``shared: false`` is reported unconditionally — the endpoint is
idempotent, so an already-unshared chat unshares cleanly (200).
"""
chat_id: uuid.UUID
shared: bool
+137
View File
@@ -155,6 +155,28 @@
* the error banner. Phase 14's local persistence is untouched: saving is * the error banner. Phase 14's local persistence is untouched: saving is
* an additional, explicit action. * 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/<token>,
* 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/<id>/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 <a> 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. * 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 bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version"); const versionEl = document.querySelector("#app-version");
const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden) 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 /* 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 * (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/<token>); 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 <a> 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 <a> 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/<id>/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. /* Brain message save point (on `done`): raw accumulated text + metadata.
Phase 17: meta.thinking and phase 37: meta.tools are optional — Phase 17: meta.thinking and phase 37: meta.tools are optional —
`undefined` drops the key from the JSON, so turns without them persist `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, * IIFE below reveals it for admin (absent-not-hidden for anonymous,
* phase 16). Status-only feedback — the live region, never stale. */ * phase 16). Status-only feedback — the live region, never stale. */
saveBtn?.addEventListener("click", saveCurrentChat); 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): /* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
* leaving the chat mid-turn would otherwise drop the in-flight * 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 isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
applyAuthState(); // chat page: the auth pair (idempotent with header.js) applyAuthState(); // chat page: the auth pair (idempotent with header.js)
if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16) 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=<id> (valid uuid + admin) boots into the saved // Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved
// conversation; every other outcome falls through to the local restore. // conversation; every other outcome falls through to the local restore.
const openedSaved = await restoreSavedChatFromUrl(); const openedSaved = await restoreSavedChatFromUrl();
+12 -1
View File
@@ -106,9 +106,20 @@ export async function initSharedHeader() {
// a query-safe "/…" string (never "//"; ? # and spaces stay // a query-safe "/…" string (never "//"; ? # and spaces stay
// percent-encoded in it), so it rides in next= as-is — the same shape // percent-encoded in it), so it rides in next= as-is — the same shape
// the static fallbacks use (login.js safeNext re-validates it). // 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/<token> 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 => { document.querySelectorAll(".sign-in-link").forEach(link => {
link.hidden = admin; 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; }); document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
const navSources = document.querySelector("#nav-sources"); const navSources = document.querySelector("#nav-sources");
+211 -5
View File
@@ -3,21 +3,40 @@
* TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat * 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." * history in a new page, then return to that history with a click."
* *
* Wires the admin-only `GET /api/chats` + `DELETE /api/chats/<id>` * Wires the admin-only `GET /api/chats` + `POST /api/chats/<id>/share`
* endpoints (phase 50 task 02) into the page's full-width table: * + `POST /api/chats/<id>/unshare` + `DELETE /api/chats/<id>`
* endpoints (phase 50 task 02; phase 51 task 01+02) into the page's
* full-width table:
* *
* • Title — an `<a href="/?chat=<id>">`: Open IS the title link * • Title — an `<a href="/?chat=<id>">`: Open IS the title link
* ("return to that history with a click") — the chat page boots * ("return to that history with a click") — the chat page boots
* into the saved conversation through ?chat= (task 03); * into the saved conversation through ?chat= (task 03);
* • Messages — the row's message_count; * • Messages — the row's message_count;
* • Updated — locale date+time, the full ISO in the title attribute; * • Updated — locale date+time, the full ISO in the title attribute;
* • Actions — Delete ONLY (phase 51 adds the share column), inline * • Share — phase 51 (owner-locked 2026-08-29, TODO.md L6): the
* TWO-STEP confirm (owner-locked 2026-08-29: no native confirm * row's share state, rendered from the list's OWN share_url (the
* dialog anywhere in this file) — the first click swaps the button for * 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/<id>/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 * "Delete? [Yes] [No]" (focus moves to Yes, so the confirm is
* keyboard-reachable), Yes fires the DELETE and removes the row, * keyboard-reachable), Yes fires the DELETE and removes the row,
* No (or a failed request) keeps it. * 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 <a> 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 * Every cell is built with the DOM APIs (textContent) — the title is
* user-derived (the auto-title is the first question), so it NEVER * user-derived (the auto-title is the first question), so it NEVER
* touches innerHTML (XSS-safe by construction, the sources.js house * touches innerHTML (XSS-safe by construction, the sources.js house
@@ -86,6 +105,13 @@ function makeRow(chat) {
updatedTd.textContent = fmtDate(chat.updated_at); updatedTd.textContent = fmtDate(chat.updated_at);
tr.appendChild(updatedTd); 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"); const actionsTd = document.createElement("td");
actionsTd.className = "history-actions-cell"; actionsTd.className = "history-actions-cell";
actionsTd.appendChild(makeDeleteControl(chat, tr)); actionsTd.appendChild(makeDeleteControl(chat, tr));
@@ -169,6 +195,186 @@ async function confirmDelete(chat, row, yesBtn, restoreDelete) {
announce(`Deleted "${chat.title}".`); announce(`Deleted "${chat.title}".`);
} }
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
/* Select every text node in the link field (an <a> 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 <a> 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 <td> 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/<id>/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/<id>/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 "<title>".` 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 /* The empty-state row reappears exactly when the last data row was
removed (the empty row itself ships in the tbody, hidden). */ removed (the empty row itself ships in the tbody, hidden). */
function showEmptyIfLast() { function showEmptyIfLast() {
+347
View File
@@ -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);
})();
+153
View File
@@ -338,6 +338,37 @@ html::after {
the whole control below 640px (mirrored in the ≤640 block below). */ the whole control below 640px (mirrored in the ≤640 block below). */
.save-chat-btn svg { width: 16px; height: 16px; display: none; } .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 /* 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. 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. 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; cursor: pointer;
} }
.history-confirm-no:hover { background: var(--brand-soft); color: var(--brand-ink); } .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 /* Empty-state row: the muted centered message at full table width
(the .git-sources-empty language, inline in the table). */ (the .git-sources-empty language, inline in the table). */
.history-empty-row td { .history-empty-row td {
@@ -1906,6 +1991,61 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-style: italic; 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) ---------- */ /* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO /* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
rows in one sticky <header> — row 1 reuses the standard .app-header / 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-btn { padding: 0.4rem 0.3rem; }
.save-chat-label { display: none; } .save-chat-label { display: none; }
.save-chat-btn svg { display: block; } .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 /* 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). */ hide the icon (the button lives inside .chat-shell, not the navbar). */
.chat-shell .new-chat-label { display: inline; } .chat-shell .new-chat-label { display: inline; }
.chat-shell .new-chat-btn svg { display: none; } .chat-shell .new-chat-btn svg { display: none; }
.chat-shell .save-chat-label { display: inline; } .chat-shell .save-chat-label { display: inline; }
.chat-shell .save-chat-btn svg { display: none; } .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 /* Phase 16: the auth pill goes icon-only like New chat — brand text
ellipsizes as the designated squeeze target, no bar overflow. */ ellipsizes as the designated squeeze target, no bar overflow. */
.auth-link { padding: 0.4rem 0.3rem; } .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. */ the two-step confirm pair fits the phone width. */
.history-actions-cell { white-space: normal; } .history-actions-cell { white-space: normal; }
.history-actions { flex-wrap: wrap; } .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; } .footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
main { padding-bottom: env(safe-area-inset-bottom, 0); } main { padding-bottom: env(safe-area-inset-bottom, 0); }
/* Sync button goes icon-only on mobile; the label hides, aria-label /* Sync button goes icon-only on mobile; the label hides, aria-label
+5 -2
View File
@@ -135,7 +135,9 @@
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny <!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
list): Title (the Open link → /?chat=<id>) | Messages | 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 history.js fills #history-tbody; #history-empty-row ships
hidden and is revealed by a 0-row fetch. The Actions column hidden and is revealed by a 0-row fetch. The Actions column
header is visually-hidden — the row buttons carry their own header is visually-hidden — the row buttons carry their own
@@ -148,12 +150,13 @@
<th scope="col">Title</th> <th scope="col">Title</th>
<th scope="col">Messages</th> <th scope="col">Messages</th>
<th scope="col">Updated</th> <th scope="col">Updated</th>
<th scope="col">Share</th>
<th scope="col"><span class="visually-hidden">Actions</span></th> <th scope="col"><span class="visually-hidden">Actions</span></th>
</tr> </tr>
</thead> </thead>
<tbody id="history-tbody"> <tbody id="history-tbody">
<tr class="history-empty-row" id="history-empty-row" hidden> <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> </tr>
</tbody> </tbody>
</table> </table>
+23
View File
@@ -134,6 +134,29 @@
<span class="save-chat-label">Save</span> <span class="save-chat-label">Save</span>
</button> </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 <!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
bubble can carry JS-injected actions (app.js) — Tune (admin bubble can carry JS-injected actions (app.js) — Tune (admin
only, phase 15) and Retry (every visitor; the LAST brain only, phase 15) and Retry (every visitor; the LAST brain
+175
View File
@@ -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</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="app-header">
<div class="container header-inner">
<span class="brand">
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
mobile hamburger — visible ≤640px only (CSS); opens the nav as
an animated dropdown. Behavior: assets/header.js. -->
<button type="button" class="nav-toggle" id="nav-toggle"
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<nav class="app-nav" id="app-nav" aria-label="Primary">
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): no nav link
is "current" here — the shared page is a read-only detail
view reachable from a link, not one of the app's pages
(the document.html convention, phase 10/13). -->
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19 (now every page — phase 34, owner confirmation
2026-08-26): the Sources link is admin-only (owner
permission 2026-08-23) — hidden by default, header.js
reveals it once whoami says admin. A guest on this page
never sees it. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
History link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Tuning link above. -->
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" 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 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" 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="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</nav>
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
into every system prompt) — owned by the shared header
module (assets/header.js); the #steering-panel section
ships in every page's <main>. The navbar toggle was
removed at owner request (2026-08-28): note management
lives on /tuning.html. -->
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): ?next=/ —
a guest signing in FROM a shared page returns to the APP
ROOT, not the shared URL (the shared link stays valid and
public either way; the app root is where a signed-in
visitor's chat lives). header.js rewrites ?next= to the
current pathname for an admin; the static fallback above is
the guest's. -->
<a href="/login.html?next=/" class="auth-link sign-in-link" id="sign-in-link" 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 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" 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="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat pages;
the chat page keeps it after #kb-banner. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): the
anonymous shared conversation — READ-ONLY, ZERO CONTROLS.
The shell maps to the 46rem centered chat column (styles.css
.shared-shell, the PLAN §7 column contract): the conversation
reads exactly like the chat page's, minus the composer, the
New chat / Save / Share pills, and every meta-row action.
shared.js renders the records through the SAME .msg/.bubble/
.thinking/.tool-calls structure the chat page uses, so the
existing CSS applies unchanged. Nothing below is interactive:
no form or button element in the content (the header's own
controls are the shared bar's, not the conversation's), the
"Maybe try" chips are plain <span> text (a guest tapping a
chip has nowhere to go), and the source chips are plain text
too (no href — guests cannot open documents, the documents
API is admin-only, phase 16). -->
<div class="container shared-shell">
<h1 id="shared-title">Shared conversation</h1>
<p class="shared-note">Shared via Brain of Reese — read-only.</p>
<!-- The invalid / revoked state — ship-hidden; shared.js reveals
it for a malformed token (no fetch of any kind) and for a
404 (wrong or revoked) / network / malformed-body read. The
title keeps its fallback, nothing else renders, and there is
no error banner on this page (zero controls). -->
<div id="shared-invalid" hidden>This share link is invalid or was revoked.</div>
<section class="messages" id="messages" aria-label="Shared conversation">
<!-- shared.js appends one .msg per record here. -->
</section>
</div>
</main>
<footer class="app-footer">
<div class="container footer-inner">
<span>Powered by Reese's self-hosted models</span>
<span class="footer-version" id="app-version"></span>
</div>
</footer>
<!-- Phase 39: the brand layer — a CLASSIC script, first on every
page: window.BOR_BRAND is set at parse time (before the module
scripts evaluate) and refreshed from /api/config (a byte-
identical no-op for the default name).
Phase 10: the classic markdown renderer (markdown.js) —
escape-first, XSS-safe; shared.js calls the global
renderMarkdown on the stored raw text.
Phase 19: the shared header module loads through the page
script's own `import "./header.js"` — a hoisted import that is
evaluated before the page script body calls initSharedHeader()
at boot (no direct header.js <script> tag — single-evaluation
design). NO modal overlay, NO composer, NO Save/Share/
Retry/Tune markup anywhere (owner-locked: zero controls). -->
<!-- ABSOLUTE asset paths on purpose: the page is served from the
NESTED route /shared/<token> (not a root-level .html), so a
relative "assets/…" ref would resolve to /shared/assets/… and
404 (the middleware's ?v= rewrite handles both forms, but it
cannot change the relative-ness). The static /shared.html URL
works with absolute refs too. -->
<script src="/assets/brand.js"></script>
<script src="/assets/markdown.js"></script>
<script type="module" src="/assets/shared.js"></script>
</body>
</html>
+70
View File
@@ -16,14 +16,18 @@ mock LLM keeps the SSE check deterministic (no live aipi).
from __future__ import annotations from __future__ import annotations
import json import json
import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from playwright.sync_api import Page from playwright.sync_api import Page
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
CHAT_QUESTION = "How is my Kubernetes cluster set up?" CHAT_QUESTION = "How is my Kubernetes cluster set up?"
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
def _expected_token() -> str: def _expected_token() -> str:
@@ -44,6 +48,16 @@ def _version_token(url: str) -> str:
return url.rsplit("?v=", 1)[1] 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]]: def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
"""Minimal SSE chat request (same pattern as ``test_chat_rag.py``): """Minimal SSE chat request (same pattern as ``test_chat_rag.py``):
POST /api/chat and collect the ``data:`` frames until the stream ends.""" 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 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/<token>` (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/<token> 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: def test_api_responses_unaffected(page: Page, app_url: str, db_ready: None) -> None:
"""`/api/*` passes through untouched: no injected Cache-Control on the """`/api/*` passes through untouched: no injected Cache-Control on the
health endpoint, and the SSE chat stream still streams to done.""" health endpoint, and the SSE chat stream still streams to done."""
+498
View File
@@ -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/<uuid4>``); 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/<token>`` 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/<id>`` 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/<uuid>`` 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/<uuid> 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 <details> 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 <span>: zero <a.source-chip> 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}"
+7 -2
View File
@@ -89,6 +89,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
("/tuning.html", "Global Tuning"), # phase 27: global tuning page ("/tuning.html", "Global Tuning"), # phase 27: global tuning page
("/git-sources.html", "Git sources"), # phase 35: admin git sources page ("/git-sources.html", "Git sources"), # phase 35: admin git sources page
("/history.html", "Saved chats"), # phase 50: admin saved-chats 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: 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( @pytest.mark.parametrize(
"path", "path",
["/sources.html", "/document.html", "/login.html", "/tuning.html", ["/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: 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 """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/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/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/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 # 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/login.js", # phase 16
"/assets/document-modal.js", # phase 26: the document modal module "/assets/document-modal.js", # phase 26: the document modal module
"/assets/git-sources.js", # phase 35: the git sources page 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", "/assets/styles.css",
], ],
) )
@@ -234,7 +239,7 @@ def test_ui_chrome_has_no_emoji(client, path: str) -> None:
r = client.get(path) r = client.get(path)
assert r.status_code == 200 assert r.status_code == 200
text = r.text 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('"🔎 Listing documents"', "")
text = text.replace('"📄 Reading "', "") text = text.replace('"📄 Reading "', "")
assert _find_emoji(text) == [], f"emoji found in {path}: {_find_emoji(text)!r}" assert _find_emoji(text) == [], f"emoji found in {path}: {_find_emoji(text)!r}"
+303
View File
@@ -16,10 +16,12 @@ Requires: podman compose up -d db
""" """
from __future__ import annotations from __future__ import annotations
import re
import time import time
import uuid import uuid
from collections.abc import Iterator from collections.abc import Iterator
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
@@ -27,12 +29,21 @@ from fastapi.testclient import TestClient
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import Settings
from app.main import app as fastapi_app from app.main import app as fastapi_app
from app.models import SavedChat from app.models import SavedChat
FIRST_QUESTION = "How did I install gitlab?" FIRST_QUESTION = "How did I install gitlab?"
EXPLICIT_TITLE = "My backup notes" 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 #: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional
#: key present; the round-trip test asserts it survives byte-identical. #: key present; the round-trip test asserts it survives byte-identical.
FULL_BRAIN: dict[str, Any] = { 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), ("GET", f"/api/chats/{unknown}", None),
("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}), ("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}),
("DELETE", f"/api/chats/{unknown}", None), ("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: for method, path, body in cases:
r = anon.request(method, path, json=body) r = anon.request(method, path, json=body)
assert r.status_code == 403, f"{method} {path} must be 403 for anonymous" 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: def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
assert admin_client.delete("/api/chats/not-a-uuid").status_code == 422 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/<lowercase uuid>: {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/<lowercase uuid>: {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/<token> 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("<html>shared page</html>", 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 == "<html>shared page</html>"
assert "text/html" in r.headers["content-type"]
+230
View File
@@ -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"
+66
View File
@@ -17,6 +17,7 @@ import asyncio
import os import os
import re import re
import subprocess import subprocess
import uuid
from collections.abc import AsyncIterator, Iterator from collections.abc import AsyncIterator, Iterator
from pathlib import Path from pathlib import Path
@@ -205,6 +206,7 @@ def test_html_pages_include_history() -> None:
"/tuning.html", "/tuning.html",
"/git-sources.html", "/git-sources.html",
"/history.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" 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") resp = StreamingResponse(content=gen(), media_type="text/html")
assert asyncio.run(caching._read_body(resp)) == b"<a></a>" assert asyncio.run(caching._read_body(resp)) == b"<a></a>"
# ---------------------------------------------------------------------------
# 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/<token>`` (``text/html`` — the page the route serves) and
``/api/shared/<token>`` (the JSON read), plus an unknown path."""
app = FastAPI()
@app.get("/shared/{token}", response_class=HTMLResponse)
def shared_page(token: str) -> str:
return (
"<html><head>"
'<link rel="stylesheet" href="/assets/styles.css">'
"</head><body>shared</body></html>"
)
@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/<uuid>`` 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/<uuid>`` — 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}
+7 -2
View File
@@ -29,6 +29,7 @@ HTML_PAGES = (
"login.html", "login.html",
"git-sources.html", "git-sources.html",
"history.html", # phase 50: the admin saved-chats page "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).""" default must be set first)."""
for page in HTML_PAGES: for page in HTML_PAGES:
html = _text(FRONTEND / page) html = _text(FRONTEND / page)
brand_idx = html.find('<script src="assets/brand.js"></script>') # Optional leading slash: root-level pages use "assets/brand.js",
# but the shared page is served from the NESTED /shared/<token>
# route, where a relative ref would 404 — it uses "/assets/…".
m = re.search(r'<script src="(?:/)?assets/brand\.js"></script>', html)
assert m, f"{page}: brand.js must load"
brand_idx = m.start()
module_idx = html.find('<script type="module"') module_idx = html.find('<script type="module"')
assert brand_idx != -1, f"{page}: brand.js must load"
assert module_idx != -1, f"{page}: the page module must load" assert module_idx != -1, f"{page}: the page module must load"
assert brand_idx < module_idx, ( assert brand_idx < module_idx, (
f"{page}: brand.js must load BEFORE the module script" f"{page}: brand.js must load BEFORE the module script"
+7 -2
View File
@@ -42,8 +42,11 @@ FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets" ASSETS = FRONTEND / "assets"
STYLES_CSS = ASSETS / "styles.css" STYLES_CSS = ASSETS / "styles.css"
#: The six pages of the app (phase 46: the shared bar contract extends to #: The app's pages (phase 46: the shared bar contract extends to the
#: the phase-35 git-sources page — the hamburger is part of that bar). #: phase-35 git-sources page — the hamburger is part of that bar; the
#: phase-50 History page and the phase-51 shared page carry the same
#: bar — the full seven-page set). The two pages added after phase 46
#: keep the identical header block, so they pin here too.
PAGES = ( PAGES = (
FRONTEND / "index.html", FRONTEND / "index.html",
FRONTEND / "sources.html", FRONTEND / "sources.html",
@@ -51,6 +54,8 @@ PAGES = (
FRONTEND / "git-sources.html", FRONTEND / "git-sources.html",
FRONTEND / "login.html", FRONTEND / "login.html",
FRONTEND / "tuning.html", FRONTEND / "tuning.html",
FRONTEND / "history.html",
FRONTEND / "shared.html",
) )
NAV_TAG = '<nav class="app-nav" id="app-nav" aria-label="Primary">' NAV_TAG = '<nav class="app-nav" id="app-nav" aria-label="Primary">'
+199 -12
View File
@@ -39,11 +39,13 @@ TUNING_HTML = FRONTEND / "tuning.html"
DOCUMENT_HTML = FRONTEND / "document.html" DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html" LOGIN_HTML = FRONTEND / "login.html"
HISTORY_HTML = FRONTEND / "history.html" HISTORY_HTML = FRONTEND / "history.html"
SHARED_HTML = FRONTEND / "shared.html" # phase 51: the anonymous shared page
HISTORY_JS = ASSETS / "history.js" HISTORY_JS = ASSETS / "history.js"
HEADER_JS = ASSETS / "header.js" HEADER_JS = ASSETS / "header.js"
STYLES_CSS = ASSETS / "styles.css" STYLES_CSS = ASSETS / "styles.css"
#: The phase-34 one-bar contract + the new History page: SEVEN pages. #: The phase-34 one-bar contract + the History page + the shared
#: page: EIGHT pages.
ALL_PAGES = ( ALL_PAGES = (
INDEX_HTML, INDEX_HTML,
SOURCES_HTML, SOURCES_HTML,
@@ -52,6 +54,7 @@ ALL_PAGES = (
DOCUMENT_HTML, DOCUMENT_HTML,
LOGIN_HTML, LOGIN_HTML,
HISTORY_HTML, HISTORY_HTML,
SHARED_HTML,
) )
@@ -110,16 +113,17 @@ def test_nav_history_present_on_all_seven_pages() -> None:
) )
def test_nav_history_count_is_exactly_seven_pages() -> None: def test_nav_history_count_is_exactly_eight_pages() -> None:
"""The pin counting occurrences across ``frontend/*.html`` — exactly """The pin counting occurrences across ``frontend/*.html`` — exactly
one ``id="nav-history"`` per page, seven pages, no duplicates and no one ``id="nav-history"`` per page, eight pages (phase 51: + the
extra page that forgot (or added twice).""" shared page), no duplicates and no extra page that forgot (or
added twice)."""
total = 0 total = 0
for html in sorted(FRONTEND.glob("*.html")): for html in sorted(FRONTEND.glob("*.html")):
count = html.read_text(encoding="utf-8").count('id="nav-history"') count = html.read_text(encoding="utf-8").count('id="nav-history"')
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times" assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
total += count total += count
assert total == 7, f"expected #nav-history on 7 pages, found {total}" assert total == 8, f"expected #nav-history on 8 pages, found {total}"
def test_header_js_reveals_nav_history_for_admin() -> None: def test_header_js_reveals_nav_history_for_admin() -> None:
@@ -173,27 +177,35 @@ def test_history_page_scaffold_and_landmarks() -> None:
def test_history_table_skeleton() -> None: def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the four columns — """The table skeleton: ``.history-table`` with the five columns —
Title | Messages | Updated | Actions (the Actions header text is Title | Messages | Updated | Share (phase 51) | Actions (the
visually-hidden — the row buttons carry their own aria-labels) — Actions header text is visually-hidden — the row buttons carry
and the empty-state row (ship-hidden, the exact copy).""" their own aria-labels) — and the empty-state row (ship-hidden, the
exact copy)."""
html = _text(HISTORY_HTML) html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>', for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>'): '<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
assert col in html assert col in html
# The Share column sits BETWEEN Updated and Actions.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Share column must sit between Updated and Actions"
actions_th = re.search( actions_th = re.search(
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>', r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
html, html,
) )
assert actions_th, "the Actions column header must be visually-hidden text" assert actions_th, "the Actions column header must be visually-hidden text"
assert actions_th.group(1) == "", "no visible text beside the hidden header" assert actions_th.group(1) == "", "no visible text beside the hidden header"
# The empty-state row: ship-hidden, colspan 4, the exact copy. # The empty-state row: ship-hidden, colspan 5 (the Share column
# joined the table in phase 51), the exact copy.
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html) row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
assert row, "the empty-state row must ship in the skeleton" assert row, "the empty-state row must ship in the skeleton"
assert "hidden" in row.group(0) assert "hidden" in row.group(0)
assert 'id="history-empty-row"' in row.group(0) assert 'id="history-empty-row"' in row.group(0)
assert "<td colspan=\"4\">" in html assert "<td colspan=\"5\">" in html
assert ( assert (
"No saved chats yet — finish a conversation and press" "No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat." " <strong>Save</strong> in the chat."
@@ -426,3 +438,178 @@ def test_history_table_mobile_behavior() -> None:
mbody = mobile.group(1) mbody = mobile.group(1)
assert ".history-actions-cell { white-space: normal; }" in mbody assert ".history-actions-cell { white-space: normal; }" in mbody
assert ".history-actions { flex-wrap: wrap; }" in mbody assert ".history-actions { flex-wrap: wrap; }" in mbody
# ---------- the Share column (phase 51, owner-locked 2026-08-29) ----------
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
"""makeRow: the Share <td> (with the share control) lands BETWEEN
the Updated cell and the Actions cell — the column order in
history.html is Title | Messages | Updated | Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < share_i < actions_i, (
"the share cell must sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
f"row cell order must be title/count/updated/share/actions, got {seq}"
)
def test_share_control_three_states_and_two_step_unshare() -> None:
"""The share cell's THREE states — unshared → [Create link];
shared → [Copy] [Unshare]; confirming → "Unshare? [Yes] [No]" —
plus the inline two-step unshare (the phase-50 Delete-confirm
pattern: focus moves to Yes, No restores the shared state, no
native dialog). The shipped state comes from the row's share_url
(the list endpoint populates it — no second fetch)."""
js = _js()
assert "window.confirm" not in js, "history.js must use the inline two-step only"
# makeShareControl: the shipped state branches on chat.share_url.
make = _fn(js, "makeShareControl")
assert "cell.className = \"history-share\"" in make
assert "chat.share_url" in make
assert "renderShareShared(chat, cell)" in make
assert "renderShareUnshared(chat, cell)" in make
# Unshared state: the Create link button (labeled, textContent).
unshared = _fn(js, "renderShareUnshared")
assert 'create.className = "history-share-create"' in unshared
assert 'create.textContent = "Create link"' in unshared
assert 'create.setAttribute("aria-label", `Create share link: ${chat.title}`)' in unshared
assert "innerHTML" not in unshared, "XSS contract: textContent only"
# Shared state: Copy + Unshare, then the two-step confirm.
shared = _fn(js, "renderShareShared")
assert 'copy.className = "history-share-copy"' in shared
assert 'copy.textContent = "Copy"' in shared
assert 'unshare.className = "history-unshare"' in shared
assert 'unshare.textContent = "Unshare"' in shared
assert 'label.textContent = "Unshare?"' in shared
assert 'yes.className = "history-confirm-yes"' in shared, (
"the unshare two-step reuses the phase-50 .history-confirm-* pair"
)
assert 'no.className = "history-confirm-no"' in shared
assert "cell.replaceChildren(label, yes, no)" in shared
swap_i = shared.find("cell.replaceChildren(label, yes, no)")
assert shared.find("yes.focus(", swap_i) > 0, "focus moves to Yes after the swap"
assert 'no.addEventListener("click", restoreShared)' in shared
restore_i = shared.find("function restoreShared")
assert restore_i != -1
assert "cell.replaceChildren(copy, unshare)" in shared[restore_i:restore_i + 120], (
"No (and a failed request) restore the shared state"
)
def test_share_create_and_unshare_request_outcomes() -> None:
"""createShareLink: POST /api/chats/<id>/share → the response's
share_url becomes the row's data, the cell re-renders shared, and
the ABSOLUTE link is offered for copying (clipboard → fallback);
non-2xx / network keep the unshared state (retryable) + the error
line. confirmUnshare: POST /api/chats/<id>/unshare → the cell
re-renders unshared + `Unshared "<title>".`; non-2xx / network
restore the shared state + the error line. Both double-fire
guarded."""
js = _js()
create = _fn(js, "createShareLink")
assert "createBtn.disabled = true" in create
assert 'fetch(`/api/chats/${chat.id}/share`, { method: "POST" })' in create
assert "renderShareShared(chat, cell)" in create, "success re-renders the shared state"
assert "chat.share_url = share_url" in create, "the row's data gains the link"
assert "new URL(share_url, window.location.origin).toString()" in create, (
"the ABSOLUTE link is what gets copied (the origin supplies scheme/host)"
)
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in create
assert "is the app reachable?" in create, "the network-error line"
assert "try again" in create, "the non-2xx line"
# A failed request keeps the button (re-enabled) — retryable.
assert create.count("createBtn.disabled = false") == 2, (
"both failure paths re-enable the Create link button"
)
unshare = _fn(js, "confirmUnshare")
assert "yesBtn.disabled = true" in unshare
assert 'fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" })' in unshare
assert "chat.share_url = null" in unshare, "a revoked link drops the row's share_url"
assert "renderShareUnshared(chat, cell)" in unshare, "success re-renders the unshared state"
assert 'announce(`Unshared "${chat.title}".`)' in unshare
assert unshare.count("restoreShared()") == 2, (
"non-2xx and network both restore the shared state (retryable)"
)
assert "is the app reachable?" in unshare
assert "try again" in unshare
def test_share_copy_uses_own_per_page_clipboard_helper_with_fallback() -> None:
"""The per-page duplication house style: history.js keeps its OWN
~10-line copy of the clipboard + inline-link fallback helper (no
import from app.js, no new shared module). A non-secure (http)
origin rejects navigator.clipboard → a transient .share-link-fallback
<a> field lands in the row's share cell (selects its full URL on
focus — the range-based selectAllInField), one field at a time."""
js = _js()
import_lines = [line for line in js.splitlines() if line.strip().startswith("import")]
assert all("app.js" not in line for line in import_lines), (
"no cross-page import — the helper is duplicated per page"
)
copy = _fn(js, "copyShareLink")
assert "navigator.clipboard.writeText(absoluteUrl)" in copy, "the clipboard try"
assert 'cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove())' in copy, (
"one field at a time — a new offer replaces the old"
)
assert 'field.className = "share-link-fallback"' in copy
assert "field.href = absoluteUrl" in copy
assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only"
assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy
assert "field.focus({ preventScroll: true })" in copy, "selects the URL on focus"
sel = _fn(js, "selectAllInField")
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
# Copy (shared state) goes through the same helper.
rowcopy = _fn(js, "copyRowShareLink")
assert "copyShareLink(" in rowcopy
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in rowcopy
def test_share_column_css() -> None:
"""styles.css: the Share cell's ghost buttons (the Tune/Retry family
— transparent, --line border, ink-soft, ≥44px) + Unshare's
error-rose hover (it revokes — the Delete language) + the inline
fallback field (input-like: mono, surface fill, --line border,
ellipsis, 3px focus-visible). The unshare two-step reuses the
.history-confirm-* pair CSS (no new confirm styles)."""
css = _css()
for cls in (".history-share-create", ".history-share-copy", ".history-unshare"):
assert re.search(re.escape(cls), css), f"styles.css must style {cls}"
btn = re.search(
r"\.history-share-create,\n\.history-share-copy,\n\.history-unshare \{([\s\S]*?)\n\}",
css,
)
assert btn, "the share buttons share one ghost-button block"
body = btn.group(1)
assert "min-height: 44px" in body, "≥44px comfortable target"
assert "border: 1px solid var(--line)" in body
assert "background: transparent" in body
assert "var(--ink-soft)" in body
assert (
".history-unshare:hover:not(:disabled) { background: var(--err-bg);"
" color: var(--err-ink); border-color: var(--err-line); }"
) in css, "Unshare hovers the error rose (it revokes the link)"
field = re.search(r"\.share-link-fallback \{([\s\S]*?)\n\}", css)
assert field, "the inline fallback field must be styled"
fbody = field.group(1)
assert "var(--mono)" in fbody, "input-like: mono (the URL is data)"
assert "background: var(--surface)" in fbody
assert "border: 1px solid var(--line)" in fbody
assert "text-overflow: ellipsis" in fbody
assert re.search(r"\.share-link-fallback:focus-visible \{[^}]*outline[^}]*3px", css), (
"the fallback field keeps a 3px :focus-visible outline"
)
+156
View File
@@ -291,3 +291,159 @@ def test_no_cdn_added() -> None:
"""AGENTS.md rule 6: the Save button adds no external script/link.""" """AGENTS.md rule 6: the Save button adds no external script/link."""
index = _index() index = _index()
assert 'src="http' not in index and 'href="http' not in index assert 'src="http' not in index and 'href="http' not in index
# ---------- the Share button on the chat page (phase 51, task 02) ----------
def test_share_button_ships_hidden_beside_save() -> None:
"""#share-chat-btn: a real type=button with the accessible name
"Share chat", SHIPPED HIDDEN (app.js reveals it for admin only),
BESIDE #save-chat-btn in .chat-shell inside <main>, above
#messages — the chat-shell actions read as a pair (Save | Share).
No other page carries it (chat-page only, like Save)."""
html = _index()
btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #share-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="Share chat"' in tag
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
# The label: the visible text is "Share" (the link SVG is aria-hidden
# decoration; the aria-label carries the accessible name).
btn_block = html[btn.start() : html.find("</button>", btn.start())]
assert '>Share</span>' in btn_block
# Beside Save: after it, still inside .chat-shell, above #messages.
shell_idx = html.find('class="container chat-shell"')
save_idx = html.find('id="save-chat-btn"')
messages_idx = html.find('id="messages"')
assert -1 < shell_idx < save_idx < btn.start() < messages_idx, (
"the button must sit beside #save-chat-btn in .chat-shell, above #messages"
)
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the Share button is chat-page only"
)
def test_share_button_css_is_the_exact_save_family() -> None:
"""styles.css: .share-chat-btn carries the EXACT visual family of
.save-chat-btn (same solid brand pill — --bg on --brand = 5.2:1, AA;
borderless; 999px radius; ≥44px target; hover lightens the brand
fill); the ≤640px block mirrors the Save overrides (label stays
visible in .chat-shell, icon hidden there; icon-only elsewhere)."""
css = _css()
block = re.search(r"\.share-chat-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .share-chat-btn"
body = block.group(1)
assert "min-height: 44px" in body
assert "border-radius: 999px" in body
assert "border: 0" in body
assert "background: var(--brand)" in body, "same solid brand fill as Save"
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
hover = re.search(r"\.share-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
svg = re.search(r"\.share-chat-btn svg \{([\s\S]*?)\n\}", css)
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like Save)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
assert ".share-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, ("squeezes with Save")
assert ".share-chat-label { display: none; }" in mbody
assert ".share-chat-btn svg { display: block; }" in mbody
assert ".chat-shell .share-chat-label { display: inline; }" in mbody, (
"in .chat-shell the label stays visible, as for Save"
)
assert ".chat-shell .share-chat-btn svg { display: none; }" in mbody
def test_share_current_chat_save_then_share_branch() -> None:
"""shareCurrentChat: the same empty-conversation no-op guard as
Save (live region, no request). The save-then-share branch: linked
(currentChatId set) → POST /api/chats/<id>/share (the idempotent
token); unlinked → POST /api/chats with { messages: conversation,
share: true } and link currentChatId to the created id — one action
saves AND shares (owner-locked). Success: the ABSOLUTE URL is
copied — the clipboard try succeeds → the live region reads
"Share link copied."; the rejection (a non-secure http origin)
renders the .share-link-fallback field + "Share link ready — copy it
from the field." 403/5xx → the actionable banner (signed-out hint);
network → the reachable? banner. The double-click guard releases in
the finally — never stale."""
js = _js()
body = _fn(js, "shareCurrentChat")
# No-op first: nothing to share → live-region line, no fetch.
noop = body.find('sendStatus.textContent = "Nothing to share yet."')
first_fetch = body.find("await fetch(")
assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch"
assert "if (!conversation.length)" in body
# The branch: POST share when linked, create-with-share when not.
assert "if (currentChatId)" in body
linked = '`/api/chats/${currentChatId}/share`'
share_fetch = body.find(linked)
assert share_fetch != -1, "the linked branch POSTs the idempotent share"
assert 'fetch("/api/chats", {' in body, "the unlinked branch POSTs /api/chats"
assert 'JSON.stringify({ messages: conversation, share: true })' in body, (
"the create-with-share payload — the server sets the token in the same commit"
)
post_idx = body.find('fetch("/api/chats", {')
assert -1 < share_fetch < post_idx, "the linked branch precedes the unlinked fallback"
created_idx = body.find("currentChatId = String(created.id)", post_idx)
assert created_idx != -1, "one action saved AND shared: the conversation links to the row"
# The copy: the ABSOLUTE URL (share_url resolved against the page
# origin) + the two live-region outcomes (success / the owner-locked
# inline-field fallback).
abs_fn = _fn(js, "absoluteShareUrl")
assert "new URL(shareUrl, window.location.origin).toString()" in abs_fn, (
"the ABSOLUTE URL is what gets copied (the origin supplies scheme/host)"
)
assert "copyShareLinkWithFallback(absoluteShareUrl(shareUrl))" in body
assert (
'sendStatus.textContent = copied\n'
' ? "Share link copied."\n'
' : "Share link ready — copy it from the field."'
) in body
# Failures raise an actionable banner (non-ok HTTP + network).
assert 'showErrorBanner("Couldn\'t share the conversation — is the app reachable?")' in body
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
assert body.count("check you're still signed in and try again") == 2, (
"both the linked and the unlinked branch carry the non-ok banner"
)
# The double-click guard releases on EVERY outcome.
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
# The clipboard + fallback helpers live in app.js (the chat page's
# copy of the per-page helper).
copy = _fn(js, "copyShareLinkWithFallback")
assert "navigator.clipboard.writeText(absoluteUrl)" in copy
assert 'field.className = "share-link-fallback"' in copy
assert "field.href = absoluteUrl" in copy
assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only"
assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy, (
"select-on-focus — the field-like behavior"
)
assert "composer.appendChild(field)" in copy, "near the status line (the composer)"
sel = _fn(js, "selectAllInField")
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
def test_share_button_revealed_only_for_admin() -> None:
"""The ship-hidden/reveal-for-admin contract: app.js queries
#share-chat-btn, binds the click to shareCurrentChat, and the boot
IIFE sets shareBtn.hidden = !isAdmin in the SAME admin-reveal block
as Save (phase 16 absent-not-hidden — no trace for anonymous)."""
js = _js()
assert 'document.querySelector("#share-chat-btn")' in js
assert 'shareBtn?.addEventListener("click", shareCurrentChat)' in js
assert "shareBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
# The reveal happens in the boot IIFE (after whoami), not at module
# evaluation — and right next to Save's own reveal line.
boot_start = js.find("(async () => {")
reveal = js.find("shareBtn.hidden = !isAdmin")
save_reveal = js.find("saveBtn.hidden = !isAdmin")
assert boot_start < save_reveal < reveal, (
"the Share reveal joins the same admin-reveal block as Save"
)
+10 -2
View File
@@ -424,7 +424,12 @@ def test_init_shared_header_rewrites_sign_in_next_to_current_page() -> None:
"""Phase 34 task 02: initSharedHeader points #sign-in-link at """Phase 34 task 02: initSharedHeader points #sign-in-link at
/login.html?next=<current pathname> (default "/") — the admin lands /login.html?next=<current pathname> (default "/") — the admin lands
back on the page they signed in from. The page markup keeps its own back on the page they signed in from. The page markup keeps its own
static ?next= as the no-JS fallback.""" static ?next= as the no-JS fallback.
Phase 51 (owner-locked 2026-08-29, TODO.md L6): the ONE exception —
the NESTED /shared/<token> page rewrites to the APP ROOT ("/")
instead of the shared URL: a guest signing in from a shared page
returns to the app root, not to a public link."""
js = _text(HEADER_JS) js = _text(HEADER_JS)
fn = js.find("function initSharedHeader") fn = js.find("function initSharedHeader")
assert fn != -1 assert fn != -1
@@ -432,7 +437,10 @@ def test_init_shared_header_rewrites_sign_in_next_to_current_page() -> None:
# raw pathname (always a query-safe "/…" string — never "//", and ? # raw pathname (always a query-safe "/…" string — never "//", and ?
# # / spaces stay percent-encoded inside it; login.js safeNext # # / spaces stay percent-encoded inside it; login.js safeNext
# re-validates), same shape as the static markup fallbacks # re-validates), same shape as the static markup fallbacks
assert '"/login.html?next=" + (window.location.pathname || "/")' in body assert 'const nextPath = window.location.pathname || "/";' in body
assert 'link.href = "/login.html?next=" + signInNext;' in body
# the shared-page exception: /shared/<token> → the app root
assert 'nextPath.startsWith("/shared/") ? "/" : nextPath' in body
# ---------- phase 34 task 01: the steering controls move to the module ---------- # ---------- phase 34 task 01: the steering controls move to the module ----------
+449
View File
@@ -0,0 +1,449 @@
"""Unit: the phase-51 task-03 shared page contract (anonymous,
read-only, zero controls).
The browser behavior itself is E2E-gated by the story suite (task 04);
like the other frontend-adjacent unit files, this module pins the
JS/CSS/HTML markers the shared-conversation contract depends on, so a
silent regression is caught without a browser:
* the page scaffold (the identical shared header — every admin-only
link ships hidden, the auth pair's static fallback is ``?next=/`` — a
guest signing in from a shared page returns to the app root — the
steering panel, the h1 fallback, the read-only note, the invalid
state, the messages section);
* the token parse (a malformed path → the invalid state, NO fetch of
any kind);
* the 404 / network / malformed read → the invalid state (no data
render, no banner);
* ZERO interactive controls: ``renderSharedMessage`` never calls the
chat page's interactive builders, and the rendered messages contain
no button/form/link — the chips are plain spans, the source chips
carry no ``href``;
* the shared shell's 46rem column mapping + the static-chip and
invalid-state CSS (the ≤640px squeeze included).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
SHARED_HTML = FRONTEND / "shared.html"
SHARED_JS = ASSETS / "shared.js"
STYLES_CSS = ASSETS / "styles.css"
def _js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _html() -> str:
return SHARED_HTML.read_text(encoding="utf-8")
def _fn(js: str, name: str) -> str:
"""The source of a top-level ``function <name>(...)`` (to its close)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist in shared.js"
return js[start : js.find("\n}\n", start) + 4]
# ---------- shared.html: the page scaffold ----------
def test_shared_html_scaffold_and_landmarks() -> None:
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
shared header, the steering panel + announcer (phase 34 — ships on
every page), the h1 with its static fallback, the read-only note,
the invalid state (ship-hidden), the messages section, and the
footer with the version span (the index.html shape)."""
html = _html()
assert '<a class="skip-link" href="#main">' in html
assert 'class="app-header"' in html
assert '<nav class="app-nav" id="app-nav" aria-label="Primary">' in html
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
# The steering panel (hidden) + its announcer, first children of <main>.
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
assert 'id="steering-list"' in html
assert 'id="steering-empty"' in html
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
assert html.find('id="steering-panel"') < html.find('id="steering-announcer"')
# The title: JS-filled, with the static fallback text.
assert "<h1 id=\"shared-title\">Shared conversation</h1>" in html
# The read-only note (brand resolves through window.BOR_BRAND at
# call time in shared.js; the static copy is the default name).
assert "<p class=\"shared-note\">Shared via Brain of Reese — read-only.</p>" in html
# The invalid / revoked state: ship-hidden, the exact copy.
invalid = re.search(r'<div[^>]*id="shared-invalid"[^>]*>', html)
assert invalid and "hidden" in invalid.group(0), (
"#shared-invalid must ship hidden"
)
assert (
"<div id=\"shared-invalid\" hidden>"
"This share link is invalid or was revoked.</div>"
) in html
# The messages section: the SAME structure container as the chat page.
assert (
'<section class="messages" id="messages" aria-label="Shared conversation">'
in html
)
# Footer with the version span.
assert '<span class="footer-version" id="app-version"></span>' in html
def test_shared_html_identical_header_guest_safe() -> None:
"""The IDENTICAL shared header (the phase-34 one-bar contract):
every admin-only nav link SHIPS hidden (a guest never sees one for
a frame), no nav link is "current" (a detail view — the
document.html convention), and the auth pair's static fallback is
?next=/ — a guest signing in from a shared page returns to the
app root (owner-locked; the comment notes it)."""
html = _html()
# The one-bar inventory (brand, hamburger, nav, auth pair).
assert 'class="brand"' in html
assert re.search(r'<button[^>]*id="nav-toggle"[^>]*aria-label="Menu"[^>]*>', html)
for link in (
r'<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>',
r'<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>',
r'<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>',
r'<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>',
):
assert re.search(link, html), f"missing the ship-hidden nav link: {link}"
# No link is current — the shared page is a read-only detail view.
assert "is-active" not in html, "no nav link is current on the shared page"
# The auth pair: BOTH copies (bar + mobile dropdown) fall back to
# the app root — ?next=/ — with the owner-locked note in a comment.
assert (
'<a href="/login.html?next=/" class="auth-link sign-in-link" id="sign-in-link" hidden>'
in html
)
assert (
'<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile"'
' id="sign-in-link-mobile" hidden>'
in html
)
assert re.search(
r'<button[^>]*class="auth-link sign-out-btn" id="sign-out-btn"[^>]*>', html
)
assert (
"a guest signing in FROM a shared page returns to the APP" in html
), "the ?next=/ app-root contract must be documented in the markup"
def test_shared_html_scripts_and_no_controls() -> None:
"""Script load order (the house pattern): brand.js classic FIRST,
the classic markdown renderer second, the shared.js module last —
ALL with ABSOLUTE /assets/ paths: the page is served from the
NESTED /shared/<token> route, where a relative "assets/…" ref
would resolve to /shared/assets/… and 404. NO direct header.js
<script> tag (single-evaluation design — shared.js imports it
relatively). Zero controls (owner-locked): no document-modal, no
composer, no Send/Save/Share/New chat buttons, no form element,
and the ONLY <button>s on the page are the shared bar's own (the
hamburger + the two sign-out copies)."""
html = _html()
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert srcs == ["/assets/brand.js", "/assets/markdown.js", "/assets/shared.js"], (
f"shared.html must load brand.js + markdown.js (classic, absolute "
f"/assets/ paths — the nested route breaks relative refs) and the "
f"shared.js module, in that order, got {srcs}"
)
# The stylesheet too (a relative href would break the same way).
assert '<link rel="stylesheet" href="/assets/styles.css">' in html
assert 'src="header.js"' not in html, (
"no direct header.js <script> tag — the single-evaluation design"
)
assert 'type="module" src="/assets/shared.js"' in html
# No CDN (AGENTS.md rule 6): every asset is local.
assert 'src="http' not in html and 'href="http' not in html
# Zero controls: nothing the chat page's controls would look like.
for marker in (
"document-modal",
'id="composer"',
'id="send-btn"',
'id="new-chat-btn"',
'id="save-chat-btn"',
'id="share-chat-btn"',
'id="tune',
"<form",
):
assert marker not in html, f"shared.html must not carry {marker!r}"
# Exactly the shared bar's three buttons — nothing in the content.
assert html.count("<button") == 3, (
"only the hamburger + the two sign-out copies may be buttons"
)
# ---------- shared.js: the token + the no-fetch rule ----------
def test_token_parse_last_segment_uuid_only() -> None:
"""parseSharedToken: the last path segment of /shared/<token> — a
well-formed uuid only; a malformed or missing token (no final
segment, a non-uuid segment) returns null."""
js = _js()
body = _fn(js, "parseSharedToken")
assert "window.location.pathname.split(\"/\").filter(Boolean)" in body, (
"the token is the LAST path segment"
)
assert re.search(
r"TOKEN_RE = /\^\[0-9a-f\]\{8\}-\[0-9a-f\]\{4\}-\[0-9a-f\]\{4\}-"
r"\[0-9a-f\]\{4\}-\[0-9a-f\]\{12\}\$/i",
js,
), "the uuid gate is the house TOKEN_RE shape"
assert "TOKEN_RE.test(last)" in body
def test_malformed_token_shows_invalid_with_no_fetch() -> None:
"""The boot: the malformed-token branch shows #shared-invalid and
returns BEFORE any fetch of any kind — no /api/shared read, no
whoami (initSharedHeader), nothing: the page ships in its guest
state, which is already correct for a bad URL."""
js = _js()
boot_start = js.find("(async () => {")
assert boot_start != -1, "the boot IIFE must exist"
boot = js[boot_start:]
gate = boot.find("if (!token)")
assert gate != -1, "the malformed-token gate must run in boot"
ret = boot.find("return;", gate)
branch = boot[gate:ret]
assert "showInvalid()" in branch, "the invalid state shows for a bad token"
assert "fetch(" not in branch, "a malformed token must trigger NO fetch"
assert "initSharedHeader" not in branch, (
"no whoami either — the header ships in its guest state"
)
# The note is set before the gate (the brand resolves at call time).
note_i = boot.find("noteEl.textContent")
assert 0 < note_i < gate, "the brand note is set before the token gate"
assert "Shared via ${brand()} — read-only." in boot
def test_boot_order_header_then_public_read() -> None:
"""A well-formed token: initSharedHeader() FIRST (the header works
for guests — whoami anonymous, the admin links stay hidden), then
the public read GET /api/shared/<token>; a null read shows the
invalid state, a 200 renders through renderSharedChat."""
js = _js()
boot = js[js.find("(async () => {"):]
header_i = boot.find("await initSharedHeader();")
read_i = boot.find("fetchSharedChat(token)")
render_i = boot.find("renderSharedChat(data)")
assert -1 < header_i < read_i < render_i, (
"boot order: header init → public read → render"
)
invalid_i = boot.find("if (!data)")
assert -1 < invalid_i < render_i, "the null read gates the render"
branch = boot[invalid_i:render_i]
assert "showInvalid()" in branch
assert "renderSharedChat" not in branch, "no render on a null read"
# The relative import of the shared header module (no absolute
# /assets/ import — the esbuild bundle contract).
assert 'import { initSharedHeader } from "./header.js";' in js
assert '"/assets/header.js"' not in js
# No cross-page import (the per-page duplication house style).
import_lines = [
line for line in js.splitlines() if line.strip().startswith("import")
]
assert all("app.js" not in line for line in import_lines)
def test_public_read_collapses_failures_to_null() -> None:
"""fetchSharedChat: a network failure, a non-2xx (the 404 for a
wrong or revoked token), or a malformed body all return null →
the invalid state (no data render, no banner — this page has no
error banner). A 200 without a messages array is unusable too."""
js = _js()
body = _fn(js, "fetchSharedChat")
assert "fetch(`/api/shared/${token}`)" in body
# Exactly three failure collapses to null (network / non-ok /
# malformed) + the shape guard on the 200 path.
assert body.count("return null") == 3, (
"network, non-2xx and malformed body each collapse to null"
)
assert "if (!res.ok) return null" in body, "404 (wrong/revoked) → null"
assert "Array.isArray(data.messages)" in body, "the 200 shape guard"
# ---------- shared.js: the read-only render ----------
def test_render_shared_message_record_shape() -> None:
"""renderSharedMessage: the SAME record shape the chat page
restores — user → the .msg.user bubble; brain → the .msg.brain
bubble with the optional thinking block (restored COLLAPSED — the
phase-17 convention), the tool lines in saved order, the
is-deflected treatment + the plain-text "Maybe try" chips, the
plain-text source chips, and the stopped note. Markdown through
the GLOBAL escape-first renderMarkdown (no local copy)."""
js = _js()
body = _fn(js, "renderSharedMessage")
assert 'addSharedMessage("user", renderMarkdown(m.text))' in body
assert 'addSharedMessage("brain", renderMarkdown(m.text))' in body
assert "renderMarkdown" in body, "the escape-first global renderer"
assert "function renderMarkdown" not in js, (
"shared.js must NOT define its own renderer — markdown.js is the one copy"
)
# The thinking block: restored COLLAPSED.
think = _fn(js, "addThinkingBlock")
assert 'block.className = "thinking"' in think
assert "block.open = false" in think, "the phase-17 restore convention: collapsed"
assert 'summary.textContent = "Thinking"' in think
assert "renderMarkdown(thinking)" in think, "the raw reasoning is markdown-rendered"
assert 'textEl.className = "thinking-text"' in think
# The tool lines (phase 37): the exact app.js template strings (the
# frontend emoji guard strips precisely these two literals here).
tools = _fn(js, "addToolLines")
assert 'container.className = "tool-calls"' in tools
assert '"🔎 Listing documents"' in tools
assert '"📄 Reading "' in tools
assert "code.textContent = argument" in tools, "the path is data, never markup"
# Deflection: the class + the plain-text "Maybe try" chips.
assert 'wrap.classList.add("is-deflected")' in body
maybe = _fn(js, "addMaybeTry")
assert 'group.className = "maybe-try"' in maybe
assert 'chip.className = "suggestion-chip"' in maybe
assert 'chip.setAttribute("role", "listitem")' in maybe
assert "chip.textContent = text" in maybe, "XSS contract: textContent only"
# Sources: plain text spans (the label is data).
sources = _fn(js, "addSources")
assert 'meta.className = "msg-meta"' in sources
assert 'chip.className = "source-chip"' in sources
assert "chip.textContent = label" in sources
assert "chip.title = label" in sources
# The stopped note (phase 48): the local copy of the chat markup.
stopped = _fn(js, "addStoppedNote")
assert 'note.className = "stopped-note"' in stopped
assert 'label.textContent = "Stopped"' in stopped
assert "rect x=\"6.5\" y=\"6.5\" width=\"11\" height=\"11\" rx=\"2\"" in stopped
def test_zero_interactive_controls_in_the_renderer() -> None:
"""Owner-locked zero controls, pinned at source level:
renderSharedMessage (and the whole file) never calls the chat
page's interactive builders, never creates a button/form/anchor,
and binds no click handler — the chips are spans, the source chips
carry no href, and the document modal is never wired."""
js = _js()
for name in ("renderChips", "appendTuneButton", "appendRetryButton",
"openDocumentModal", "openTuneForm"):
assert name not in js, (
f"shared.js must never reference {name} (the chat page's interactive path)"
)
for marker in ('createElement("button")', 'createElement("form")',
'createElement("a")', 'addEventListener("click"'):
assert marker not in js, f"no interactive markup: {marker}"
assert "chip.href" not in js and ".href =" not in js, (
"the source chips carry no href (guests cannot open documents)"
)
assert "document-modal" not in js, "no document-modal wiring on the shared page"
# The rendered message wrapper: the .msg/.bubble structure only.
add = _fn(js, "addSharedMessage")
assert "wrap.className = `msg ${who}`" in add
assert '<div class="bubble">' in add
def test_title_and_defensive_filter_on_the_200_path() -> None:
"""renderSharedChat: the h1 gets the title only when it is a
non-blank string (the static fallback stays otherwise — and on a
failed read the function is never called, so the fallback keeps);
the same defensive record filter as the chat page's restore keeps
a corrupted row from poisoning the render."""
js = _js()
body = _fn(js, "renderSharedChat")
assert "typeof data.title === \"string\"" in body
assert "titleEl.textContent = title" in body
assert 'm.who === "user" || m.who === "brain"' in body
assert 'typeof m.text === "string"' in body
assert "renderSharedMessage(m)" in body
def test_brand_note_resolves_at_call_time() -> None:
"""Phase 39: the note reads window.BOR_BRAND through the house
brand() fallback (exactly one copy of the literal in the file —
the brand() fallback), so a label set after the /api/config fetch
lands carries the configured name."""
js = _js()
assert 'const brand = () => window.BOR_BRAND || "Brain of Reese";' in js
assert js.count('"Brain of Reese"') == 1, (
"the literal must appear only in the brand() fallback"
)
# ---------- styles.css: the shared page ----------
def test_shared_shell_maps_to_the_46rem_column() -> None:
"""The PLAN §7 column contract: .shared-shell is the centered
46rem chat column (the conversation reads exactly like the chat
page's, so the existing .msg/.bubble CSS applies unchanged)."""
css = _css()
block = re.search(r"\.shared-shell \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .shared-shell"
body = block.group(1)
assert "max-width: 46rem" in body, "the PLAN §7 centered chat column"
assert "margin-inline: auto" in body, "centered"
assert "display: flex" in body and "flex-direction: column" in body
def test_shared_page_title_note_and_invalid_css() -> None:
"""#shared-title (the page-head h1 size), .shared-note (the muted
meta line under the h1), and #shared-invalid (a centered muted
block — the not-found language: surface card, --line border,
italic ink-soft)."""
css = _css()
title = re.search(r"#shared-title \{([^}]*)\}", css)
assert title and "font-size: 1.7rem" in title.group(1)
note = re.search(r"\.shared-note \{([\s\S]*?)\n\}", css)
assert note and "var(--ink-soft)" in note.group(1), (
"the note is the muted meta line"
)
invalid = re.search(r"#shared-invalid \{([\s\S]*?)\n\}", css)
assert invalid, "the invalid state must be styled"
ibody = invalid.group(1)
for decl in (
"text-align: center",
"var(--ink-soft)",
"font-style: italic",
"background: var(--surface)",
"border: 1px solid var(--line)",
):
assert decl in ibody, f"#shared-invalid missing {decl!r}"
def test_static_chips_are_text_only_in_the_shared_scope() -> None:
"""The guest's chips are plain text (owner-locked zero controls):
the pill families' pointer treatments are scoped OFF in
.shared-shell and only there — the chat page's interactive chips
keep their styles untouched."""
css = _css()
block = re.search(
r"\.shared-shell \.suggestion-chip,\n\.shared-shell \.source-chip \{([\s\S]*?)\n\}",
css,
)
assert block, "the static-chip rule must scope both chip families"
body = block.group(1)
assert "pointer-events: none" in body, "no pointer (the hover rules die with it)"
assert "cursor: default" in body, "no cursor"
# The interactive treatments live OUTSIDE the shared scope
# (the chat page's chips are untouched).
hover = re.search(r"\.suggestion-chip:hover \{([^}]*)\}", css)
assert hover, "the chat page's chip hover must stay"
assert "pointer-events: none" not in (hover.group(1) or "")
def test_shared_page_mobile_squeeze() -> None:
"""≤640px (the phase-07 contract): the shared title + note step
down (the empty-state-title family); the shell keeps its column
and the global .msg-body 92% override applies."""
css = _css()
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
mbody = mobile.group(1)
assert "#shared-title { font-size: 1.35rem; }" in mbody
assert ".shared-note { font-size: 0.88rem; }" in mbody
+4 -2
View File
@@ -22,8 +22,9 @@ ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js" HEADER_JS = ASSETS / "header.js"
#: All seven pages carry the shared header block (phase 34's five pages #: All eight pages carry the shared header block (phase 34's five pages
#: + phase 35's git-sources page + phase 50's History page). #: + phase 35's git-sources page + phase 50's History page +
#: phase 51's shared page).
PAGES = ( PAGES = (
FRONTEND / "index.html", FRONTEND / "index.html",
FRONTEND / "sources.html", FRONTEND / "sources.html",
@@ -32,6 +33,7 @@ PAGES = (
FRONTEND / "login.html", FRONTEND / "login.html",
FRONTEND / "tuning.html", FRONTEND / "tuning.html",
FRONTEND / "history.html", FRONTEND / "history.html",
FRONTEND / "shared.html",
) )