diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 31dae76406..4b09896faf 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -106,7 +106,8 @@ NEXT_FRONTEND_URL=http://localhost:3000 # clients read once at startup to load the live /sunset page instead of their # bundled frontend (plans/community-local/contracts/04-sunset-flag.md). # Read per request, so the flip needs no redeploy. Accepts 1 / true / yes / on. -# Self-hosters leave this unset -- unset means "not sunset". +# Only takes effect when DEPLOYMENT_MODE=cloud -- self-hosted instances ignore +# this flag entirely, so leaving it unset (or set) makes no difference there. SUNSET_MODE= # Where those clients are sent. Defaults to https://surfsense.com/sunset. SUNSET_URL= diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 42e92865a9..24840dae2e 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -473,7 +473,7 @@ class Config: # Deployment Mode (self-hosted or cloud) # self-hosted: Full access to local file system connectors (Obsidian, etc.) # cloud: Only cloud-based connectors available - DEPLOYMENT_MODE = os.getenv("SURFSENSE_DEPLOYMENT_MODE", "self-hosted") + DEPLOYMENT_MODE = os.getenv("DEPLOYMENT_MODE", "self-hosted") ENABLE_DESKTOP_LOCAL_FILESYSTEM = ( os.getenv("ENABLE_DESKTOP_LOCAL_FILESYSTEM", "FALSE").upper() == "TRUE" ) diff --git a/surfsense_backend/app/sunset.py b/surfsense_backend/app/sunset.py index 445c98eb59..b8945bdd3b 100644 --- a/surfsense_backend/app/sunset.py +++ b/surfsense_backend/app/sunset.py @@ -6,9 +6,10 @@ Two properties matter more than anything else here: -* **Self-hosters run this same code forever with the flag unset.** Every path - through this module has to be a no-op in that case, which is why the flag is - checked before anything else and defaults to off. +* **Self-hosters can never hit this, even by accident.** ``SUNSET_MODE`` alone + is not enough -- the flag only takes effect when ``DEPLOYMENT_MODE=cloud``, + so a stray ``SUNSET_MODE=1`` in a self-hosted ``.env`` (copied from a hosted + template, say) is a no-op rather than an outage. * **Signing in has to keep working.** Export is behind a session, so blocking authentication would lock people out of the one action still available and make the 30-day window meaningless. @@ -38,10 +39,16 @@ # signing in, and the legacy desktop client authenticates through # ``/auth/desktop/*``. Registration is the one exception -- the service is # winding down, so there is nobody new to sign up. +# +# PATs keep working unchanged through the tail (00d-pivot-plan.md, "Existing +# MCP users"): they die at the T+30 purge, not at T-0, so create/revoke has to +# stay open here or every PAT-holding client -- MCP included -- loses the only +# credential it has before the purge actually happens. _ALLOWED_PREFIXES = ( "/auth/", "/api/v1/license/", "/api/v1/stripe/webhook", + "/api/v1/pats", ) _BLOCKED_PATHS = frozenset({"/auth/register"}) @@ -58,10 +65,14 @@ def is_sunset_mode() -> bool: """Whether the hosted service is winding down. - Read from the environment on every call rather than through ``config``, - which resolves at import: contract 4 requires that flipping the flag take - effect without a deploy. + Only ever true for a cloud deployment. ``SUNSET_MODE`` is read from the + environment on every call rather than cached, so flipping it takes effect + without a deploy; ``DEPLOYMENT_MODE`` is the safety net that keeps a + self-hosted instance immune to that flag regardless of what its own + ``.env`` sets. """ + if os.getenv("DEPLOYMENT_MODE", "self-hosted") != "cloud": + return False return os.getenv("SUNSET_MODE", "").strip().lower() in _TRUTHY diff --git a/surfsense_backend/tests/unit/test_sunset_flag.py b/surfsense_backend/tests/unit/test_sunset_flag.py index 4551f0be48..0808ffa6bd 100644 --- a/surfsense_backend/tests/unit/test_sunset_flag.py +++ b/surfsense_backend/tests/unit/test_sunset_flag.py @@ -44,6 +44,7 @@ def test_every_documented_spelling_turns_the_flag_on(client, monkeypatch, value) Both have to work. The cost of a spelling that silently reads as false is a sunset day where nothing happens and nothing says why. """ + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert _health(client)["sunset"] is True @@ -51,13 +52,23 @@ def test_every_documented_spelling_turns_the_flag_on(client, monkeypatch, value) @pytest.mark.parametrize("value", ["", "0", "false", "no", "off", " "]) def test_anything_else_leaves_the_flag_off(client, monkeypatch, value): + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert _health(client)["sunset"] is False +def test_self_hosted_stays_off_even_if_sunset_mode_is_set(client, monkeypatch): + """A stray ``SUNSET_MODE=1`` in a self-hosted ``.env`` must be a no-op.""" + monkeypatch.delenv("DEPLOYMENT_MODE", raising=False) + monkeypatch.setenv("SUNSET_MODE", "1") + + assert _health(client)["sunset"] is False + + def test_the_flag_is_read_per_request(client, monkeypatch): """No restart beyond the flag change, so it cannot be cached at import.""" + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.delenv("SUNSET_MODE", raising=False) assert _health(client)["sunset"] is False @@ -71,6 +82,7 @@ def test_sunset_is_a_json_boolean_not_a_string(client, monkeypatch): ``is True`` rather than ``== True`` on purpose: ``1 == True`` in Python, so equality would pass on the raw environment value this once returned. """ + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", "1") assert isinstance(_health(client)["sunset"], bool) diff --git a/surfsense_backend/tests/unit/test_sunset_write_block.py b/surfsense_backend/tests/unit/test_sunset_write_block.py index 07a5911d7c..9d6b5bfaa3 100644 --- a/surfsense_backend/tests/unit/test_sunset_write_block.py +++ b/surfsense_backend/tests/unit/test_sunset_write_block.py @@ -40,6 +40,8 @@ "/api/v1/export", "/api/v1/workspaces/7/scrapers/reddit/search", "/api/v1/workspaces/7/scrapers/capabilities", + "/api/v1/pats", + "/api/v1/pats/42", ) @@ -66,6 +68,7 @@ def client() -> TestClient: @pytest.fixture def sunset_on(monkeypatch): + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", "1") @@ -92,13 +95,27 @@ def test_nothing_is_blocked_while_the_flag_is_unset(client, sunset_off, path, me @pytest.mark.parametrize("value", ["", "0", "false", "no", "off", " "]) def test_a_flag_that_does_not_mean_yes_blocks_nothing(client, monkeypatch, value): """Anything short of an explicit yes leaves the service fully writable.""" + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert client.post("/api/v1/documents").status_code == 200 +def test_self_hosted_ignores_a_stray_sunset_flag(client, monkeypatch): + """``DEPLOYMENT_MODE`` defaults to self-hosted, which must win over SUNSET_MODE. + + A ``.env`` copied from a hosted template can carry ``SUNSET_MODE=1`` by + accident; a self-hosted instance must stay fully writable regardless. + """ + monkeypatch.delenv("DEPLOYMENT_MODE", raising=False) + monkeypatch.setenv("SUNSET_MODE", "1") + + assert client.post("/api/v1/documents").status_code == 200 + + def test_the_flag_is_read_per_request(client, monkeypatch): """Sunset is thrown by changing the environment, not by redeploying.""" + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.delenv("SUNSET_MODE", raising=False) assert client.post("/api/v1/documents").status_code == 200 @@ -175,6 +192,18 @@ def test_the_scraper_api_keeps_running(client, sunset_on): assert client.post("/api/v1/workspaces/7/scrapers/reddit/search").status_code == 200 +@pytest.mark.parametrize("method", ["post", "delete"]) +def test_pats_keep_working_through_the_tail(client, sunset_on, method): + """PATs die at the T+30 purge, not at T-0 (00d-pivot-plan.md, "Existing MCP users"). + + Losing create/revoke at T-0 would strand every PAT-holding client -- MCP + included -- long before the purge that is actually supposed to end it. + """ + path = "/api/v1/pats" if method == "post" else "/api/v1/pats/42" + + assert getattr(client, method)(path).status_code == 200 + + # -------------------------------------------------------------------------- # The flag itself # -------------------------------------------------------------------------- @@ -182,6 +211,7 @@ def test_the_scraper_api_keeps_running(client, sunset_on): @pytest.mark.parametrize("value", ["1", "true", "TRUE", "True", "yes", "on"]) def test_every_documented_spelling_means_yes(monkeypatch, value): + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert is_sunset_mode() is True @@ -189,6 +219,14 @@ def test_every_documented_spelling_means_yes(monkeypatch, value): @pytest.mark.parametrize("value", ["", "0", "false", "no", "off", " ", "1 "]) def test_anything_else_means_no(monkeypatch, value): + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert is_sunset_mode() is (value.strip() == "1") + + +def test_self_hosted_means_no_regardless_of_sunset_mode(monkeypatch): + monkeypatch.delenv("DEPLOYMENT_MODE", raising=False) + monkeypatch.setenv("SUNSET_MODE", "1") + + assert is_sunset_mode() is False diff --git a/surfsense_local/backend/modules/llm/models.py b/surfsense_local/backend/modules/llm/models.py index 3731ea6824..0727336e38 100644 --- a/surfsense_local/backend/modules/llm/models.py +++ b/surfsense_local/backend/modules/llm/models.py @@ -1,6 +1,7 @@ import enum from datetime import datetime +from cryptography.fernet import InvalidToken from sqlalchemy import CheckConstraint, ForeignKey, String, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column @@ -91,7 +92,14 @@ class ProviderConnection(Base): def api_key(self) -> str | None: if self.api_key_ciphertext is None: return None - return decrypt(self.api_key_ciphertext) + try: + return decrypt(self.api_key_ciphertext) + except InvalidToken: + # ponytail: SURFSENSE_LOCAL_SECRET rotated (secret.bin reminted). + # Drop the unreadable blob so the UI asks for a new key; a 500 + # here takes down model discovery for an otherwise healthy API. + self.api_key_ciphertext = None + return None @api_key.setter def api_key(self, value: str | None) -> None: diff --git a/surfsense_local/backend/tests/unit/shared/test_secrets.py b/surfsense_local/backend/tests/unit/shared/test_secrets.py index 2b4a60fc56..fcc6c0524d 100644 --- a/surfsense_local/backend/tests/unit/shared/test_secrets.py +++ b/surfsense_local/backend/tests/unit/shared/test_secrets.py @@ -25,3 +25,21 @@ def test_tampered_ciphertext_is_rejected() -> None: token[-1] ^= 0xFF with pytest.raises(InvalidToken): decrypt(bytes(token)) + + +def test_rotated_secret_reads_as_no_key(monkeypatch: pytest.MonkeyPatch) -> None: + """secret.bin reminted: the old ciphertext is gone, not a 500.""" + connection = ProviderConnection( + label="x", provider="openai_compatible", base_url="http://h", api_key="sk-1" + ) + token = connection.api_key_ciphertext + monkeypatch.setenv("SURFSENSE_LOCAL_SECRET", "a-different-secret") + from shared.secrets import _fernet + + _fernet.cache_clear() + stale = ProviderConnection( + label="y", provider="openai_compatible", base_url="http://h" + ) + stale.api_key_ciphertext = token + assert stale.api_key is None + assert stale.api_key_ciphertext is None