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,
)