diff --git a/.env.example b/.env.example index f069047..10e6d39 100644 --- a/.env.example +++ b/.env.example @@ -172,10 +172,14 @@ ENGRAPHIS_DECAY_HALFLIFE_DAYS=7 # ENGRAPHIS_SMTP_PASSWORD= # # ── Client side (end-user machines) ───────────────────────────────────────── -# Optional license-server override. When unset, clients use the URL signed into the key, -# then the built-in managed relay. Paid Pro/Team keys always require a renewable lease. -# ENGRAPHIS_CLOUD_URL=https:// - -# Vendor/fulfillment server only: URL signed into newly minted keys. When unset, issued -# keys use ENGRAPHIS_RELAY_URL (the built-in managed relay by default). -# ENGRAPHIS_KEY_CLOUD_URL=https://your-license-server.example.com +# Paid keys require a revocable, machine-bound lease from the managed service. The +# built-in default is https://team.engraphis.com; override only for a different vendor +# deployment. Free-tier use remains local and does not require the service. +# ENGRAPHIS_CLOUD_URL=https://team.engraphis.com + +# Server-side license enforcement (vendor/fulfillment server only): when set, every key +# minted by the Polar webhook carries a signed enforce:"cloud" claim + this URL, and the +# client requires a live lease from it (revocable, seat-counted, useless offline). +# Set this to the canonical managed-service URL for newly issued keys. Older keys carrying +# the retired Railway hostname are migrated by the client without changing their signature. +# ENGRAPHIS_KEY_CLOUD_URL=https://team.engraphis.com diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fe34b..409adc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to Engraphis are documented here. Format loosely follows ## [Unreleased] +### Fixed +- Existing paid keys signed before the managed-service domain migration now route the + retired Railway license host to `https://team.engraphis.com`; new defaults use the + canonical domain for license leases and managed sync. License, trial, and invite + requests now send an explicit Engraphis User-Agent so Cloudflare does not reject + Python's default `urllib` signature with error 1010. A configured key that falls back + to free during an outage now retries automatically, including from the dashboard's + background refresh loop, so service recovery no longer requires a restart or re-entry. + ### Added - **Agent Connect for hosted Team instances.** Members can mint SHA-256-hashed per-user bearer tokens in Settings and use the hosted v2 store through `POST /api/remember`, diff --git a/README.md b/README.md index ca79e75..5b63dd7 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,7 @@ All via environment (or `.env`): | `ENGRAPHIS_LOOP_INTERVAL` | `60` | Background consolidation loop interval in seconds (0 = disabled) | | `ENGRAPHIS_DECAY_HALFLIFE_DAYS` | `7` | Ebbinghaus decay half-life (higher = memories persist longer) | | `ENGRAPHIS_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted reverse-proxy IPs for TLS termination (`*` = trust all) | -| `ENGRAPHIS_RELAY_URL` | built-in | Managed sync relay URL (Pro/Team) | +| `ENGRAPHIS_RELAY_URL` | `https://team.engraphis.com` | Managed sync relay URL (Pro/Team) | | `ENGRAPHIS_AUTOSYNC_LOOP` | `1` | Kill switch for the in-process auto-sync loop (0 = off) | See `.env.example` for the full list including commercial/vendor, email delivery, and diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index d73f130..967ede4 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -17,7 +17,7 @@ from fastapi.testclient import TestClient from engraphis import cloud_license, licensing -from engraphis.config import settings +from engraphis.config import DEFAULT_RELAY_URL, settings from engraphis.inspector import license_cloud from engraphis.inspector import license_registry as reg from engraphis.licensing import LicenseError, ed25519_public_key, parse_key @@ -229,6 +229,27 @@ def _urlopen_5xx(req, timeout=None): raise _HTTPError(503) assert cloud_license.register("http://cloud.test", _key(), "m-1") is None +def test_license_client_sets_cloudflare_safe_headers(monkeypatch): + captured = {} + + class _Resp: + def read(self): return b'{"lease": null}' + def __enter__(self): return self + def __exit__(self, *args): return False + + def fake_urlopen(req, timeout=None): + captured["user_agent"] = req.get_header("User-agent") + captured["accept"] = req.get_header("Accept") + return _Resp() + + monkeypatch.setattr(cloud_license.urllib.request, "urlopen", fake_urlopen) + assert cloud_license.register("http://cloud.test", _key(), "m-1") is None + assert captured == { + "user_agent": "Engraphis/1.0 (+https://engraphis.com)", + "accept": "application/json", + } + + def test_revalidate_revoked_deletes_lease(monkeypatch): # A paid key with a valid local lease is periodically checked online. Background # revalidation uses the same denial path and deletes the lease immediately. @@ -626,6 +647,26 @@ def fake_register(base, k, mid, **kw): assert got.plan == "pro" and got.has("sync") +def test_retired_baked_in_url_migrates_to_current_relay(monkeypatch): + """Existing signed keys must survive the vendor's Railway-to-domain migration.""" + key = _enforced_key(cloud_url="https://engraphis-production.up.railway.app") + lic_parsed = parse_key(key) + calls = {} + + def fake_register(base, k, mid, **kw): + calls["base"] = base + payload = {"v": 1, "key_id": lic_parsed.key_id, "plan": lic_parsed.plan, + "features": sorted(lic_parsed.features), "machine_id": mid, + "issued": int(time.time()), "expires": int(time.time() + 3600)} + return cloud_license.compose_lease(payload, SECRET) + + monkeypatch.setattr(cloud_license, "register", fake_register) + monkeypatch.setenv("ENGRAPHIS_LICENSE_KEY", key) + got = licensing.current_license(refresh=True) + assert calls["base"] == DEFAULT_RELAY_URL == "https://team.engraphis.com" + assert got.plan == "pro" and got.has("sync") + + def test_all_paid_keys_require_server_even_without_enforce_claim(monkeypatch): """Online-only (closes the offline-key bypass): even a key WITHOUT the enforce claim (old "offline" style) must obtain a live lease. Server unreachable → fail closed; @@ -645,6 +686,26 @@ def ok_register(base, k, mid, **kw): assert got.plan == "pro" and got.has("sync") +def test_configured_key_retries_after_free_fallback_cache(monkeypatch): + """A transient outage must not pin a valid configured key to free forever.""" + key = _key() + lic_parsed = parse_key(key) + monkeypatch.setenv("ENGRAPHIS_LICENSE_KEY", key) + monkeypatch.setattr(cloud_license, "register", lambda *a, **k: None) + assert licensing.current_license(refresh=True).plan == "free" + assert licensing._cache_recheck_at != float("inf") + + def ok_register(base, k, mid, **kw): + payload = {"v": 1, "key_id": lic_parsed.key_id, "plan": lic_parsed.plan, + "features": sorted(lic_parsed.features), "machine_id": mid, + "issued": int(time.time()), "expires": int(time.time() + 3600)} + return cloud_license.compose_lease(payload, SECRET) + + monkeypatch.setattr(cloud_license, "register", ok_register) + monkeypatch.setattr(licensing, "_cache_recheck_at", 0) + assert licensing.current_license().plan == "pro" + + # ── team-invite relay: self-hosted dashboards with no mail account of their own ──────── # borrow the vendor's, gated by a real 'team' key (same trust boundary as every other # licensed feature) and rate-limited per key so it can't become an open relay. diff --git a/tests/test_config.py b/tests/test_config.py index f78ab77..a5e080b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,6 +9,9 @@ from engraphis.config import Settings +RETIRED_RELAY_URL = "https://engraphis-production.up.railway.app" + + def test_rerank_model_defaults_to_empty(monkeypatch): monkeypatch.delenv("ENGRAPHIS_RERANK_MODEL", raising=False) assert Settings().rerank_model == "" @@ -62,3 +65,17 @@ def test_license_server_url_migrates_retired_signed_host(monkeypatch): assert config.resolve_license_server_url( "https://engraphis-production.up.railway.app/", ) == config.DEFAULT_RELAY_URL + + +def test_retired_cloud_url_override_is_canonicalized(monkeypatch): + monkeypatch.setenv("ENGRAPHIS_CLOUD_URL", RETIRED_RELAY_URL + "/") + assert ( + config.resolve_license_server_url("https://signed.example") + == config.DEFAULT_RELAY_URL + ) + + +def test_retired_relay_url_override_is_canonicalized(monkeypatch): + monkeypatch.delenv("ENGRAPHIS_CLOUD_URL", raising=False) + monkeypatch.setattr(config.settings, "relay_url", RETIRED_RELAY_URL) + assert config.resolve_license_server_url() == config.DEFAULT_RELAY_URL diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index ff5334c..3b5f45e 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -76,6 +76,17 @@ def test_team_mode_env_opt_out_parsing(monkeypatch, raw): assert _enabled() is False +def test_license_background_refresh_retries_configured_key(monkeypatch): + from engraphis.dashboard_app import _refresh_configured_license + + calls = [] + monkeypatch.setattr(lic, "_read_key_material", lambda: "configured-key") + monkeypatch.setattr( + lic, "current_license", lambda *, refresh=False: calls.append(refresh)) + _refresh_configured_license() + assert calls == [True] + + def test_team_setup_waits_for_active_license(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path, team=True, key=None) as c: state = c.get("/api/auth/state").json()