diff --git a/backend/app/frontend.py b/backend/app/frontend.py
index 8bf97ee..b7925bd 100644
--- a/backend/app/frontend.py
+++ b/backend/app/frontend.py
@@ -46,6 +46,14 @@ async def serve_extension(filename: str):
The application/x-xpinstall MIME tells Firefox to show its native
install prompt instead of downloading the file as a blob.
+
+ Caching differs by name, and has to. A versioned name is one build's bytes
+ forever, so it can be cached for good. `fabledcurator-latest.xpi` is ONE
+ URL whose bytes change on every release, and Quart's default for a file
+ is `public, max-age=43200`: a browser that fetched it once reused those
+ bytes for 12 hours, so "install the latest" quietly reinstalled the
+ previous build (operator-flagged 2026-09-25). It is `no-cache` — the ETag
+ still makes an unchanged file a cheap 304.
"""
if not _XPI_NAME_RE.fullmatch(filename):
abort(404)
@@ -56,10 +64,11 @@ async def serve_extension(filename: str):
if not xpis:
abort(404)
latest = xpis[-1]
- return await send_file(
+ resp = await send_file(
latest, mimetype="application/x-xpinstall",
attachment_filename=latest.name,
)
+ return _cache(resp, "no-cache")
target = (XPI_DIR / filename).resolve()
try:
target.relative_to(XPI_DIR)
@@ -67,10 +76,19 @@ async def serve_extension(filename: str):
abort(404)
if not target.is_file():
abort(404)
- return await send_file(
+ resp = await send_file(
target, mimetype="application/x-xpinstall",
attachment_filename=filename,
)
+ return _cache(resp, "public, max-age=31536000, immutable")
+
+
+def _cache(resp, policy: str):
+ """Set the XPI's Cache-Control, dropping the Expires send_file adds so the
+ two can never disagree."""
+ resp.headers["Cache-Control"] = policy
+ resp.headers.pop("Expires", None)
+ return resp
@frontend_bp.route("/")
diff --git a/extension/background/background.js b/extension/background/background.js
index d7e82c5..f41d117 100644
--- a/extension/background/background.js
+++ b/extension/background/background.js
@@ -88,7 +88,12 @@ async function checkForUpdateInfo() {
currentVersion,
latestVersion,
channel,
- xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
+ // Where the Update button sends the operator: FC's own install card, not
+ // the XPI. Firefox refuses an add-on install whose navigation an extension
+ // started (tabs.create on the .xpi dies with NS_ERROR_FAILURE — operator-
+ // flagged 2026-09-25); it accepts one from a user click on a web page,
+ // which is exactly what the card's Install button is.
+ installPageUrl: base ? `${base}/subscriptions?tab=settings` : null,
};
}
diff --git a/extension/popup/popup.js b/extension/popup/popup.js
index 1874c9b..1fe7410 100644
--- a/extension/popup/popup.js
+++ b/extension/popup/popup.js
@@ -76,7 +76,7 @@ function updateConnectionDot(connected) {
async function checkForUpdate() {
try {
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' });
- if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r);
+ if (r && r.updateAvailable && r.installPageUrl) showUpdateBanner(r);
} catch { /* non-fatal */ }
}
@@ -86,10 +86,14 @@ function showUpdateBanner(r) {
// exactly as it did before the field existed.
const channel = r.channel ? ` (${r.channel})` : '';
document.getElementById('update-text').textContent =
- `Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion})`;
- // Opening the signed XPI triggers Firefox's native install prompt.
+ `Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion}). ` +
+ 'Opens FabledCurator — click “Install Firefox extension” there.';
+ // Opens FC's install card rather than the XPI: Firefox only installs an
+ // add-on from a user click on a web page, never from a tab an extension
+ // opened on the .xpi itself.
document.getElementById('update-btn').addEventListener('click', () => {
- browser.tabs.create({ url: r.xpiUrl });
+ browser.tabs.create({ url: r.installPageUrl });
+ window.close();
});
document.getElementById('update-banner').classList.remove('hidden');
}
diff --git a/frontend/src/components/settings/BrowserExtensionCard.vue b/frontend/src/components/settings/BrowserExtensionCard.vue
index 50b6a6a..0b96c0e 100644
--- a/frontend/src/components/settings/BrowserExtensionCard.vue
+++ b/frontend/src/components/settings/BrowserExtensionCard.vue
@@ -49,16 +49,20 @@
sometimes triggered nothing instead of the install dialog
(operator-flagged 2026-05-26). No `download` attribute —
that would force a save dialog instead of install. -->
+
Install Firefox extension
Download XPI
diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py
index f1dfee5..24f1c31 100644
--- a/tests/test_api_extension.py
+++ b/tests/test_api_extension.py
@@ -779,6 +779,28 @@ async def test_serve_extension_latest_returns_most_recent_xpi(
assert data == b"new"
+@pytest.mark.asyncio
+async def test_the_latest_alias_is_never_served_from_a_stale_cache(
+ client, monkeypatch, tmp_path,
+):
+ """One URL whose bytes change every release: a cached copy reinstalls the
+ previous build (operator-flagged 2026-09-25, when it was max-age=43200)."""
+ (tmp_path / "fabledcurator-1.0.1.xpi").write_bytes(b"new")
+ monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
+ resp = await client.get("/extension/fabledcurator-latest.xpi")
+ assert resp.headers["Cache-Control"] == "no-cache"
+ assert "Expires" not in resp.headers
+
+
+@pytest.mark.asyncio
+async def test_a_versioned_xpi_is_cached_for_good(client, monkeypatch, tmp_path):
+ """A versioned name is one build's bytes forever."""
+ (tmp_path / "fabledcurator-1.0.1.xpi").write_bytes(b"new")
+ monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
+ resp = await client.get("/extension/fabledcurator-1.0.1.xpi")
+ assert "immutable" in resp.headers["Cache-Control"]
+
+
@pytest.mark.asyncio
async def test_serve_extension_latest_404_when_dir_empty(client, monkeypatch, tmp_path):
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)