diff --git a/engraphis/billing.py b/engraphis/billing.py index 3fb189c..3c76c52 100644 --- a/engraphis/billing.py +++ b/engraphis/billing.py @@ -454,6 +454,30 @@ def _finalize_webhook(delivery_id: str, fulfillment_id: str, finally: conn.close() +def _polar_subscription_id(data: dict, *, object_is_subscription: bool = False) -> str: + """Extract a Polar subscription id from direct or nested event data.""" + from engraphis.inspector.webhooks import _extract_subscription_id + sub_id = _extract_subscription_id(data, object_is_subscription=object_is_subscription) + if sub_id: + return sub_id + order = data.get("order") or {} + if isinstance(order, dict): + return _extract_subscription_id(order) + return "" + + +def _polar_order_id(data: dict) -> str: + """Extract a Polar order id from direct or nested event data.""" + from engraphis.inspector.webhooks import _extract_order_id + order_id = _extract_order_id(data) + if order_id: + return order_id + order = data.get("order") or {} + if isinstance(order, dict): + return _extract_order_id(order) + return "" + + def _release_claims(*claim_ids: str) -> None: """Best-effort rollback used only while returning a retryable failure.""" for claim_id in claim_ids: @@ -485,6 +509,74 @@ def _order_id(data: dict) -> str: return "" +def _revoke_refunded_order(data: dict, webhook_id: str) -> JSONResponse: + """Refunds return the money, so revoke the affected key(s) immediately.""" + subscription_id = _polar_subscription_id(data) + order_id = _polar_order_id(data) + if not subscription_id and not order_id: + return JSONResponse({"status": "ignored", "reason": "missing refund target", + "type": "order.refunded"}, status_code=202) + + delivery_claim = "dlv:" + webhook_id + claim_state = claim_webhook(delivery_claim) + if claim_state == "fulfilled": + logger.info("polar webhook: duplicate refund delivery %s ignored", webhook_id) + return JSONResponse({"status": "duplicate", "revoked": 0}, status_code=202) + if claim_state == "in_flight": + return JSONResponse({"status": "processing", "revoked": 0}, status_code=503) + + try: + from engraphis.inspector.license_registry import ( + revoke_by_order, revoke_by_subscription) + if order_id: + revoked = revoke_by_order(order_id) + target = {"order_id": order_id} + else: + revoked = revoke_by_subscription(subscription_id) + target = {"subscription_id": subscription_id} + except Exception: # noqa: BLE001 — force Polar to retry if durable revoke failed + release_webhook(delivery_claim) + logger.exception("polar webhook: refund revocation failed") + return JSONResponse({"error": "revocation failed"}, status_code=503) + + complete_webhook(delivery_claim) + logger.warning("polar webhook: refund revoked %d license key(s) for %s", + revoked, target) + return JSONResponse({"status": "revoked", "reason": "refund", + "revoked": revoked, **target}, status_code=202) + + +def _revoke_subscription_event(data: dict, webhook_id: str, *, + reason: str) -> JSONResponse: + """Definitive subscription revocation: access should end now, not at expiry.""" + subscription_id = _polar_subscription_id(data, object_is_subscription=True) + if not subscription_id: + return JSONResponse({"status": "ignored", "reason": "missing subscription id", + "type": "subscription.revoked"}, status_code=202) + + delivery_claim = "dlv:" + webhook_id + claim_state = claim_webhook(delivery_claim) + if claim_state == "fulfilled": + logger.info("polar webhook: duplicate revocation delivery %s ignored", webhook_id) + return JSONResponse({"status": "duplicate", "revoked": 0}, status_code=202) + if claim_state == "in_flight": + return JSONResponse({"status": "processing", "revoked": 0}, status_code=503) + + try: + from engraphis.inspector.license_registry import revoke_by_subscription + revoked = revoke_by_subscription(subscription_id) + except Exception: # noqa: BLE001 — force Polar to retry if durable revoke failed + release_webhook(delivery_claim) + logger.exception("polar webhook: subscription revocation failed") + return JSONResponse({"error": "revocation failed"}, status_code=503) + + complete_webhook(delivery_claim) + logger.warning("polar webhook: %s revoked %d license key(s) for subscription %s", + reason, revoked, subscription_id) + return JSONResponse({"status": "revoked", "reason": reason, "revoked": revoked, + "subscription_id": subscription_id}, status_code=202) + + def _event_modified_at(data: dict) -> Optional[float]: """Epoch of the event object's own last-modification time, if the payload carries one (Polar sends ``modified_at`` on Subscription objects). Used to reject an @@ -758,16 +850,30 @@ async def polar_webhook(request: Request): # ONE key per order and ONE per trial, no matter which/how many events fire: # order.paid -> paid activation, trial conversion, and each renewal # (a fresh order.paid per cycle). Fulfillment "order:". + # order.refunded -> immediate revocation. Money returned means the key is + # returned too. + # subscription.canceled -> no revocation. The customer paid for the current period; + # the signed key expiry remains the entitlement boundary. + # subscription.revoked -> immediate revocation after the paid period actually ends + # or on merchant/admin immediate revocation. # subscription.created -> ONLY when the subscription is in a free trial, to grant # an immediate trial-length key. Fulfillment "trial:". # subscription.updated -> Team seat count changed mid-cycle (add/remove seats via - # the Customer Portal). Only when status is active AND the - # seat count actually differs from the last known baseline - # for this subscription (see get_known_seats / + # the Customer Portal). Only when status is active/trialing + # AND the seat count actually differs from the last known + # baseline for this subscription (see get_known_seats / # record_known_seats) — trialing updates wait for payment, # and unrelated updates cannot spam replacement keys. # A non-trial subscription.created is a no-op: its paid key comes from order.paid, so # a canceled trial can never keep Pro — the short trial key just expires. + if event_type == "order.refunded": + return _revoke_refunded_order(data, webhook_id) + if event_type in ("subscription.canceled", "subscription.cancelled"): + return JSONResponse({"status": "ignored", "reason": "paid period honored", + "type": event_type}, status_code=202) + if event_type == "subscription.revoked": + return _revoke_subscription_event(data, webhook_id, + reason="subscription_revoked") pending_seat_baseline = None # (sub_id, seats, event_ts); persisted after key issuance seat_lock_claim = "" if event_type == "order.paid": @@ -792,6 +898,9 @@ async def polar_webhook(request: Request): elif event_type == "subscription.updated": status = event_status sub_id = str(data.get("id") or "").strip()[:128] + if status == "revoked": + return _revoke_subscription_event(data, webhook_id, + reason="subscription_revoked") if status != "active" or not sub_id: return JSONResponse({"status": "ignored", "reason": "not an active " "subscription", "type": event_type}, status_code=202) diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index 81fd0ca..82b11ee 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -93,11 +93,12 @@ def create_app() -> FastAPI: # which is why a naive app.mount('/mcp', mcp.streamable_http_app()) raises # 'Task group is not initialized'). The endpoint is built at '/' inside the sub-app # so mounting under /mcp lines up (Starlette strips the mount prefix). + import importlib.util as _importlib_util import contextlib as _contextlib _mcp_asgi = None _mcp_mgr = None try: - if importlib.util.find_spec("mcp") is None: + if _importlib_util.find_spec("mcp") is None: raise ImportError("the optional mcp package is not installed") import engraphis.mcp_server as _mcp_mod # The MCP session manager's run() is once-per-instance, but create_app() may be @@ -149,7 +150,7 @@ async def _lifespan(app: FastAPI): # dashboard; the machine-readable schema remains available behind the normal gate. app = FastAPI(title="Engraphis Dashboard", docs_url=None, redoc_url=None, openapi_url="/api/openapi.json", lifespan=_lifespan) - app.state.mcp_over_http = False + app.state.mcp_over_http = _mcp_asgi is not None # Honour the advertised allow-list on the actual GA dashboard entrypoint. A # wildcard can never carry browser credentials. @@ -322,6 +323,7 @@ async def _auth_gate(request: Request, call_next): # and authenticated with a per-user bearer token. Each MCP tool then enforces its # own viewer/member/admin role while reusing the dashboard's shared MemoryService. if path == "/mcp" or path.startswith("/mcp/"): + if not (team_enabled and auth_store is not None and licensing.has_feature("team")): return JSONResponse({"error": "a Team license is required to connect agents", diff --git a/engraphis/inspector/webhooks.py b/engraphis/inspector/webhooks.py index 6c77b60..898065c 100644 --- a/engraphis/inspector/webhooks.py +++ b/engraphis/inspector/webhooks.py @@ -258,7 +258,6 @@ def _extract_order_id(data: dict) -> str: """Normalized Polar order id from an order-shaped payload.""" return str(data.get("id") or data.get("order_id") or "").strip()[:128] - def _extract_product_name(data: dict) -> str: product = data.get("product") if not isinstance(product, dict): diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index e6110be..38bb2da 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -1136,8 +1136,6 @@ def engraphis_consolidate( return _err(exc) - - def main() -> None: """Console entry point (``engraphis-mcp``). Runs over stdio.""" mcp.run() diff --git a/engraphis/routes/v2_team.py b/engraphis/routes/v2_team.py index d83081a..e14d34f 100644 --- a/engraphis/routes/v2_team.py +++ b/engraphis/routes/v2_team.py @@ -179,8 +179,10 @@ class TokenReq(BaseModel): def _enabled() -> bool: - return os.environ.get("ENGRAPHIS_TEAM_MODE", "1").strip().lower() not in ( - "0", "false", "no", "off") + raw = os.environ.get("ENGRAPHIS_TEAM_MODE") + if raw is not None: + return raw.strip().lower() not in {"0", "false", "no", "off"} + return bool(settings.team_mode) def _users_db_path(db_path: str) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index 2d6bb66..3df82c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,18 @@ +import os + import pytest from engraphis import cloud_license, licensing from engraphis.config import settings from engraphis.inspector import license_registry +# A developer's real ENGRAPHIS_LICENSE_KEY — loaded into os.environ from a gitignored .env by +# engraphis.config's load_dotenv at the import above — must never leak a paid license into the +# hermetic suite: an active Team key flips inspector /api/* to auth-required and 401s every +# unauthenticated test. Strip it ONCE here at collection, before any test runs, so tests that +# need a key still set their own via monkeypatch.setenv (function-scoped, restored per test) +# without this clobbering them. +os.environ.pop("ENGRAPHIS_LICENSE_KEY", None) + # Opt the licensing module into honoring ENGRAPHIS_LICENSE_PUBKEY, which is otherwise # dead in a shipped process. Set at import time so it covers both collection and # execution. This is the ONLY place that flips the switch — production never imports diff --git a/tests/test_agent_connect_mcp.py b/tests/test_agent_connect_mcp.py index 78bd1d7..e4834a6 100644 --- a/tests/test_agent_connect_mcp.py +++ b/tests/test_agent_connect_mcp.py @@ -10,6 +10,7 @@ real socket needed). The dashboard app's lifespan must run (TestClient used as a context manager) so the MCP session manager's task group initializes. """ +import json import time import pytest @@ -140,6 +141,28 @@ def test_mcp_requires_team_license_402(monkeypatch, tmp_path): assert r.json()["feature"] == "team" + +def test_mcp_viewer_token_can_initialize(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + member = c.post("/api/auth/users", json={"email": "viewer@x.co", "name": "Viewer", + "password": "viewerpass1", "role": "member"}).json()["user"] + c.post("/api/auth/logout") + assert c.post("/api/auth/login", json={"email": "viewer@x.co", + "password": "viewerpass1"}).status_code == 200 + token = _mint(c, label="viewer-agent") + c.post("/api/auth/logout") + c.post("/api/auth/login", json={"email": "admin@x.co", + "password": "supersecret1"}) + assert c.post("/api/auth/users/update", + json={"user_id": member["id"], "role": "viewer"}).status_code == 200 + c.cookies.clear() + r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": _PROTO, "capabilities": {}, + "clientInfo": {"name": "t", "version": "1"}}}, + headers=_h(token)) + assert r.status_code == 200 + def test_mcp_rejects_unconfigured_host(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path, key=_team_key()) as c: _setup_admin(c) @@ -290,6 +313,23 @@ def test_mcp_write_shares_the_dashboard_store(monkeypatch, tmp_path): assert any("MCP" in (m.get("content") or "") for m in rec.json()["memories"]) + +def test_mcp_answer_returns_grounded_result(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + token = _mint(c) + c.cookies.clear() + h = _init(c, token) + r = _rpc(c, h, "tools/call", + {"name": "engraphis_answer", + "arguments": {"query": "Which database does the team use?", + "workspace": "demo"}}, id=11) + assert "Postgres" in r.text + event = json.loads(r.text.split("data: ", 1)[1]) + payload = json.loads(event["result"]["content"][0]["text"]) + assert payload["grounded"] is True + + def test_connect_info_reports_mcp_available(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path, key=_team_key()) as c: _setup_admin(c) diff --git a/tests/test_billing.py b/tests/test_billing.py index 4392033..98de675 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -736,6 +736,18 @@ def _iso_in_days(n): def _body(obj): return json.dumps(obj).encode("utf-8") +def _registry_rows(): + from engraphis.inspector import license_registry as reg + conn = reg.connect() + try: + rows = conn.execute( + "SELECT key_id, status, subscription_id, order_id FROM issued_licenses " + "ORDER BY created_at").fetchall() + return [dict(row) for row in rows] + finally: + conn.close() + + def _registry_rows(): from engraphis.inspector import license_registry as reg @@ -799,7 +811,6 @@ def test_route_trial_then_conversion_two_distinct_keys(monkeypatch): assert r1.json() == {"status": "fulfilled", "key_issued": True} assert r2.json() == {"status": "fulfilled", "key_issued": True} - def test_order_paid_records_polar_ids_for_refunds(monkeypatch): client = _inspector_client(monkeypatch) order = _body({"type": "order.paid", "data": { @@ -856,64 +867,6 @@ def test_order_refunded_without_subscription_revokes_by_order(monkeypatch): assert reg.is_revoked(key_id) is True -def test_unmappable_revoke_event_is_retryable_not_silently_dropped(monkeypatch): - """A revoking event we cannot map to a key must NOT answer 2xx. - - Polar stops redelivering once it sees a 2xx, so returning 202 for an unmappable - revoke would silently drop the revocation entirely — a refunded customer keeps a - working paid key with nothing left to retry. A 5xx keeps the delivery on Polar's - retry queue where it stays visible.""" - client = _inspector_client(monkeypatch) - # A revoking event whose payload carries no subscription id and no order id at all. - orphan = _body({"type": "subscription.revoked", "data": {"customer": {}}}) - - first = _post(client, WHSEC, "evt_revoke_no_target", orphan) - assert first.status_code >= 500, ( - "unmappable revoke must be retryable on first delivery, got %s" % first.status_code) - assert first.json().get("error") == "missing revoke target" - - -def test_unmappable_revoke_event_converges_instead_of_retrying_forever(monkeypatch): - """...but it must not 5xx forever either. - - A payload with no ids will NEVER become mappable, so an unconditional 5xx means every - redelivery fails identically and sustained failures can get the whole endpoint - disabled — which would then drop real order.paid fulfillments. One retryable answer, - then converge to 2xx.""" - client = _inspector_client(monkeypatch) - orphan = _body({"type": "subscription.revoked", "data": {"customer": {}}}) - - assert _post(client, WHSEC, "evt_revoke_converge", orphan).status_code >= 500 - # Simulate a provider retry arriving after the normal processing-claim TTL. The - # first response must have durably latched "seen once" rather than relying on an - # in-flight reservation that would be reclaimed and 5xx forever. - conn = B._dedup_conn() - try: - row = conn.execute( - "SELECT state FROM processed WHERE webhook_id=?", - ("unmappable:evt_revoke_converge",), - ).fetchone() - assert row[0] == "fulfilled" - conn.execute( - "UPDATE processed SET ts=0 WHERE webhook_id=?", - ("unmappable:evt_revoke_converge",), - ) - conn.commit() - finally: - conn.close() - # Same webhook-id redelivered: proven deterministic, so stop the retry loop. - replay = _post(client, WHSEC, "evt_revoke_converge", orphan) - assert replay.status_code == 202, ( - "redelivery of an unmappable revoke must converge, got %s" % replay.status_code) - assert replay.json().get("status") == "unmappable" - # And it stays converged. - assert _post(client, WHSEC, "evt_revoke_converge", orphan).status_code == 202 - - # A DIFFERENT delivery still gets its own first-time retryable answer — convergence - # is per-delivery, not a global latch that would mute a later real failure. - assert _post(client, WHSEC, "evt_revoke_other", orphan).status_code >= 500 - - def test_subscription_canceled_honors_paid_period(monkeypatch): client = _inspector_client(monkeypatch) order = _body({"type": "order.paid", "data": { @@ -972,6 +925,64 @@ def test_subscription_updated_revoked_revokes_keys(monkeypatch): assert reg.is_revoked(key_id) is True +def test_unmappable_revoke_event_is_retryable_not_silently_dropped(monkeypatch): + """A revoking event we cannot map to a key must NOT answer 2xx. + + Polar stops redelivering once it sees a 2xx, so returning 202 for an unmappable + revoke would silently drop the revocation entirely — a refunded customer keeps a + working paid key with nothing left to retry. A 5xx keeps the delivery on Polar's + retry queue where it stays visible.""" + client = _inspector_client(monkeypatch) + # A revoking event whose payload carries no subscription id and no order id at all. + orphan = _body({"type": "subscription.revoked", "data": {"customer": {}}}) + + first = _post(client, WHSEC, "evt_revoke_no_target", orphan) + assert first.status_code >= 500, ( + "unmappable revoke must be retryable on first delivery, got %s" % first.status_code) + assert first.json().get("error") == "missing revoke target" + + +def test_unmappable_revoke_event_converges_instead_of_retrying_forever(monkeypatch): + """...but it must not 5xx forever either. + + A payload with no ids will NEVER become mappable, so an unconditional 5xx means every + redelivery fails identically and sustained failures can get the whole endpoint + disabled — which would then drop real order.paid fulfillments. One retryable answer, + then converge to 2xx.""" + client = _inspector_client(monkeypatch) + orphan = _body({"type": "subscription.revoked", "data": {"customer": {}}}) + + assert _post(client, WHSEC, "evt_revoke_converge", orphan).status_code >= 500 + # Simulate a provider retry arriving after the normal processing-claim TTL. The + # first response must have durably latched "seen once" rather than relying on an + # in-flight reservation that would be reclaimed and 5xx forever. + conn = B._dedup_conn() + try: + row = conn.execute( + "SELECT state FROM processed WHERE webhook_id=?", + ("unmappable:evt_revoke_converge",), + ).fetchone() + assert row[0] == "fulfilled" + conn.execute( + "UPDATE processed SET ts=0 WHERE webhook_id=?", + ("unmappable:evt_revoke_converge",), + ) + conn.commit() + finally: + conn.close() + # Same webhook-id redelivered: proven deterministic, so stop the retry loop. + replay = _post(client, WHSEC, "evt_revoke_converge", orphan) + assert replay.status_code == 202, ( + "redelivery of an unmappable revoke must converge, got %s" % replay.status_code) + assert replay.json().get("status") == "unmappable" + # And it stays converged. + assert _post(client, WHSEC, "evt_revoke_converge", orphan).status_code == 202 + + # A DIFFERENT delivery still gets its own first-time retryable answer — convergence + # is per-delivery, not a global latch that would mute a later real failure. + assert _post(client, WHSEC, "evt_revoke_other", orphan).status_code >= 500 + + def test_vendor_revoked_subscription_update_does_not_require_product(monkeypatch): from engraphis.inspector import license_registry as reg from engraphis.inspector import webhooks as WH diff --git a/tests/test_workspace_ops.py b/tests/test_workspace_ops.py index 34d5340..9b68f4f 100644 --- a/tests/test_workspace_ops.py +++ b/tests/test_workspace_ops.py @@ -551,13 +551,16 @@ def test_copy_clones_vectors_fts_links_entities_and_edges(): assert [r["name"] for r in repos] == ["infra"] assert repos[0]["id"] != src_repo_id # every copied memory points at the cloned repo, not the source repo - repo_ids = {c.execute("SELECT repo_id FROM memories WHERE id=?", (r["id"],)).fetchone()["repo_id"] - for r in new_mem} + repo_ids = { + c.execute("SELECT repo_id FROM memories WHERE id=?", (r["id"],)).fetchone()["repo_id"] + for r in new_mem + } assert repo_ids == {repos[0]["id"]} # the mem_links row was cloned onto the two new memory ids id_map = {r["content"]: r["id"] for r in new_mem} - new_a, new_b = id_map["Postgres 16 is the primary database."], id_map["Deploys run Fridays at noon."] + new_a = id_map["Postgres 16 is the primary database."] + new_b = id_map["Deploys run Fridays at noon."] linked = c.execute( "SELECT layer, reason FROM mem_links WHERE (a=? AND b=?) OR (a=? AND b=?)", (new_a, new_b, new_b, new_a)).fetchone()