From aa30c2ab4eec25022fedf8d1a80ca0bfb56a3749 Mon Sep 17 00:00:00 2001 From: rimkusaurimas Date: Wed, 6 May 2026 15:02:24 +0200 Subject: [PATCH 1/4] feat(trusted_endpoints): support {id} and {path:path} placeholders in registered URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registered URL may now contain FastAPI/Express-style path placeholders so a single entry covers a family of concrete URLs: {name} - matches exactly one path segment (no '/'). e.g. https://api.example.com/customers/{id} matches /customers/42 but NOT /customers/42/orders. {name:path} - matches any subtree, including '/' separators. e.g. https://api.example.com/customers/{rest:path} matches both /customers/42 and /customers/42/orders. Closes #14. Why: customer-support-sdk-demo had to enumerate ~70 concrete URLs at startup for templated routes (/customers/{id}). Runtime-generated ids (e.g. POST /tickets returning a fresh id) couldn't be trusted until manually registered. A single placeholder entry replaces the enumeration. Implementation: - Plain URLs without '{' keep exact-match semantics. No schema change. No migration needed for existing rows. Existing exact-match tests unchanged. - Pattern matching is auto-detected from URL content. Pattern compilation is LRU-cached so repeated lookups don't recompile the regex. - is_trusted_endpoint uses a two-phase lookup: exact match first (single indexed query, fast path), then a pattern-only scan (LIKE '%{%' filter) for rows containing placeholders. Plain registries see no perf regression. - The snapshot tamper-check inside check_claim_endpoints_are_trusted honors the same syntax — a payload built against a pattern entry verifies cleanly on the receiver side. Tests: 12 new (94 total). Ruff clean. --- CHANGELOG.md | 4 + README.md | 25 +++++ src/provably/trusted_endpoints.py | 89 +++++++++++++++++- tests/unit/test_trusted_endpoints.py | 135 +++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17bd1a..199bfed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- `trusted_endpoints`: registered URLs may now contain FastAPI/Express-style path placeholders. `{id}` matches exactly one path segment, `{rest:path}` matches any subtree. Plain URLs without `{` keep exact-match semantics — no migration needed for existing rows. Both `is_trusted_endpoint` and the snapshot tamper-check inside `evaluate_handoff` honor the new syntax. Closes #14. + ## 0.2.0 - Added `provably.configure_indexing(enable_indexing: bool)`: one-call bootstrap (`initialize_runtime` + `init_interceptor` + `enable` / `disable`) for sender agents. diff --git a/README.md b/README.md index 389c962..3209975 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,31 @@ URLs are normalized (lowercase scheme + host, default ports collapsed, trailing slash dropped) before any read or write so that `https://API.EXAMPLE.COM/x/` and `https://api.example.com/x` collide on the same row. +#### Path-pattern entries + +Concrete URLs match exactly. To authorize a family of URLs with a single entry — +useful for templated routes like `/customers/{id}` or runtime-generated ids — +register the URL with FastAPI/Express-style placeholders: + +| Placeholder | Matches | Example | +|---|---|---| +| `{name}` | exactly one path segment (no `/`) | `https://api.example.com/customers/{id}` matches `…/customers/42` but **not** `…/customers/42/orders` | +| `{name:path}` | any subtree (including `/` separators) | `https://api.example.com/customers/{rest:path}` matches both `…/customers/42` and `…/customers/42/orders` | + +The placeholder name (`id`, `rest`, …) is purely descriptive and does not affect +matching. Plain URLs without `{` characters keep exact-match semantics — no +behavior change for existing entries. + +```sql +-- Register a templated route once instead of enumerating every concrete id +INSERT INTO trusted_endpoints (org_id, normalized_url, display_label, entry_type) +VALUES ('my-org', 'https://api.example.com/customers/{id}', 'Customers (by id)', 'endpoint'); +``` + +`is_trusted_endpoint` and the snapshot tamper-check inside `evaluate_handoff` +both honor the same matching rules, so a claim against `…/customers/42` will +pass both gates when only the templated entry is registered. + ## Public API All public symbols are re-exported from the top-level `provably` namespace. See diff --git a/src/provably/trusted_endpoints.py b/src/provably/trusted_endpoints.py index e687a15..51851f4 100644 --- a/src/provably/trusted_endpoints.py +++ b/src/provably/trusted_endpoints.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re +from functools import lru_cache from typing import TYPE_CHECKING from urllib.parse import urlparse @@ -12,6 +14,58 @@ _DDL_DONE = False +# --------------------------------------------------------------------------- +# Pattern matching +# +# A registered URL may contain FastAPI/Express-style path placeholders so a single +# entry can authorize a family of concrete URLs: +# +# {name} — matches one path segment (no '/'). E.g. /customers/{id} matches +# /customers/123 but NOT /customers/123/orders. +# {name:path} — matches any subtree, including '/' separators. E.g. +# /customers/{rest:path} matches both /customers/123 and +# /customers/123/orders. +# +# Plain URLs (no '{' character) keep exact-match semantics — no behavior change for +# existing entries. +# --------------------------------------------------------------------------- + +_PLACEHOLDER_RE = re.compile(r"\{[^}/]+(?::path)?\}") + + +@lru_cache(maxsize=512) +def _compile_pattern(registered: str) -> re.Pattern[str] | None: + """Compile a registered URL into a regex if it has placeholders, else return None. + + Cache keeps regex compilation off the hot per-request path. + """ + if "{" not in registered: + return None + parts: list[str] = [] + cursor = 0 + has_placeholder = False + for match in _PLACEHOLDER_RE.finditer(registered): + parts.append(re.escape(registered[cursor : match.start()])) + is_path = ":path" in match.group(0) + parts.append(".+?" if is_path else "[^/]+?") + cursor = match.end() + has_placeholder = True + if not has_placeholder: + return None + parts.append(re.escape(registered[cursor:])) + try: + return re.compile(f"^{''.join(parts)}$") + except re.error: + return None + + +def _matches_registered(claim_url: str, registered: str) -> bool: + """``True`` when ``claim_url`` exactly matches ``registered`` or matches its pattern.""" + if claim_url == registered: + return True + pattern = _compile_pattern(registered) + return pattern is not None and pattern.match(claim_url) is not None + def normalize_url_for_trust(url: str) -> str: """Return the canonical form of ``url`` used for trust look-ups. @@ -74,7 +128,13 @@ def ensure_trusted_endpoints_table(conn: psycopg2.extensions.connection) -> None def is_trusted_endpoint(url: str, org_id: str, conn: psycopg2.extensions.connection) -> bool: - """Return whether ``url`` is currently allowlisted for ``org_id``; normalizes URL before look-up.""" + """Return whether ``url`` is currently allowlisted for ``org_id``. + + Two-phase lookup: exact match first (fast path, single indexed query), then a + pattern-match scan over only the rows containing ``{`` in their ``normalized_url``. + Plain URLs without placeholders never enter the slow path, so existing exact-match + registries see no perf regression. + """ if not url or not org_id: return False norm = normalize_url_for_trust(url) @@ -82,6 +142,7 @@ def is_trusted_endpoint(url: str, org_id: str, conn: psycopg2.extensions.connect return False _ensure_trusted_table(conn) with conn.cursor() as cur: + # Fast path: exact match. cur.execute( """ SELECT 1 FROM trusted_endpoints @@ -90,7 +151,21 @@ def is_trusted_endpoint(url: str, org_id: str, conn: psycopg2.extensions.connect """, (org_id, norm), ) - return cur.fetchone() is not None + if cur.fetchone() is not None: + return True + # Slow path: pattern entries only. + cur.execute( + """ + SELECT normalized_url FROM trusted_endpoints + WHERE org_id = %s AND entry_type = 'endpoint' AND revoked_at IS NULL + AND normalized_url LIKE '%%{%%' + """, + (org_id,), + ) + for (registered,) in cur.fetchall(): + if _matches_registered(norm, str(registered or "")): + return True + return False def list_trusted_endpoints( @@ -208,7 +283,15 @@ def check_claim_endpoints_are_trusted( registry = {n for url in hp.trusted_endpoint_registry if (n := normalize_url_for_trust(str(url)))} if registry: - missing = list(dict.fromkeys(u for u in claim_urls if u not in registry)) + pattern_entries = [r for r in registry if "{" in r] + missing: list[str] = [] + for claim_url in claim_urls: + if claim_url in registry: + continue + if any(_matches_registered(claim_url, entry) for entry in pattern_entries): + continue + missing.append(claim_url) + missing = list(dict.fromkeys(missing)) if missing: raise ValueError(f"handoff has endpoints missing from trusted snapshot: {', '.join(missing)}") diff --git a/tests/unit/test_trusted_endpoints.py b/tests/unit/test_trusted_endpoints.py index 0f914e5..b740722 100644 --- a/tests/unit/test_trusted_endpoints.py +++ b/tests/unit/test_trusted_endpoints.py @@ -5,6 +5,8 @@ import pytest from provably.trusted_endpoints import ( + _compile_pattern, + _matches_registered, is_trusted_endpoint, list_trusted_endpoints, normalize_url_for_trust, @@ -46,6 +48,139 @@ def test_is_trusted_queries_normalized_row(monkeypatch: pytest.MonkeyPatch) -> N assert args[1][1] == "https://x.com/a" +# --------------------------------------------------------------------------- +# Pattern matching ({name} and {name:path} placeholders) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "registered", + [ + "https://api.example.com/customers", + "https://api.example.com/customers/123", + "https://example.com", + ], +) +def test_compile_pattern_returns_none_for_plain_urls(registered: str) -> None: + assert _compile_pattern(registered) is None + + +def test_pattern_single_segment_matches_one_path_segment() -> None: + pattern = _compile_pattern("https://api.example.com/customers/{id}") + assert pattern is not None + assert pattern.match("https://api.example.com/customers/123") is not None + assert pattern.match("https://api.example.com/customers/abc-DEF") is not None + # Must NOT swallow additional path segments + assert pattern.match("https://api.example.com/customers/123/orders") is None + # Must NOT match a different prefix + assert pattern.match("https://api.example.com/clients/123") is None + # Must NOT match the bare prefix without an id segment + assert pattern.match("https://api.example.com/customers/") is None + + +def test_pattern_path_placeholder_matches_subtree() -> None: + pattern = _compile_pattern("https://api.example.com/customers/{rest:path}") + assert pattern is not None + assert pattern.match("https://api.example.com/customers/123") is not None + assert pattern.match("https://api.example.com/customers/123/orders/456") is not None + # Still anchored at the prefix + assert pattern.match("https://api.example.com/clients/123") is None + + +def test_pattern_multiple_placeholders() -> None: + pattern = _compile_pattern("https://api.example.com/customers/{cust}/orders/{order}") + assert pattern is not None + assert pattern.match("https://api.example.com/customers/c1/orders/o9") is not None + assert pattern.match("https://api.example.com/customers/c1/orders/o9/items/x") is None + + +def test_matches_registered_falls_back_to_exact() -> None: + assert _matches_registered("https://x.com/a", "https://x.com/a") is True + assert _matches_registered("https://x.com/a", "https://x.com/b") is False + + +def test_matches_registered_uses_pattern_when_present() -> None: + assert _matches_registered("https://x.com/customers/9", "https://x.com/customers/{id}") is True + assert _matches_registered("https://x.com/customers/9/orders", "https://x.com/customers/{id}") is False + + +def test_is_trusted_endpoint_matches_pattern_entry(monkeypatch: pytest.MonkeyPatch) -> None: + """A claim URL matching a registered ``{id}`` pattern is trusted via the slow path.""" + monkeypatch.setattr("provably.trusted_endpoints._ensure_trusted_table", lambda _c: None) + conn = MagicMock() + cur = MagicMock() + conn.cursor.return_value.__enter__ = lambda *_: cur + conn.cursor.return_value.__exit__ = lambda *_: None + # First query (exact match) misses; second query (pattern entries) returns one row. + cur.fetchone.return_value = None + cur.fetchall.return_value = [("https://api.example.com/customers/{id}",)] + + assert is_trusted_endpoint("https://api.example.com/customers/42", "org-1", conn) is True + # Exact-then-pattern: two execute calls. + assert cur.execute.call_count == 2 + + +def test_is_trusted_endpoint_rejects_nonmatching_pattern(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("provably.trusted_endpoints._ensure_trusted_table", lambda _c: None) + conn = MagicMock() + cur = MagicMock() + conn.cursor.return_value.__enter__ = lambda *_: cur + conn.cursor.return_value.__exit__ = lambda *_: None + cur.fetchone.return_value = None + # Registered pattern allows /customers/{id} only — claim hits a deeper path. + cur.fetchall.return_value = [("https://api.example.com/customers/{id}",)] + + assert is_trusted_endpoint("https://api.example.com/customers/42/orders", "org-1", conn) is False + + +def test_snapshot_check_accepts_pattern_match(monkeypatch: pytest.MonkeyPatch) -> None: + """The snapshot tamper-check must honor pattern entries the same way the live DB check does.""" + from provably.handoff.types import HandoffClaim, HandoffPayload + from provably.trusted_endpoints import check_claim_endpoints_are_trusted + + # Live DB check is exercised separately; stub it as trusting whatever made it past + # the snapshot check (returns True). + monkeypatch.setattr("provably.trusted_endpoints.is_trusted_endpoint", lambda *_a, **_kw: True) + monkeypatch.setattr("psycopg2.connect", lambda *_a, **_kw: MagicMock()) + + payload = HandoffPayload( + provably_org_id="org-1", + trusted_endpoint_registry=["https://api.example.com/customers/{id}"], + claims=[ + HandoffClaim( + action_name="get_customer", + request_payload={"url": "https://api.example.com/customers/42", "method": "GET"}, + ) + ], + ) + + # Should NOT raise — pattern entry covers the concrete URL. + check_claim_endpoints_are_trusted(payload, postgres_url="postgresql://x") + + +def test_snapshot_check_rejects_url_outside_pattern(monkeypatch: pytest.MonkeyPatch) -> None: + from provably.handoff.types import HandoffClaim, HandoffPayload + from provably.trusted_endpoints import check_claim_endpoints_are_trusted + + monkeypatch.setattr("provably.trusted_endpoints.is_trusted_endpoint", lambda *_a, **_kw: True) + monkeypatch.setattr("psycopg2.connect", lambda *_a, **_kw: MagicMock()) + + payload = HandoffPayload( + provably_org_id="org-1", + trusted_endpoint_registry=["https://api.example.com/customers/{id}"], + claims=[ + HandoffClaim( + action_name="get_orders", + # Goes one segment deeper than {id} permits. + request_payload={"url": "https://api.example.com/customers/42/orders", "method": "GET"}, + ) + ], + ) + + with pytest.raises(ValueError, match="missing from trusted snapshot"): + check_claim_endpoints_are_trusted(payload, postgres_url="postgresql://x") + + def test_list_trusted_endpoints_excludes_given_urls(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("provably.trusted_endpoints._ensure_trusted_table", lambda _c: None) conn = MagicMock() From a99aaf0961a0926825baea5bd4f750ae176b8097 Mon Sep 17 00:00:00 2001 From: rimkusaurimas Date: Wed, 6 May 2026 15:03:37 +0200 Subject: [PATCH 2/4] docs(readme): add onboarding steps for PROVABLY_API_KEY + PROVABLY_ORG_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'Getting PROVABLY_API_KEY and PROVABLY_ORG_ID' subsection under Configuration: 1. Sign up at app.provably.ai 2. Create an organisation (org id shown in the URL) 3. Left-side menu → Integrations → create one (generated key) Plus a pointer to provably.ai/docs for full product documentation. Closes the gap surfaced while users were trying to find where these values come from — the env-var table told them they were required but not where to source them. --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 3209975..d46204f 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,14 @@ The SDK reads configuration from environment variables. A typed `Provably(api_key=..., org_id=..., ...)` client that replaces these globals is planned (issue [#2](https://github.com/ProvablyAI/provably-python-sdk/issues/2)). +#### Getting `PROVABLY_API_KEY` and `PROVABLY_ORG_ID` + +1. Sign up at [app.provably.ai](https://app.provably.ai). +2. Create an organisation. The org id is shown in the URL after creation and is what goes in `PROVABLY_ORG_ID`. +3. In the left-side menu, go to **Integrations** and create one. The generated key is your `PROVABLY_API_KEY`. + +Full product docs: [provably.ai/docs](https://provably.ai/docs). + | Variable | Used by | Required | |---|---|---| | `PROVABLY_API_KEY` | `initialize_runtime`, integration cache | yes | From d3713e950cf232d141aeeecf28fc2006848c7fc8 Mon Sep 17 00:00:00 2001 From: rimkusaurimas Date: Wed, 6 May 2026 15:13:50 +0200 Subject: [PATCH 3/4] chore: remove default_cluster_b_url + CLUSTER_B_URL env var (langgraph-demo leftover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `default_cluster_b_url()` was extracted verbatim from the langgraph-demo monorepo (per CHANGELOG: 'Initial extraction from the langraph-demo monorepo'). Two problems: 1. The localhost:8082 default presumes a specific deployment shape (the original two-cluster demo); it is meaningless to a fresh SDK user. 2. 'cluster B' is internal terminology a user reading the SDK has no way to understand without context. Configuration of where YOUR receiver lives belongs in your application, not the SDK. `post_handoff` already takes the URL as a positional arg — drop the convenience helper, drop the env var, rename the arg from `cluster_b_url` to `receiver_url` for clarity. Breaking for callers that import `default_cluster_b_url` or pass `cluster_b_url=...` as a keyword arg. CHANGELOG entry added. --- CHANGELOG.md | 2 ++ README.md | 7 +++---- docs/architecture.md | 2 +- docs/handoff.md | 6 +++--- src/provably/__init__.py | 3 +-- src/provably/handoff/transport.py | 18 +++++++++--------- tests/unit/test_transport.py | 12 +----------- 7 files changed, 20 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 199bfed..5cc33a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased - `trusted_endpoints`: registered URLs may now contain FastAPI/Express-style path placeholders. `{id}` matches exactly one path segment, `{rest:path}` matches any subtree. Plain URLs without `{` keep exact-match semantics — no migration needed for existing rows. Both `is_trusted_endpoint` and the snapshot tamper-check inside `evaluate_handoff` honor the new syntax. Closes #14. +- README: new "Getting `PROVABLY_API_KEY` and `PROVABLY_ORG_ID`" subsection walking through sign-up at app.provably.ai → create org → Integrations menu, plus a pointer to provably.ai/docs. +- **BREAKING:** removed `default_cluster_b_url()` and the `CLUSTER_B_URL` env var — leftovers from the langgraph-demo monorepo extraction with a `localhost:8082` default and opaque "cluster B" naming the SDK has no business assuming. `post_handoff(receiver_url, payload)` (positional arg renamed from `cluster_b_url`) takes the URL directly — supply it from your application's own configuration. ## 0.2.0 diff --git a/README.md b/README.md index d46204f..ac10053 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,6 @@ Full product docs: [provably.ai/docs](https://provably.ai/docs). | `PROVABLY_RUST_BE_URL` | `initialize_runtime`, evaluator | yes | | `POSTGRES_URL` | intercept storage, trusted endpoints, handoff preprocess | yes | | `PROVABLY_APP_UI_URL` | optional UI deep-links | no | -| `CLUSTER_B_URL` | `default_cluster_b_url()` helper only | no | | `PROVABLY_QUERY_RESOLVE_MAX_WAIT_S` | max seconds to wait for a query record to appear (default 15) | no | `POSTGRES_URL` is a hard dependency today. Three SDK modules open Postgres @@ -266,11 +265,11 @@ on any non-2xx response. interceptor's in-memory state — no manual claim construction needed: ```python -from provably import build_handoff_payload, post_handoff, default_cluster_b_url +from provably import build_handoff_payload, post_handoff # fetch_and_claim is the raw JSON dict the LLM emitted payload = build_handoff_payload(fetch_and_claim, run_id="run-001") -post_handoff(default_cluster_b_url(), payload) +post_handoff("https://your-verifier.example.com", payload) ``` `claim_contract` generates the system-prompt text that tells an LLM how to @@ -397,7 +396,7 @@ from provably import ( HandoffPayload, HandoffClaim, HandoffProofAction, HandoffProofBundle, BenchmarkRow, Outcome, VerificationMode, # handoff transport - post_handoff, default_cluster_b_url, + post_handoff, # handoff builders build_handoff_payload, DEFAULT_HANDOFF_TASK, claim_contract, default_instructions, field_descriptions, diff --git a/docs/architecture.md b/docs/architecture.md index b67628b..f8836b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,7 +21,7 @@ src/provably/ __init__.py client.py initialize_runtime types.py HandoffPayload v2, HandoffClaim, etc. - transport.py post_handoff, default_cluster_b_url + transport.py post_handoff evaluator.py evaluate_handoff, extract_indexed_from_query_record eval_modes.py the four verification modes json_utils.py canonical_json diff --git a/docs/handoff.md b/docs/handoff.md index 5530dbb..4d9689a 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -67,9 +67,9 @@ post_handoff( There is no retry, no batching, no fallback. Failures bubble up as `httpx.HTTPError` / `httpx.HTTPStatusError`. -`default_cluster_b_url()` is a small convenience that returns -`os.getenv("CLUSTER_B_URL", "http://localhost:8082")` with whitespace and -trailing-slash trimming. Use it where it helps; ignore it otherwise. +The `receiver_url` is supplied by the caller — the SDK does not read it from the +environment or assume any default. Configuration of where YOUR verifier lives +belongs in your application, not the SDK. ## Eval comparison modes diff --git a/src/provably/__init__.py b/src/provably/__init__.py index 5466478..e02e14d 100644 --- a/src/provably/__init__.py +++ b/src/provably/__init__.py @@ -6,7 +6,7 @@ from provably.handoff.guide import default_instructions, field_descriptions from provably.handoff.outcomes import aggregate_outcome, outcome_from_trace from provably.handoff.payload_builder import DEFAULT_HANDOFF_TASK, build_handoff_payload -from provably.handoff.transport import default_cluster_b_url, post_handoff +from provably.handoff.transport import post_handoff from provably.handoff.types import ( BenchmarkRow, HandoffClaim, @@ -49,7 +49,6 @@ "check_claim_endpoints_are_trusted", "claim_contract", "configure_indexing", - "default_cluster_b_url", "default_instructions", "disable", "enable", diff --git a/src/provably/handoff/transport.py b/src/provably/handoff/transport.py index 85dcfec..3cd1477 100644 --- a/src/provably/handoff/transport.py +++ b/src/provably/handoff/transport.py @@ -1,7 +1,5 @@ from __future__ import annotations -import os - import httpx from provably.handoff.types import HandoffPayload @@ -11,15 +9,21 @@ def post_handoff( - cluster_b_url: str, + receiver_url: str, handoff_payload: HandoffPayload, *, headers: dict[str, str] | None = None, timeout_s: float = 120.0, ) -> None: - base = (cluster_b_url or "").strip().rstrip("/") + """POST a serialized ``HandoffPayload`` to ``{receiver_url}/handoffs/receive``. + + The receiver is whatever service runs ``evaluate_handoff`` on the payload — typically + a separate verifier in a two-service deployment, but the SDK has no opinion on its + location: ``receiver_url`` is supplied by the caller, never read from the environment. + """ + base = (receiver_url or "").strip().rstrip("/") if not base: - raise ValueError("cluster_b_url is empty — set CLUSTER_B_URL to post handoff") + raise ValueError("receiver_url is empty — pass the verifier's base URL to post_handoff") url = f"{base}/handoffs/receive" body = handoff_payload.model_dump(mode="json") hdrs = {"Content-Type": "application/json", **(headers or {})} @@ -29,7 +33,3 @@ def post_handoff( except Exception as e: _log.error("post_handoff_failed", url=url, error=str(e)) raise - - -def default_cluster_b_url() -> str: - return (os.getenv("CLUSTER_B_URL") or "http://localhost:8082").strip().rstrip("/") diff --git a/tests/unit/test_transport.py b/tests/unit/test_transport.py index baea620..f33a768 100644 --- a/tests/unit/test_transport.py +++ b/tests/unit/test_transport.py @@ -4,20 +4,10 @@ import pytest -from provably.handoff.transport import default_cluster_b_url, post_handoff +from provably.handoff.transport import post_handoff from provably.handoff.types import HandoffPayload -def test_default_cluster_b_url_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("CLUSTER_B_URL", "http://custom:9999/") - assert default_cluster_b_url() == "http://custom:9999" - - -def test_default_cluster_b_url_fallback(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("CLUSTER_B_URL", raising=False) - assert default_cluster_b_url() == "http://localhost:8082" - - def test_post_handoff_empty_url_raises() -> None: with pytest.raises(ValueError, match="empty"): post_handoff("", HandoffPayload()) From 98f22d5f335c23075d6838690e05e1a240f9d86d Mon Sep 17 00:00:00 2001 From: rimkusaurimas Date: Wed, 6 May 2026 15:15:44 +0200 Subject: [PATCH 4/4] docs(readme): drop 'org id shown in URL' detail --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ac10053..a9ca987 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ planned (issue [#2](https://github.com/ProvablyAI/provably-python-sdk/issues/2)) #### Getting `PROVABLY_API_KEY` and `PROVABLY_ORG_ID` 1. Sign up at [app.provably.ai](https://app.provably.ai). -2. Create an organisation. The org id is shown in the URL after creation and is what goes in `PROVABLY_ORG_ID`. +2. Create an organisation. Its id is what goes in `PROVABLY_ORG_ID`. 3. In the left-side menu, go to **Integrations** and create one. The generated key is your `PROVABLY_API_KEY`. Full product docs: [provably.ai/docs](https://provably.ai/docs).