go/

directory
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

README

FORA SDK — Go

Layered protocol libraries co-located with the contract (ADR-020). The SDK is consumed off-commit from this repo; it imports the generated L0 wire types directly with no replace directive.

Layer Package What it is
L0 gen/go/fora/v1, gen/go/vocab/* generated wire types (consumed, never rebuilt)
L1 sdk/go/helpers stateless, IO-free protocol helpers — RFC 9421/7638 crypto, offer/acceptance verify, static key resolution, validation
L2 · I/O sdk/go/resolvers the network-fetching tier: well-known JWKS / WBA directory / fora.json endpoint / offer-key resolvers, the uncached registration-requirements reader, + the SSRF-guarded HTTP client. Runs on a maintained net/http client behind the SSRF guard; composes L1, never the reverse
L2 · transport sdk/go/core (transport-neutral: Verifier, {verified,rejected}, DiscoveryResult per-URI groups, VerifiedOffer guard, signing RoundTripper, ReplayStore — zero Connect) · sdk/go/connect (Connect client binding: NewClient + NewBrokerClient + NewCatalogClient; the agent verbs Discover · Resolve · Execute · ReportUsage · Dispute · Fetch, the account-setup verbs Register · GetAccountStatus and the publisher verbs PushResources · RemoveResources · RefreshCatalog + client options + the CallError taxonomy + ErrorDetailFrom) · sdk/go/connectserver (Connect server binding: NewExchangeServiceHandler + NewBrokerServiceHandler + NewCatalogServiceHandler + server options + AsConnectError + AttachErrorDetail/AttachDetail + the reject answer RejectCode · IsBodyTooLarge · WriteReject, the one place the 413/429/401 split and the error-envelope body are decided) transport-neutral core + Connect client/server bindings (state injected)
L3 separate packages framework adapters (convert, never replace) — later

The L2 tier is split by kind: the I/O package (resolvers) is the only tier that dials the network (it holds the maintained HTTP client and the SSRF guard), while the transport packages (core / connect / connectserver) hold no dialing surface of their own — connect composes the I/O tier's guarded transport and content fetcher rather than opening a second one. An io-leaf guard (helpers/io_leaf_guard_test.go) fails the build if any pure L1 file drags in a dialing surface, so the fetch surface cannot leak back down.

L1 — helpers

Stateless, no network IO (no HTTP client, no dial), no secret custody, no state. The same code the Broker, Exchange, MCP, Edge, and external implementors build on. (The well-known JWKS, WBA-directory, and fora.json endpoint fetches moved one tier up into sdk/go/resolvers; helpers keeps only the pure KeyResolver interface and the static resolver.)

import "github.com/FORA-Protocol/protocol/sdk/go/helpers"

RFC 9421 request signing / verification — the Signer interface signs the SDK-built signature base, so a KMS/HSM signer never exposes its key:

signer, _ := helpers.NewEd25519Signer("agent.v1", priv)
_ = helpers.SignRequest(ctx, req, body, signer,
    helpers.SignOptions{Created: created, Expires: expires})

// verify with the key injected (pure) ...
vr, err := helpers.VerifyRequest(req, body, pub, helpers.VerifyOptions{})
// ... or resolve the key via a KeyResolver (static in L1; well-known/WBA in L2 resolvers):
vr, err = helpers.VerifyRequestResolved(ctx, req, body, resolver, helpers.VerifyOptions{})

License-term pre-check — the two tiers an Exchange applies to a pushed entry, runnable by a publisher before signing: the wire rules over the entry as given, then canonicalisation (RFC 8259 trim, ASCII-only fold, alias resolution through the generated vocabulary), then registry membership and canonical disjointness over a copy of its terms. The second of those is why the tier order matters: permitted: ["scrape"] with prohibited: ["crawl"] clears the boundary rule, which compares the tokens as written, and names one token once folded. Warning messages are the exact PushResourcesResponse.warnings strings; rule ids share the CEL-id namespace (pricing.unit.registered, restriction.canonical_disjoint, …):

verdict := helpers.ValidateResourceEntry(entry)   // never modifies entry
for _, v := range verdict.Violations { log.Println(v.Rule, v.Path, v.Message) }
helpers.NormalizeResourceEntry(entry)             // the form the Exchange stores

Offer authenticity — verify a received offer before selecting on it (the gap the SDK closes); the signature covers pricing, terms, and expires_at:

err := helpers.VerifyOffer(offer, offer.GetSignature(), exchangePub)

Signed delivery URLs + proof-of-possession (ADR-013), byte-identical with the edge worker:

su, _ := helpers.SignURLEd25519(priv, "ex.v1", rawURL, agentThumbprint, expiry)
v, err := helpers.VerifyURLEd25519(su.URL, exchangePub, time.Now())
err = v.CheckProofOfPossession(presentedAgentPubKey) // bound-fetch enforcement

Money — exact decimal in/out, canonical decimal string on the wire:

rate, _ := helpers.ParseMoney(offer.GetPricing().GetRate()) // shopspring/decimal
wire, _ := helpers.FormatMoney(rate.Mul(decimal.NewFromInt(qty)))

Validation — wraps protovalidate (the oracle), including cross-field CEL:

if err := helpers.Validate(req); err != nil { /* helpers.ValidationRuleIDs(err) */ }

Agent-binding proof of possession (ADR-013) — the SIGN face of the header pair a bound delivery fetch presents. The covered set is exactly @method + @target-uri: a GET has no body to digest, and the signed URL is itself the credential. The key arrives as a Signer plus the public half, so custody never moves into the SDK:

binding, _ := helpers.SignAgentBinding(ctx, signer, agentPub, helpers.PoPOptions{
    URL: signedURL, Created: created, Expires: expires, // keep the window short
})
binding.Apply(req.Header) // X-FORA-Agent-Key + Signature-Input + Signature

Routing predicates — the two pure checks that precede a signed call to an address a network party named (a manifest may point at itself or a subdomain of itself, and nothing else):

bare, _ := helpers.IsBareHost(offer.GetExchange())      // no scheme/path/query
ok, _ := helpers.HostAnchored(exchangeDomain, endpoint) // label-boundary match

Audience check — the other direction: a request arrived, does it name THIS Exchange? The signature does not already answer that: it proves the sender signed the URL it dialled, and that URL came out of a fetched, cached fora.json, so a poisoned resolution redirects the request while every signature still verifies. The body field says whom the sender meant. The check is pure, so it runs before any lookup:

verdict, err := helpers.CheckAudience(cfg.ExchangeDomain, report.GetExchange())
if err != nil {           // this deployment's identity is unusable -> internal
    return err
}
if verdict != helpers.AudienceAccepted {   // "empty" | "malformed" | "mismatch"
    return reject(verdict.String())        // the request's fault -> invalid argument
}

Pass one value, or many where the audience lives per item — a TransactionRequest states it once per item, inside each item's signed offer:

claimed := make([]string, 0, len(req.GetItems()))
for _, it := range req.GetItems() {
    claimed = append(claimed, it.GetOffer().GetExchange())
}
verdict, err := helpers.CheckAudience(cfg.ExchangeDomain, claimed...)

cfg.ExchangeDomain is the domain this Exchange publishes as its identity — the value it stamps into the offers it issues — never the host the process listens on. The two may differ: an Exchange at exchange.example may serve its API from api.exchange.example. Configure it from the listening host and every correctly addressed request is refused, so a global mismatch storm means check your own identity before suspecting callers; the check is pure and cannot detect this for you.

The match is EXACT — a subdomain is a different party — which is narrower than the endpoint rule above, where a manifest MAY advertise a subdomain of itself. The shape both sides admit is helpers.IsBareDomain, whose BareDomainPattern is byte for byte the protovalidate pattern the contract's recipient-addressing fields carry, held there by a conformance guard. Reach for it wherever you vet a domain that arrived in a message; note that the client's own send path still vets with the wider IsBareHost, so passing that one is not yet evidence the wire will accept a value.

Registration schema — an Exchange MAY publish a JSON Schema for the registration_data it expects. Both ends of a registration validate against it, so the rules live here once: 2020-12 only, same-document $ref only and no reference cycles, 16KB, 32 containers, 10,000 evaluations of work, and a pattern alphabet all three SDK languages express identically. raw is the schema's bytes as served in fora.json — the size cap is defined over those:

schema, verdict := helpers.CompileRegistrationSchema(raw)
if verdict != helpers.SchemaAccepted {
    // "wrong_dialect" | "remote_ref" | "too_large" | "too_deep" | "unsafe_pattern" | ...
}

The two callers read a non-accepted verdict differently, and getting it backwards is the easy mistake. A client pre-checking a payload skips the check and sends anyway — the Exchange's enforcement decides, and a local check that cannot run must not become a local veto against your own user. An Exchange compiling its OWN configured schema treats anything but SchemaAccepted or SchemaNotPublished as a misconfigured deployment, and must not advertise a schema it is not enforcing.

Do not discard the verdict. A nil *RegistrationSchema reports no failures, because "no schema" means "nothing to enforce" — which is right for SchemaNotPublished and right for a client that could not check locally. It is also what you hold after every refusal, so an Exchange that drops the verdict enforces nothing and cannot tell. The verdict is the only thing that separates the two cases:

fieldErrors := schema.Validate(req.GetRegistrationData().AsMap())
if len(fieldErrors) > 0 {
    return helpers.RegistrationFailureDetail(domain, "registration_data does not conform",
        forav1.RegistrationFailureReason_REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA,
        fieldErrors...)
}

Each entry is an RFC 6901 pointer relative to registration_data ("" addresses the whole object) plus the violated constraint. The text states the constraint and never the submitted value — registration data is an operator's business data and a refusal travels back over the wire, so the validating library's own messages, which quote the failing value, are deliberately not used. The list is deduplicated by pointer and keyword and sorted before the 64-item cap, so the same entries survive in every language. The POINTERS survive identically; the constraint text does not, and the contract says so of that field — it is validator-defined and not stable across implementations, so branch on the pointer and the typed reason, never on the prose.

The client's own pre-check returns this same list, on the CallError it refuses with, as a RegistrationFailure detail reachable through ErrorDetailFrom. That is deliberate: an agent developer's tooling renders the Exchange's refusal and the local one through the same code, and a structured list on one side and a sentence on the other would read as two different problems. Its domain names the client rather than the Exchange, which never saw the request.

Also: RFC 7638 Thumbprint, ADR-019 ErrorDetail constructors + AsConnectError/ErrorDetailFrom/Reason, NewIdempotencyKey, scope helpers, RedactURL (a signed URL carries its credential in the query — never log it raw), and RetrievalAuthFailureReasonFromToken (the delivery edge's refusal vocabulary, mapped onto the typed enum).

L2 · I/O — resolvers

The network-fetching tier. Everything that dials a third-party-influenceable host lives here, behind a single SSRF-guarded HTTP client, so the pre-auth-reachable network surface never enters the pure trust core:

import "github.com/FORA-Protocol/protocol/sdk/go/resolvers"
  • Key resolversNewWellKnownKeyResolver (well-known JWKS, TTL cache), NewWBAKeyResolver (WBA directory, revocation/expiry-aware, with a Run poller).
  • Endpoint resolverNewWellKnownEndpointResolver discovers an Exchange's own service endpoint (WellKnownManifest.endpoint) from /.well-known/fora.json, host-keyed and cached per host. Three sentinels, and the difference decides whether a caller should retry: ErrManifestVersionRefused when the manifest was read and carries a ver this reader does not accept (or none at all), ErrNoEndpoint when it advertises no endpoint, ErrEndpointRefused when it advertises one this resolver will not hand back — on a host unrelated to the one that served the manifest, or carrying userinfo. All three are verdicts, so all three are final; anything else is a transport failure and worth retrying. The key is an offer-supplied host, so the cache evicts least-recently-used at a fixed cap and concurrent lookups for one host coalesce to a single fetch.
  • Registration-requirements readerNewWellKnownRequirementsReader reads what one Exchange asks of a registration — the terms revision submitting one accepts and the schema its registration_data must match — from the same /.well-known/fora.json, and holds NO document cache: the contract requires the digest to come from a freshly fetched manifest. It reads the document's ver before any other member, exactly as the endpoint face does — the rule is stated for every consumer of this document, and a layout no reader can classify must not supply a terms digest a request signature then covers. Three sentinels, same retry question as above: ErrExchangeNotPermitted when the deployment's own overlay excluded the domain before anything was dialled, ErrManifestNotExchange when the document describes another role, and ErrManifestUnusable when the document cannot be read for what a registration owes — which is what a refused version answers, and what a reader stricter than this one reaches for, since the seam is injectable. All three are verdicts; anything else is a transport failure and worth retrying. Note that ErrManifestVersionRefused is NOT in that set: it belongs to the endpoint seam, and the two vocabularies are disjoint on purpose. An off-spec optional member reads as ABSENT rather than failing the document, because the projection is shared with the two faces above.
  • Active-key selectionActiveEd25519Key / ActiveEd25519KeyWithExpiry pick an identity's window-active key by document order; the …Screened variants fold in a revoked-thumbprint screen. NewCachedOfferKeyResolver caches the selected offer key with an expiry clamped to the key's not_after.
  • SSRF-guarded clientNewGuardedClientFromEnv is the single construction path every fetch uses; NewGuardedTransport composes the same guard over a caller's own base transport, so application settings ride UNDER the guard rather than replacing it; SSRFGuard / SSRFCheckRedirect are the injectable dial-time address guard and redirect re-vet for callers wiring their own http.Client.
  • Content fetchNewContentFetcher retrieves the bytes a signed delivery URL names, presenting a proof of possession through the injected ProofSigner seam (so this tier holds no key material). Bounded body, bounded error body, media type reported rather than sniffed, and a typed FetchError class.

Redirects: the guarded client follows, a signed leg refuses. Following five hops is right for a public well-known document — the address is re-pinned and the scheme re-vetted on each. It is wrong for anything carrying a credential, so the content fetch and the RPC legs take only the guarded .Transport and 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.

The guard is driven by exactly two orthogonal env flags — SKIP_SSRF (drop the dial-time address guard) and ALLOW_INSECURE (permit plaintext http) — both defaulting to the guarded posture. The transport caps redirect depth at 5, caps well-known/JWKS bodies at 1 MiB, and fails closed if any resolved address of a host is reserved. The address/scheme decisions are corpus-locked (resolvers/testdata/ssrf-*-vectors.json).

Guarantees

  • Relocation, not rewrite. The RFC 9421 / 7638 crypto is byte-identical with the service-internal implementations it replaces (ADR-020 §8).
  • Cross-field CEL is pinned to conformance/corpus/crossfield.json, generated by the same protovalidate oracle the rest of the conformance suite uses.
  • Covered by the repo gate: go build/vet/test ./... via scripts/ci-local.sh.
  • The host rule does not depend on how the consumer is configured. net/url's host-colon strictness is the urlstrictcolons GODEBUG, and a GODEBUG is the consumer's to set: under urlstrictcolons=0 — from the environment or a //go:debug line — url.Parse accepts exchange.example::443 and six near relatives the committed vectors record refused. helpers.parseRef therefore refuses a second colon after the host itself, and the guard asserts that through the predicate rather than through url.Parse, so it means the same thing under either setting. (The setting's default is not the reachable path: it derives from the main module's go directive, and a main module cannot declare below this module's 1.26 — the build is refused first.)

Directories

Path Synopsis
Package connect is the opt-in Connect-Go CLIENT binding over the transport-neutral sdk/go/core L2 substance.
Package connect is the opt-in Connect-Go CLIENT binding over the transport-neutral sdk/go/core L2 substance.
Package connectserver is the opt-in Connect-Go SERVER binding over the transport-neutral sdk/go/core L2 substance: the verify http-seam handlers (NewExchangeServiceHandler, NewBrokerServiceHandler and NewCatalogServiceHandler — request-id outermost · verify · validate · error-detail), the reject→connect.Code mapping (RejectCode · IsBodyTooLarge · WriteReject), and the EMIT direction of the ADR-019 ErrorDetail↔Connect bridge (AsConnectError — a server emits a typed error detail).
Package connectserver is the opt-in Connect-Go SERVER binding over the transport-neutral sdk/go/core L2 substance: the verify http-seam handlers (NewExchangeServiceHandler, NewBrokerServiceHandler and NewCatalogServiceHandler — request-id outermost · verify · validate · error-detail), the reject→connect.Code mapping (RejectCode · IsBodyTooLarge · WriteReject), and the EMIT direction of the ADR-019 ErrorDetail↔Connect bridge (AsConnectError — a server emits a typed error detail).
Package core is the transport-neutral L2 substance of the FORA SDK: the unified offer Verifier, the fail-closed {verified, rejected} contract (Result), the per-URI discovery shape both discovery verbs return (DiscoveryResult / OfferGroupResult, sorted by Verifier.SortGroups), the unforgeable VerifiedOffer compile guard with the loud RejectedOffer.Unsafe escape, the client signing http.RoundTripper (NewSigningTransport), the injected ReplayStore interface, and the neutral request-id mint/middleware — all built on the sdk/go/helpers L1 primitives with net/http as the only transport dependency (ADR-020 §2/§3).
Package core is the transport-neutral L2 substance of the FORA SDK: the unified offer Verifier, the fail-closed {verified, rejected} contract (Result), the per-URI discovery shape both discovery verbs return (DiscoveryResult / OfferGroupResult, sorted by Verifier.SortGroups), the unforgeable VerifiedOffer compile guard with the loud RejectedOffer.Unsafe escape, the client signing http.RoundTripper (NewSigningTransport), the injected ReplayStore interface, and the neutral request-id mint/middleware — all built on the sdk/go/helpers L1 primitives with net/http as the only transport dependency (ADR-020 §2/§3).
examples
agent command
Command agent is the worked Go agent example the documentation site shows.
Command agent is the worked Go agent example the documentation site shows.
Package helpers is the FORA SDK low-tier L1: stateless protocol helpers built directly on the L0 generated wire types (github.com/FORA-Protocol/protocol/gen/go).
Package helpers is the FORA SDK low-tier L1: stateless protocol helpers built directly on the L0 generated wire types (github.com/FORA-Protocol/protocol/gen/go).
internal
endpointrule
Package endpointrule holds the one predicate that decides whether an endpoint a well-known manifest advertises may be used at all.
Package endpointrule holds the one predicate that decides whether an endpoint a well-known manifest advertises may be used at all.
failure
Package failure holds the rendering the SDK's two failure taxonomies share.
Package failure holds the rendering the SDK's two failure taxonomies share.
hostredact
Package hostredact keeps a credential out of a message built from a host reference.
Package hostredact keeps a credential out of a message built from a host reference.
lrucache
Package lrucache is the one bounded, least-recently-used map the SDK's caching tiers share.
Package lrucache is the one bounded, least-recently-used map the SDK's caching tiers share.
vectorio
Package vectorio holds the byte semantics of a committed conformance corpus.
Package vectorio holds the byte semantics of a committed conformance corpus.

Jump to

Keyboard shortcuts

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