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
- func SigningTransportFor(priv ed25519.PrivateKey, directory string, base http.RoundTripper) (http.RoundTripper, error)
- type DiscoveryResult
- type Mode
- type OfferGroupResult
- type RejectedOffer
- type ReplayStore
- type RequestIDFunc
- type Result
- type SignerSource
- 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 Web Bot Auth Signature-Agent member and the RFC 9421 Signature-Input / Signature headers via the injected Signer, then forwards to base. A request already carrying a Signature header gets an additional signature (helpers.AppendSignature) rather than having its signature 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 — the directory (WithSignatureAgent, required unless a source supplies it), a per-request signer source, the freshness window, append mode and coverage of an earlier signature, and the sign predicate.
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.
func SigningTransportFor ¶ added in v1.1.0
func SigningTransportFor(priv ed25519.PrivateKey, directory string, base http.RoundTripper) (http.RoundTripper, error)
SigningTransportFor returns a signing RoundTripper over base that signs as priv: the RFC 9421 keyid is priv's RFC 7638 thumbprint, and Signature-Agent names directory, the WBA key directory that publishes the key (see helpers.DirectoryDocument). It is the third step of minting an identity, after helpers.GenerateKey and the directory document. A nil base is http.DefaultTransport.
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, a metered offer whose stated estimate is not positive). 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 SignerSource ¶ added in v1.1.0
SignerSource returns the signer and its Signature-Agent directory origin for one request, for a service that signs as many identities: an identity service signing as the calling agent, a console signing as the calling publisher.
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 (a fresh sig1 via helpers.SignRequest, an appended signature via helpers.AppendSignature when an incoming Signature is present). AppendSignature produces a plain sig1 when no incoming signature is present and adds a signature with its own label and Signature-Agent member when one is, leaving the earlier signatures untouched. The appended signature covers only its own request and member unless WithCoverPrevious is set. Each appended signature carries a fresh nonce, so identical back-to-back requests do not collide in the server's replay store.
func WithCoverPrevious ¶ added in v1.1.0
func WithCoverPrevious() SigningOption
WithCoverPrevious makes an appended signature cover the last signature already on the request, as WG-00 §5.2.2 permits a party that forwards a request unchanged in every component that signature covers (see helpers.SignOptions.CoverPrevious). It applies to the append branch only. A party that changed the request, such as a Broker re-packaging a purchase, must not use it.
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 names dir, the signer's own key-directory origin ("https://agent.example"), as the Signature-Agent member every signature this transport makes covers. It is required unless WithSignerSource supplies the directory per request: a request signed with no directory is refused with helpers.ErrSignatureAgentRequired, and a value that is not an https origin with helpers.ErrSignatureAgentNotOrigin. On the append branch the member is added to the request's Signature-Agent dictionary beside the earlier signers' members, which stay untouched.
func WithSignerSource ¶ added in v1.1.0
func WithSignerSource(src SignerSource) SigningOption
WithSignerSource resolves the signer and its directory per request through src, in place of the transport's fixed signer and WithSignatureAgent directory. Every signature it makes follows the same profile, with a fresh nonce. A source error, a nil signer or an empty directory means nothing is sent: RoundTrip returns the error and never reaches the base transport.
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, for example ClockWindow with the deployment's own TTL. Signature uniqueness does not depend on the window: every signature carries a fresh nonce.
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. An offer whose term carries pricing is rejected too (helpers.ErrOfferTermPriced), and so is a metered offer that states an estimate of zero or less (helpers.ErrMeteredEstimateNotPositive). A metered offer that states no estimate is not rejected for it: the estimate is optional.
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 a wall-clock instant plus a fixed TTL (ClockWindow) or monotonically increasing values (MonotonicWindow); created and expires should derive from the same source so a deterministic-clock test stays inside the verifier's freshness window. The signing transport makes each signature unique with a fresh nonce, whatever the window returns.
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
deprecated
MonotonicWindow returns a Window that stamps each outbound signature at the clock's current time: created=now() and expires=now()+ttl, exactly as ClockWindow does.
It once bumped expires (and later created with it) by one second per call inside a wall-clock second, so that no two signatures shared a window. That stamped signatures in the future at more than one request per second, by as many seconds as there were requests, which a verifier's future-skew allowance then absorbed. Uniqueness never needed it: every signature carries a fresh 64-byte nonce, so two identical requests in the same second already sign to different bytes.
Deprecated: use ClockWindow. MonotonicWindow is kept so existing callers compile, and behaves identically.