core

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: 11 Imported by: 0

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

View Source
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

View Source
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

type RejectedOffer struct {
	Offer  *forav1.Offer
	Reason error
}

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

func NewVerifier(mode Mode, resolver helpers.KeyResolver, now func() time.Time) Verifier

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

func (v Verifier) Sort(ctx context.Context, offers []*forav1.Offer) Result

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

func ClockWindow(now func() time.Time, ttl time.Duration) Window

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

func MonotonicWindow(now func() time.Time, ttl time.Duration) Window

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.

Jump to

Keyboard shortcuts

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