Files
ducoterra b34b8ca672
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 182_verbatim_show_text
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**

- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
2026-10-15 08:33:12 -04:00

382 lines
15 KiB
Python

"""The admin Settings panel's read/write surface (phase 168, task 04).
``TODO.md`` L3: "…can edit anything in .env and save it to the
database. The database value should override the env var. The change
should take effect on clicking 'save'."
The whole router sits behind :func:`app.core.auth.require_admin`
(router-wide ``dependencies`` — the :mod:`app.api.ui_settings` pattern):
anonymous callers AND token users get 403 on every route (only the
admin retunes the deployment).
Routes (under ``/api`` via the ``main`` registration):
* ``GET /api/admin/settings`` — the whole catalogue: one item per
``Settings`` field, in ``GROUPS`` order (the stable order the E2E
pins against), each with its effective value in env-native string
form, a ``db``/``env``/``default`` source badge, and the three
capability flags. Reads only; creates nothing.
* ``PUT /api/admin/settings/{key}`` — set one value (the body's string
is stored as-is in ``app_settings``). Fail loud BEFORE the write
(LOCKED A5): the FULL candidate set (every overridable stored row +
this key/value, layered over the env base) is run through the
real ``Settings`` validators — a bad shape OR a cross-field rule
(e.g. ``lexical_support_floor`` above a DB-overridden
``relevance_threshold``) is a 422 that names the field and stores
NOTHING. Unknown key → 404; a non-overridable key (LOCKED A3) → 422
naming the env var; a ``docs_repo`` value with embedded credentials
(``://user:pass@``, the phase-173 leak class) → 422 pointing at the
``docs_user`` / ``docs_token`` fields (C4); a blank value for a
secret → 422 (an empty API key / session secret is a self-lockout;
``DELETE`` is the explicit clear). A successful write commits, calls
``invalidate_settings()`` (task 03 makes it live — no restart) and
returns the refreshed item.
* ``DELETE /api/admin/settings/{key}`` — reset to env: deletes the row
(idempotent — deleting an absent key is a success, not a 404),
invalidates, returns the item with ``source="env"``/``"default"``.
* ``DELETE /api/admin/settings`` — "Clear all overrides": deletes every
row, invalidates, ``{"cleared": rowcount}``.
Three invariants (the unit tests pin them):
1. **Validate-before-store** — the write path never persists a value
the ``Settings`` validators reject (A5);
2. **Masked secrets** (LOCKED A4) — a ``secret`` field reports
``masked``/``has_value`` only, never the plaintext, in no response
body; every write logs one line carrying the key and the action,
NEVER the value (AGENTS rule 10);
3. **Never write ``.env``** (LOCKED A1) — this module contains no file
writes, never shells out and never ``eval``s a value; the env file
stays the base, the DB row wins, the reset deletes the row.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any, Literal, get_origin
from fastapi import APIRouter, Depends, HTTPException
from pydantic import ValidationError
from sqlalchemy import delete, func, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.config import Settings, get_settings, invalidate_settings
from app.core.auth import require_admin
from app.core.settings_catalog import CATALOG, GROUPS, env_var
from app.core.settings_overlay import LIST_FIELDS, _coerce, enabled
from app.db import get_db
from app.models import AppSetting
from app.schemas import (
AdminSettingIn,
AdminSettingItem,
AdminSettingsCleared,
AdminSettingsOut,
AdminSettingsTotals,
)
logger = logging.getLogger("app.api.admin_settings")
router = APIRouter(
prefix="/admin/settings",
tags=["admin-settings"],
dependencies=[Depends(require_admin)], # phase 168: the settings panel is admin-only
)
def _field_type(key: str) -> Literal["int", "float", "bool", "str", "json"]:
"""The panel control kind for one field, from its annotation
(``bool`` before ``int`` — ``isinstance(True, int)`` is ``True``;
the one ``list[str]`` field is ``json``, K2)."""
annotation = Settings.model_fields[key].annotation
if get_origin(annotation) in (list, tuple):
return "json"
if annotation is bool:
return "bool"
if annotation is int:
return "int"
if annotation is float:
return "float"
return "str"
def _display(field_name: str, value: Any) -> str:
"""Stringify one effective value in the env-native string form
(D1: stored strings, one coercion place — the router's display is
the mirror of ``settings_overlay._coerce``): bool →
``"true"``/``"false"``, the one ``list[str]`` field → a JSON array
(what the env spelling would carry, K2), everything else
``str()``."""
if isinstance(value, bool):
return "true" if value else "false"
if field_name in LIST_FIELDS:
return json.dumps(value)
return str(value)
def _rows(db: Session) -> dict[str, str]:
"""``SELECT key, value FROM app_settings`` — the whole table in one
round trip (the ``ui_settings`` "display fact, never a query
surface" note)."""
return {key: value for key, value in db.execute(text("SELECT key, value FROM app_settings"))}
def _item(
key: str,
rows: dict[str, str],
effective: Settings,
) -> AdminSettingItem:
"""One catalogue entry for one field (the stable builder for all
three read paths — GET, the PUT/DELETE returns, and the error
paths' neighbours)."""
meta = CATALOG[key]
value = getattr(effective, key)
#: Only an OVERRIDABLE row counts as an override: a hand-inserted
#: ``database_url`` / ``embedding_dim`` row is never in effect
#: (the overlay drops it, LOCKED A3) — it is not an override.
has_override = meta.overridable and key in rows
#: ``source="db"`` means "in effect": a row the overlay actually
#: applies. With the kill switch off, no row is in effect, so the
#: badge falls back to env/default (the panel's ``overlay_enabled``
#: banner explains the rest — D6).
in_effect = has_override and enabled()
if in_effect:
source: str = "db"
elif os.environ.get(env_var(key)) is not None:
source = "env"
else:
source = "default"
if meta.secret:
# LOCKED A4 — write-only: the plaintext appears in NO response;
# the read side reports has_value (is a non-empty value in
# effect at all) and nothing else about it.
return AdminSettingItem(
key=key,
env_var=env_var(key),
group=meta.group,
label=meta.label,
type=_field_type(key),
secret=True,
restart_required=meta.restart_required,
overridable=meta.overridable,
source=source,
value=None,
masked=True,
has_value=bool(value),
has_override=has_override,
db_value=None,
default="",
)
return AdminSettingItem(
key=key,
env_var=env_var(key),
group=meta.group,
label=meta.label,
type=_field_type(key),
secret=False,
restart_required=meta.restart_required,
overridable=meta.overridable,
source=source,
value=_display(key, value),
masked=False,
has_value=bool(_display(key, value)),
has_override=has_override,
db_value=rows.get(key) if has_override else None,
default=_display(key, Settings.model_fields[key].default),
)
def _validate_candidate(candidate: dict[str, str]) -> None:
"""Validate the FULL candidate set before anything is stored
(LOCKED A5, D5): every overridable stored row + the new
key/value, layered over the env base, through the SAME coercion
(``settings_overlay._coerce`` — one place for the string →
field-value decision) and the REAL ``Settings`` validators. A bad
shape OR a cross-field rule is a 422 that names the field — and
nothing is written (the caller writes only on return)."""
kwargs: dict[str, Any] = {}
for key, raw in candidate.items():
try:
kwargs[key] = _coerce(key, raw)
except ValueError as exc:
raise HTTPException(
status_code=422, detail=f"invalid value for {key}: {exc}"
) from exc
try:
Settings(**kwargs)
except ValidationError as exc:
err = exc.errors()[0]
field = str(err["loc"][0])
raise HTTPException(
status_code=422, detail=f"invalid value for {field}: {err['msg']}"
) from exc
#: A git URL with embedded credentials (``://<user>:<pass>@``) — the
#: phase-173 leak class. Refused for ``docs_repo`` on the way in:
#: the credentials belong in ``docs_user`` / ``docs_token`` (C4).
_EMBEDDED_CREDS = re.compile(r"://[^/@:]+:[^@/]+@")
def _refuse_embedded_credentials(key: str, value: str) -> None:
"""The phase-173 guard (C4): a ``docs_repo`` PUT whose value embeds
``://<user>:<pass>@`` is a 422 that names the replacement — the
leak cannot be re-created from the UI. Only ``docs_repo`` is
guarded: ``ssh://`` URLs, local paths and clean ``https://`` URLs
pass untouched (a ``://user:pass@`` *subdomain* does not match —
the trailing ``@`` requirement does that job). A legacy ``.env``
value with embedded credentials is NOT a PUT — the task-04 startup
migration handles that path."""
if key == "docs_repo" and _EMBEDDED_CREDS.search(value):
raise HTTPException(
status_code=422,
detail=(
"docs_repo must not contain embedded credentials — "
"keep the URL clean and set them in the Docs user / "
"Docs token fields instead"
),
)
@router.get("", response_model=AdminSettingsOut)
def get_catalogue(
db: Session = Depends(get_db), # noqa: B008
) -> AdminSettingsOut:
"""The whole catalogue (one item per ``Settings`` field, ``GROUPS``
order — the stable order the E2E pins against).
Reads the table once, resolves the effective ``Settings`` through
``get_settings()`` (the overlay — DB-over-env, task 02), and
emits one item per field. GETs log nothing here: the phase-167
``ResponseBudgetMiddleware`` already writes the
``api method=… bytes=…`` line, and the whole catalogue must stay
under ``BOR_LOG_LARGE_RESPONSE_BYTES``' INFO bar (the whole catalogue
is ≈ 20 KB — verified in task 06 with the audit script).
"""
rows = _rows(db)
effective = get_settings()
items = [
_item(key, rows, effective)
for group in GROUPS
for key, meta in CATALOG.items()
if meta.group == group
]
return AdminSettingsOut(
items=items,
totals=AdminSettingsTotals(
count=len(items),
overridden=sum(1 for item in items if item.source == "db"),
),
overlay_enabled=enabled(),
)
@router.put("/{key}", response_model=AdminSettingItem)
def set_value(
key: str,
payload: AdminSettingIn,
db: Session = Depends(get_db), # noqa: B008
) -> AdminSettingItem:
"""Set one value (store it in ``app_settings`` — env-native string
form, D1), make it live (``invalidate_settings()`` — task 03) and
return the refreshed item.
The write path (LOCKED A5, A3, A4, C4): unknown key → 404; a
non-overridable key → 422 naming the env var (A3: the engine is
built from ``database_url`` — an override is a chicken-and-egg
brick; ``chunks.embedding`` is fixed at ``embedding_dim`` by A6,
so a dim override would corrupt retrieval); a ``docs_repo`` value
with embedded credentials → 422 pointing at the ``docs_user`` /
``docs_token`` fields (C4 — the phase-173 leak class, refused on
the way in); a blank value for a secret → 422 (a self-lockout;
``DELETE`` is the explicit clear); then the FULL candidate set is
validated before anything is stored (a 422 names the field and
writes nothing).
"""
meta = CATALOG.get(key)
if meta is None:
raise HTTPException(status_code=404, detail=f"Unknown setting {key}")
if not meta.overridable:
raise HTTPException(
status_code=422,
detail=f"{env_var(key)} is not settable from the database",
)
_refuse_embedded_credentials(key, payload.value)
if meta.secret and not payload.value.strip():
raise HTTPException(
status_code=422,
detail=(
f"{env_var(key)} must not be blank — use Reset to fall "
"back to the environment"
),
)
# The candidate set: the overridable stored rows + this key/value
# (the same drop rules ``settings_overlay.merge`` applies on the
# read side — an orphan / non-overridable row can never affect
# validation or the effective set).
rows = _rows(db)
candidate = {
k: v for k, v in rows.items() if k in CATALOG and CATALOG[k].overridable
}
candidate[key] = payload.value
_validate_candidate(candidate)
# The write: upsert on the PK (INSERT … ON CONFLICT (key) DO
# UPDATE — the row's updated_at moves with the save).
stmt = pg_insert(AppSetting).values(key=key, value=payload.value)
stmt = stmt.on_conflict_do_update(
index_elements=["key"],
set_={"value": stmt.excluded.value, "updated_at": func.now()},
)
db.execute(stmt)
db.commit()
# One line per write, never the value (AGENTS rule 10, A4).
logger.info("settings action=set key=%s status=200", key)
# Make it live (task 03): clear the cache + run the runtime reset
# hooks, then report the refreshed item from the fresh settings.
invalidate_settings()
return _item(key, _rows(db), get_settings())
@router.delete("/{key}", response_model=AdminSettingItem)
def reset_value(
key: str,
db: Session = Depends(get_db), # noqa: B008
) -> AdminSettingItem:
"""Reset one key to env (LOCKED A1: the reset is a row DELETE —
never an empty value, so the resolver never distinguishes "no
override" from "override to empty").
Idempotent: deleting a key with NO ROW is a success, not a 404
(the panel's per-row Reset is safe to double-click — the env
value is already in effect; an unknown *setting* key is a 404,
like the PUT — a typo in the key is a client bug, and the item
shape needs the catalog metadata, so "nothing to reset" can only
mean "the row is already gone"). A successful delete logs one
value-free line; a no-op double Reset logs nothing.
"""
if key not in CATALOG:
raise HTTPException(status_code=404, detail=f"Unknown setting {key}")
result = db.execute(
delete(AppSetting).where(AppSetting.key == key).returning(AppSetting.key)
)
db.commit()
if len(result.all()):
logger.info("settings action=clear key=%s", key)
invalidate_settings()
return _item(key, _rows(db), get_settings())
@router.delete("", response_model=AdminSettingsCleared)
def reset_all(db: Session = Depends(get_db)) -> AdminSettingsCleared: # noqa: B008
"""The panel's "Clear all overrides": delete every row (the env
values win again for every setting), invalidate (task 03) and
report how many rows went."""
result = db.execute(delete(AppSetting).returning(AppSetting.key))
db.commit()
count = len(result.all())
logger.info("settings action=clear_all count=%d", count)
invalidate_settings()
return AdminSettingsCleared(cleared=count)