Documentation
¶
Overview ¶
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).
The discovery shape is grouped rather than flat because a discovery call is per-URI: a URI that yielded nothing has no offer to carry its identity back, so flattening would erase both which resource was refused and the typed reason it was — and those reasons are different agent actions, not shades of "none".
core imports NOTHING from connectrpc: FORA is an HTTP protocol, and this package only needs net/http, so a team on grpc-go / plain net/http / any transport can compose the Verifier, the signing transport, and the guard WITHOUT a Connect dependency. The Connect bindings (sdk/go/connect for the client, sdk/go/connectserver for the server) are one opt-in adapter family over this core among potentially several; they depend one-directionally on core.
The client SIGN face is a signing http.RoundTripper (NewSigningTransport) — Content-Digest is computed over the marshaled body bytes, which an RPC-framework interceptor never sees — so it is realized at the HTTP seam, not as an interceptor. The Verifier surfaces the fail-closed Result{Verified, Rejected}; only an unforgeable VerifiedOffer is executable, with RejectedOffer.Unsafe() the single explicit escape. ReplayStore is defined here so the client and server faces share one interface name.
Index ¶
- Constants
- Variables
- func DefaultRequestID() string
- func NewSigningTransport(signer helpers.Signer, base http.RoundTripper, opts ...SigningOption) http.RoundTripper
- func RequestIDMiddleware(mint RequestIDFunc, next http.Handler) http.Handler
- type DiscoveryResult
- type Mode
- type OfferGroupResult
- type RejectedOffer
- type ReplayStore
- type RequestIDFunc
- type Result
- type SigningOption
- type VerifiedOffer
- type Verifier
- type Window
Constants ¶
const RequestIDHeader = "X-Request-ID"
RequestIDHeader is the correlation header the request-id middleware/interceptor mints and propagates on every RPC (the client and server faces share the name).
Variables ¶
var ErrOfferExpired = errors.New("fora: offer expires_at is in the past")
ErrOfferExpired signals an offer whose expires_at is in the past. Verification is fail-closed on freshness as well as signature: an expired offer is rejected even if its signature is genuine (ADR-020 §4 "verify everything").
Functions ¶
func DefaultRequestID ¶
func DefaultRequestID() string
DefaultRequestID returns a random 128-bit hex request id. It is the default mint both faces fall back to when no RequestIDFunc is injected.
func NewSigningTransport ¶
func NewSigningTransport(signer helpers.Signer, base http.RoundTripper, opts ...SigningOption) http.RoundTripper
NewSigningTransport returns the client sign-face RoundTripper: it buffers each outbound request body, stamps Content-Digest, binds Authorization, and writes the RFC 9421 Signature-Input / Signature headers via the injected Signer, then forwards to base. A request already carrying a Signature header is CHAINED onto (helpers.AppendSignature, the forwarding chain) rather than replaced. base defaults to http.DefaultTransport when nil. It is exported so an application can compose the SDK sign face onto its own *http.Client (e.g. to wrap it in a tracing or metrics RoundTripper). Options tune transport behavior — freshness window, relay append mode, Signature-Agent stamping, and the sign predicate; with zero options the transport behaves exactly as it did before options existed (sign every bodied request, time.Now + 5m).
func RequestIDMiddleware ¶
func RequestIDMiddleware(mint RequestIDFunc, next http.Handler) http.Handler
RequestIDMiddleware stamps (or propagates) X-Request-ID on the response BEFORE the next handler runs, so a rejected request still returns a correlated id. It is a plain net/http middleware — transport-neutral — kept at the http seam so it wraps the reject path too. The Connect server face composes it OUTERMOST (a reject response must still carry a stamped X-Request-ID); a non-Connect net/http server can compose it directly.
Types ¶
type DiscoveryResult ¶
type DiscoveryResult struct {
// Groups is one entry per requested URI, in the order the responder returned.
Groups []OfferGroupResult
// AbsenceReason says why the CALL as a whole yielded nothing. It is set only
// on that path — when any group carries offers it stays nil, and the per-URI
// causes ride on each group instead.
//
// Only a Broker resolve can set it: the Exchange's own discovery response has
// no whole-call reason field, so from Discover this is always nil and the
// per-URI groups carry everything the responder said.
AbsenceReason *forav1.OfferAbsenceReason
// Exchange is the canonical domain of the responding Exchange. Empty from a
// Broker resolve, whose response names no single Exchange — each offer carries
// its own issuing domain.
Exchange string
// RateLimit is the caller's rate-limit standing, when the responder reported
// it, so an agent can throttle before a fan-out meets a hard limit. Nil from a
// Broker resolve, whose message has no such field.
RateLimit *forav1.RateLimitInfo
}
DiscoveryResult is what Discover and Resolve return: one group per requested URI, plus the whole-call refusal when the call as a whole yielded nothing.
func (DiscoveryResult) Rejected ¶
func (d DiscoveryResult) Rejected() []RejectedOffer
Rejected flattens every rejected offer across all groups, with the reason each failed. The same caveat as Verified applies: a URI that yielded no offers at all is not a rejection and appears only in Groups.
func (DiscoveryResult) Verified ¶
func (d DiscoveryResult) Verified() []VerifiedOffer
Verified flattens every verified offer across all groups, for a caller that does not care which URI an offer answers.
It is a convenience over Groups, never a substitute. A URI that was REFUSED contributes nothing here — it has no offer to contribute — so a caller that reads only this cannot tell a refusal from a resource it never asked about. That is exactly the information Groups exists to keep.
type Mode ¶
type Mode int
Mode selects offer-verification strictness. Strict (the default) is fail-closed: an offer that does not verify against the exchange's offer-signing key lands in Rejected and cannot reach Execute. Off is the single, loud, named opt-out — it surfaces every offer as Verified WITHOUT checking a signature.
const ( // Strict is the fail-closed default: every returned offer is verified and an // unverifiable one is rejected, never silently promoted. Strict Mode = iota // Off disables offer verification entirely — a loud, explicit opt-out. Offers // are surfaced as Verified with no signature check. Named so an audit of the // call site shows the guarantee was deliberately waived. Off )
type OfferGroupResult ¶
type OfferGroupResult struct {
// URI is the resource this group answers for, echoed by the responder.
URI string
// AbsenceReason says why this URI yielded no offers. Nil means the responder
// stated no reason — which is a legitimate answer, not an omission: where the
// existence of a resource must itself stay hidden, a responder MAY withhold
// the reason rather than confirm the resource exists. Distinguishing "absent"
// from the unspecified enum value is why this is a pointer.
AbsenceReason *forav1.OfferAbsenceReason
// DiscoveryMethod is how the responder found this URI, when it said.
DiscoveryMethod *forav1.DiscoveryMethod
// RestrictionFilters names the restriction axes that drove a convenience
// pre-filter, when the absence reason is a restriction filter. Advisory
// diagnostics, not an enforcement verdict — but they tell an agent which axis
// to vary on a retry.
RestrictionFilters []forav1.RestrictionKind
// Result is the fail-closed split for this URI's offers.
Result
}
OfferGroupResult is one requested URI's answer: the verified/rejected split for that URI, plus why it is empty when it is.
type RejectedOffer ¶
RejectedOffer is an offer the Verifier could NOT accept: the wrapped Offer plus the Reason it failed (signature invalid, expired, no resolvable key). It is VISIBLE — the application learns which offers failed and why — but not directly executable. Acting on it requires the explicit .Unsafe() escape.
func (RejectedOffer) Unsafe ¶
func (r RejectedOffer) Unsafe() VerifiedOffer
Unsafe converts a RejectedOffer into an executable VerifiedOffer. It is the SINGLE, explicit, audit-visible escape hatch: without it, Execute(ctx, rejected) does not compile. Named "Unsafe" so a reviewer sees the guarantee was bypassed at the exact call site.
type ReplayStore ¶
type ReplayStore interface {
// Seen reports whether nonce was already recorded, WITHOUT recording it.
// The verify face checks all of a multisig request's nonces in a read-only
// phase before committing any of them, so a request rejected part-way never
// burns its other signatures' nonces.
Seen(ctx context.Context, nonce string) (bool, error)
// SeenOrAdd reports whether nonce was already recorded (a replay → true) and
// records it with ttl when it is new (→ false). The SDK decides WHEN to call it;
// the app decides HOW long a nonce is remembered.
SeenOrAdd(ctx context.Context, nonce string, ttl time.Duration) (bool, error)
}
ReplayStore is the narrow nonce-dedup interface the SERVER verify face orchestrates over. The SDK owns the replay-CHECK control-flow (it calls SeenOrAdd at the correct point in fail-closed verification, so replay protection is on by default), but owns NO replay state, TTL, or persistence — the application SUPPLIES the store and its TTL policy. This is the KeyResolver-shaped middle: the SDK orchestrates over injected state, exactly as it resolves keys over an injected KeyResolver without owning keys (ADR-020 §3 / Core Invariant).
It lives in package core (not connect) so the client and server faces share one interface name and an app defines its store once for both.
type RequestIDFunc ¶
type RequestIDFunc func() string
RequestIDFunc mints a fresh request id when a call carries none. The default is a random 128-bit hex token; an application overrides it (e.g. to reuse a trace id) via the client/server WithRequestIDFunc option. It is transport-neutral — the Connect binding wraps it in a connect.Interceptor, the http-seam server face wraps it in RequestIDMiddleware, and both share this one mint type.
type Result ¶
type Result struct {
Verified []VerifiedOffer
Rejected []RejectedOffer
}
Result is the fail-closed {verified, rejected} contract every discover/resolve call returns. Neither list is silently dropped: a caller can act on Verified and inspect Rejected (count + reason). It is the canonical cross-language shape (fora-sdk-api.md); Go/TS add the VerifiedOffer compile guard on top.
type SigningOption ¶
type SigningOption func(*signingTransport)
SigningOption customizes the signing transport built by NewSigningTransport.
func WithAppendSigner ¶
func WithAppendSigner() SigningOption
WithAppendSigner routes EVERY signed request through helpers.AppendSignature instead of the default split (fresh sig1 via helpers.SignRequest, chained sigN+1 via helpers.AppendSignature when an incoming Signature is present). This is the relay caller's mode: AppendSignature degrades to a plain sig1 when no incoming signature is present and appends a forwarding-chain-linked sigN+1 when one is, so a single always-append branch serves both the broker-originated and the relayed call. Pair it with MonotonicWindow so identical back-to-back relay requests do not collide in the server's replay store.
func WithSignPredicate ¶
func WithSignPredicate(fn func(*http.Request) bool) SigningOption
WithSignPredicate gates signing on fn: only requests for which fn returns true are signed; everything else passes through unmodified. The default (no predicate) signs every bodied request — the behavior existing callers rely on. A FORA application that shares one *http.Client across FORA and non-FORA traffic typically passes a procedure-namespace predicate such as
core.WithSignPredicate(func(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/fora.")
})
mirroring the /fora. procedure boundary the server-side verify seam already enforces.
func WithSignatureAgent ¶
func WithSignatureAgent(dir string) SigningOption
WithSignatureAgent stamps the covered Signature-Agent header with dir — the signer's own directory origin — when, and only when, the request does not already carry one. The option supplies the covered VALUE; the sign helpers still bind-if-absent to the empty string for the no-directory bootstrap path. The set-if-absent guard is load-bearing on the relay path: the originating agent already set Signature-Agent to ITS directory and its sig1 covers that value, so overwriting it would break sig1 at the verifier.
func WithWindow ¶
func WithWindow(w Window) SigningOption
WithWindow replaces the default freshness window (time.Now + 5 minutes) with a caller-supplied source of the RFC 9421 (created, expires) pair. Use ClockWindow for a plain wall-clock TTL and MonotonicWindow for the relay path's replay-store uniqueness requirement.
type VerifiedOffer ¶
type VerifiedOffer struct {
// contains filtered or unexported fields
}
VerifiedOffer wraps an Offer that has passed the SDK's fail-closed verification (genuine exchange signature, not expired) — OR was surfaced under WithVerification(Off) / RejectedOffer.Unsafe(). Its wrapped field is UNEXPORTED and it has no exported constructor, so an application cannot forge one with a composite literal: the only way to obtain a VerifiedOffer is the SDK verify path or the explicit .Unsafe() escape. That is what makes Client.Execute(ctx, VerifiedOffer) a real COMPILE guard rather than a runtime check a caller can forget (ADR-020 §4, fora-sdk-api.md compile-time guard).
func (VerifiedOffer) Offer ¶
func (v VerifiedOffer) Offer() *forav1.Offer
Offer returns the wrapped, verified *forav1.Offer for read access (id, pricing, terms). It is a getter, not a constructor — reading a VerifiedOffer is fine; only minting one is gated.
type Verifier ¶
type Verifier struct {
// contains filtered or unexported fields
}
Verifier runs the per-offer authenticity + freshness check over the L1 helpers.VerifyOffer primitive, keyed through the injected KeyResolver (the same interface the request-signature face resolves through). It is PURE apart from the resolver IO — no state, no clock beyond the injected now. It is transport- neutral: a plain net/http or grpc-go caller composes it directly, without any Connect binding.
func NewVerifier ¶
NewVerifier builds a Verifier from the injected verification mode, offer KeyResolver, and clock. It is the transport-neutral constructor the Connect client composes (and any non-Connect consumer can use directly): the Verifier's unexported fields are not composite-literal-constructible across packages, so this constructor is the sole way to build one outside package core.
func (Verifier) Sort ¶
Sort splits offers into verified and rejected per the configured mode. Under Off every offer is surfaced verified with no check. Under Strict each offer is verified against its resolved exchange key and its expiry — a failure of either lands it in Rejected with the reason.
func (Verifier) SortGroups ¶
func (v Verifier) SortGroups(ctx context.Context, groups []*forav1.OfferGroup) []OfferGroupResult
SortGroups verifies every group's offers through this one Verifier and returns the per-URI results, preserving each group's URI and its typed reasons.
One Verifier sorts every group deliberately: it is stateless apart from the injected resolver and clock, so a fresh one per group would mean N resolver caches and N clock readings for a single logical answer.
type Window ¶
type Window func() (created, expires int64)
Window returns the RFC 9421 (created, expires) cutoffs (unix seconds) to stamp on the next outbound signature. It is invoked once per signed request. Both values matter: SignRequest/AppendSignature receive created and expires from the caller — the multisig verifier rejects any signature missing created. An implementation may return monotonically increasing values to keep each on-the-wire signature unique (the relay's replay-store uniqueness need — see MonotonicWindow) or a wall-clock instant plus a fixed TTL (ClockWindow); created and expires should derive from the same source so a deterministic-clock test stays inside the verifier's freshness window.
func ClockWindow ¶
ClockWindow returns the plain production Window: it stamps each outbound signature with wall-clock-derived created=now() and expires=now()+ttl. Both axes come from the single now() reading so created ≤ expires and both land in the verifier's window. To adapt an application clock interface with a Now() method, pass the method value: ClockWindow(clk.Now, ttl).
func MonotonicWindow ¶
MonotonicWindow returns a Window whose expires cutoff is strictly increasing across calls: it tracks now+ttl but, when a burst of requests lands in the same wall-clock second, bumps expires by one second per call so no two back-to-back signatures share a (keyid, expires) pair. This keeps identical relay requests from colliding in the server's replay store. created tracks now() — the pair stays clock-consistent for any caller that reads created. Safe for concurrent RoundTrips: the running maximum is held in an atomic updated by compare-and-swap. To adapt an application clock interface with a Now() method, pass the method value: MonotonicWindow(clk.Now, ttl).
ONE INSTANCE PER CLIENT, never one per call. The running maximum is the whole mechanism: a window minted per request starts from zero, cannot see the previous signature, and provides exactly none of the uniqueness it was chosen for — while still looking correct at the call site.