diff --git a/docs/engineering/login_organisation_preferences.md b/docs/engineering/login_organisation_preferences.md index 7bd4e2b90..04047ea08 100644 --- a/docs/engineering/login_organisation_preferences.md +++ b/docs/engineering/login_organisation_preferences.md @@ -35,3 +35,8 @@ DGX-local Mongo and Mac/Atlas are separate authority stores. Configure and verify each independently; federation does not replicate these authority or preference records. A stored default is not proof of a successful Google login: validate normal issuance, resulting tab context and actual effect namespace. + +Template-generated static URLs carry a release prefix, inherited by relative +ES-module imports. This prevents a new backend footer/build from accompanying +old cached login-bootstrap JavaScript. A different release gets different asset +URLs; an old release prefix is never served with the new release's bytes. diff --git a/src/backend/server/utils_flask.py b/src/backend/server/utils_flask.py index 30e16bc69..7e96ef29b 100644 --- a/src/backend/server/utils_flask.py +++ b/src/backend/server/utils_flask.py @@ -1544,6 +1544,9 @@ def create_flask_app( _install_request_timing_middleware(app) _configure_flask_app_core(app, list_models_func, generate_func) + from .versioned_static import install_versioned_static + + install_versioned_static(app, get_runtime_code_version()) from ..security.cloudflare_access import install_cloudflare_access install_cloudflare_access(app) diff --git a/src/backend/server/versioned_static.py b/src/backend/server/versioned_static.py new file mode 100644 index 000000000..b8de35f53 --- /dev/null +++ b/src/backend/server/versioned_static.py @@ -0,0 +1,22 @@ +"""Release-scoped static paths keep relative ES-module graphs coherent.""" +from hashlib import sha256 + +from flask import abort + + +def install_versioned_static(app, version: str) -> None: + build = sha256(version.encode()).hexdigest()[:20] + + @app.url_defaults + def version_static_urls(endpoint, values): + if endpoint == "static" and "filename" in values: + filename = values["filename"] + if not filename.startswith("build/"): + values["filename"] = f"build/{build}/{filename}" + + @app.get(f"{app.static_url_path}/build//") + def versioned_static(build_id, filename): + if build_id != build: + # Never return new bytes under an old release's URL. + abort(404) + return app.send_static_file(filename) diff --git a/tests/backend/test_versioned_static.py b/tests/backend/test_versioned_static.py new file mode 100644 index 000000000..cd467eea0 --- /dev/null +++ b/tests/backend/test_versioned_static.py @@ -0,0 +1,34 @@ +from pathlib import Path +from urllib.parse import urljoin + +from flask import Flask, url_for +from src.backend.server.versioned_static import install_versioned_static + + +def test_release_paths_include_relative_modules_and_styles(tmp_path): + (tmp_path / 'js').mkdir() + (tmp_path / 'js' / 'main.js').write_text("import './apiService.js';") + (tmp_path / 'js' / 'apiService.js').write_text('export const release = 2;') + (tmp_path / 'styles.css').write_text('body {}') + app = Flask(__name__, static_folder=str(tmp_path), static_url_path='/static') + install_versioned_static(app, 'release-2') + with app.test_request_context(): + entry = url_for('static', filename='js/main.js') + css = url_for('static', filename='styles.css') + assert entry.startswith('/static/build/') + client = app.test_client() + assert client.get(entry).data == b"import './apiService.js';" + assert client.get(urljoin(entry, './apiService.js')).data == b'export const release = 2;' + assert client.get(css).status_code == 200 + assert client.get('/static/build/old-release/js/main.js').status_code == 404 + assert client.get(entry.replace('/js/main.js', '/../../private.env')).status_code == 404 + + +def test_build_change_changes_entry_url_without_rewriting_templates(tmp_path): + urls=[] + for version in ('release-1','release-2'): + app=Flask(__name__,static_folder=str(tmp_path),static_url_path='/static') + install_versioned_static(app,version) + with app.test_request_context(): + urls.append(url_for('static',filename='js/main.js')) + assert urls[0] != urls[1]