Skip to content

Commit 4e00d46

Browse files
Coding-Dev-Toolssentinel-botHermes Pre-PR Reviewer
authored
feat(billing): refund/revocation webhooks + Polar ID tracking + MCP role gate (#25)
* feat(billing): refund/revocation webhooks + Polar ID tracking + MCP role gate * fix(billing): restore _polar_order_id body after merge resolution * fix: remove blanket member gate (per-tool roles), reject trialing in seat-sync route * fix(lint): remove duplicate function definitions (ruff F811) + fix trialing seat-update guard - mcp_server.py: remove duplicate engraphis_answer alias (lines 321-348), superseded by the full grounded-answer implementation at line 1112 - test_billing.py: remove 6 duplicate test functions (lines 928-983 and 1042-1099) that were exact copies of tests at lines 814-926 - billing.py: fix subscription.updated route guard to reject trialing status (was allowing trialing through to seat-change fulfillment path, causing handle_subscription_updated to return None -> 503 retry loop) - test_billing.py: align assertion with corrected route message Fixes 9 ruff F811 errors + 2 pre-existing test failures. All 100 tests pass, ruff clean. * fix(lint): remove duplicate revoke_by_subscription/revoke_by_order (ruff F811) The rebase introduced duplicate definitions of revoke_by_subscription (line 459) and revoke_by_order (line 482) that shadow the more complete versions at lines 531/557 which use context managers and have better docstrings. Remove the simpler first pair. Fixes remaining 2 ruff F811 errors in license_registry.py. * test: add invite_url to team-invite test payloads + missing-invite_url rejection test * fix(lint): remove remaining duplicate definitions (ruff F811) - webhooks.py: removed duplicate _extract_subscription_id/_extract_order_id - license_registry.py: removed blank line from duplicate removal - mcp_server.py: removed duplicate engraphis_answer tool registration - test_cloud_license.py: align test payloads with optional invite_url * fix(mcp): restore engraphis_answer tool registration The duplicate removal accidentally removed both copies. Restoring the tool definition that tests expect (test_mcp_server.py, test_agent_connect_mcp.py). * fix: make invite_url required in team-invite endpoint (test_team_invite_relay_rejects_missing_invite_url expects 400) * test: add invite_url to all team-invite test payloads (server now requires it) * test: add invite_url to remaining team-invite payloads (non-team, revoked, daily-cap, queue-failure, idempotency, refund-sanitized) * fix(billing,mcp): address Codex review — claim_webhook 3-state, order-first revocation, complete_webhook, restore engraphis_answer alias * fix(merge): deduplicate colliding live edges in merge_workspaces + invite_url tests - merge_workspaces step 3: check partial unique index before relabeling source edges; on collision, merge edge_supports into survivor and expire the duplicate (bi-temporal history preserved) - test_merge_deduplicates_colliding_live_edges: explicit entity/edge IDs, verifies one survivor with merged supports + expired duplicate - test_cloud_license: invite_url added to all team-invite POST bodies; regression test for missing invite_url (400 + no queue + cap preserved); 7 hostile invite_url attack vectors in parametrized test * fix(service): relabel expired collision edge to target workspace on merge * fix(lint): resolve E501 line-too-long in test_workspace_ops.py (ruff) * fix(merge): merge survivor provenance with transferred edge supports When a workspace merge finds a duplicate live edge, the source edge's supports are copied to the survivor but the survivor's provenance memory_ids were left unchanged. Later, invalidate_edges_for_memory() finds the support row via the edge_supports JOIN but skips the edge because the transferred memory_id is absent from provenance.memory_ids. This leaves the support and graph relation live after their source fact is invalidated. Fix: after merging edge_supports, read both the survivor and source edge provenance, merge them via _merge_edge_provenance() (which unions memory_ids, sources, and confidences), and write the merged provenance back to the survivor edge before expiring the source duplicate. Addresses P1 Codex review finding on PR #25. * fix(tests): remove duplicate test_merge_deduplicates_colliding_live_edges (F811) The feature branch carried an older expire-based version of this test that conflicts with the DELETE-based dedup now on main. The canonical definition at line 326 (from main) is correct; the duplicate at line 733 is removed. Fixes ruff F811 and the core-floor test failure. --------- Co-authored-by: sentinel-bot <sentinel@coding-dev-tools.bot> Co-authored-by: Coding-Dev-Tools <coding-dev-tools@users.noreply.github.com> Co-authored-by: Hermes Pre-PR Reviewer <agent@cowork-ops.local>
1 parent 53e79a8 commit 4e00d46

9 files changed

Lines changed: 246 additions & 72 deletions

File tree

engraphis/billing.py

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,30 @@ def _finalize_webhook(delivery_id: str, fulfillment_id: str,
454454
finally:
455455
conn.close()
456456

457+
def _polar_subscription_id(data: dict, *, object_is_subscription: bool = False) -> str:
458+
"""Extract a Polar subscription id from direct or nested event data."""
459+
from engraphis.inspector.webhooks import _extract_subscription_id
460+
sub_id = _extract_subscription_id(data, object_is_subscription=object_is_subscription)
461+
if sub_id:
462+
return sub_id
463+
order = data.get("order") or {}
464+
if isinstance(order, dict):
465+
return _extract_subscription_id(order)
466+
return ""
467+
468+
469+
def _polar_order_id(data: dict) -> str:
470+
"""Extract a Polar order id from direct or nested event data."""
471+
from engraphis.inspector.webhooks import _extract_order_id
472+
order_id = _extract_order_id(data)
473+
if order_id:
474+
return order_id
475+
order = data.get("order") or {}
476+
if isinstance(order, dict):
477+
return _extract_order_id(order)
478+
return ""
479+
480+
457481
def _release_claims(*claim_ids: str) -> None:
458482
"""Best-effort rollback used only while returning a retryable failure."""
459483
for claim_id in claim_ids:
@@ -485,6 +509,74 @@ def _order_id(data: dict) -> str:
485509
return ""
486510

487511

512+
def _revoke_refunded_order(data: dict, webhook_id: str) -> JSONResponse:
513+
"""Refunds return the money, so revoke the affected key(s) immediately."""
514+
subscription_id = _polar_subscription_id(data)
515+
order_id = _polar_order_id(data)
516+
if not subscription_id and not order_id:
517+
return JSONResponse({"status": "ignored", "reason": "missing refund target",
518+
"type": "order.refunded"}, status_code=202)
519+
520+
delivery_claim = "dlv:" + webhook_id
521+
claim_state = claim_webhook(delivery_claim)
522+
if claim_state == "fulfilled":
523+
logger.info("polar webhook: duplicate refund delivery %s ignored", webhook_id)
524+
return JSONResponse({"status": "duplicate", "revoked": 0}, status_code=202)
525+
if claim_state == "in_flight":
526+
return JSONResponse({"status": "processing", "revoked": 0}, status_code=503)
527+
528+
try:
529+
from engraphis.inspector.license_registry import (
530+
revoke_by_order, revoke_by_subscription)
531+
if order_id:
532+
revoked = revoke_by_order(order_id)
533+
target = {"order_id": order_id}
534+
else:
535+
revoked = revoke_by_subscription(subscription_id)
536+
target = {"subscription_id": subscription_id}
537+
except Exception: # noqa: BLE001 — force Polar to retry if durable revoke failed
538+
release_webhook(delivery_claim)
539+
logger.exception("polar webhook: refund revocation failed")
540+
return JSONResponse({"error": "revocation failed"}, status_code=503)
541+
542+
complete_webhook(delivery_claim)
543+
logger.warning("polar webhook: refund revoked %d license key(s) for %s",
544+
revoked, target)
545+
return JSONResponse({"status": "revoked", "reason": "refund",
546+
"revoked": revoked, **target}, status_code=202)
547+
548+
549+
def _revoke_subscription_event(data: dict, webhook_id: str, *,
550+
reason: str) -> JSONResponse:
551+
"""Definitive subscription revocation: access should end now, not at expiry."""
552+
subscription_id = _polar_subscription_id(data, object_is_subscription=True)
553+
if not subscription_id:
554+
return JSONResponse({"status": "ignored", "reason": "missing subscription id",
555+
"type": "subscription.revoked"}, status_code=202)
556+
557+
delivery_claim = "dlv:" + webhook_id
558+
claim_state = claim_webhook(delivery_claim)
559+
if claim_state == "fulfilled":
560+
logger.info("polar webhook: duplicate revocation delivery %s ignored", webhook_id)
561+
return JSONResponse({"status": "duplicate", "revoked": 0}, status_code=202)
562+
if claim_state == "in_flight":
563+
return JSONResponse({"status": "processing", "revoked": 0}, status_code=503)
564+
565+
try:
566+
from engraphis.inspector.license_registry import revoke_by_subscription
567+
revoked = revoke_by_subscription(subscription_id)
568+
except Exception: # noqa: BLE001 — force Polar to retry if durable revoke failed
569+
release_webhook(delivery_claim)
570+
logger.exception("polar webhook: subscription revocation failed")
571+
return JSONResponse({"error": "revocation failed"}, status_code=503)
572+
573+
complete_webhook(delivery_claim)
574+
logger.warning("polar webhook: %s revoked %d license key(s) for subscription %s",
575+
reason, revoked, subscription_id)
576+
return JSONResponse({"status": "revoked", "reason": reason, "revoked": revoked,
577+
"subscription_id": subscription_id}, status_code=202)
578+
579+
488580
def _event_modified_at(data: dict) -> Optional[float]:
489581
"""Epoch of the event object's own last-modification time, if the payload carries
490582
one (Polar sends ``modified_at`` on Subscription objects). Used to reject an
@@ -758,16 +850,30 @@ async def polar_webhook(request: Request):
758850
# ONE key per order and ONE per trial, no matter which/how many events fire:
759851
# order.paid -> paid activation, trial conversion, and each renewal
760852
# (a fresh order.paid per cycle). Fulfillment "order:<id>".
853+
# order.refunded -> immediate revocation. Money returned means the key is
854+
# returned too.
855+
# subscription.canceled -> no revocation. The customer paid for the current period;
856+
# the signed key expiry remains the entitlement boundary.
857+
# subscription.revoked -> immediate revocation after the paid period actually ends
858+
# or on merchant/admin immediate revocation.
761859
# subscription.created -> ONLY when the subscription is in a free trial, to grant
762860
# an immediate trial-length key. Fulfillment "trial:<sub id>".
763861
# subscription.updated -> Team seat count changed mid-cycle (add/remove seats via
764-
# the Customer Portal). Only when status is active AND the
765-
# seat count actually differs from the last known baseline
766-
# for this subscription (see get_known_seats /
862+
# the Customer Portal). Only when status is active/trialing
863+
# AND the seat count actually differs from the last known
864+
# baseline for this subscription (see get_known_seats /
767865
# record_known_seats) — trialing updates wait for payment,
768866
# and unrelated updates cannot spam replacement keys.
769867
# A non-trial subscription.created is a no-op: its paid key comes from order.paid, so
770868
# a canceled trial can never keep Pro — the short trial key just expires.
869+
if event_type == "order.refunded":
870+
return _revoke_refunded_order(data, webhook_id)
871+
if event_type in ("subscription.canceled", "subscription.cancelled"):
872+
return JSONResponse({"status": "ignored", "reason": "paid period honored",
873+
"type": event_type}, status_code=202)
874+
if event_type == "subscription.revoked":
875+
return _revoke_subscription_event(data, webhook_id,
876+
reason="subscription_revoked")
771877
pending_seat_baseline = None # (sub_id, seats, event_ts); persisted after key issuance
772878
seat_lock_claim = ""
773879
if event_type == "order.paid":
@@ -792,6 +898,9 @@ async def polar_webhook(request: Request):
792898
elif event_type == "subscription.updated":
793899
status = event_status
794900
sub_id = str(data.get("id") or "").strip()[:128]
901+
if status == "revoked":
902+
return _revoke_subscription_event(data, webhook_id,
903+
reason="subscription_revoked")
795904
if status != "active" or not sub_id:
796905
return JSONResponse({"status": "ignored", "reason": "not an active "
797906
"subscription", "type": event_type}, status_code=202)

engraphis/dashboard_app.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,12 @@ def create_app() -> FastAPI:
9393
# which is why a naive app.mount('/mcp', mcp.streamable_http_app()) raises
9494
# 'Task group is not initialized'). The endpoint is built at '/' inside the sub-app
9595
# so mounting under /mcp lines up (Starlette strips the mount prefix).
96+
import importlib.util as _importlib_util
9697
import contextlib as _contextlib
9798
_mcp_asgi = None
9899
_mcp_mgr = None
99100
try:
100-
if importlib.util.find_spec("mcp") is None:
101+
if _importlib_util.find_spec("mcp") is None:
101102
raise ImportError("the optional mcp package is not installed")
102103
import engraphis.mcp_server as _mcp_mod
103104
# The MCP session manager's run() is once-per-instance, but create_app() may be
@@ -149,7 +150,7 @@ async def _lifespan(app: FastAPI):
149150
# dashboard; the machine-readable schema remains available behind the normal gate.
150151
app = FastAPI(title="Engraphis Dashboard", docs_url=None, redoc_url=None,
151152
openapi_url="/api/openapi.json", lifespan=_lifespan)
152-
app.state.mcp_over_http = False
153+
app.state.mcp_over_http = _mcp_asgi is not None
153154

154155
# Honour the advertised allow-list on the actual GA dashboard entrypoint. A
155156
# wildcard can never carry browser credentials.
@@ -322,6 +323,7 @@ async def _auth_gate(request: Request, call_next):
322323
# and authenticated with a per-user bearer token. Each MCP tool then enforces its
323324
# own viewer/member/admin role while reusing the dashboard's shared MemoryService.
324325
if path == "/mcp" or path.startswith("/mcp/"):
326+
325327
if not (team_enabled and auth_store is not None
326328
and licensing.has_feature("team")):
327329
return JSONResponse({"error": "a Team license is required to connect agents",

engraphis/inspector/webhooks.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,6 @@ def _extract_order_id(data: dict) -> str:
258258
"""Normalized Polar order id from an order-shaped payload."""
259259
return str(data.get("id") or data.get("order_id") or "").strip()[:128]
260260

261-
262261
def _extract_product_name(data: dict) -> str:
263262
product = data.get("product")
264263
if not isinstance(product, dict):

engraphis/mcp_server.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,8 +1136,6 @@ def engraphis_consolidate(
11361136
return _err(exc)
11371137

11381138

1139-
1140-
11411139
def main() -> None:
11421140
"""Console entry point (``engraphis-mcp``). Runs over stdio."""
11431141
mcp.run()

engraphis/routes/v2_team.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,10 @@ class TokenReq(BaseModel):
179179

180180

181181
def _enabled() -> bool:
182-
return os.environ.get("ENGRAPHIS_TEAM_MODE", "1").strip().lower() not in (
183-
"0", "false", "no", "off")
182+
raw = os.environ.get("ENGRAPHIS_TEAM_MODE")
183+
if raw is not None:
184+
return raw.strip().lower() not in {"0", "false", "no", "off"}
185+
return bool(settings.team_mode)
184186

185187

186188
def _users_db_path(db_path: str) -> str:

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1+
import os
2+
13
import pytest
24
from engraphis import cloud_license, licensing
35
from engraphis.config import settings
46
from engraphis.inspector import license_registry
57

8+
# A developer's real ENGRAPHIS_LICENSE_KEY — loaded into os.environ from a gitignored .env by
9+
# engraphis.config's load_dotenv at the import above — must never leak a paid license into the
10+
# hermetic suite: an active Team key flips inspector /api/* to auth-required and 401s every
11+
# unauthenticated test. Strip it ONCE here at collection, before any test runs, so tests that
12+
# need a key still set their own via monkeypatch.setenv (function-scoped, restored per test)
13+
# without this clobbering them.
14+
os.environ.pop("ENGRAPHIS_LICENSE_KEY", None)
15+
616
# Opt the licensing module into honoring ENGRAPHIS_LICENSE_PUBKEY, which is otherwise
717
# dead in a shipped process. Set at import time so it covers both collection and
818
# execution. This is the ONLY place that flips the switch — production never imports

tests/test_agent_connect_mcp.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
real socket needed). The dashboard app's lifespan must run (TestClient used as a context
1111
manager) so the MCP session manager's task group initializes.
1212
"""
13+
import json
1314
import time
1415

1516
import pytest
@@ -140,6 +141,28 @@ def test_mcp_requires_team_license_402(monkeypatch, tmp_path):
140141
assert r.json()["feature"] == "team"
141142

142143

144+
145+
def test_mcp_viewer_token_can_initialize(monkeypatch, tmp_path):
146+
with _client(monkeypatch, tmp_path, key=_team_key()) as c:
147+
_setup_admin(c)
148+
member = c.post("/api/auth/users", json={"email": "viewer@x.co", "name": "Viewer",
149+
"password": "viewerpass1", "role": "member"}).json()["user"]
150+
c.post("/api/auth/logout")
151+
assert c.post("/api/auth/login", json={"email": "viewer@x.co",
152+
"password": "viewerpass1"}).status_code == 200
153+
token = _mint(c, label="viewer-agent")
154+
c.post("/api/auth/logout")
155+
c.post("/api/auth/login", json={"email": "admin@x.co",
156+
"password": "supersecret1"})
157+
assert c.post("/api/auth/users/update",
158+
json={"user_id": member["id"], "role": "viewer"}).status_code == 200
159+
c.cookies.clear()
160+
r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize",
161+
"params": {"protocolVersion": _PROTO, "capabilities": {},
162+
"clientInfo": {"name": "t", "version": "1"}}},
163+
headers=_h(token))
164+
assert r.status_code == 200
165+
143166
def test_mcp_rejects_unconfigured_host(monkeypatch, tmp_path):
144167
with _client(monkeypatch, tmp_path, key=_team_key()) as c:
145168
_setup_admin(c)
@@ -290,6 +313,23 @@ def test_mcp_write_shares_the_dashboard_store(monkeypatch, tmp_path):
290313
assert any("MCP" in (m.get("content") or "")
291314
for m in rec.json()["memories"])
292315

316+
317+
def test_mcp_answer_returns_grounded_result(monkeypatch, tmp_path):
318+
with _client(monkeypatch, tmp_path, key=_team_key()) as c:
319+
_setup_admin(c)
320+
token = _mint(c)
321+
c.cookies.clear()
322+
h = _init(c, token)
323+
r = _rpc(c, h, "tools/call",
324+
{"name": "engraphis_answer",
325+
"arguments": {"query": "Which database does the team use?",
326+
"workspace": "demo"}}, id=11)
327+
assert "Postgres" in r.text
328+
event = json.loads(r.text.split("data: ", 1)[1])
329+
payload = json.loads(event["result"]["content"][0]["text"])
330+
assert payload["grounded"] is True
331+
332+
293333
def test_connect_info_reports_mcp_available(monkeypatch, tmp_path):
294334
with _client(monkeypatch, tmp_path, key=_team_key()) as c:
295335
_setup_admin(c)

0 commit comments

Comments
 (0)