From edc8cb36050e67e8de07dfef0d35257f31ecadb5 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 11 Oct 2026 03:05:22 +0300 Subject: [PATCH 1/2] Verify public web cache policy and compiled app before delivery succeeds --- docs/RELEASING.md | 6 +++++ tools/install_web.py | 6 +++++ tools/tests/test_delivery.py | 49 ++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 447a7b7..702fee2 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -210,6 +210,12 @@ release identity, then switches `current` atomically under a file lock. Public `/release.json`, `/`, `/login` and `/flutter_bootstrap.js` must match the artifact. A failed verification restores the previous target. Old directories are retained; there is no automatic data or artifact deletion. Caddy revalidates web content. +If Cloudflare fronts the app, configure an app-host-only cache rule for +`http.host eq "app.papyrus-reader.com"`: bypass the edge cache and set Browser TTL +to **Respect origin TTL**. The default browser TTL can otherwise replace Caddy's +`no-cache` header with a four-hour lifetime for JavaScript. The public deployment +probe checks revalidation headers and exact bytes for metadata, both entrypoint +routes, the Flutter bootstrap and `main.dart.js`; failure rolls activation back. The deployment user cannot restart or change the API, sync service or database. Delivery rejects a lower build number than the active web release; an equal diff --git a/tools/install_web.py b/tools/install_web.py index 7f69dd2..0d21473 100644 --- a/tools/install_web.py +++ b/tools/install_web.py @@ -72,6 +72,7 @@ def verify_public(url: str, metadata: dict, release: Path) -> None: ("/", "index.html"), ("/login", "index.html"), ("/flutter_bootstrap.js", "flutter_bootstrap.js"), + ("/main.dart.js", "main.dart.js"), ): request = urllib.request.Request( f"{url}{route}?deployment={metadata['revision']}", @@ -82,6 +83,11 @@ def verify_public(url: str, metadata: dict, release: Path) -> None: ) with urllib.request.urlopen(request, timeout=10) as response: + directives = {value.strip().lower() for value in response.headers.get("Cache-Control", "").split(",")} + + if not directives.intersection({"no-cache", "no-store"}): + raise ValueError(f"Public web content must revalidate before reuse: {route}") + if response.read() != (release / filename).read_bytes(): raise ValueError(f"Public web content does not match the release: {route}") diff --git a/tools/tests/test_delivery.py b/tools/tests/test_delivery.py index b3694dc..d1a7b4f 100644 --- a/tools/tests/test_delivery.py +++ b/tools/tests/test_delivery.py @@ -1,6 +1,7 @@ """Regression tests for release identity, Play retries and transactional web delivery.""" import hashlib +import io import json import os import sys @@ -173,6 +174,54 @@ def test_activate_retry_and_preserve_previous(self): self.assertEqual(os.readlink(self.root / "previous"), "releases/initial") self.assertEqual((self.root / "previous/index.html").read_text(), "previous") + def public_response(self, request, timeout): + route = request.full_url.split("?", 1)[0].removeprefix("https://app.example.com") + filename = "index.html" if route in ("/", "/login") else route.removeprefix("/") + response = io.BytesIO((self.root / "current" / filename).read_bytes()) + response.headers = {"Cache-Control": "no-cache"} + return response + + def test_public_probe_checks_entrypoints_and_compiled_app(self): + self.install() + + with patch.object(web.urllib.request, "urlopen", side_effect=self.public_response) as request: + web.verify_public("https://app.example.com", self.metadata, self.root / "current") + + routes = [call.args[0].full_url.split("?", 1)[0] for call in request.call_args_list] + self.assertEqual(len(routes), 5) + self.assertIn("https://app.example.com/main.dart.js", routes) + + def test_cdn_cache_override_rolls_back_release(self): + def response(request, timeout): + result = self.public_response(request, timeout) + + if "/flutter_bootstrap.js?" in request.full_url: + result.headers["Cache-Control"] = "max-age=14400" + + return result + + with patch.object(web.urllib.request, "urlopen", side_effect=response), patch.object(web.time, "sleep"): + with self.assertRaisesRegex(ValueError, "must revalidate"): + self.install(lambda metadata, release: web.verify_public("https://app.example.com", metadata, release)) + + self.assertEqual(os.readlink(self.root / "current"), "releases/initial") + + def test_stale_compiled_app_rolls_back_release(self): + def response(request, timeout): + result = self.public_response(request, timeout) + + if "/main.dart.js?" in request.full_url: + result = io.BytesIO(b"previous compiled app") + result.headers = {"Cache-Control": "no-cache"} + + return result + + with patch.object(web.urllib.request, "urlopen", side_effect=response), patch.object(web.time, "sleep"): + with self.assertRaisesRegex(ValueError, "does not match"): + self.install(lambda metadata, release: web.verify_public("https://app.example.com", metadata, release)) + + self.assertEqual(os.readlink(self.root / "current"), "releases/initial") + def test_older_build_is_rejected_without_activation(self): self.install() self.metadata = assets.identity("v0.0.1+1", "b" * 40) From cbd6433d2a0a349d56db13adf2ad736c98cd1da1 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 11 Oct 2026 03:11:49 +0300 Subject: [PATCH 2/2] Probe canonical browser URLs without cache-bypassing requests --- tools/install_web.py | 3 +-- tools/tests/test_delivery.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tools/install_web.py b/tools/install_web.py index 0d21473..284969e 100644 --- a/tools/install_web.py +++ b/tools/install_web.py @@ -75,9 +75,8 @@ def verify_public(url: str, metadata: dict, release: Path) -> None: ("/main.dart.js", "main.dart.js"), ): request = urllib.request.Request( - f"{url}{route}?deployment={metadata['revision']}", + f"{url}{route}", headers={ - "Cache-Control": "no-cache", "User-Agent": "PapyrusReleaseVerifier/1.0 (+https://github.com/PapyrusReader/client)", }, ) diff --git a/tools/tests/test_delivery.py b/tools/tests/test_delivery.py index d1a7b4f..fe8a072 100644 --- a/tools/tests/test_delivery.py +++ b/tools/tests/test_delivery.py @@ -187,7 +187,7 @@ def test_public_probe_checks_entrypoints_and_compiled_app(self): with patch.object(web.urllib.request, "urlopen", side_effect=self.public_response) as request: web.verify_public("https://app.example.com", self.metadata, self.root / "current") - routes = [call.args[0].full_url.split("?", 1)[0] for call in request.call_args_list] + routes = [call.args[0].full_url for call in request.call_args_list] self.assertEqual(len(routes), 5) self.assertIn("https://app.example.com/main.dart.js", routes) @@ -195,7 +195,7 @@ def test_cdn_cache_override_rolls_back_release(self): def response(request, timeout): result = self.public_response(request, timeout) - if "/flutter_bootstrap.js?" in request.full_url: + if request.full_url.endswith("/flutter_bootstrap.js"): result.headers["Cache-Control"] = "max-age=14400" return result @@ -210,7 +210,7 @@ def test_stale_compiled_app_rolls_back_release(self): def response(request, timeout): result = self.public_response(request, timeout) - if "/main.dart.js?" in request.full_url: + if request.full_url.endswith("/main.dart.js"): result = io.BytesIO(b"previous compiled app") result.headers = {"Cache-Control": "no-cache"}