Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-vendor-host>

# 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 62 additions & 1 deletion tests/test_cloud_license.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ""
Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions tests/test_dashboard_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down