phase: 91_admin_theme_tab
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All verification is complete — this pass needed no code changes. Final report:

**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**

- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
- No defects found; zero changes made — working tree left exactly as the task executors left it.
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
- Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
2026-09-09 17:22:24 -04:00
parent 3095c4c577
commit d22d260b8b
74 changed files with 4448 additions and 675 deletions
+75 -2
View File
@@ -28,6 +28,36 @@ outbound validators. The asymmetry is deliberate: a 304 on
a 304 on an HTML page is never safe — the served body depends on the
process token, which the validator ignores.
Phase 91 (task 02) — the pre-paint theme tag: in the SAME rewrite
branch, AFTER the ``?v=<token>`` asset rewrite, the effective
``ui_settings`` row (task 01's :func:`app.core.theming.effective_settings`
resolver — one short-lived session per response, NO process cache: the
owner changes the theme at runtime from the admin tab, so the next
request must see it without a restart, and a single-row SELECT is
negligible at homelab page traffic) is rendered as an inline
``<style id="bor-theme">:root{…}</style>`` and inserted immediately
BEFORE the first ``</head>`` (:func:`app.core.theming.inject_theme`),
so a themed deployment paints its palette on the FIRST paint — no red
flash, no pop-in. An unset/defaults deployment gets ``tag == ""`` —
the identity no-op — and serves the EXACT pre-phase-91 rewrite-only
bytes (the byte-identical contract, B4); a DB blip (or a pre-migration
boot) is the same no-op, the page never breaks. The asset rewrite is
untouched, and ``/api/*`` / ``/assets/*`` still pass through
byte-identical.
Phase 91 (task 05, defect fix) — the CSP extension: the phase-82
policy (A1, ``default-src 'self'`` with no ``style-src``) BLOCKS the
inline tag in every real browser, so a themed HTML page's response
also carries ``style-src 'self' 'sha256-<hash>'`` appended to the A1
string, where ``<hash>`` is the CSP3 hash of the EXACT tag content
(:func:`app.core.theming.theme_csp_hash`) — the current theme is the
only inline style ever permitted (no ``'unsafe-inline'``; a different
palette or any other inline style is still blocked). The untagged
response keeps the plain A1 string (the outer
:class:`~app.core.security_headers.SecurityHeadersMiddleware`
preserves a CSP an inner layer has already set), and no non-HTML
response ever gets the extension.
The token is computed **once per process** (``functools.cache``, i.e.
``lru_cache(maxsize=None)``) — zero per-request git/file cost. It changes
when a new commit lands (git path) or the frontend tree's mtimes/sizes
@@ -61,6 +91,9 @@ from starlette.requests import Request
from starlette.responses import Response
from app.config import get_settings
from app.core import theming
from app.core.security_headers import CSP
from app.db import SessionLocal
logger = logging.getLogger("app")
@@ -143,6 +176,7 @@ HTML_PAGES: tuple[str, ...] = (
"/git-sources.html", # phase 35: the admin git sources page
"/history.html", # phase 50: the admin saved-chats page
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
"/theme.html", # phase 91 task 04: the admin theme page (shell route)
# 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=
@@ -317,8 +351,35 @@ class CachingMiddleware(BaseHTTPMiddleware):
response.headers["Cache-Control"] = HTML_CACHE_CONTROL
return response
# Phase 91 (task 02): the pre-paint theme tag. One short-lived
# session per response (the sync-endpoint house pattern from
# app/db.py — the middleware world is sync); NO process cache —
# the theme changes at runtime from the admin tab, so the next
# request must see it without a restart. A DB blip (or a
# pre-migration boot) must never break the page: fall back to
# ``tag == ""`` (the built-in palette) and keep the no-cache
# contract (loadHealth house style).
tag = ""
try:
new_body = rewrite_asset_refs(body.decode("utf-8"), token).encode("utf-8")
db = SessionLocal()
try:
effective = theming.effective_settings(db)
finally:
db.close()
tag = theming.theme_style_tag(
{key: effective[key] for key in theming.COLOR_FIELDS}
)
except Exception:
logger.exception(
"cache busting: theme read failed for %s — serving without the theme tag",
path,
)
tag = ""
try:
new_body = theming.inject_theme(
rewrite_asset_refs(body.decode("utf-8"), token), tag
).encode("utf-8")
except Exception:
# The body IS buffered — re-serve the ORIGINAL bytes so a
# rewrite hiccup never loses the page.
@@ -329,10 +390,22 @@ class CachingMiddleware(BaseHTTPMiddleware):
headers=_no_cache_headers(response),
)
headers = _no_cache_headers(response)
if tag:
# Phase 91 (task 05): the inline tag needs a style-src
# exemption or the phase-82 CSP blocks it in the browser —
# the strictest one: a sha256 hash of the EXACT tag content
# (theming.theme_csp_hash), appended to the A1 string. The
# outer SecurityHeadersMiddleware preserves this (it only
# fills in a missing CSP); the untagged page keeps A1
# verbatim — byte- AND header-identical to pre-phase-91.
headers["Content-Security-Policy"] = (
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
)
return Response(
content=new_body,
status_code=response.status_code,
headers=_no_cache_headers(response),
headers=headers,
)
+16 -2
View File
@@ -55,7 +55,16 @@ class SecurityHeadersMiddleware:
Adds exactly three headers to every HTTP response:
* ``Content-Security-Policy``: the strict same-origin policy above
(``frame-ancestors 'none'`` → clickjacking closed, SEC-04);
(``frame-ancestors 'none'`` → clickjacking closed, SEC-04) —
EXCEPT when an inner layer has already set one: the phase-91
(task 05) pre-paint theme tag is an inline ``<style>`` that the
A1 policy would block in the browser, so the caching middleware
publishes, on themed HTML pages only, the A1 string with
``style-src 'self' 'sha256-<tag-content-hash>'`` appended (the
current theme is the only inline style ever permitted — no
``'unsafe-inline'``). A pre-existing CSP is that inner layer's
deliberate one and is preserved; every other response (including
every untagged page) gets the plain A1 string.
* ``X-Frame-Options: DENY`` — legacy no-framing fallback;
* ``X-Content-Type-Options: nosniff`` — MIME-confusion belt.
@@ -73,7 +82,12 @@ class SecurityHeadersMiddleware:
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableHeaders(scope=message)
headers["Content-Security-Policy"] = CSP
# Phase 91 (task 05): preserve a CSP an inner layer set
# (the caching middleware's theme-extended policy — see
# the class docstring); the A1 string covers every
# response without one.
if "content-security-policy" not in headers:
headers["Content-Security-Policy"] = CSP
headers["X-Frame-Options"] = "DENY"
headers["X-Content-Type-Options"] = "nosniff"
await send(message)
+207
View File
@@ -0,0 +1,207 @@
"""The built-in identity palette + the effective UI-settings resolver
(phase 91).
Single source of the built-in **identity** palette. Phase 62's
custom-CSS-file theming (an env var named a drop-in ``:root`` override
stylesheet that ``brand.js`` linked AFTER the boot fetch — the "red
first, then pop" the owner saw) is retired in this phase: task 03
deleted the env var, the example-stylesheet directory, and the link
insertion, and the admin Theme tab is now the only theming surface.
The contract that directory's authoring guide carried is re-homed here
(built-in table, the five contrast pairs, the never-white-on-brand
trap — see below), and the 8 variables + built-in values are the
authoritative table (the unit drift test parses
``frontend/assets/styles.css``'s ``:root`` and asserts equality, so
the two can never silently diverge).
The **8 identity variables** (bare names, README order) and their
built-in values (from ``frontend/assets/styles.css`` ``:root``):
=================== ========== =================================================
Variable Built-in Role
=================== ========== =================================================
``bg`` ``#0f0a0a`` page background (text on it: ``ink``)
``surface`` ``#1a0f0f`` cards, panels, code blocks (text: ``ink``)
``ink`` ``#f0e6e6`` primary text
``ink_soft`` ``#b8a8a8`` secondary text (5.1:1 on ``surface``)
``line`` ``#2d1a1a`` decorative 1px borders (no contrast duty)
``brand`` ``#f43f5e`` brand accent — buttons, links (text ON
it is the DARK ``bg`` ink)
``brand_soft`` ``#2d0a0a`` brand-tinted surface (chips, hover washes)
``brand_ink`` ``#fca5a5`` brand-tinted text (9.0:1 on ``surface``)
=================== ========== =================================================
The **semantic families are deliberately NOT identity** (B3,
owner-locked 2026-09-09): ``--accent-*`` (deflection amber), ``--ok-*``
(success green), ``--err-*`` (error red) encode *states*, are already AA
in the built-in theme, and are not configurable from the tab — a theme
that keeps them stays honest.
**The five contrast pairs** that must meet WCAG 2.1 AA (>= 4.5:1,
AGENTS.md rule 5) — the pairs the layout actually pairs: ``ink`` on
``bg``, ``ink`` on ``surface``, ``ink_soft`` on ``surface``, ``bg`` on
``brand`` (the text on brand buttons is the DARK background ink —
that is the pattern; never white on brand: white on the built-in
``#f43f5e`` is 3.7:1, it fails), and ``brand_ink`` on ``surface``. The
tab's client-side warnings (task 05) compute exactly these five ratios
against the values being saved; the built-in palette itself passes, so
the default deployment stays AA without any warning.
Effective-value resolution (:func:`effective_settings`) — the DB-over-
env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single
``ui_settings`` row (id 1, task 01) wins column-by-column when set; a
NULL/empty string column falls back to the ``BOR_`` env value, a NULL
color column to the built-in. ONE resolver is used by BOTH
``GET /api/ui-settings`` (the tab) and ``GET /api/config`` (the brand
layer), so the tab and the running UI can never disagree.
"""
from __future__ import annotations
import base64
import hashlib
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.models import UiSettings
#: The 8 built-in identity colors, keyed by BARE variable name (no ``--``)
#: in the themes-README order. Copied from ``frontend/assets/styles.css``
#: ``:root`` — the unit drift test (``tests/unit/test_theming.py``)
#: re-parses the stylesheet and asserts equality on every run.
BUILTIN_COLORS: dict[str, str] = {
"bg": "#0f0a0a",
"surface": "#1a0f0f",
"ink": "#f0e6e6",
"ink_soft": "#b8a8a8",
"line": "#2d1a1a",
"brand": "#f43f5e",
"brand_soft": "#2d0a0a",
"brand_ink": "#fca5a5",
}
#: The 8 color field names in the README's order (dicts preserve
#: insertion order) — used by the resolver, the API, and the
#: ``theme_style_tag`` renderer (task 02).
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
#: The 3 display strings the ``ui_settings`` row carries — env fallback
#: (B1: unlike the colors, the env vars stay the strings' default).
STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text")
def effective_settings(
session: Session, settings: Settings | None = None
) -> dict[str, str]:
"""Resolve the EFFECTIVE UI settings — DB-over-env / DB-over-built-in.
Reads the single ``ui_settings`` row (id 1) and merges it over the
defaults, column by column:
* **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``)
— the DB value when it is a non-empty string, else the env value
(``settings.app_name`` etc. — B1: the env vars stay the fallback);
* **colors** (the 8 :data:`COLOR_FIELDS`) — the DB value when not
``None``, else :data:`BUILTIN_COLORS` (B1: no env fallback for
colors — the built-in palette IS the default).
A missing row (``GET`` creates nothing) means "defaults" — the env
strings + the built-in palette. The ``settings`` parameter names the
env-fallback source explicitly (the routes pass their
dependency-injected instance so test overrides apply); ``None`` uses
the cached :func:`app.config.get_settings`. Returns all 11 keys.
"""
if settings is None:
settings = get_settings()
row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
effective: dict[str, str] = {}
for field in STRING_FIELDS:
value = getattr(row, field, None) if row is not None else None
effective[field] = value if isinstance(value, str) and value else getattr(settings, field)
for key in COLOR_FIELDS:
value = getattr(row, key, None) if row is not None else None
effective[key] = value if value is not None else BUILTIN_COLORS[key]
return effective
def theme_style_tag(colors: dict[str, str]) -> str:
"""The pre-paint inline theme tag (task 02's injection input).
``""`` when every color equals its built-in — the byte-identical
contract: an unset (or "defaults saved") deployment must serve
exactly the pre-phase-91 HTML, no ``<style>`` tag anywhere.
Otherwise one ``<style id="bor-theme">`` tag with ALL 8 variables in
:data:`COLOR_FIELDS` order (the non-overridden ones repeat their
built-in value — the tag is a complete ``:root`` override, so the
page never mixes partial palettes)::
<style id="bor-theme">:root{--bg:#0f0a0a;…;--brand-ink:#fca5a5}</style>
Pure function of its input — :func:`inject_theme` places it before
the first ``</head>`` of every served HTML page (the phase-91
pre-paint injection), so the themed deployment renders its palette
on the FIRST paint (no red flash, no pop-in).
"""
if all(colors[key] == BUILTIN_COLORS[key] for key in COLOR_FIELDS):
return ""
declarations = "".join(
f"--{key.replace('_', '-')}:{colors[key]};" for key in COLOR_FIELDS
)
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
def inject_theme(html: str, tag: str) -> str:
"""Insert ``tag`` immediately BEFORE the first ``</head>`` of
``html`` — the pure half of the phase-91 pre-paint injection.
The :class:`~app.core.caching.CachingMiddleware` (task 02) calls
this on every known HTML page's rewritten body, so the helper stays
pure (no DB, no app) and unit-testable on its own. Identity rules —
the byte-identical contract (B4, owner-locked 2026-09-09):
* ``tag == ""`` (an unset or "defaults saved" deployment —
:func:`theme_style_tag` returns exactly that) → ``html`` is
returned EXACTLY as passed in, byte for byte;
* no ``</head>`` occurrence → unchanged (nothing to anchor to);
* ``id="bor-theme"`` already present → unchanged (defensive
idempotence — the static files never contain the id, and one
body can never reach the helper twice, but the guarantee is free
for a pure function).
Otherwise the tag is placed with a leading newline (readable HTML)
immediately before the FIRST ``</head>`` — the browser meets the
complete ``:root`` override before it applies any stylesheet, so
the palette is live on the first paint.
"""
if not tag or "</head>" not in html or 'id="bor-theme"' in html:
return html
index = html.index("</head>")
return html[:index] + "\n" + tag + html[index:]
def theme_csp_hash(tag: str) -> str:
"""The CSP3 ``sha256-`` source expression for an inline theme tag.
Phase 91 (task 05 defect fix): the phase-82 CSP (A1 —
``default-src 'self'`` with no explicit ``style-src``) BLOCKS the
inline ``<style id="bor-theme">`` tag in every real browser
(``style-src`` falls back to ``default-src 'self'``), so the
pre-paint injection would be dead bytes in the served HTML. The
fix is the strictest one that works: the hashing source expression
of the tag's EXACT content (CSP3 §13.4 — the character data between
the tags; the rendered content carries no leading/trailing
whitespace, so no stripping applies). The caching middleware
publishes it on themed HTML pages only, as ``style-src 'self'
'sha256-…'`` appended to the A1 string — the current theme is the
only inline style ever permitted, and a different palette (or any
other inline style) is still blocked. No blanket
``'unsafe-inline'`` — the A1 posture holds everywhere else. Returns
``""`` for an empty tag (an unset/defaults deployment keeps the
plain A1 policy — the byte- AND header-identical contract).
"""
if not tag:
return ""
content = tag.split(">", 1)[1].rsplit("</style>", 1)[0]
digest = hashlib.sha256(content.encode("utf-8")).digest()
return "sha256-" + base64.b64encode(digest).decode("ascii")