From 2d9dda6084a972c9a918c3abd2701e42a53c3d42 Mon Sep 17 00:00:00 2001 From: Chelsea Kelly-Reif <3114598+ChelseaKR@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:11:47 -0700 Subject: [PATCH] Package the production stylesheet --- .github/workflows/release.yml | 13 +++++++++++++ CHANGELOG.md | 3 +++ infra/Dockerfile | 5 +++-- pyproject.toml | 6 ++++++ src/ledger/server.py | 22 +++++++++++++++++----- tests/test_server_remediation.py | 16 ++++++++++++++++ 6 files changed, 58 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 143da17..87744f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,6 +126,19 @@ jobs: pip install build - name: Build sdist and wheel (hatchling) run: python -m build + - name: Wheel contains the public stylesheet + run: | + python - <<'PY' + import glob + import zipfile + + wheel = glob.glob("dist/*.whl") + if len(wheel) != 1: + raise SystemExit(f"expected one wheel, found {wheel}") + with zipfile.ZipFile(wheel[0]) as archive: + if "ledger/static/app.css" not in archive.namelist(): + raise SystemExit("wheel is missing ledger/static/app.css") + PY # Integrity: a tag that doesn't match the package's own declared version is # almost always a cut-from-the-wrong-commit mistake, not an intentional # release — fail loudly instead of publishing a mislabeled artifact. diff --git a/CHANGELOG.md b/CHANGELOG.md index ac5be47..24aa713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,6 +153,9 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **Missing production stylesheet (2026-07-12).** Package `web/static/app.css` + inside wheel and container installs so `/static/app.css` no longer returns 404 + when the server runs outside a source checkout. - **Cloud-init secret tracing (SEV2, 2026-07-12).** Removed shell xtrace from AWS first-boot provisioning after the initial synthetic demo deploy revealed that expanded secret assignments reached the IAM-restricted EC2 console log. diff --git a/infra/Dockerfile b/infra/Dockerfile index 6877148..28724bd 100644 --- a/infra/Dockerfile +++ b/infra/Dockerfile @@ -42,8 +42,9 @@ WORKDIR /app # for this Dockerfile is the repository root (see docker-compose.yml `build`). COPY pyproject.toml README.md LICENSE ./ COPY src ./src -# The browse server serves the bundled, framework-free web assets (web/static), -# resolved relative to the installed package's parent. Ship them in the image. +# Keep the source web tree in the build context for documentation and source-form +# use. Hatch also embeds app.css under ledger/static in the installed wheel, which +# is the path the production server uses. COPY web ./web RUN pip install . \ && rm -rf /root/.cache diff --git a/pyproject.toml b/pyproject.toml index 34b1865..016e7b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,12 @@ ledger = "ledger.cli:main" [tool.hatch.build.targets.wheel] packages = ["src/ledger"] +# The public server links this stylesheet at /static/app.css. Ship it inside the +# Python package so wheel installs and containers do not depend on the repository +# layout that happened to exist while the artifact was built. +[tool.hatch.build.targets.wheel.force-include] +"web/static/app.css" = "ledger/static/app.css" + # SEC-13/CQ-09/CQ-27: dev tooling lives in a PEP 735 dependency group, not a # `[project.optional-dependencies]` extra. Extras ship in the built wheel's # metadata and are installable by any consumer (`pip install ledger-archive[dev]`); diff --git a/src/ledger/server.py b/src/ledger/server.py index 1338b43..cf8c1a5 100644 --- a/src/ledger/server.py +++ b/src/ledger/server.py @@ -113,11 +113,23 @@ ) from ledger.tombstones import PRIMARY_LOCATION, TombstoneStore -# Where the bundled, framework-free web assets live, resolved relative to this -# module so the server works from any working directory (portability). The static -# root is the canonical boundary the traversal guard enforces. -_WEB_ROOT: Path = Path(__file__).resolve().parent.parent.parent / "web" -_STATIC_ROOT: Path = (_WEB_ROOT / "static").resolve() + +def _resolve_static_root(module_root: Path) -> Path: + """Locate static assets in an installed wheel or a source checkout. + + Hatch places ``web/static`` inside ``ledger/static`` in built wheels. A + source checkout keeps the canonical editable files at repository-level + ``web/static``. Prefer the packaged location so an installed application + never depends on its build context or current working directory. + """ + packaged = module_root / "static" + if packaged.is_dir(): + return packaged.resolve() + return (module_root.parent.parent / "web" / "static").resolve() + + +# The canonical boundary enforced by the traversal-safe static allowlist. +_STATIC_ROOT: Path = _resolve_static_root(Path(__file__).resolve().parent) _STAGED_UPLOAD_FILENAME = "payload.bin" diff --git a/tests/test_server_remediation.py b/tests/test_server_remediation.py index dc4077c..2f95bdf 100644 --- a/tests/test_server_remediation.py +++ b/tests/test_server_remediation.py @@ -20,6 +20,7 @@ import pytest +from ledger import server from ledger.access.grants import issue_grant_token from ledger.config import Config from ledger.consent import issue_claim_token @@ -286,6 +287,21 @@ def test_static_is_cacheable(site: tuple[str, str, str]) -> None: assert headers.get("etag") +def test_static_root_resolution_prefers_installed_package(tmp_path: Path) -> None: + """Wheel installs use packaged CSS, independent of checkout/cwd layout.""" + module_root = tmp_path / "site-packages" / "ledger" + packaged = module_root / "static" + source = tmp_path / "web" / "static" + source.mkdir(parents=True) + (source / "app.css").write_text("source", encoding="utf-8") + + assert server._resolve_static_root(module_root) == source.resolve() + + packaged.mkdir(parents=True) + (packaged / "app.css").write_text("installed", encoding="utf-8") + assert server._resolve_static_root(module_root) == packaged.resolve() + + def test_static_rejects_traversal_and_unknown_names(site: tuple[str, str, str]) -> None: """Static serving is a name allowlist: a traversal or unknown name 404s.