fora-protocol-sdk
FORA protocol libraries for Python, co-located with the contract (ADR-020). One
distribution, the fora_sdk package, layered so that the parts holding no keys and doing
no I/O can be used on their own.
| Layer | Module | What it is |
|---|---|---|
| L0 | wire.models, vocab.* |
generated wire types, from the separate fora-protocol distribution (consumed, never rebuilt) |
| L1 | fora_sdk (top level) |
stateless, IO-free protocol mechanics — RFC 9421/7638 crypto, offer and acceptance signatures, signed URLs, validation. Includes fora_sdk.core (transport-neutral: Verifier, VerifiedOffer, DiscoveryResult), fora_sdk.window (Window) and fora_sdk.server_verify (the server side of RFC 9421). Byte-parity-guarded against the sdk/go oracle |
| L2 · I/O | fora_sdk.resolvers |
the only tier that dials the network: Web Bot Auth directories, well-known JWKS and fora.json, plus the SSRF-guarded HTTP client. Which faces take that client by default is decided by URL provenance, below — WellKnownEndpointResolver is the one request-derived face that still defaults to a plain client |
| L2 · transport | fora_sdk.client (the async Connect-unary JSON client: the agent verbs discover · execute · report_usage · dispute · fetch, the account-setup verbs register · get_account_status, the broker verbs resolve · execute and the publisher verbs push_resources · remove_resources · refresh_catalog) · fora_sdk.sync (the same faces, blocking) |
state is injected, never owned |
| test support | fora_sdk.testing |
acceptance signers that admit an empty requester, for a test proving a verifier refuses one. Not re-exported from fora_sdk, and imported by no module of it |
pip install fora-protocol-sdk
Python 3.11 or later. The package ships py.typed, so mypy reads its annotations.
httpx and httpcore are installed with every consumer, including one that uses only
the IO-free mechanics: keeping them non-optional is what makes their version ceilings,
which guard the resolvers' SSRF seam, bind every install.
The generated wire types are a separate distribution, fora-protocol: Pydantic v2 models
at wire.models and the registered vocabulary at vocab.*. This package pins it to its
own version and installs it as a dependency. It owns the top-level import names wire and
vocab, so it can collide with another distribution that owns either name; install it
into an environment that does not. Its README explains the tradeoff.
A complete agent
Everything below is one working client: identity from a seed, offers verified against keys fetched from the issuing Exchange, a purchase, a bound fetch, and a usage report. The rest of this document explains the pieces it composes.
import asyncio
import os
import sys
import time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fora_sdk.core import Mode, StaticOfferKeyResolver, Verifier
from fora_sdk.resolvers import (
CachedOfferKeyResolver,
WellKnownEndpointResolver,
create_wba_offer_directory_fetch,
guarded_client,
)
from fora_sdk.signing_transport import SigningTransport
from fora_sdk.sync import Client, ClientConfig
from fora_sdk.thumbprint import thumbprint
from vocab.functiontokens import AI_INPUT
# This agent's identity, as it states it to an Exchange.
AGENT = {"id": "agent-1", "domain": "agent.example", "type": "REQUESTER_TYPE_AGENT"}
# Where THIS agent publishes its own signing key: the Web Bot Auth key directory at
# {AGENT_DIRECTORY}/.well-known/http-message-signatures-directory, served as
# application/http-message-signatures-directory+json and signed by the key it lists.
# Every request signature names it in its own covered Signature-Agent member,
# sig1="https://agent.example"; the Exchange fetches that directory and looks for the
# key whose RFC 7638 thumbprint equals the keyid below. It is the https origin alone,
# with no path. Publish before you call.
AGENT_DIRECTORY = f"https://{AGENT['domain']}"
# https in production. A local sandbox serving plaintext sets FORA_WELLKNOWN_SCHEME=http,
# and ALLOW_INSECURE=true for the guarded transports.
SCHEME = os.environ.get("FORA_WELLKNOWN_SCHEME", "https")
def buy_and_fetch(*, exchange: str, uri: str, seed: bytes) -> bytes:
"""Discover an offer for `uri`, buy it, fetch the bytes, and report the usage."""
# 1. Identity. The RFC 9421 keyid IS the RFC 7638 thumbprint of the agent's public
# key, which is also the value a delivery URL gets bound to. One key, one name.
#
# signature_agent names the directory that key is published in. It is required:
# a signer given none refuses to sign, and the call fails locally as MALFORMED
# before anything is sent.
public = Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw()
signer = SigningTransport(
signer_seed=seed, keyid=thumbprint(public), signature_agent=AGENT_DIRECTORY
)
# 2. Where this Exchange serves its API, read from its own /.well-known/fora.json
# rather than from configuration. The same resolver later routes the usage report
# back to whichever Exchange issued the offer.
#
# The client is passed in rather than left to default. This resolver's host comes
# off an offer, so a third party chose it, and its default is still the plain
# client — the one request-derived face that has not caught up with the rule.
endpoints = WellKnownEndpointResolver(scheme=SCHEME, http=guarded_client())
# 3. Offer-signing keys, from the Exchange's Web Bot Auth directory — the only place
# they are published. Fetched and TTL-cached with each entry's expiry clamped to
# the key's own not_after, then frozen into the map the (synchronous) Verifier
# resolves against. STRICT plus a map that resolves nothing rejects every offer:
# that is the fail-closed posture, not a bug.
#
# `revoked` is NOT passed, and that is a choice worth making deliberately. It
# screens a candidate key by thumbprint and exchange against that exchange's own
# revocation list, so leaving it out waives emergency revocation. It is
# defensible here because this function fetches the directory and spends the
# keys inside one call. A client that holds its key map for hours must pass a
# predicate `revoked(thumbprint, exchange)` that answers from that exchange's own
# list (`WBAKeyResolver.revoked` has this shape, and knows a directory's list once
# it has fetched that directory), and must re-run the prefetch rather than freeze
# one map for its lifetime — a frozen map keeps serving a key after its TTL and
# its not_after have both passed.
directory = CachedOfferKeyResolver(fetch=create_wba_offer_directory_fetch(scheme=SCHEME))
keys = asyncio.run(directory.prefetch([exchange]))
verifier = Verifier(
mode=Mode.STRICT,
resolver=StaticOfferKeyResolver(keys),
now=lambda: int(time.time()),
)
config = ClientConfig(
base_url=endpoints.resolve_endpoint(exchange),
signer=signer,
requester=AGENT,
verifier=verifier,
endpoint_resolver=endpoints,
)
with Client(config) as client:
# 4. Discover. Every offer arrives already sorted into verified or rejected, and
# a rejected one keeps its reason instead of being dropped silently.
found = client.discover({"exchange": exchange, "uris": [uri]})
offers = found.verified()
if not offers:
refused = [r.reason for r in found.rejected()]
raise RuntimeError(f"no verifiable offer for {uri}: {refused}")
# 5. Buy it. execute() accepts only a verified offer, so an unverified one
# cannot be paid for by mistake.
item = client.execute(offers[0]).items[0]
# 6. Fetch. The delivery URL is bound to the agent's thumbprint and the client
# presents the matching proof of possession, so a copied link fetches nothing.
# The delivery edge checks the URL and the proof.
content = client.fetch(item.retrieval_endpoint)
# 7. Report what was used. It goes to the Exchange the offer named, resolved the
# same way as step 2 — never to whatever base_url happened to be configured.
client.report_usage(
{
"exchange": exchange,
"transaction_id": item.transaction_id,
"billing_id": item.billing_id,
"usage": {"consumed_quantity": len(content.body), "function": [AI_INPUT]},
}
)
return content.body
if __name__ == "__main__":
sys.stdout.buffer.write(
buy_and_fetch(
exchange=os.environ["FORA_EXCHANGE"], # a bare domain, e.g. "exchange.example"
uri=sys.argv[1],
seed=bytes.fromhex(os.environ["FORA_AGENT_SEED"]),
)
)
fora_sdk.client.Client is the same surface with await; nothing else changes. That one
is the core, and fora_sdk.sync is a blocking facade over a synchronous httpx client
rather than an asyncio.run wrapper, which would break inside a running event loop.
That example is not decoration: a test extracts this exact block from this file, runs it against an in-process Exchange, and fails if any name in it stops resolving.
L1 — the protocol mechanics
Stateless, no network I/O, no secret custody, no state. The same code the Broker, Exchange, MCP adapter and edge worker build on.
from fora_sdk import thumbprint, sign_request, verify_request
RFC 9421 request signing and verification, under the Web Bot Auth profile.
sign_request covers @method, @target-uri, the exact body bytes (Content-Digest),
the Authorization header and the signature's own Signature-Agent member; verify_request
checks a received one against a key you hold, and
fora_sdk.server_verify.verify_request_server is the framework-agnostic server face.
verify_multisig_request_server verifies a request carrying several signers' signatures:
now = int(time.time())
signed = sign_request(
method="POST", url=url, body=body,
authorization="", # covered, and sent even when empty
signer_seed=seed, keyid=keyid,
created=now, expires=now + 300, # the window, minted by the caller; 300 s at most
signature_agent=agent_directory, # your key directory's https origin; required
nonce=nonce, # fresh per signature; SigningTransport mints one
)
verdict = verify_request_server(
method="POST", url=url, body=body, headers=headers,
resolver=resolver, replay_store=replay_store, now=now,
)
One signature labelled sig1 by an agent whose directory is https://agent.example
carries:
Signature-Agent: sig1="https://agent.example"
Signature-Input: sig1=("@method" "@target-uri" "content-digest" "authorization" "signature-agent";key="sig1");created=C;expires=E;keyid="K";alg="ed25519";nonce="N";tag="web-bot-auth"
Signature: sig1=:<standard base64>:
Signature-Agent is a structured-field Dictionary, so a party that forwards a request adds
its own signature with append_signature: the next free label (sig2), its own member in
the same dictionary, and the earlier signatures left untouched. The appended signature
covers only its own request and member, unless cover_previous=True: then it also covers
the last earlier signature completely, which only a party forwarding the request unchanged
may do. A verifier resolves each signature's keyid in the directory that signature's own
member names, passing that origin to KeyResolver.resolve(keyid, directory), and reports
it on the verdict. Labels carry no meaning; coverage of an earlier signature is optional and
must be complete. The verifier also accepts what other Web Bot Auth signers send — the
legacy String form "https://agent.example" on a single signature, a member key that
differs from the label, a type=directory member parameter, no nonce, no alg (the
Ed25519 key decides the algorithm; an alg that is present must be ed25519) — and
refuses the bare unquoted value. A refusal for a missing component, a wrong tag, a refused
Signature-Agent form or a member that is not an https origin carries accept_signature, the Accept-Signature value
(accept_signature()) to answer the 401 with. A signer given no directory, a value that
is not an https origin (check_https_origin), or a window over MAX_SIGNATURE_LIFETIME
raises a SignatureProfileError before anything is signed.
Offer authenticity. The signature covers the offer's pricing, terms and expiry, so an
offer must be verified before anything selects on it. That is what fora_sdk.core.Verifier
does in bulk, and sign_offer_jcs / verify_offer_acceptance_jcs are the primitives
underneath it. Canonicalization is RFC 8785 JCS through a vetted library, never
hand-rolled.
Signed delivery URLs and proof of possession. Byte-identical with the edge worker:
url = sign_ed25519_signed_url(raw_url, seed=seed, kid="ex.v1", agent_id=tp, exp=exp)
verdict = verify_ed25519_signed_url(url, now=now, resolve_key=resolve)
proof = verify_agent_binding(method="GET", url=url, headers=headers,
agent_id=verdict.agent_id, now=now)
The proof is a Web Bot Auth signature covering @method, @target-uri and the agent's
own Signature-Agent member (sig1="<the agent's directory>"), with the raw public key in
X-FORA-Agent-Key: a GET has no body to digest, and the signed URL is itself the
credential. The verifier requires those components AT LEAST, so a Web Bot Auth library's
proof that also covers @authority or a header verifies; a refusal the fetcher can fix
carries accept_signature (POP_ACCEPT_SIGNATURE). sign_agent_binding returns the four header values as an AgentBinding; the
client passes a fresh 64-byte nonce, and the verifier requires the web-bot-auth tag and
the three-way identity agent_id == keyid == thumbprint(presented key).
License-term pre-check. The two tiers an Exchange applies to a pushed catalog entry, runnable by a publisher before signing — the wire rules over the entry as given, then canonicalization and registry membership over a copy of its terms:
verdict = validate_resource_entry(entry) # never modifies entry
normalize_resource_entry(entry) # the form the Exchange stores
Money. Exact decimal in and out, canonical decimal string on the wire:
parse_money, format_money, canonicalize_money. Never floats.
Metered offers. A PER_UNIT offer may carry an estimate. The purchase charges
estimate × rate, or one unit's rate without an estimate, and the charge is final.
is_metered_offer(offer) says whether an offer is metered, and
check_metered_estimate(offer) raises on a metered offer whose stated estimate is not
positive; the Verifier rejects such an offer.
One price per offer. An offer's price is offer["pricing"], and the term it sells
carries none. check_offer_terms_unpriced(offer) raises ValueError on a priced term;
sign_offer_jcs refuses to sign such an offer, and the Verifier rejects one.
Registration schema. An Exchange may publish a JSON Schema for the
registration_data it expects. Both ends validate against it, so the rules live in one
place: 2020-12 only, same-document $ref only, size, depth and evaluation caps, and a
pattern alphabet all three SDK languages express identically.
schema, verdict = compile_registration_schema(raw)
Do not discard that verdict. The two callers read a non-accepted one differently, and getting it backwards is the easy mistake. A client pre-checking a payload skips the check and sends anyway, because the Exchange's enforcement decides and a local check that could not run must not veto your own user. An Exchange compiling its own configured schema treats anything but accepted or not-published as a misconfigured deployment, and must not advertise a schema it is not enforcing.
Routing and audience. is_bare_host and host_anchored are the pure checks that
precede a signed call to an address a network party named. check_audience is the other
direction: a request arrived, does it name this Exchange? The signature does not answer
that — it proves the sender signed the URL it dialled, and that URL came out of a fetched
manifest, so a poisoned resolution redirects the request while every signature still
verifies.
Also: errordetail (the typed error taxonomy and its constructors),
generate_idempotency_key, apply_scopes, hash_url, monotonic_window,
check_https_origin, accept_signature, sign_directory_response and
verify_directory_response.
L2 · I/O — fora_sdk.resolvers
The network-fetching tier. Everything that dials a host a third party can influence lives
here, so the pre-auth-reachable network surface never enters the pure core. Almost all of it
dials through one SSRF-guarded HTTP client; WellKnownEndpointResolver is the exception
and still defaults to a plain one, which is a known gap rather than a decision. Pass it
http=guarded_client() until that default changes, as the example above does.
- Key resolvers —
WellKnownKeyResolver(well-known JWKS, TTL-cached) andWBAKeyResolver(Web Bot Auth directory, revocation- and expiry-aware, with a background poller). A key directory is fetched with no redirect, must be served asapplication/http-message-signatures-directory+json, and only the keys that signed its response (verify_directory_response) are handed out. A directory that answers 404 raisesDocumentNotFoundError, and one that answers with any 3xxRedirectRefusedError, both subclasses ofDirectoryUnavailableError. A Signature-Agent member is always an https origin and its directory is always fetched over https; a local sandbox serves the directory over TLS and injects anhttp=client that trusts it. - Endpoint resolver —
WellKnownEndpointResolverdiscovers an Exchange's own service endpoint from/.well-known/fora.json, host-keyed and cached. Its exits are worth knowing apart, because the difference decides whether a caller should retry:ManifestVersionRefusedErrorwhen the document carries averthis reader does not accept or none at all,NoEndpointErrorwhen it advertises none, andEndpointRefusedErrorwhen it advertises one this resolver will not hand back. All three are verdicts and therefore final; anything else is a transport failure and worth retrying. - Offer keys —
CachedOfferKeyResolvercaches an Exchange's offer-signing key with an expiry clamped to the key's ownnot_after, andcreate_wba_offer_directory_fetchis the fetch it runs on. Give both the samenow=clock, so the directory's response signatures and the key selection are judged at one time.active_ed25519_key_screenedand its siblings are the underlying window-and-revocation selection. - Registration requirements —
WellKnownRequirementsReaderreads what one Exchange asks of a registration from the same manifest, and holds no document cache: the contract requires the terms digest to come from a freshly fetched document. - The guarded client —
guarded_clientandguarded_async_clientare the one construction path a third-party-influenceable fetch uses.ssrf_guardandasync_ssrf_guardare the transports underneath, for a caller wiring its own.
Which default a resolver takes follows its URL's provenance, and that is the whole
rule. A fixed, operator-chosen address — an on-premise JWKS — may legitimately be
private, and the operator rather than an attacker chose it. A request-derived host — the
directory named by a Signature-Agent header, an Exchange domain read off an offer —
takes the guarded client, because the party choosing the address is not the party running
the process.
Redirects: the guarded client follows, a signed leg refuses. Following a bounded chain is right for a public well-known document, where the address is re-pinned and the scheme re-vetted at every hop — the manifest and a revocation list follow up to five. A key directory is the exception: its address is the origin its signer committed to, so every directory fetch refuses a redirect. It is wrong for anything carrying a credential, so the content fetch and the RPC legs install their own refusal: following a redirect either replays a proof bound to the old URL, or hands a fresh proof of possession of the agent's key to whatever host the first hop named.
Two orthogonal environment flags drive the guard, both defaulting to the guarded posture:
SKIP_SSRF drops the dial-time address check, and ALLOW_INSECURE permits plaintext
http. The transport caps redirect depth, bounds well-known bodies at 1 MiB, and fails
closed if any resolved address of a host is reserved.
L2 · transport — fora_sdk.client and fora_sdk.sync
ClientConfig is the whole of a client's wiring, and three of its fields are things the
client refuses to guess:
signer— aSigningTransportover the agent's own key. The SDK holds one agent key: the one the request is signed with. Itssignature_agent(the key directory's https origin) is required, and every signature carries a fresh 64-byte nonce. Used on its own, the transport also takes asigner_source(aSignerSource, which names the seed, keyid and directory per request, for a service signing as many identities),append_only=True(every request goes throughappend_signature, so a request already signed gets one more signature beside the earlier ones) andcover_previous=True(an appended signature covers the last earlier one, for a party forwarding a request unchanged).verifier— fail-closed by default. An unconfigured client gets a verifier over a resolver that resolves nothing, so it rejects every offer, with a reason. That is the correct default and on a first run it looks exactly like a broken stack.endpoint_resolver— turns the exchange domain inside a signed offer into the origin that Exchange advertises for itself. A usage report goes where the signed offer says, never where configuration says, so this is not an optional convenience.
The rest are bounds and seams with working defaults:
| Field | Default | What it bounds |
|---|---|---|
call_timeout_sec |
30.0 |
one RPC, end to end |
max_rpc_read_bytes |
1048576 (1 MiB) |
an RPC response body |
content_timeout_sec |
30.0 |
the delivery fetch as a whole; its remainder is carried across the legs of one fetch |
max_content_bytes |
8388608 (8 MiB) |
one fetched body, buffered whole |
proof_window |
30 s | validity of the proof of possession a bound fetch presents |
sign_window |
the signer's own window (300 s, the longest the profile allows) | validity of an outbound request signature. It is not needed for uniqueness: every request signature carries a fresh RFC 9421 nonce, so two identical requests inside one second still sign to different bytes |
request_id |
None, meaning no header is sent |
mints the X-Request-ID correlation value |
validation |
"strict" |
whether an outbound message is checked against its generated model before it is sent. Orthogonal to offer verification, which is about what comes back |
registration_requirements |
a reader built on the guarded client, once per client | where register reads an Exchange's terms revision and registration schema. It holds no document cache on purpose: the contract requires the terms digest to come from a freshly fetched manifest |
strict |
False |
refuse an answer carrying an unknown field, or breaking a field-level or cross-field rule, and an error envelope or ErrorDetail the contract does not define. See Testing a FORA service |
strict_requests |
False |
refuse a plain dict, and a request carrying an unknown field or breaking a cross-field rule, before it is signed. Independent of validation. See Testing a FORA service |
before_sign |
None |
a hook that receives each request just before it is signed. See Testing a FORA service |
Build a client once and reuse it. ClientConfig, the resolvers and the Verifier
are all designed to be shared: the endpoint resolver caches each Exchange's manifest
behind its own locks, the offer-key resolver caches keys with a TTL, and the client owns
HTTP connection pools that close() releases. One per process, or one per Exchange, is
the shape to aim for — not one per purchase. The one thing that must not be shared across
processes is the signing key.
The verbs
Requests are plain dicts in proto-JSON snake_case, or the generated request models
from wire.models; a camelCase key is refused as malformed rather than silently ignored.
Any other key the model does not declare passes the check and is sent, and the server's
JSON codec discards it; ClientConfig(strict_requests=True) refuses it instead.
to_wire(model) shows the JSON object a model is sent as. Responses are the generated
Pydantic models.
| Verb | Send | Get back |
|---|---|---|
discover(query) |
exchange, uris, optional filters |
DiscoveryResult: groups (one per requested URI, each with uri, result.verified, result.rejected, absence_reason), plus exchange and rate_limit. verified() and rejected() flatten across groups |
execute(offer, *, idempotency_key=None) |
a VerifiedOffer, or a sequence of them issued by one Exchange — nothing else is accepted |
TransactionResponse: items, each with transaction_id, billing_id, retrieval_endpoint, expires_at, cost |
fetch(signed_url) |
one retrieval_endpoint |
Content: url, mime_type, body |
report_usage(report, *, idempotency_key=None) |
exchange, transaction_id, billing_id, usage |
UsageReportResponse: report_id, which a later dispute must cite |
dispute(request, *, idempotency_key=None) |
exchange, transaction_id, report_id, reason |
DisputeResponse |
register(request) / get_account_status(request) |
account setup with an Exchange | RegisterResponse / GetAccountStatusResponse |
A rejected offer keeps both the offer and the reason it was refused, so a caller can
tell a bad signature from an unresolvable Exchange. A group with no offers at all keeps
its absence_reason instead, and the distinction matters: "not in the catalogue" means
give up, "temporarily unavailable" means retry later, and "content blocked" means never
retry.
Where a URI comes from is not the SDK's job. discover asks one Exchange about URIs
you already have — from your own crawl frontier, a publisher's catalogue, or a Broker.
BrokerClient.resolve is the fan-out case: it asks a Broker, which queries the Exchanges
it knows and relays back what they offered, so a caller with no idea which Exchange sells
a resource starts there rather than with discover.
The delivery edge checks the delivery URL. execute hands each retrieval_endpoint
back exactly as the Exchange issued it, and fetch dials it as given with the agent's
proof of possession attached. The edge verifies the URL signature and, where it can, the
binding to this agent's key; an edge that cannot check the binding (CloudFront with its
RSA key pair) treats the URL as a bearer token. An edge refusal comes back from fetch
as a CallError carrying a retrieval_auth_failure detail with the edge's reason.
Failures arrive as one CallError carrying a CallErrorKind — NOT_SENT, REFUSED,
UNREACHABLE, MALFORMED, TOO_LARGE, NOT_SIGNABLE, UNKNOWN — plus the peer's own
reason token, the Connect code when the peer answered with one, the status and
headers of a refusal, and, when the peer sent one, a typed ErrorDetail, decoded from
the binary details[].value the error carries. One failure type for
every verb, so a caller branches in one place. NOT_SENT is worth singling out: it means
the client refused before anything left the process, so retrying without changing
something will fail the same way.
BrokerClient carries resolve for brokered discovery and execute for a brokered
purchase, CatalogClient carries the publisher verbs, and AdminClient carries the
operator verbs: set_tenant_fee_rate and set_reporting_policy (fora.admin.v1), and
request_domain_verification and confirm_domain_verification. All take the same
ClientConfig, because each addresses a different endpoint, often with a different key.
BrokerClient.execute(offers, *, idempotency_key=None) buys offers from any number of
Exchanges in one call and returns the BrokerTransactionResponse: items in request order,
exchanges (one ExchangeOutcome per Exchange contacted) and totals (one Cost per
currency, never summed across currencies). The Broker sends one sub-request per Exchange,
signed with its own key, and your acceptances travel in each body, so every Exchange still
verifies your consent. An Exchange that refused its whole sub-request is not an error: the
affected items carry refusal with that Exchange's code and typed reason, and the other
items come back unchanged. A refusal whose code is unavailable or deadline_exceeded
leaves the item's outcome unknown, since the Exchange may have bought it before the answer
was lost, and totals does not count it; retry the same request with the same idempotency
key to settle it, through this Broker, another one or directly: each Exchange scopes the key
to the agent whatever the route. The signer must set signature_agent, and its directory host
must be requester.domain: a Broker refuses any other pairing, so the client refuses it
first, as MALFORMED. Client.execute with offers from more than one Exchange is refused
the same way; buy those through the Broker.
Testing a FORA service
The client is also a test harness. These capabilities let a conformance or e2e suite drive a service through the SDK and check every answer through it:
ClientConfig.before_signreceives each request as anhttpx.Requestafter the SDK built and checked it, just before it is signed. The request it returns is signed and sent, and the answer decoded as usual, so a deliberately broken message still goes through the SDK's own signer and decoder. A hook may not change the method or URL, or set a header the signer writes.RawBody(body)passed to any verb in place of its request sendsbodyexactly as given: nover,idempotency_keyorrequesteris filled in and nothing about the message is refused locally. It is still signed and its answer still decoded. A routed verb still reads its destination from the body'sexchange.ClientConfig(strict=True)refuses an answer carrying an unknown field at any depth, or breaking a field-level or cross-field rule, using the published strict JSON Schema of the response message (wire.schemas) and the SDK's cross-field rules. An error answer is checked too: the Connect envelope may carry onlycode,messageanddetails, must name a known Connect code and carry well-formed details, and everyErrorDetail, binaryvalueanddebugprojection alike, must pass the strictErrorDetailschema and the cross-field rules. A refused envelope isCallError(MALFORMED)keepingcode,statusandheaders: the peer's Connect code, HTTP status and header fields.ClientConfig(strict_requests=True)refuses a request the contract does not describe, before it is signed. A verb then takes only its generated request model or aRawBody: a plain dict, or a model of another message, isCallError(MALFORMED)naming the model to pass, raised before anything is resolved, read or sent. The message as stamped, withver, the configuredrequesterand a purchase's reflected offers in it, must pass the published strict JSON Schema of the request message and the cross-field rules, and an unknown member is named by its path. It applies whatevervalidationsays, and aRawBodyis still sent exactly as given. A keyword argument a model does not declare is dropped by the model itself, so run a type checker over the harness: it reports one.CallError.headersholds the header fields of a refusal, an answer of status 400 or above on an RPC leg orfetch: a verifier'sAccept-Signature, a gateway'sRetry-After, an edge's discovery headers, whichparse_discovery_hint(err.status or 0, err.headers)reads (statusisNonewhen no answer arrived). It is the error's own copy, looked up case-insensitively, a field sent on several lines joined with ", ". A refused redirect, a 2xx the client could not read and a local or transport failure carry none, and the fields are never rendered into the message.- Identity helpers in
fora_sdk.identitymint a throwaway agent:generate_key(),directory_document(keys)for the directory to serve, andsigning_transport_for(key, directory). Serve the directory asapplication/http-message-signatures-directory+jsonwith the three headerssign_directory_response(authority, body, seeds, created, expires)returns, signed by every key it lists for the authority it is served under; a verifier hands out no key that did not sign. fora_sdk.testingsigns an acceptance that names an empty requester, which thefora_sdk.coresigners refuse to produce, as fora.proto requires of a signer:jcs_acceptance_payload_any_requester,sign_offer_acceptance_any_requester,jcs_request_acceptance_payload_any_requesterandsign_request_acceptance_any_requester. Each skips only that refusal, so the acceptance is valid in every respect but the requester, and a verifier's refusal of it is attributable to that alone. Test support only: import it from a test or a harness.- Document readers in
fora_sdk.resolversread what a party publishes and check it:read_manifest(domain),read_wba_directory(url_or_domain),read_revocation_list(url)andread_license_document(license). Each fetches through the guarded client, refuses the wrong media type (MediaTypeRefusedError) and a body the strict contract refuses (StrictViolationError), and returns aDocumentwith the parsed model, the URL, the bytes and the media type. The license reader verifies the bytes againsturi_digest(DigestMismatchError) and returns aLicenseDocument. A failed fetch isDirectoryUnavailableError, and two say which:DocumentNotFoundErrorfor a 404 andRedirectRefusedErrorfor a directory that answers with any 3xx, both subclasses of it; no reader returnsNone. The directory reader fetches with no redirect and refuses a directory unless every listed key signed its response (DirectoryResponseUnsignedError), judged atnow=(unix seconds) or the wall clock. check_strict(message_name, payload)applies the same strict check to any decoded message, raisingStrictViolationError. A delivery-fetch proof is signed with the publicsign_agent_binding(orSigningTransport.sign_agent_binding).parse_discovery_hint(status, headers)reads theX-Content-RulesandX-FORA-Exchangeheaders of an edge's 403 into aDiscoveryHint: each value with a state of"absent","valid"or"malformed". Any other status reads as absent.reconcile_discovery_hint(hint, listed)checks the hinted Exchange against the domains the publisher manifest'sexchangeslists and answers"listed","unlisted"or"no_exchange"; the manifest wins. The header names areContentRulesHeaderandExchangeHeader.
Running against a local Exchange
Three environment variables. WHEN each is read matters, because setting one after the object that reads it was built has no effect:
| Variable | Effect |
|---|---|
FORA_WELLKNOWN_SCHEME=http |
the resolvers that take scheme= read manifests and offer-key directories over plaintext. Consumer-side: the SDK never reads it for you, which is why the example above passes scheme= explicitly. WBAKeyResolver takes no scheme: a Signature-Agent directory is always fetched over https |
ALLOW_INSECURE=true |
the scheme gate permits a plaintext http origin. Needed for the RPC and delivery legs, which check the scheme ABOVE the transport, so injecting a client is not enough |
SKIP_SSRF=true |
the dial-time address guard is dropped, so loopback and private addresses are reachable. Read when guarded_client BUILDS a client, so set it before constructing a resolver |
All three default to the guarded, https-only posture. Set them for a sandbox, never in production — together they remove the whole pre-auth SSRF defence.
Guarantees
- Byte-parity with Go. Thumbprints, RFC 9421 signature bases, acceptance payload
bytes, delivery-proof values and money formatting are byte-for-byte identical to the
sdk/gooracle. If those bytes differ, signatures do not verify and nothing works. Go emits the vectors; this package's parity suites replay them. - The public surface is gated. Every public Go symbol is either mapped to its Python and TypeScript counterparts or recorded as a deliberate divergence with a reason, and the allowlist may only shrink.
- Fail-closed by default. An unresolvable Exchange is absent rather than trusted, an unverified offer is rejected rather than dropped, and a directory fetch that fails returns nothing rather than an empty document.
- Library-first. JCS canonicalization, JSON Schema evaluation, the HTTP client and the cryptography are vetted libraries. The SDK owns the protocol, not the primitives.
Source, tests and the release process are in
FORA-Protocol/protocol under sdk/python and
gen/python. Licensed under Apache-2.0.
Metadata
Release files for fora-protocol-sdk 1.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| fora_protocol_sdk-1.2.0.tar.gz | 506.9 kB | Without provenance | Details for fora_protocol_sdk-1.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| fora_protocol_sdk-1.2.0-py3-none-any.whl | Python 3 | none | any | Details for fora_protocol_sdk-1.2.0-py3-none-any.whl |
Total release size: 783.0 kB
Release files / fora_protocol_sdk-1.2.0.tar.gz
| Download URL | fora_protocol_sdk-1.2.0.tar.gz |
|---|---|
| Size | 506.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d5efdc737da5b293e3a38fed44949091de9106f0ffb3b16f0a6d6e6cbfb7ecb8
|
|
BLAKE2b-256 checksum How to use checksums |
cb337e9efe8769a45f91c9ab77570091838c179cf254f4cf54c36add5f423822
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / fora_protocol_sdk-1.2.0-py3-none-any.whl
| Download URL | fora_protocol_sdk-1.2.0-py3-none-any.whl |
|---|---|
| Size | 276.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0815c9c8374c33e3a9e3018181d2455206cd6497447d6ce930dcac03afce624e
|
|
BLAKE2b-256 checksum How to use checksums |
211c8b305aa49f164e4cbf58dc4b03716cea9c1f77a79f5b5417540d45cf39be
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for fora-protocol-sdk 1.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.