feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
+2
-1
@@ -21,10 +21,11 @@ RUN mkdir -p /out/assets \
|
||||
&& esbuild ./assets/tuning.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/tuning.js \
|
||||
&& esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \
|
||||
&& esbuild ./assets/history.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/history.js \
|
||||
&& esbuild ./assets/shared.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/shared.js \
|
||||
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html /out/
|
||||
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html /out/
|
||||
|
||||
# ---------- Stage 2: python dependencies ----------
|
||||
FROM docker.io/python:3.12-slim AS python
|
||||
|
||||
@@ -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
@@ -14,20 +14,43 @@ payload is the exact ``bor.chat.v1`` localStorage record shape
|
||||
(phase 14 — raw text, never HTML), so a saved chat restores
|
||||
pixel-identical through the existing ``renderStoredMessage`` path.
|
||||
|
||||
Routes: ``GET`` (list, latest activity first — no payloads), ``POST``
|
||||
(create — auto-title from the first question when no ``title`` is
|
||||
supplied), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
|
||||
Routes: ``GET`` (list, latest activity first — no payloads, but the
|
||||
phase-51 ``share_url`` so the History's Share column renders without a
|
||||
second fetch), ``POST`` (create — auto-title from the first question
|
||||
when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets
|
||||
the token in the SAME commit — the save-then-share contract),
|
||||
``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
|
||||
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
|
||||
only when supplied), ``DELETE /{chat_id}``.
|
||||
only when supplied), ``DELETE /{chat_id}``, and (phase 51, task 01)
|
||||
``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this
|
||||
admin-gated router.
|
||||
|
||||
Sharing (phase 51, owner-locked 2026-08-29): a saved chat's
|
||||
``share_token`` (a 128-bit ``uuid4``, migration 0009) makes it
|
||||
publicly readable at ``/shared/<token>`` — two more routers in this
|
||||
file, registered in ``app.main`` with **no** admin dependency:
|
||||
|
||||
* ``public_router`` — ``GET /shared/{token}`` (mounted under ``/api``
|
||||
→ ``GET /api/shared/<token>``): the anonymous read, a minimal
|
||||
``SharedChatOut`` snapshot (no id/timestamps/token); wrong or
|
||||
revoked tokens 404 with one message (no enumeration).
|
||||
* ``shared_page_router`` — ``GET /shared/{token}`` (mounted with **no**
|
||||
prefix, before the static catch-all): serves
|
||||
``frontend/shared.html`` (the page lands in phase 51, task 03). A
|
||||
stale deploy without the file 404s with the SAME JSON as the API —
|
||||
never a 500.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.auth import require_admin
|
||||
from app.db import get_db
|
||||
from app.models import SavedChat
|
||||
@@ -38,6 +61,9 @@ from app.schemas import (
|
||||
SavedChatOut,
|
||||
SavedChatRow,
|
||||
SavedChatUpdate,
|
||||
SharedChatOut,
|
||||
ShareOut,
|
||||
UnshareOut,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
@@ -65,6 +91,15 @@ def _auto_title(messages: list[ChatMessage]) -> str:
|
||||
return " ".join(first_user.split())[:_AUTO_TITLE_MAX]
|
||||
|
||||
|
||||
def _share_url(row: SavedChat) -> str | None:
|
||||
"""The row's share link path (phase 51) — ``None`` when unshared.
|
||||
|
||||
``None`` is ABSENT from the JSON (the schemas' omission rule), so an
|
||||
unshared chat exposes no share surface at all.
|
||||
"""
|
||||
return f"/shared/{row.share_token}" if row.share_token else None
|
||||
|
||||
|
||||
def _to_out(row: SavedChat) -> SavedChatOut:
|
||||
"""The full-payload response shape (create/get/put)."""
|
||||
return SavedChatOut(
|
||||
@@ -74,16 +109,19 @@ def _to_out(row: SavedChat) -> SavedChatOut:
|
||||
updated_at=row.updated_at,
|
||||
message_count=len(row.messages),
|
||||
messages=[ChatMessage.model_validate(m) for m in row.messages],
|
||||
share_url=_share_url(row),
|
||||
)
|
||||
|
||||
|
||||
def _to_row(row: SavedChat) -> SavedChatRow:
|
||||
"""The list-page row shape (no payloads in the list)."""
|
||||
"""The list-page row shape (no payloads in the list; ``share_url``
|
||||
is not a payload — it is the History Share column's data)."""
|
||||
return SavedChatRow(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
updated_at=row.updated_at,
|
||||
message_count=len(row.messages),
|
||||
share_url=_share_url(row),
|
||||
)
|
||||
|
||||
|
||||
@@ -110,6 +148,11 @@ def create_chat(
|
||||
text, whitespace-collapsed, truncated to 120 chars (owner-locked
|
||||
convention); a conversation with no user message (defensive) falls
|
||||
back to ``"Chat <id-hex8>"``.
|
||||
|
||||
``share: true`` (phase 51, task 02 — the save-then-share contract):
|
||||
the fresh row carries ``share_token = uuid.uuid4()`` in the SAME
|
||||
INSERT/commit — one request saves AND shares, and the 201 body
|
||||
carries ``share_url`` (the chat page copies it in one action).
|
||||
"""
|
||||
title = (payload.title or "").strip() or _auto_title(payload.messages)
|
||||
row = SavedChat(
|
||||
@@ -121,6 +164,10 @@ def create_chat(
|
||||
# restore path is null-safe for every optional key).
|
||||
messages=[m.model_dump() for m in payload.messages],
|
||||
)
|
||||
if payload.share:
|
||||
# Phase 51: set on the PENDING row, so the token ships in the
|
||||
# same INSERT (one commit — save and share are one action).
|
||||
row.share_token = uuid.uuid4()
|
||||
db.add(row)
|
||||
db.flush() # python-side uuid default lands the id before the fallback
|
||||
if not row.title:
|
||||
@@ -181,3 +228,129 @@ def delete_chat(
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/{chat_id}/share", response_model=ShareOut)
|
||||
def share_chat(
|
||||
chat_id: uuid.UUID,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> ShareOut:
|
||||
"""Turn a saved chat into a public link (phase 51, task 01).
|
||||
|
||||
Returns ``{"chat_id", "share_url"}`` with ``share_url =
|
||||
"/shared/<token>"`` (200, idempotent — an existing token is
|
||||
returned unchanged; a new token is a 128-bit ``uuid4``).
|
||||
``updated_at`` is **not** bumped: sharing is not a content edit,
|
||||
so the token is written with a raw SQL ``UPDATE`` that touches ONLY
|
||||
``share_token`` — the column's ``onupdate=func.now()`` default is
|
||||
registered on the Table (via ``mapped_column``), so even a Core
|
||||
``update(SavedChat)`` DML statement would pick it up; the ORM
|
||||
object is never mutated either. 404 when the id is unknown.
|
||||
"""
|
||||
row = db.get(SavedChat, chat_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="unknown chat")
|
||||
token = row.share_token
|
||||
if token is None:
|
||||
token = uuid.uuid4()
|
||||
# Raw SQL on purpose: sets ONLY share_token, so the column's
|
||||
# onupdate default for ``updated_at`` never fires (the History
|
||||
# page's "latest activity" order must follow content edits only).
|
||||
db.execute(
|
||||
text("UPDATE saved_chats SET share_token = :tok WHERE id = :id"),
|
||||
{"tok": token, "id": chat_id},
|
||||
)
|
||||
db.commit()
|
||||
return ShareOut(chat_id=row.id, share_url=f"/shared/{token}")
|
||||
|
||||
|
||||
@router.post("/{chat_id}/unshare", response_model=UnshareOut)
|
||||
def unshare_chat(
|
||||
chat_id: uuid.UUID,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> UnshareOut:
|
||||
"""Revoke a shared chat (phase 51, task 01): ``share_token`` → NULL.
|
||||
|
||||
Idempotent — an unshared chat unshares cleanly (200, no write).
|
||||
``updated_at`` is not bumped (raw SQL ``UPDATE`` touching only
|
||||
``share_token``, same reasoning as :func:`share_chat`); 404 when
|
||||
the id is unknown.
|
||||
"""
|
||||
row = db.get(SavedChat, chat_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="unknown chat")
|
||||
if row.share_token is not None:
|
||||
db.execute(
|
||||
text("UPDATE saved_chats SET share_token = NULL WHERE id = :id"),
|
||||
{"id": chat_id},
|
||||
)
|
||||
db.commit()
|
||||
return UnshareOut(chat_id=row.id, shared=False)
|
||||
|
||||
|
||||
#: Public read surface (phase 51, task 01) — **no** admin dependency:
|
||||
#: a guest with the link reads the shared chat anonymously. Mounted
|
||||
#: under ``/api`` in ``app.main`` → ``GET /api/shared/<token>``.
|
||||
public_router = APIRouter(tags=["chats"])
|
||||
|
||||
|
||||
def _to_shared_out(row: SavedChat) -> SharedChatOut:
|
||||
"""The PUBLIC read shape: title + messages only — no id,
|
||||
timestamps, or token (a content snapshot, not a handle)."""
|
||||
return SharedChatOut(
|
||||
title=row.title,
|
||||
messages=[ChatMessage.model_validate(m) for m in row.messages],
|
||||
)
|
||||
|
||||
|
||||
@public_router.get("/shared/{token}", response_model=SharedChatOut)
|
||||
def read_shared_chat(
|
||||
token: uuid.UUID,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> SharedChatOut:
|
||||
"""Anonymous read of a shared chat by token (phase 51, task 01).
|
||||
|
||||
No admin dependency — the token IS the credential. A wrong or a
|
||||
revoked (unshared) token 404s with ONE message: ``unknown or
|
||||
revoked share link`` — deliberately no enumeration between the two
|
||||
cases.
|
||||
"""
|
||||
row = db.execute(
|
||||
select(SavedChat).where(SavedChat.share_token == token).limit(1)
|
||||
).scalars().first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="unknown or revoked share link")
|
||||
return _to_shared_out(row)
|
||||
|
||||
|
||||
#: The share PAGE route (phase 51, task 01) — mounted with **no**
|
||||
#: prefix, BEFORE the static catch-all in ``app.main`` (the
|
||||
#: API-routes-first convention): ``/shared/<uuid>`` is not a static
|
||||
#: file, so without this route the ``StaticFiles`` mount would 404 it.
|
||||
shared_page_router = APIRouter(tags=["chats"])
|
||||
|
||||
#: The page's filename inside the static dir (the file lands in phase
|
||||
#: 51, task 03; until then the guard below keeps a stale deploy from
|
||||
#: 500'ing).
|
||||
_SHARED_PAGE_NAME = "shared.html"
|
||||
|
||||
|
||||
@shared_page_router.get("/shared/{token}", response_model=None)
|
||||
def shared_page(token: uuid.UUID) -> FileResponse | JSONResponse:
|
||||
"""Serve the anonymous shared-chat page for ``/shared/<token>``.
|
||||
|
||||
The page (``frontend/shared.html``, task 03) fetches
|
||||
``GET /api/shared/<token>`` itself and renders the conversation
|
||||
read-only — including the "invalid or revoked" state for a wrong
|
||||
or revoked token — so this route serves the page for any well-
|
||||
formed token and never 404s on the token's validity. The guard:
|
||||
when the page file is missing (a stale deploy — the API is ahead
|
||||
of the static bundle), return the SAME 404 JSON as the API rather
|
||||
than a 500.
|
||||
"""
|
||||
page = Path(get_settings().static_dir).expanduser().resolve() / _SHARED_PAGE_NAME
|
||||
if not page.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404, content={"detail": "unknown or revoked share link"}
|
||||
)
|
||||
return FileResponse(page)
|
||||
|
||||
+21
-7
@@ -6,9 +6,10 @@ Two layers, one module:
|
||||
append to their asset URLs (``?v=<token>``).
|
||||
* **Response middleware** (``CachingMiddleware`` / ``configure_caching``)
|
||||
— applies the caching behavior at the transport layer: the known
|
||||
HTML pages (``HTML_PAGES``) are always revalidated (``no-cache``) and
|
||||
their local asset references are rewritten to carry ``?v=<token>``;
|
||||
``/assets/*`` is
|
||||
HTML pages (``HTML_PAGES``) and the dynamic share page
|
||||
``/shared/<token>`` (phase 51) are always revalidated (``no-cache``)
|
||||
and their local asset references are rewritten to carry
|
||||
``?v=<token>``; ``/assets/*`` is
|
||||
served ``immutable`` for a year; everything else — all of ``/api/*``,
|
||||
including the SSE chat stream — passes through byte-identical.
|
||||
|
||||
@@ -125,6 +126,11 @@ HTML_PAGES: tuple[str, ...] = (
|
||||
"/tuning.html",
|
||||
"/git-sources.html", # phase 35: the admin git sources page
|
||||
"/history.html", # phase 50: the admin saved-chats page
|
||||
# phase 51: the shared page's STATIC path (the static mount serves
|
||||
# shared.html at /shared.html as well as the real route serves the
|
||||
# dynamic /shared/<token> — both must carry the no-cache + ?v=
|
||||
# contract, so the direct URL can never pin stale assets).
|
||||
"/shared.html",
|
||||
)
|
||||
|
||||
#: Prefix of the versioned static assets (header-only caching; the body is
|
||||
@@ -194,9 +200,10 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
* ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable``
|
||||
(header only — the body is never read).
|
||||
* the known HTML pages (``HTML_PAGES``) — ``Cache-Control: no-cache``,
|
||||
and (for ``text/html`` bodies) every local asset reference gains
|
||||
``?v=<token>``.
|
||||
* the known HTML pages (``HTML_PAGES``) plus the dynamic share page
|
||||
``/shared/<token>`` (phase 51, by path prefix) —
|
||||
``Cache-Control: no-cache``, and (for ``text/html`` bodies) every
|
||||
local asset reference gains ``?v=<token>``.
|
||||
|
||||
Everything else — all of ``/api/*`` (including the SSE chat stream) —
|
||||
passes through byte-identical: no header changes, the body stream is
|
||||
@@ -213,7 +220,14 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
response.headers["Cache-Control"] = ASSET_CACHE_CONTROL
|
||||
return response
|
||||
|
||||
if path not in HTML_PAGES:
|
||||
# Phase 51: the dynamic share page — ``/shared/<token>`` is a
|
||||
# REAL route (not a static file) serving ``shared.html``, so it
|
||||
# joins the known-page contract by path prefix: no-cache +
|
||||
# ``?v=`` asset rewrite. (``/api/shared/<token>`` — the JSON
|
||||
# read — starts with ``/api/`` and passes through below.)
|
||||
is_known_page = path in HTML_PAGES or path.startswith("/shared/")
|
||||
|
||||
if not is_known_page:
|
||||
# /api/* (incl. SSE), /favicon.ico, unknown paths: untouched.
|
||||
return response
|
||||
|
||||
|
||||
+14
-1
@@ -21,7 +21,15 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.chat import router as chat_router
|
||||
from app.api.chats import router as chats_router
|
||||
from app.api.chats import (
|
||||
public_router as chats_public_router,
|
||||
)
|
||||
from app.api.chats import (
|
||||
router as chats_router,
|
||||
)
|
||||
from app.api.chats import (
|
||||
shared_page_router as chats_shared_page_router,
|
||||
)
|
||||
from app.api.config import router as config_router
|
||||
from app.api.docs import router as docs_router
|
||||
from app.api.git_sources import router as git_sources_router
|
||||
@@ -73,6 +81,11 @@ def create_app() -> FastAPI:
|
||||
app.include_router(steering_router, prefix="/api")
|
||||
app.include_router(sync_router, prefix="/api")
|
||||
app.include_router(chats_router, prefix="/api")
|
||||
# Phase 51: the anonymous shared-chat read — NO admin dependency.
|
||||
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
|
||||
# page route below, registered without a prefix) is the page.
|
||||
app.include_router(chats_public_router, prefix="/api")
|
||||
app.include_router(chats_shared_page_router) # no prefix — /shared/<token>
|
||||
|
||||
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
|
||||
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
|
||||
|
||||
+13
-3
@@ -21,7 +21,9 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
|
||||
explicitly Saved conversation (auto-``title`` +
|
||||
the ``bor.chat.v1`` message list as JSONB, phase
|
||||
14 shape) — phase 50; ``/api/chat`` stays
|
||||
stateless.
|
||||
stateless; ``share_token`` (NULL = private,
|
||||
``uuid4`` = publicly readable at
|
||||
``/shared/<token>``) — phase 51.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -178,8 +180,7 @@ class SavedChat(Base):
|
||||
saved. ``messages`` holds the exact ``bor.chat.v1`` localStorage
|
||||
record shape (phase 14 — raw text, never HTML), so a saved chat
|
||||
restores pixel-identical through the existing
|
||||
``renderStoredMessage`` path. No share-related columns here —
|
||||
phase 51 adds ``share_token`` in migration 0009.
|
||||
``renderStoredMessage`` path.
|
||||
"""
|
||||
|
||||
__tablename__ = "saved_chats"
|
||||
@@ -193,6 +194,15 @@ class SavedChat(Base):
|
||||
#: tools?, stopped?}``); the API always supplies a list, so no
|
||||
#: default is needed.
|
||||
messages: Mapped[list] = mapped_column(JSONB)
|
||||
#: Anonymous share link (phase 51): a 128-bit ``uuid4`` token; when
|
||||
#: set, the chat is publicly readable at ``/shared/<token>`` without
|
||||
#: any admin session, and unsharing (token → NULL) revokes it.
|
||||
#: Unique — Postgres treats NULLs as distinct under a unique index
|
||||
#: (the ``git_sources.path`` precedent, phase 38), so any number of
|
||||
#: unshared chats coexist while two identical tokens can never exist.
|
||||
share_token: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), unique=True, nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
|
||||
+99
-6
@@ -3,9 +3,16 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
SerializerFunctionWrapHandler,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
)
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
@@ -324,16 +331,26 @@ class ChatMessage(BaseModel):
|
||||
|
||||
|
||||
class SavedChatCreate(BaseModel):
|
||||
"""``POST /api/chats`` body (phase 50, task 02).
|
||||
"""``POST /api/chats`` body (phase 50, task 02; ``share``, phase 51
|
||||
task 02).
|
||||
|
||||
``title`` is optional: when absent or blank the API auto-titles the
|
||||
row (the first user message's text, whitespace-collapsed, truncated
|
||||
to 120 chars — the owner-locked convention). ``messages`` must be
|
||||
non-empty — a saved chat with nothing to restore is meaningless.
|
||||
|
||||
``share`` (phase 51, owner-locked 2026-08-29): when true, the row is
|
||||
shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on
|
||||
the fresh row before the INSERT, so one request saves AND shares
|
||||
(the chat page's Share button on an unsaved conversation, the
|
||||
save-then-share contract). The response then carries ``share_url``
|
||||
(see :class:`SavedChatOut`). Default false — a plain Save is
|
||||
unchanged by phase 51.
|
||||
"""
|
||||
|
||||
title: str | None = Field(default=None, max_length=500)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
share: bool = False
|
||||
|
||||
|
||||
class SavedChatUpdate(BaseModel):
|
||||
@@ -349,11 +366,35 @@ class SavedChatUpdate(BaseModel):
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
|
||||
|
||||
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
|
||||
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat
|
||||
exposes no share surface at all, and the History column renders the
|
||||
unshared state from the key's absence.
|
||||
|
||||
A ``mode="wrap"`` model serializer: the default (recursive) dump runs
|
||||
first, then only the TOP-LEVEL key is dropped when null. The
|
||||
recursion matters — a route-level ``response_model_exclude_none``
|
||||
would also drop the nested ``ChatMessage`` nulls (``sources: null``
|
||||
and friends), which the byte-identical round-trip contract (phase
|
||||
50) forbids.
|
||||
"""
|
||||
data = handler(model)
|
||||
if data.get("share_url") is None:
|
||||
data.pop("share_url", None)
|
||||
return data
|
||||
|
||||
|
||||
class SavedChatOut(BaseModel):
|
||||
"""One saved chat, full payload (create/get/put response, phase 50).
|
||||
"""One saved chat, full payload (create/get/put response, phase 50;
|
||||
``share_url``, phase 51 task 02).
|
||||
|
||||
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
|
||||
— the restore path is pixel-identical by construction.
|
||||
|
||||
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
|
||||
shared, ABSENT from the JSON when unshared (``None`` → dropped by
|
||||
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
@@ -362,19 +403,33 @@ class SavedChatOut(BaseModel):
|
||||
updated_at: datetime
|
||||
message_count: int
|
||||
messages: list[ChatMessage]
|
||||
share_url: str | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
return _drop_absent_share_url(self, handler)
|
||||
|
||||
|
||||
class SavedChatRow(BaseModel):
|
||||
"""One row of ``GET /api/chats`` (the History page's list shape).
|
||||
"""One row of ``GET /api/chats`` (the History page's list shape,
|
||||
phase 50; ``share_url``, phase 51 task 02).
|
||||
|
||||
No payloads in the list — the row carries only what the table needs
|
||||
(Title, Messages count, Updated).
|
||||
(Title, Messages count, Updated). ``share_url`` is populated here so
|
||||
the History page's Share column renders straight from ``GET
|
||||
/api/chats`` — no second fetch per row (``None`` → absent, the same
|
||||
omission rule as :class:`SavedChatOut`).
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
updated_at: datetime
|
||||
message_count: int
|
||||
share_url: str | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
return _drop_absent_share_url(self, handler)
|
||||
|
||||
|
||||
class SavedChatList(BaseModel):
|
||||
@@ -382,3 +437,41 @@ class SavedChatList(BaseModel):
|
||||
(``updated_at desc, id desc``)."""
|
||||
|
||||
chats: list[SavedChatRow]
|
||||
|
||||
|
||||
class SharedChatOut(BaseModel):
|
||||
"""``GET /api/shared/{token}`` body (phase 51, task 01) — the PUBLIC
|
||||
read shape of a shared chat.
|
||||
|
||||
Deliberately minimal: ``title`` + ``messages`` only. No id, no
|
||||
timestamps, no token, no ``message_count`` — a shared chat is a
|
||||
content snapshot, not a handle: nothing in the body can be turned
|
||||
back into an admin-surface request, and the token itself never
|
||||
round-trips (it is the URL, not data).
|
||||
"""
|
||||
|
||||
title: str
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
class ShareOut(BaseModel):
|
||||
"""``POST /api/chats/{chat_id}/share`` response (phase 51, task 01).
|
||||
|
||||
``share_url`` is the path (``/shared/<token>``) the UI copies into
|
||||
the clipboard — the owner's own origin supplies the scheme/host.
|
||||
Idempotent: a re-share returns the existing, unchanged token.
|
||||
"""
|
||||
|
||||
chat_id: uuid.UUID
|
||||
share_url: str
|
||||
|
||||
|
||||
class UnshareOut(BaseModel):
|
||||
"""``POST /api/chats/{chat_id}/unshare`` response (phase 51, task 01).
|
||||
|
||||
``shared: false`` is reported unconditionally — the endpoint is
|
||||
idempotent, so an already-unshared chat unshares cleanly (200).
|
||||
"""
|
||||
|
||||
chat_id: uuid.UUID
|
||||
shared: bool
|
||||
|
||||
@@ -155,6 +155,28 @@
|
||||
* the error banner. Phase 14's local persistence is untouched: saving is
|
||||
* an additional, explicit action.
|
||||
*
|
||||
* Share the conversation (phase 51, owner-locked 2026-08-29, TODO.md
|
||||
* L6): the "Share" pill (#share-chat-btn — admin-only, SHIPS HIDDEN,
|
||||
* revealed at boot in the SAME admin-reveal block as Save) turns the
|
||||
* CURRENT conversation into a public read-only link (/shared/<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.
|
||||
*/
|
||||
|
||||
@@ -178,6 +200,7 @@ const banner = document.querySelector("#kb-banner");
|
||||
const bannerText = document.querySelector("#kb-banner-text");
|
||||
const versionEl = document.querySelector("#app-version");
|
||||
const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden)
|
||||
const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden)
|
||||
|
||||
/* Phase 39: the display name resolves from one place — window.BOR_BRAND
|
||||
* (the classic assets/brand.js sets it at parse time; its /api/config
|
||||
@@ -1135,6 +1158,116 @@ async function saveCurrentChat() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- share the conversation (phase 51, owner-locked 2026-08-29) ---------- */
|
||||
|
||||
/* The share link's ABSOLUTE URL: the API reports the PATH
|
||||
* (/shared/<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.
|
||||
Phase 17: meta.thinking and phase 37: meta.tools are optional —
|
||||
`undefined` drops the key from the JSON, so turns without them persist
|
||||
@@ -1550,6 +1683,9 @@ composer.addEventListener("submit", handleSend);
|
||||
* IIFE below reveals it for admin (absent-not-hidden for anonymous,
|
||||
* phase 16). Status-only feedback — the live region, never stale. */
|
||||
saveBtn?.addEventListener("click", saveCurrentChat);
|
||||
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill —
|
||||
* same ship-hidden/reveal contract as Save (the boot IIFE below). */
|
||||
shareBtn?.addEventListener("click", shareCurrentChat);
|
||||
|
||||
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
|
||||
* leaving the chat mid-turn would otherwise drop the in-flight
|
||||
@@ -1585,6 +1721,7 @@ window.addEventListener("pagehide", () => {
|
||||
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
|
||||
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
|
||||
if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16)
|
||||
if (shareBtn) shareBtn.hidden = !isAdmin; // phase 51: the Share pill joins the same block
|
||||
// Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved
|
||||
// conversation; every other outcome falls through to the local restore.
|
||||
const openedSaved = await restoreSavedChatFromUrl();
|
||||
|
||||
@@ -106,9 +106,20 @@ export async function initSharedHeader() {
|
||||
// a query-safe "/…" string (never "//"; ? # and spaces stay
|
||||
// percent-encoded in it), so it rides in next= as-is — the same shape
|
||||
// the static fallbacks use (login.js safeNext re-validates it).
|
||||
//
|
||||
// Phase 51 (owner-locked 2026-08-29, TODO.md L6): the ONE exception —
|
||||
// on the NESTED /shared/<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 => {
|
||||
link.hidden = admin;
|
||||
link.href = "/login.html?next=" + (window.location.pathname || "/");
|
||||
link.href = "/login.html?next=" + signInNext;
|
||||
});
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
|
||||
+211
-5
@@ -3,21 +3,40 @@
|
||||
* TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
|
||||
* history in a new page, then return to that history with a click."
|
||||
*
|
||||
* Wires the admin-only `GET /api/chats` + `DELETE /api/chats/<id>`
|
||||
* endpoints (phase 50 task 02) into the page's full-width table:
|
||||
* Wires the admin-only `GET /api/chats` + `POST /api/chats/<id>/share`
|
||||
* + `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
|
||||
* ("return to that history with a click") — the chat page boots
|
||||
* into the saved conversation through ?chat= (task 03);
|
||||
* • Messages — the row's message_count;
|
||||
* • Updated — locale date+time, the full ISO in the title attribute;
|
||||
* • Actions — Delete ONLY (phase 51 adds the share column), inline
|
||||
* TWO-STEP confirm (owner-locked 2026-08-29: no native confirm
|
||||
* dialog anywhere in this file) — the first click swaps the button for
|
||||
* • Share — phase 51 (owner-locked 2026-08-29, TODO.md L6): the
|
||||
* row's share state, rendered from the list's OWN share_url (the
|
||||
* GET /api/chats endpoint populates it — no second fetch per row).
|
||||
* Three states: unshared → [Create link] (POST share → the cell
|
||||
* re-renders shared + the link is offered for copying); shared →
|
||||
* [Copy] [Unshare]; confirming → the inline two-step "Unshare?
|
||||
* [Yes] [No]" (the phase-50 Delete-confirm pattern + CSS — no
|
||||
* native dialog) — Yes POSTs /api/chats/<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
|
||||
* keyboard-reachable), Yes fires the DELETE and removes the row,
|
||||
* No (or a failed request) keeps it.
|
||||
*
|
||||
* The share copy has the owner-locked inline-link fallback: a
|
||||
* non-secure (http) homelab origin rejects navigator.clipboard, so the
|
||||
* offer renders a transient .share-link-fallback <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
|
||||
* user-derived (the auto-title is the first question), so it NEVER
|
||||
* touches innerHTML (XSS-safe by construction, the sources.js house
|
||||
@@ -86,6 +105,13 @@ function makeRow(chat) {
|
||||
updatedTd.textContent = fmtDate(chat.updated_at);
|
||||
tr.appendChild(updatedTd);
|
||||
|
||||
// Phase 51: the Share cell (between Updated and Actions) — the
|
||||
// three-state share control (unshared / shared / confirming-unshare).
|
||||
const shareTd = document.createElement("td");
|
||||
shareTd.className = "history-share-cell";
|
||||
shareTd.appendChild(makeShareControl(chat));
|
||||
tr.appendChild(shareTd);
|
||||
|
||||
const actionsTd = document.createElement("td");
|
||||
actionsTd.className = "history-actions-cell";
|
||||
actionsTd.appendChild(makeDeleteControl(chat, tr));
|
||||
@@ -169,6 +195,186 @@ async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
||||
announce(`Deleted "${chat.title}".`);
|
||||
}
|
||||
|
||||
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
|
||||
|
||||
/* Select every text node in the link field (an <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
|
||||
removed (the empty row itself ships in the tbody, hidden). */
|
||||
function showEmptyIfLast() {
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -338,6 +338,37 @@ html::after {
|
||||
the whole control below 640px (mirrored in the ≤640 block below). */
|
||||
.save-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill —
|
||||
the EXACT visual family of .save-chat-btn (same declarations, so the
|
||||
chat-shell actions always read as a pair): solid brand pill, --bg
|
||||
text on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover
|
||||
lightens the brand fill, focus-visible via the global 3px rule.
|
||||
Shipped hidden in index.html — app.js reveals it for admin only
|
||||
(phase 16 absent-not-hidden); ≤640px overrides below mirror the
|
||||
Save ones (label stays visible in .chat-shell, icon hidden there). */
|
||||
.share-chat-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 0;
|
||||
background: var(--brand);
|
||||
color: var(--bg);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.share-chat-btn:hover { background: #f55a72; color: var(--bg); }
|
||||
/* The link mark is hidden on desktop (the label carries the pill); it
|
||||
is the whole control below 640px (mirrored in the ≤640 block
|
||||
below). */
|
||||
.share-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* Phase 16: header auth controls (Sign in link / Sign out button) — the
|
||||
same ghost pill as New chat, so the bar keeps one visual language.
|
||||
ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1.
|
||||
@@ -1897,6 +1928,60 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-confirm-no:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
/* Share column (phase 51, owner-locked 2026-08-29): the Tune/Retry-
|
||||
family ghost buttons — Create link (unshared) and Copy (shared);
|
||||
Unshare is the .history-unshare ghost and its two-step confirm reuses
|
||||
the .history-confirm-* pair CSS above (the phase-50 pattern). The
|
||||
row-action language of .history-delete: --line border, transparent
|
||||
fill, ink-soft (>=5.1:1), ≥44px comfortable target, focus-visible
|
||||
via the global 3px rule; hover takes the brand pair (6.9:1). */
|
||||
.history-share { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; }
|
||||
.history-share-cell { white-space: nowrap; }
|
||||
.history-share-create,
|
||||
.history-share-copy,
|
||||
.history-unshare {
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-share-create:hover:not(:disabled),
|
||||
.history-share-copy:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.history-unshare:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.history-share-create:disabled,
|
||||
.history-share-copy:disabled,
|
||||
.history-unshare:disabled { opacity: 0.5; cursor: wait; }
|
||||
/* The share link's inline fallback field (phase 51, owner-locked): a
|
||||
non-secure (http) origin rejects the clipboard, so the full URL is
|
||||
offered as an input-like <a> that selects itself on focus — click or
|
||||
Tab, then Ctrl/Cmd+C. Mono (the URL is data), surface fill, --line
|
||||
border; truncates with an ellipsis at narrow widths (the full URL is
|
||||
the title + the text selection). Ink on surface ≈13.8:1. */
|
||||
.share-link-fallback {
|
||||
display: inline-block;
|
||||
max-width: 14rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.share-link-fallback:hover { border-color: var(--brand); }
|
||||
.share-link-fallback:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
|
||||
/* Empty-state row: the muted centered message at full table width
|
||||
(the .git-sources-empty language, inline in the table). */
|
||||
.history-empty-row td {
|
||||
@@ -1906,6 +1991,61 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Shared page (phase 51, task 03) ----------
|
||||
/shared/<token>: the anonymous read-only conversation (owner-locked
|
||||
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
|
||||
46rem chat column — the conversation reads exactly like the chat
|
||||
page (the .msg/.bubble/.thinking/.tool-calls/.msg-meta rules apply
|
||||
unchanged) with NO composer, so the column contract holds for a
|
||||
guest. Zero interactive controls (owner-locked): the chips are
|
||||
plain text, so the pill families' pointer treatments are switched
|
||||
off IN THIS SCOPE ONLY — the chat page's interactive chips keep
|
||||
their styles untouched. Every pair reuses the Phase-08 AA palette
|
||||
(ink-soft ≥6.9:1 on surface/bg, brand-ink on brand-soft 6.9:1);
|
||||
:focus-visible via the global 3px outline rule. No CDN, system
|
||||
fonts. */
|
||||
.shared-shell {
|
||||
width: 100%;
|
||||
max-width: 46rem; /* the PLAN §7 centered chat column */
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
/* Page title (JS-filled with the shared chat's title; the static
|
||||
fallback is "Shared conversation") — the page-head h1 size. */
|
||||
#shared-title { margin: 0; font-size: 1.7rem; }
|
||||
/* The muted meta line under the h1 ("Shared via … — read-only."):
|
||||
ink-soft on the page bg ≥8.6:1, the page-sub language. */
|
||||
.shared-note {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
/* The invalid / revoked state: a centered muted card (the not-found
|
||||
language — surface fill, --line border, italic ink-soft). Revealed
|
||||
by shared.js for a malformed token (no fetch) or a failed read. */
|
||||
#shared-invalid {
|
||||
padding: 1.4rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
font-style: italic;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
/* Static chips: a guest's "Maybe try" and source chips are TEXT — no
|
||||
pointer, no hover, no cursor (owner-locked zero controls). The pill
|
||||
look is kept; the interactivity is scoped off here and only here
|
||||
(pointer-events: none also makes the :hover rules unreachable). */
|
||||
.shared-shell .suggestion-chip,
|
||||
.shared-shell .source-chip {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
|
||||
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
|
||||
rows in one sticky <header> — row 1 reuses the standard .app-header /
|
||||
@@ -2445,12 +2585,19 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.save-chat-btn { padding: 0.4rem 0.3rem; }
|
||||
.save-chat-label { display: none; }
|
||||
.save-chat-btn svg { display: block; }
|
||||
/* Phase 51: the Share pill squeezes with Save (same family, same
|
||||
rules — the chat-shell overrides below keep both labels visible). */
|
||||
.share-chat-btn { padding: 0.4rem 0.3rem; }
|
||||
.share-chat-label { display: none; }
|
||||
.share-chat-btn svg { display: block; }
|
||||
/* But on the chat page there is room — keep the label visible and
|
||||
hide the icon (the button lives inside .chat-shell, not the navbar). */
|
||||
.chat-shell .new-chat-label { display: inline; }
|
||||
.chat-shell .new-chat-btn svg { display: none; }
|
||||
.chat-shell .save-chat-label { display: inline; }
|
||||
.chat-shell .save-chat-btn svg { display: none; }
|
||||
.chat-shell .share-chat-label { display: inline; }
|
||||
.chat-shell .share-chat-btn svg { display: none; }
|
||||
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||
.auth-link { padding: 0.4rem 0.3rem; }
|
||||
@@ -2564,6 +2711,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
the two-step confirm pair fits the phone width. */
|
||||
.history-actions-cell { white-space: normal; }
|
||||
.history-actions { flex-wrap: wrap; }
|
||||
/* Phase 51: the shared page squeezes like the chat column — the
|
||||
title and the note step down (the empty-state-title family); the
|
||||
shell keeps its 46rem column (it is already the narrowest box on
|
||||
the page) and .msg-body's 92% override above applies. */
|
||||
#shared-title { font-size: 1.35rem; }
|
||||
.shared-note { font-size: 0.88rem; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
/* Sync button goes icon-only on mobile; the label hides, aria-label
|
||||
|
||||
@@ -135,7 +135,9 @@
|
||||
|
||||
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
|
||||
list): Title (the Open link → /?chat=<id>) | Messages |
|
||||
Updated | Actions (Delete, inline two-step confirm).
|
||||
Updated | Share (phase 51: Create link / Copy / Unshare —
|
||||
the row's share_url comes from GET /api/chats itself, no
|
||||
second fetch) | Actions (Delete, inline two-step confirm).
|
||||
history.js fills #history-tbody; #history-empty-row ships
|
||||
hidden and is revealed by a 0-row fetch. The Actions column
|
||||
header is visually-hidden — the row buttons carry their own
|
||||
@@ -148,12 +150,13 @@
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Messages</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Share</th>
|
||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
<tr class="history-empty-row" id="history-empty-row" hidden>
|
||||
<td colspan="4">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
|
||||
<td colspan="5">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -134,6 +134,29 @@
|
||||
<span class="save-chat-label">Save</span>
|
||||
</button>
|
||||
|
||||
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
|
||||
turns the current conversation into a PUBLIC read-only link —
|
||||
/shared/<token> (a 128-bit uuid4 on the saved_chats row,
|
||||
migration 0009; the anonymous page is task 03). The
|
||||
save-then-share contract: an UNSAVED (unlinked) conversation
|
||||
is saved AND shared in ONE action — app.js POSTs /api/chats
|
||||
with { messages, share: true } (the server sets the token in
|
||||
the same commit) and links the conversation to the created
|
||||
row; a saved (linked) one just POSTs /api/chats/<id>/share
|
||||
(idempotent — the existing token comes back unchanged). On
|
||||
success the ABSOLUTE link is copied to the clipboard; a
|
||||
non-secure (http) homelab origin that rejects the clipboard
|
||||
gets the inline link-field fallback instead (owner-locked —
|
||||
app.js renders .share-link-fallback near the status line).
|
||||
Admin-only — ships HIDDEN exactly like Save (absent-not-
|
||||
hidden, phase 16); app.js reveals it at boot (the same
|
||||
admin-reveal block) and binds the click to shareCurrentChat.
|
||||
Unsharing lives on the History page's Share column (task 04). -->
|
||||
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<span class="share-chat-label">Share</span>
|
||||
</button>
|
||||
|
||||
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
|
||||
bubble can carry JS-injected actions (app.js) — Tune (admin
|
||||
only, phase 15) and Retry (every visitor; the LAST brain
|
||||
|
||||
@@ -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>
|
||||
@@ -16,14 +16,18 @@ mock LLM keeps the SSE check deterministic (no live aipi).
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from e2e.auth_helpers import login
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
CHAT_QUESTION = "How is my Kubernetes cluster set up?"
|
||||
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
|
||||
|
||||
|
||||
def _expected_token() -> str:
|
||||
@@ -44,6 +48,16 @@ def _version_token(url: str) -> str:
|
||||
return url.rsplit("?v=", 1)[1]
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
"""The signed session cookies the browser holds after a form login
|
||||
— used to call the admin API with plain httpx."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
|
||||
"""Minimal SSE chat request (same pattern as ``test_chat_rag.py``):
|
||||
POST /api/chat and collect the ``data:`` frames until the stream ends."""
|
||||
@@ -123,6 +137,62 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
|
||||
assert sources_token == login_token == history_token == token
|
||||
|
||||
|
||||
def test_shared_page_is_no_cache_and_versioned(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""`/shared/<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:
|
||||
"""`/api/*` passes through untouched: no injected Cache-Control on the
|
||||
health endpoint, and the SSE chat stream still streams to done."""
|
||||
|
||||
@@ -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}"
|
||||
@@ -89,6 +89,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
|
||||
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
|
||||
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
|
||||
("/history.html", "Saved chats"), # phase 50: admin saved-chats page
|
||||
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
|
||||
],
|
||||
)
|
||||
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
|
||||
@@ -126,7 +127,8 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/sources.html", "/document.html", "/login.html", "/tuning.html",
|
||||
"/git-sources.html", "/history.html"], # phase 50: + the History page
|
||||
"/git-sources.html", "/history.html", # phase 50: + the History page
|
||||
"/shared.html"], # phase 51: + the anonymous shared page
|
||||
)
|
||||
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
"""Each of the other four pages revalidates and carries at least one
|
||||
@@ -179,6 +181,7 @@ def test_styles_and_js_served(client) -> None:
|
||||
assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module
|
||||
assert client.get("/assets/tuning.js").status_code == 200 # phase 27: tuning page
|
||||
assert client.get("/assets/git-sources.js").status_code == 200 # phase 35: git sources page
|
||||
assert client.get("/assets/shared.js").status_code == 200 # phase 51: shared page module
|
||||
|
||||
|
||||
# Emoji code points banned from UI chrome (phase 08): the pictograph
|
||||
@@ -218,6 +221,8 @@ def _find_emoji(text: str) -> list[str]:
|
||||
"/assets/login.js", # phase 16
|
||||
"/assets/document-modal.js", # phase 26: the document modal module
|
||||
"/assets/git-sources.js", # phase 35: the git sources page module
|
||||
"/shared.html", # phase 51: the anonymous shared page
|
||||
"/assets/shared.js", # phase 51: the shared page module
|
||||
"/assets/styles.css",
|
||||
],
|
||||
)
|
||||
@@ -234,7 +239,7 @@ def test_ui_chrome_has_no_emoji(client, path: str) -> None:
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
if path == "/assets/app.js":
|
||||
if path in ("/assets/app.js", "/assets/shared.js"):
|
||||
text = text.replace('"🔎 Listing documents"', "")
|
||||
text = text.replace('"📄 Reading "', "")
|
||||
assert _find_emoji(text) == [], f"emoji found in {path}: {_find_emoji(text)!r}"
|
||||
|
||||
@@ -16,10 +16,12 @@ Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -27,12 +29,21 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import SavedChat
|
||||
|
||||
FIRST_QUESTION = "How did I install gitlab?"
|
||||
EXPLICIT_TITLE = "My backup notes"
|
||||
|
||||
#: The share link's shape: the page path + a canonical (lowercase) UUID.
|
||||
SHARE_URL_RE = re.compile(
|
||||
r"^/shared/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
)
|
||||
|
||||
#: The PUBLIC read's exact key set — no id, no timestamps, no token.
|
||||
SHARED_OUT_KEYS = {"title", "messages"}
|
||||
|
||||
#: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional
|
||||
#: key present; the round-trip test asserts it survives byte-identical.
|
||||
FULL_BRAIN: dict[str, Any] = {
|
||||
@@ -110,7 +121,11 @@ def test_anonymous_every_route_returns_403(client: TestClient) -> None:
|
||||
("GET", f"/api/chats/{unknown}", None),
|
||||
("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}),
|
||||
("DELETE", f"/api/chats/{unknown}", None),
|
||||
("POST", f"/api/chats/{unknown}/share", None),
|
||||
("POST", f"/api/chats/{unknown}/unshare", None),
|
||||
]
|
||||
# The public read is NOT in this list — it is anonymous by design
|
||||
# (a wrong token 404s there, it never 403s).
|
||||
for method, path, body in cases:
|
||||
r = anon.request(method, path, json=body)
|
||||
assert r.status_code == 403, f"{method} {path} must be 403 for anonymous"
|
||||
@@ -444,3 +459,291 @@ def test_delete_unknown_chat_returns_404(admin_client: TestClient) -> None:
|
||||
|
||||
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.delete("/api/chats/not-a-uuid").status_code == 422
|
||||
|
||||
|
||||
# ---------- share / unshare / public read (phase 51, task 01) ----------
|
||||
|
||||
|
||||
def _share(admin_client: TestClient, chat_id: str) -> dict[str, Any]:
|
||||
r = admin_client.post(f"/api/chats/{chat_id}/share")
|
||||
assert r.status_code == 200
|
||||
return r.json()
|
||||
|
||||
|
||||
def _stored_token(db: Session, chat_id: str) -> uuid.UUID | None:
|
||||
"""The row's ``share_token`` as seen by a fresh DB read."""
|
||||
row = db.get(SavedChat, uuid.UUID(chat_id))
|
||||
assert row is not None, "the chat row must exist"
|
||||
return row.share_token
|
||||
|
||||
|
||||
def test_share_returns_200_with_share_url_and_is_idempotent(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
|
||||
|
||||
body1 = _share(admin_client, created["id"])
|
||||
assert set(body1) == {"chat_id", "share_url"}
|
||||
assert body1["chat_id"] == created["id"]
|
||||
assert SHARE_URL_RE.fullmatch(body1["share_url"]), (
|
||||
f"share_url must be /shared/<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"]
|
||||
|
||||
@@ -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"
|
||||
@@ -17,6 +17,7 @@ import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
@@ -205,6 +206,7 @@ def test_html_pages_include_history() -> None:
|
||||
"/tuning.html",
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
"/shared.html", # phase 51: the shared page's static path
|
||||
):
|
||||
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
|
||||
|
||||
@@ -386,3 +388,67 @@ def test_read_body_drains_streaming_response() -> None:
|
||||
|
||||
resp = StreamingResponse(content=gen(), media_type="text/html")
|
||||
assert asyncio.run(caching._read_body(resp)) == b"<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}
|
||||
|
||||
@@ -29,6 +29,7 @@ HTML_PAGES = (
|
||||
"login.html",
|
||||
"git-sources.html",
|
||||
"history.html", # phase 50: the admin saved-chats page
|
||||
"shared.html", # phase 51: the anonymous shared-conversation page
|
||||
)
|
||||
|
||||
|
||||
@@ -138,9 +139,13 @@ def test_every_page_loads_brand_js_before_its_module_script() -> None:
|
||||
default must be set first)."""
|
||||
for page in HTML_PAGES:
|
||||
html = _text(FRONTEND / page)
|
||||
brand_idx = html.find('<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"')
|
||||
assert brand_idx != -1, f"{page}: brand.js must load"
|
||||
assert module_idx != -1, f"{page}: the page module must load"
|
||||
assert brand_idx < module_idx, (
|
||||
f"{page}: brand.js must load BEFORE the module script"
|
||||
|
||||
@@ -42,8 +42,11 @@ FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
#: The six pages of the app (phase 46: the shared bar contract extends to
|
||||
#: the phase-35 git-sources page — the hamburger is part of that bar).
|
||||
#: The app's pages (phase 46: the shared bar contract extends to the
|
||||
#: 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 = (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "sources.html",
|
||||
@@ -51,6 +54,8 @@ PAGES = (
|
||||
FRONTEND / "git-sources.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "tuning.html",
|
||||
FRONTEND / "history.html",
|
||||
FRONTEND / "shared.html",
|
||||
)
|
||||
|
||||
NAV_TAG = '<nav class="app-nav" id="app-nav" aria-label="Primary">'
|
||||
|
||||
+199
-12
@@ -39,11 +39,13 @@ TUNING_HTML = FRONTEND / "tuning.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
HISTORY_HTML = FRONTEND / "history.html"
|
||||
SHARED_HTML = FRONTEND / "shared.html" # phase 51: the anonymous shared page
|
||||
HISTORY_JS = ASSETS / "history.js"
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
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 = (
|
||||
INDEX_HTML,
|
||||
SOURCES_HTML,
|
||||
@@ -52,6 +54,7 @@ ALL_PAGES = (
|
||||
DOCUMENT_HTML,
|
||||
LOGIN_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
|
||||
one ``id="nav-history"`` per page, seven pages, no duplicates and no
|
||||
extra page that forgot (or added twice)."""
|
||||
one ``id="nav-history"`` per page, eight pages (phase 51: + the
|
||||
shared page), no duplicates and no extra page that forgot (or
|
||||
added twice)."""
|
||||
total = 0
|
||||
for html in sorted(FRONTEND.glob("*.html")):
|
||||
count = html.read_text(encoding="utf-8").count('id="nav-history"')
|
||||
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
|
||||
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:
|
||||
@@ -173,27 +177,35 @@ def test_history_page_scaffold_and_landmarks() -> None:
|
||||
|
||||
|
||||
def test_history_table_skeleton() -> None:
|
||||
"""The table skeleton: ``.history-table`` with the four columns —
|
||||
Title | Messages | Updated | Actions (the Actions header text is
|
||||
visually-hidden — the row buttons carry their own aria-labels) —
|
||||
and the empty-state row (ship-hidden, the exact copy)."""
|
||||
"""The table skeleton: ``.history-table`` with the five columns —
|
||||
Title | Messages | Updated | Share (phase 51) | Actions (the
|
||||
Actions header text is visually-hidden — the row buttons carry
|
||||
their own aria-labels) — and the empty-state row (ship-hidden, the
|
||||
exact copy)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
assert '<table class="history-table">' in html
|
||||
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
|
||||
# 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(
|
||||
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
|
||||
html,
|
||||
)
|
||||
assert actions_th, "the Actions column header must be visually-hidden text"
|
||||
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)
|
||||
assert row, "the empty-state row must ship in the skeleton"
|
||||
assert "hidden" 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 (
|
||||
"No saved chats yet — finish a conversation and press"
|
||||
" <strong>Save</strong> in the chat."
|
||||
@@ -426,3 +438,178 @@ def test_history_table_mobile_behavior() -> None:
|
||||
mbody = mobile.group(1)
|
||||
assert ".history-actions-cell { white-space: normal; }" 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"
|
||||
)
|
||||
|
||||
@@ -291,3 +291,159 @@ def test_no_cdn_added() -> None:
|
||||
"""AGENTS.md rule 6: the Save button adds no external script/link."""
|
||||
index = _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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
/login.html?next=<current pathname> (default "/") — the admin lands
|
||||
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)
|
||||
fn = js.find("function initSharedHeader")
|
||||
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 ?
|
||||
# # / spaces stay percent-encoded inside it; login.js safeNext
|
||||
# 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 ----------
|
||||
|
||||
@@ -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
|
||||
@@ -22,8 +22,9 @@ ASSETS = FRONTEND / "assets"
|
||||
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
|
||||
#: All seven pages carry the shared header block (phase 34's five pages
|
||||
#: + phase 35's git-sources page + phase 50's History page).
|
||||
#: 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 51's shared page).
|
||||
PAGES = (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "sources.html",
|
||||
@@ -32,6 +33,7 @@ PAGES = (
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "tuning.html",
|
||||
FRONTEND / "history.html",
|
||||
FRONTEND / "shared.html",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user