connect

package
v1.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 11, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package connect is the opt-in Connect-Go CLIENT binding over the transport-neutral sdk/go/core L2 substance. It carries the agent verb set — Discover, Resolve, Execute, ReportUsage, Dispute and the low-tier content Fetch — the account-setup verb set — Register and GetAccountStatus — and the publisher verb set — PushResources, RemoveResources and RefreshCatalog — across three constructors: NewClient for an Exchange, NewBrokerClient for a Broker, and NewCatalogClient for an Exchange's CatalogService, which are different parties or different addresses and so cannot share one base URL.

The account verbs sit on NewClient rather than on a fourth constructor because they are the same party holding the same key: an account is per-Exchange and which Exchange is the agent's choice per call, so they route off the request's own exchange field exactly as a usage report does, rather than against a configured origin.

Around those sit the configurable client itself (sign face as a signing RoundTripper, request-id/validate interceptors, the fail-closed offer Verifier), the routing tier that resolves an offer's Exchange from that Exchange's own manifest and vets it before anything signed is sent, the CallError taxonomy that tells a refusal from a failure from a local decline, the shared bidirectional protovalidate interceptor (NewValidateInterceptor, the single definition the server binding also composes), and the READ direction of the ADR-019 ErrorDetail↔Connect bridge (ErrorDetailFrom — a client reads the typed error detail behind a failure, whether a peer sent it, the content leg synthesized it from an edge's refusal token, or a registration pre-check synthesized it from the schema failures it found before sending).

It depends one-directionally on core (Verifier, DiscoveryResult, VerifiedOffer guard, signing transport, ReplayStore) and on resolvers for the one thing this package must not do itself — dial. The content fetch and the offer-derived RPC leg both run on the resolvers tier's guarded transport; core and helpers stay Connect-free (ADR-020 §2/§3).

The SERVER binding is a SEPARATE package, sdk/go/connectserver (NewExchangeServiceHandler + the verify http-seam + AsConnectError / reject→code). Splitting client and server into two packages lets each expose BARE, symmetric option names — both have their own WithKeyResolver, WithValidation, WithRequestIDFunc, WithInterceptors — with no cross-face collision. connectserver imports this package for the shared Validation enum and NewValidateInterceptor; the edge is one-directional (connectserver → connect → core), no cycle.

Import name clash (consumers: alias one)

This package is named "connect" and the Connect-Go framework is imported from "connectrpc.com/connect", whose package is ALSO named "connect". Inside this package the framework is aliased as `connectrpc "connectrpc.com/connect"` to avoid the self-clash. A CONSUMER that imports BOTH this package (github.com/FORA-Protocol/protocol/sdk/go/connect) and connectrpc.com/connect MUST alias one of them, e.g.:

import (
	foraconnect "github.com/FORA-Protocol/protocol/sdk/go/connect"
	"connectrpc.com/connect"
)

Index

Constants

View Source
const DefaultCallTimeout = 30 * time.Second

DefaultCallTimeout bounds one call on the offer-derived leg. A FORA RPC is interactive — something is waiting on the other end — so a request that has not answered by now is more useful as an error than as a hang.

View Source
const DefaultMaxRPCReadBytes = 1 << 20 // 1 MiB

DefaultMaxRPCReadBytes caps the response body a single FORA call will read. Connect treats an unset cap as "any size" and compresses every exchange, so without one a hostile or misconfigured peer can decompress an unbounded body into the caller's memory. A FORA response for a realistic batch is small; the bound is what stops a peer — including one an offer named — spending the caller's memory on its behalf. Override it per client with WithClientOptions.

Variables

This section is empty.

Functions

func ErrorDetailFrom

func ErrorDetailFrom(err error) (*forav1.ErrorDetail, bool)

ErrorDetailFrom extracts the first FORA ErrorDetail attached to err's Connect error chain. It returns false when err is not a Connect error or carries no ErrorDetail. It lives in the CLIENT binding (the read direction: a client READS the typed error detail an upstream emitted) because it must unwrap a *connect.Error — a Connect-transport concern. The neutral Reason accessor (over an already-extracted detail) stays in sdk/go/helpers; the emit direction (AsConnectError) lives in the server binding sdk/go/connectserver.

func NewValidateInterceptor

func NewValidateInterceptor() (connectrpc.Interceptor, error)

NewValidateInterceptor returns the bidirectional protovalidate interceptor built on the vetted connectrpc.com/validate library. It validates requests, responses, AND error details (WithValidateResponses) — the two-way SDK-validated contract of ADR-019 — reusing the shared protovalidate engine helpers.Validate wraps so the interceptor and the L1 pre-check share one engine (zero rule drift). It is the SINGLE definition both the client (this package) and the server binding (sdk/go/connectserver, which imports it) compose, so the two faces share one validate interceptor with zero duplication.

Types

type BrokerClient

type BrokerClient struct {
	// contains filtered or unexported fields
}

BrokerClient is the Connect client for BrokerService.

It is a SEPARATE constructor rather than a second surface on the exchange client because the two speak to different parties. A Broker is not an Exchange: it fans a query out across Exchanges it knows and relays back what they offered, so its address is the Broker's, not any Exchange's. Hanging both off one base URL would mean one of the two was always pointed at the wrong party.

It shares the exchange client's plumbing — the same signing transport, the same cross-cutting interceptors, the same fail-closed offer Verifier — so the two faces cannot drift in how they sign, correlate, validate or verify.

func NewBrokerClient

func NewBrokerClient(baseURL string, opts ...ClientOption) *BrokerClient

NewBrokerClient builds a BrokerClient against a Broker's base URL. It accepts the same option type as NewClient, but only the options a discovery call has any use for actually do anything, and two of those need care:

  • WithOfferKey pins a SINGLE offer-verifying key for every exchange, which is the wrong shape here. Broker fan-out returns offers minted by different Exchanges, so anything not signed by that one key lands in Rejected. Inject WithKeyResolver instead — the resolvers tier ships one that resolves each issuing Exchange's own key.
  • WithRequester is REQUIRED, not optional: a Broker resolves the calling agent from it and declines a request that names none, so Resolve refuses locally rather than spending a round trip to be told.

The options that do nothing here are the ones belonging to legs a Broker client does not have: WithAgentKey, WithProofWindow and WithContentTimeout / WithMaxContentBytes configure the delivery fetch; WithEndpointResolver and WithGuardedBaseTransport configure the offer-derived leg. Both legs live on the exchange client. Passing them here is silently inert rather than an error, so one shared option set can build both faces.

BrokerService carries exactly one method today. The purchase path through a Broker is still a relay route rather than an RPC; when it becomes one, this type gains one method and nothing else here changes.

func (*BrokerClient) Resolve

Resolve runs discovery through the Broker, which fans out to the Exchanges it knows and returns one group per requested URI.

Every returned offer is verified through the SAME fail-closed Verifier Discover uses — not a second verification path. Broker-relayed offers are precisely the case that rule exists for: the Broker forwards offers it did not mint, and an unverified relay can steer an agent's selection with doctored terms that only fail later, at the purchase.

A resolve that finds nothing is a SUCCESSFUL answer carrying a typed reason, not an error: the whole-call reason lands on DiscoveryResult.AbsenceReason and the per-URI ones on each group. Only a genuine fault returns an error.

Resolve carries no idempotency key. Pure discovery buys nothing and changes nothing, so there is nothing for a server to deduplicate — the request message has no such field.

The request is CLONED before ver and the requester are filled in, so the message the caller built stays untouched. Both are filled only when EMPTY: a value the caller set is theirs. A Broker resolves the calling agent from requester.id and refuses a request that names none, so leaving it to every caller to remember would make the identity the client already holds useless exactly where it is needed.

type CallError

type CallError struct {
	Kind   CallErrorKind
	Op     string
	Status int    // HTTP status when the peer answered; 0 otherwise
	Reason string // the peer's own refusal token when it sent one
	Detail *forav1.ErrorDetail
	// PeerMessage is the developer message the peer put on its TYPED reason, when
	// it sent one. Empty otherwise.
	//
	// It is a field rather than something to recover from Error()'s rendering
	// because a reason rendered into prose cannot be read back out without
	// parsing it, and a layer that has to do that is a layer that will get it
	// wrong. It sits BESIDE Reason rather than in it: Reason is the peer's
	// machine token, and putting prose there was a mistake this SDK has already
	// made once and reverted.
	//
	// It is deliberately NOT filled from the transport envelope when there is no
	// typed detail. An answer that did not come from a FORA service — a draining
	// load balancer, a proxy's own page — carries no message of its own, and the
	// text a transport synthesizes for it is that transport's, not the peer's:
	// connect-go writes "502 Bad Gateway" where a fetch-based client writes
	// nothing, so carrying it would make this field's value a property of the
	// language rather than of the answer. That text is still reachable through
	// the cause, where it reads as what it is.
	//
	// It is equally NOT filled from a detail this SDK BUILT ITSELF — the content
	// leg's refusal sentence, or the registration pre-check's. Those details carry
	// a typed reason, so the envelope rule above would not stop them; what stops
	// them is that the sentence around the reason is ours. A field that exists so a
	// layer can attribute prose to a remote party cannot sometimes hold our words.
	// In Go only the decode site fills this, so the rule holds structurally; the
	// two ports state it where their constructors could otherwise derive it.
	//
	// NON-AUTHORITATIVE, and the contract says so of the field it comes from.
	// Branch on Kind or on the typed reason, never on this text. It is also
	// UNBOUNDED — the contract calls it an easy existence oracle and places the
	// no-secrets duty on the server, so a consumer that renders it to a log line
	// or to an agent bounds it there, where the audience is known. This SDK does
	// not bound it, for the same reason it does not bound Detail.Message:
	// truncating a peer's only account of why a call failed is a decision that
	// belongs to whoever displays it.
	PeerMessage string
	Err         error
}

CallError is the client's typed failure for the verbs that are not plain Connect round trips — the ones that vet an address before sending, or that speak HTTP rather than an RPC.

Detail carries the typed protocol reason when there is one. On an RPC path it is the ErrorDetail the peer emitted; on the content path it is SYNTHESIZED locally from the edge's refusal token, because the edge answers a small JSON object rather than a protobuf; and on a registration refused by the client's own pre-check it is synthesized from the schema failures that pre-check found, which are the failures the Exchange would have named had the request been sent. ErrorDetailFrom reads all three, so a caller branches on one vocabulary whichever side declined.

func (*CallError) Error

func (e *CallError) Error() string

func (*CallError) ReasonOf

func (e *CallError) ReasonOf() string

ReasonOf returns the most specific machine-readable reason available: the peer's own token when it sent one, otherwise the failure class.

func (*CallError) Unwrap

func (e *CallError) Unwrap() error

Unwrap keeps the cause matchable, so errors.Is still reaches a custody or resolver sentinel after the failure has been classified here.

type CallErrorKind

type CallErrorKind int

CallErrorKind classifies why a client call did not produce an answer.

const (
	// CallUnknown is the zero value; it carries no classification.
	CallUnknown CallErrorKind = iota
	// CallRefused is a server that answered and said no, with a status and,
	// usually, a typed reason.
	CallRefused
	// CallUnreachable is a server that did not answer: dial failure, timeout, or
	// a redirect this SDK refused to follow.
	CallUnreachable
	// CallNotSent is THIS SDK declining to send. The address failed the
	// plain-hostname, same-host or dial-time guard, so nothing left the process
	// and no signature was exposed.
	CallNotSent
	// CallMalformed is a request that could not be built or signed faithfully.
	// Nothing left the process.
	CallMalformed
	// CallTooLarge is a response body past the configured cap.
	CallTooLarge
	// CallNotSignable is a signature or proof that could not be produced —
	// typically custody declining or timing out. Nothing left the process.
	CallNotSignable
)

func (CallErrorKind) String

func (k CallErrorKind) String() string

String renders the kind for logging and for the reason a caller sees when the peer supplied none.

type CallOption

type CallOption func(*callConfig)

CallOption tunes a single state-mutating call.

func WithIdempotencyKey

func WithIdempotencyKey(key string) CallOption

WithIdempotencyKey pins the idempotency key for this call. Reusing a key makes the call a deliberate replay: the server dedupes on it (a fresh key is minted per call by default). The SDK never tracks keys — the server owns dedup (ADR-019 §4, ADR-020 §3).

Hold the key and pass the same one back when retrying, on every verb that takes this option. The key identifies the ACTION, not the attempt: a fresh key on a retry reads to the server as a second purchase, a second report, a second dispute.

type CatalogClient

type CatalogClient struct {
	// contains filtered or unexported fields
}

CatalogClient is the Connect client for CatalogService — the publisher role's face: push, remove and refresh the catalog entries a publisher, or a contributor it authorised, supplies to an Exchange.

It is a SEPARATE constructor, as the Broker client is, and for a related reason: the address is a different one. An Exchange advertises CatalogService at WellKnownManifest.catalog_endpoint, distinct from the ExchangeService endpoint the agent client dials, and the caller is a different party holding a different key — a contributor's, published in its own WBA directory and named by caller_id, never an agent's. Hanging the catalog verbs on the agent client would carry every agent-only holder (the offer Verifier, the requester, the delivery fetcher) into a client that uses none of them, and point one of the two roles at the wrong address.

The publisher chose the Exchange, so the origin is configuration and the leg runs on the plain signing transport — the posture of the agent client's home Exchange, not of its offer-derived leg: nobody but the caller named this address. A deployment that reads the address off the Exchange's manifest still dials only what it configured here.

It shares the agent client's plumbing — the same signing RoundTripper, the same redirect refusal, the same request-id and validate interceptors, the same read cap — so the faces cannot drift in how they sign or correlate.

func NewCatalogClient

func NewCatalogClient(baseURL string, opts ...ClientOption) *CatalogClient

NewCatalogClient builds a CatalogClient against an Exchange's catalog endpoint. It accepts the same option type as NewClient. WithSigner is what a real push needs — an Exchange refuses an unsigned catalog call — and WithValidation, WithHTTPClient, WithRequestIDFunc, WithInterceptors, WithSignWindow, WithSignatureAgent and WithClientOptions configure this leg exactly as they configure the agent client's home leg.

The options a catalog call has no use for are inert rather than errors, so one option set can build every face: WithOfferKey, WithKeyResolver and WithVerification (offer verification — nothing here returns an offer), WithRequester (the caller is named by caller_id, not a Requester), WithAgentKey, WithProofWindow, WithContentTimeout and WithMaxContentBytes (the delivery fetch), and WithEndpointResolver and WithGuardedBaseTransport (the offer-derived leg).

func (*CatalogClient) PushResources

PushResources pushes or updates catalog entries.

The request is CLONED before `ver` is stamped, so the message the caller built stays untouched; `ver` is filled only when empty. No idempotency key is stamped, because the message carries none: a catalog push is an upsert and naturally idempotent, so a key there would be ceremony rather than a guarantee. `exchange` is the caller's to set — the bare domain of the Exchange this push is meant for, which is the whole point of the field — and a request that names none, or names something that is not a bare domain, is refused before anything is signed or sent.

An Exchange applies two tiers to every entry: the wire rules, then canonicalisation and registry membership over the terms. Both ship in sdk/go/helpers (ValidateResourceEntry) so a publisher can run them first; the Exchange's own run is the deciding one. A push it refuses as a whole comes back as a non-OK call whose typed reason, when the Exchange attaches one, is readable through ErrorDetailFrom as a CatalogRejection.

func (*CatalogClient) RefreshCatalog

RefreshCatalog asks the Exchange to refresh the tenant's catalog from its configured sources. Same envelope rule as PushResources.

func (*CatalogClient) RemoveResources

RemoveResources removes the catalog entries the request's paths name. Same envelope rule as PushResources: `ver` filled when empty, no idempotency key, `exchange` required and refused locally when it is not a bare domain.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the L2 low-tier FORA Connect client: a configurable ExchangeService client with the sign face composed as a signing RoundTripper and the cross-cutting request-id / validate interceptors wired, plus the fail-closed offer Verifier that sorts every discovered offer into {verified, rejected}. It owns NO state — signer, keys, HTTP client, and verification policy are all injected (ADR-020 §2/§3).

func NewClient

func NewClient(baseURL string, opts ...ClientOption) *Client

NewClient builds a Client against baseURL — the agent's HOME Exchange, the one its account lives on. The sign face is composed onto the HTTP client's transport BEFORE the Connect client is built (Content-Digest needs the marshaled body bytes), then the cross-cutting interceptors are wired in the ADR order: sign(RoundTripper) · request-id · validate · (app extras). Offer verification is Strict by default.

Discovery and purchase go to baseURL. A usage report or a dispute does NOT: those reach the Exchange that issued the offer, resolved per call from that Exchange's own manifest, over a separately guarded transport.

func (*Client) Discover

func (c *Client) Discover(ctx context.Context, query *forav1.ResourceQuery) (core.DiscoveryResult, error)

Discover issues DiscoverResources and returns one group per requested URI, each carrying the fail-closed {verified, rejected} split: EVERY returned offer is verified against the exchange offer-signing key (resolved through the injected resolver) before it is handed back. Neither an unverifiable nor a doctored offer is silently dropped — it lands in Rejected with a reason. A URI that the responder GROUPED and left empty keeps its group, carrying the typed reason, so a refusal is an answer rather than an absence. (A response carrying no groups at all yields none — there is nothing to keep.) Round-trip: client sign → HTTP → server verify → origin → response, then the offer Verifier over the response.

The query is CLONED before ver and the requester are filled in, so the message the caller built stays untouched — it crossed a package boundary as an argument, not as a buffer. Both fields are filled only when EMPTY: a value the caller set is theirs.

`exchange` is NOT among them: the caller MUST set it to the bare host of the Exchange being queried, because the contract now requires every addressed request to name its recipient and a query without one is rejected on arrival. It is left to the caller rather than derived from the client's base URL on purpose — the point of the field is to state whom the SENDER meant, and a value the transport filled in from the address it was already dialling would restate the dial target instead of checking it.

func (*Client) Dispute

func (c *Client) Dispute(ctx context.Context, req *forav1.DisputeRequest, opts ...CallOption) (*forav1.DisputeResponse, error)

Dispute files a dispute with the Exchange that issued the offer, over the same vetted routing a usage report takes.

The destination comes off the request, exactly as it does for a usage report: DisputeRequest.exchange carries the recipient, and the endpoint is resolved from that Exchange's own well-known manifest. It used to be an argument here, because the message carried no field to read it from — an asymmetry the message shape forced. The field now exists, so the asymmetry would be a choice, and the reason it was tolerated is the reason to remove it: a parameter is something a configured origin can be passed as, and reading the destination off the signed message leaves no such seam.

The dispute chain is a structural invariant: an agent must have filed a usage report and received a report_id before it can dispute, so req.ReportId and req.TransactionId both name links the Exchange already holds.

func (*Client) Execute

func (c *Client) Execute(ctx context.Context, offer core.VerifiedOffer, opts ...CallOption) (*forav1.TransactionResponse, error)

Execute commits to a VERIFIED offer and returns the transaction response. It accepts ONLY a core.VerifiedOffer — passing a RejectedOffer or a raw *forav1.Offer is a COMPILE error (the unforgeable-VerifiedOffer guard). A per-call idempotency key is minted fresh unless WithIdempotencyKey pins one. Execute builds the whole TransactionRequest, so it also stamps ver from helpers.ProtocolVersion — the caller neither supplies nor overrides it.

func (*Client) Fetch

func (c *Client) Fetch(ctx context.Context, signedURL string) (resolvers.Content, error)

Fetch retrieves the content a signed delivery URL names, presenting proof of possession of the agent key that URL is bound to.

This is the LOW-TIER fetch: follow one signed URL, present the key, return the bytes. It does not discover, select, buy or report — that orchestration is a separate, higher tier.

The transport rules are the report leg's, for the same reason: the retrieval host is chosen by a party on the network. It dials through the SSRF guard and REFUSES redirects. Following one would either replay a proof bound to the old URL, which the edge's own check rejects, or hand a fresh proof of possession of the agent's key to whatever host the first hop named.

A refusal from the edge arrives as a typed reason where the edge's vocabulary maps onto the protocol's, so ErrorDetailFrom reads a fetch failure and an RPC failure through the same accessor.

It takes no CallOption: a fetch is a GET against an already-issued URL, so there is no idempotency key to pin — nothing on this path mutates state.

The URL is taken as given. Whether it is one this agent bought, and whether its agent_id matches this agent's key, are the CALLER's checks to make — the SDK exports helpers.VerifyURLEd25519 and VerifiedURL.CheckProofOfPossession for exactly that, and running them first turns an edge 403 into a local answer. Worth doing when the URL reached the caller from anywhere but its own execute response: a proof of possession is minted for whatever URL is passed in.

func (*Client) GetAccountStatus

GetAccountStatus reports whether the calling agent's account at the named Exchange is active.

The request carries no field identifying the caller — the Exchange resolves the account from the verified signature — so `exchange` is the only thing that says which account is being asked about. Accounts are per-Exchange, and that is not derivable from anything else in the message.

An answer carrying an empty account handle is a NORMAL answer: it means this agent holds no account at that Exchange yet. The response also reports the terms revision the account accepted, which is what was agreed rather than what is published now; comparing the two is how an agent discovers that an operator's terms moved under an account it already holds.

A caveat worth knowing before calling this in a loop. The request has no varying field, so two calls to the same Exchange inside one wall-clock second sign IDENTICAL bytes — signature timestamps have one-second resolution — and a peer screening replays on (key id, signature) refuses the second as a duplicate. This verb does not choose the freshness window for you, because a window is one instance per client rather than per call and the choice belongs to whoever built the client: pass core.MonotonicWindow through WithSignWindow when repeat calls are expected.

func (*Client) Register

func (c *Client) Register(
	ctx context.Context, req *forav1.RegisterRequest,
) (*forav1.RegisterResponse, error)

Register creates the calling agent's account at the Exchange the request names.

The caller's identity is the request SIGNATURE. Nothing in the message says who is registering, and the business payload is not an identity claim — so a client with no signer can build this request and cannot usefully send it.

Four bounds on registration_data are checked before anything is signed, in the order the contract fixes: top-level member count, nesting depth, whether the payload has a canonical JSON form at all, and the size of that form. They run here because a limit that exists to stop work belongs before the work it would stop, and because a payload breaking one is a MALFORMED request rather than a schema failure — a distinction the Exchange also makes.

terms_digest is filled only when the caller left it UNSET. Submitting a registration states which terms the operator accepted, the request signature covers that echo, and the contract requires the value to come from a FRESHLY fetched manifest — a cached endpoint is fine, a cached digest is not. So the fill reads the Exchange's requirements through the uncached reader, which is also where the published data_schema comes from, and the payload is pre-checked against it before signing. A caller that sets the field is managing its own requirements and gets neither the fill nor the pre-check; proto3 presence is what lets "unset" and "deliberately empty" be different requests.

The pre-check never becomes a local veto for a schema this SDK refuses. The contract is explicit that refusing locally and declining to send would turn a rule about reading a third party's document into a denial of service against the caller's own user, so an unusable schema is skipped and the Exchange's own enforcement decides. A schema that IS usable and that the payload fails is a different case: that is the pre-check working, and the request is refused here with the offending members named.

Two manifest reads happen on a first registration — one cached, for the endpoint, and one fresh, for the digest. That is the contract's own split, and registration happens once per Exchange, so the extra fetch is cheap.

A refused registration comes back as a non-OK call whose typed reason is readable through ErrorDetailFrom as a RegistrationFailure.

func (*Client) ReportUsage

func (c *Client) ReportUsage(ctx context.Context, report *forav1.UsageReport, opts ...CallOption) (*forav1.UsageReportResponse, error)

ReportUsage files a usage report with the Exchange that ISSUED the offer — never through a Broker, and never to an address from configuration.

The destination comes off the report itself: UsageReport.exchange carries the offer's signed exchange domain, and the endpoint is then resolved from that Exchange's own well-known manifest. Reading it off the message rather than taking it as an argument is what makes the rule structural — there is no parameter a configured origin could be passed as, so it cannot become the default by anyone's convenience. Set it from the offer being reported:

report.Exchange = verified.Offer().GetExchange()

The report is cloned before ver and the idempotency key are stamped, so the message the caller built stays untouched — it crossed a package boundary as an argument, not as a buffer to fill in.

The idempotency key identifies the REPORT, not the attempt. A fresh one is minted only when the caller supplied none — a value already on the message, or one pinned with WithIdempotencyKey, is left alone. That distinction matters: an application that mints its own key for its own dedup would otherwise have it silently discarded and see every retry counted as a second report.

type ClientOption

type ClientOption func(*clientConfig)

ClientOption configures a Client. Options are the ONLY way to inject the signer, keys, HTTP client, and verification policy — the SDK has no ambient defaults for custody-bearing holders.

func WithAgentKey

func WithAgentKey(pub ed25519.PublicKey) ClientOption

WithAgentKey injects the PUBLIC half of the key WithSigner signs with. A delivery fetch presents it in a header, and a Signer cannot yield it — custody keeps the private half, so the public half has to be supplied alongside. Without it the client can buy but cannot fetch what it bought.

There is deliberately no option for a SEPARATE acceptance key. The protocol carries one agent identity: agent_identity_hash is defined as the thumbprint of the agent's request-signing key, an Exchange verifies the detached acceptance against the key registered for the caller its request signature identified, and the delivery URL is bound to that same thumbprint. A second key would be refused at execute, and any URL it did produce could never be fetched — the presented key would not match the binding. The key is COPIED for the same reason WithRequester clones: the caller keeps the slice it passed, and a later append or overwrite there would otherwise change which key every subsequent fetch presents.

func WithClientOptions

func WithClientOptions(opts ...connectrpc.ClientOption) ClientOption

WithClientOptions appends raw Connect client options (a codec, a read cap tighter than the SDK default) to every Connect client the SDK builds. Interceptors belong in WithInterceptors; this is the escape hatch for the remaining client-level knobs the SDK does not model, mirroring connectserver.WithHandlerOptions on the server face.

Options are appended AFTER the SDK's own, so a caller-supplied value wins.

func WithContentTimeout

func WithContentTimeout(d time.Duration) ClientOption

WithContentTimeout bounds one delivery fetch, proof minting included. The default is resolvers.DefaultContentTimeout.

A delivery host is named by another party, so the bound is not optional — this option moves it, it does not remove it. A value <= 0 keeps the default.

func WithEndpointResolver

func WithEndpointResolver(r EndpointResolver) ClientOption

WithEndpointResolver injects the resolver that turns an offer's exchange domain into the origin that Exchange advertises for itself. It defaults to the SSRF-guarded well-known resolver.

There is deliberately no option to supply an endpoint directly. A usage report must reach the Exchange that issued the offer, and that address comes from the Exchange's own manifest — never from configuration. Leaving no configuration slot for it is what makes that structural rather than a convention.

func WithGuardedBaseTransport

func WithGuardedBaseTransport(base *http.Transport) ClientOption

WithGuardedBaseTransport carries the caller's own transport settings — a tuned connection pool, client certificates via TLSClientConfig — UNDERNEATH the SSRF guard on both legs that dial an address another party named: the content fetch, and the RPCs that route to the Exchange an offer identified.

It is not a way to replace the guard. Those two legs dial hosts the client did not configure, so the dial-time address pin and the https-only scheme check are applied in every case; a caller supplies what sits under them. The only way to reach a private or plaintext endpoint is the deliberate, deployment-level SKIP_SSRF / ALLOW_INSECURE opt-out.

One setting is dropped rather than carried: a custom TLS dialer. net/http prefers a transport's own TLS dialer over the pinned one on https, so honouring it would take every signed call around the address check. TLS itself is configured through TLSClientConfig, which is kept.

The home Exchange and the Broker are operator-configured origins and are trusted as far as that configuration is, so they dial through WithHTTPClient's transport instead.

func WithHTTPClient

func WithHTTPClient(h *http.Client) ClientOption

WithHTTPClient injects a custom *http.Client (proxy, mTLS, pooling). The SDK composes its signing RoundTripper onto the client's transport, so a custom transport is preserved as the base.

func WithInterceptors

func WithInterceptors(is ...connectrpc.Interceptor) ClientOption

WithInterceptors appends application interceptors (tracing, metrics) to the client SDK stack. They run inside the SDK cross-cutting interceptors.

func WithKeyResolver

func WithKeyResolver(r helpers.KeyResolver) ClientOption

WithKeyResolver injects a custom offer-key resolver (a private registry, a proxy, a preloaded set) for the client's offer Verifier. It is the same KeyResolver interface the server verify face resolves request-signing keys through (connectserver.WithKeyResolver) — one interface for both faces. Overrides WithOfferKey.

func WithMaxContentBytes

func WithMaxContentBytes(n int64) ClientOption

WithMaxContentBytes caps one fetched body. The default is resolvers.DefaultMaxContentBytes.

Worth setting when the application carries its own per-item budget: a cap here that disagrees with the one the caller accounts against makes that accounting wrong, and an over-cap body is reported as CallTooLarge rather than truncated. A value <= 0 keeps the default.

The two bounds are separate scalars rather than one options struct because ContentFetchOptions also carries the base transport, which arrives through WithGuardedBaseTransport — a second way to set it would be a field that had to be silently ignored.

func WithOfferKey

func WithOfferKey(pub ed25519.PublicKey) ClientOption

WithOfferKey injects the exchange's offer-verifying Ed25519 public key. The client's offer Verifier resolves every returned offer against it. Without it (and without WithKeyResolver) the client cannot verify offers, so under Strict every offer is rejected as unverifiable.

func WithProofWindow

func WithProofWindow(w core.Window) ClientOption

WithProofWindow overrides the freshness window stamped on a delivery-fetch proof. The default is 30 seconds from the wall clock.

Deliberately NOT the signed URL's own expiry, which can be hours: the proof covers only the method and the URL, so anyone who observes the request can repeat it until the window closes.

func WithRegistrationRequirements

func WithRegistrationRequirements(r RegistrationRequirementsReader) ClientOption

WithRegistrationRequirements injects the reader that reports what one Exchange asks of a registration — the terms revision submitting one accepts, and the schema its registration_data must match. It defaults to the SSRF-guarded well-known reader.

The reader it takes holds no document cache, and that is the point rather than an implementation detail: the contract requires a registering client to read the terms digest from a FRESHLY fetched manifest, so an implementation that serves this from a cache breaks the rule the field exists to record. There is deliberately no option to supply a digest or a schema directly — a caller that wants to manage its own requirements sets RegisterRequest.terms_digest, which suppresses the read and says so on the message the signature covers.

func WithRequestIDFunc

func WithRequestIDFunc(fn core.RequestIDFunc) ClientOption

WithRequestIDFunc overrides the client request-id source (e.g. to reuse a trace id).

func WithRequester

func WithRequester(r *forav1.Requester) ClientOption

WithRequester injects the agent's own identity, forwarded on a purchase for authorization and audit and covered by the detached offer acceptance.

It is client-level rather than per-call on purpose: the requester IS the identity the injected Signer already fixes for the transport signature, so a per-call requester would let the two disagree about who is buying — the exact ambiguity the acceptance exists to remove. A verifying Broker refuses a requester id that does not normalise to the signer's own directory host.

The message is cloned here, so a later mutation by the caller cannot reach a request already in flight.

func WithSignWindow

func WithSignWindow(w core.Window) ClientOption

WithSignWindow overrides the freshness window stamped on every outbound RFC 9421 REQUEST signature — the home Exchange, the Broker, and the leg that routes to the Exchange an offer named. The default is five minutes from the wall clock.

Two reasons an application supplies its own. A deployment with a shorter freshness policy sets its own TTL, and through this option the value it already reads from configuration keeps meaning something. And a peer that screens replays on (key id, signature) refuses a repeat: signature timestamps have one-second resolution, so two identical requests inside one second sign to the same bytes. core.MonotonicWindow keeps each signature unique for exactly that.

Distinct from WithProofWindow, which stamps a delivery-fetch proof rather than a request signature. Both take a core.Window; neither substitutes for the other.

func WithSignatureAgent

func WithSignatureAgent(dir string) ClientOption

WithSignatureAgent names the WBA directory origin this client signs as — the place a peer fetches to find the key that signed the request. It is stamped into the Signature-Agent header of every outbound RFC 9421 request.

Leaving it unset does not omit the header: signature-agent is one of the five REQUIRED covered components, so the signature covers it either way and an unset client signs an EMPTY value. A peer that resolves the caller's key from that origin then has nothing to resolve, and refuses the call at verification — after the request was routed, signed and sent, which is why the symptom is a 401 from a healthy Exchange rather than anything the routing checks would catch.

One value per client, because one client speaks for one agent — the same reason WithRequester is held rather than passed per call. An application signing as several agents builds a client per agent.

Stamped SET-IF-ABSENT. A request that already carries a Signature-Agent keeps it, so a relay forwarding an originating agent's call does not overwrite the value that agent's own signature covers.

func WithSigner

func WithSigner(s helpers.Signer) ClientOption

WithSigner injects the RFC 9421 request Signer (custody stays with the app — the SDK never sees the private key). Without it the client sends unsigned requests, which a verifying server rejects.

func WithValidation

func WithValidation(v Validation) ClientOption

WithValidation sets protovalidate strictness for the client. The default is ValidationOff; a caller opts into bidirectional wire-shape enforcement with WithValidation(ValidationStrict). It is orthogonal to WithVerification (offer signature authenticity).

func WithVerification

func WithVerification(m core.Mode) ClientOption

WithVerification sets offer-verification strictness. The default is Strict (fail-closed); WithVerification(core.Off) is the single loud, named opt-out.

type EndpointResolver

type EndpointResolver interface {
	ResolveEndpoint(ctx context.Context, host string) (string, error)
}

EndpointResolver turns a signed exchange domain into the origin that Exchange advertises for itself. It is an interface so a test can drive reporting without standing up a manifest server — and, more to the point, so this package has no way to accept a report endpoint from configuration.

An implementation's ERROR decides how a caller is told to react, so it is part of the contract rather than an implementation detail. A failure that is a VERDICT — the host is unusable, the host is not allowed, the manifest carries a version this reader does not accept, advertises no endpoint, or advertises one that must not be used — MUST wrap helpers.ErrInvalidHost, resolvers.ErrManifestVersionRefused, resolvers.ErrNoEndpoint or resolvers.ErrEndpointRefused; those four surface as CallNotSent, which tells the caller not to retry. Anything else is read as a transport failure and reported as CallUnreachable, i.e. worth retrying. An implementation that returns a bare error for a refusal therefore has its final answer retried indefinitely.

type ExecuteOption

type ExecuteOption = CallOption

ExecuteOption is the original name for CallOption, kept because the option set is identical across execute, report and dispute — the three RPCs the protocol requires an idempotency key on.

type RegistrationRequirementsReader

type RegistrationRequirementsReader interface {
	ResolveRegistrationRequirements(
		ctx context.Context, exchange string,
	) (resolvers.RegistrationRequirements, error)
}

RegistrationRequirementsReader reports what one Exchange asks of a registration. It is an interface for the same two reasons the endpoint seam is one: a test can drive a registration without standing up a manifest server, and this package has no way to accept a terms digest or a schema from configuration — the only way to skip the read is to set RegisterRequest.terms_digest, where the signature covers it.

An implementation MUST NOT serve the answer from a cache. The contract requires a registering client to read the digest from a freshly fetched manifest, so a cached one breaks the rule the field exists to record.

Its ERROR decides how a caller is told to react, as the endpoint seam's does. A failure that is a VERDICT — the domain is unusable, the deployment excludes it, the document served is not an Exchange's, or it is one this reader cannot use — MUST wrap helpers.ErrInvalidHost, resolvers.ErrExchangeNotPermitted, resolvers.ErrManifestNotExchange or resolvers.ErrManifestUnusable; those four surface as CallNotSent, which tells the caller not to retry. Anything else is read as a transport failure and reported as CallUnreachable, i.e. worth retrying. An implementation that returns a bare error for a refusal therefore has its final answer retried indefinitely.

ErrManifestUnusable is the seam's word for "the document arrived and cannot be read for what a registration owes". The SDK's own reader reaches it for a document version it cannot classify, and treats its other two disappointments as absence or as a transport failure. An implementation STRICTER than that one — validating the whole document, or applying a narrower version rule — reaches for the same word, and would otherwise hold a final answer this seam reported as transient.

Note which sentinel that is NOT. resolvers.ErrManifestVersionRefused belongs to the endpoint seam and is absent from the four above on purpose: the two vocabularies are disjoint, one answering whether an endpoint may be dialled and this one whether a document can be read. An implementation that wraps the endpoint sentinel for a version refusal here has its verdict read as a transport failure.

type Validation

type Validation int

Validation selects protovalidate strictness for the SDK validate interceptor. Strict wires the vetted connectrpc.com/validate interceptor BIDIRECTIONALLY — requests, responses, AND error details are one SDK-validated contract (ADR-019). Off omits the interceptor. It is a distinct axis from WithVerification (offer authenticity): validation is proto-shape conformance, verification is signature authenticity. This enum is SHARED by both faces: the server binding (sdk/go/connectserver) references connect.Validation so client and server select strictness with one type.

const (
	// ValidationOff omits the protovalidate interceptor (the default). A caller
	// opts into wire-shape enforcement with WithValidation(ValidationStrict).
	ValidationOff Validation = iota
	// ValidationStrict wires the bidirectional protovalidate interceptor.
	ValidationStrict
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL