**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`).
382 lines
15 KiB
Python
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)
|