connectserver

package
v1.0.5 Latest Latest
Warning

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

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

Documentation

Overview

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). KeyResolver and ReplayStore are injected by the application (ADR-020 §2/§3; the server interceptor order is deliberate — see NewExchangeServiceHandler).

One authority for how a refusal is answered

The three reject functions are exported, not internal, because the answer they produce is not the canonical one: the Connect specification maps ResourceExhausted to 429 for every cause, and this binding answers 413 for a body past the read cap, since that is the one refusal a caller fixes by sending less. A consumer that re-derives the split lands on the canonical answer and its mount then disagrees with a mount built here — silently, and only for the case a cap exists to handle. So the split and the error-envelope body live in WriteReject alone, and it takes the Connect code as a PARAMETER: a gate carrying a resource-limit sentinel this package cannot know about answers that one case itself and defers the rest to RejectCode, rather than keeping a second copy of the mapping.

Validation is opt-in, on every one of the three handlers

The `validate` step named in that stack is composed ONLY when the application passes WithValidation(connect.ValidationStrict). ValidationOff is the enum's zero value, so a handler built with no options serves without protovalidate in either direction — and a deleted WithValidation line is indistinguishable from an explicit opt-out. Anything the contract describes as refused "at the boundary" — the ResourceEntry envelope rules, the LicenseTerm rules and the caps the catalog rejection reasons were retired against — arrives with that option and not otherwise. A deployment that wants the contract's own wire tier passes it on every mount; the reference implementation does, on all of them.

It is a SEPARATE package from the client binding sdk/go/connect so that each face exposes BARE, symmetric option names — this package's WithKeyResolver, WithValidation, WithRequestIDFunc, WithInterceptors do not collide with the client's identically-named options because they live in different packages. It imports sdk/go/connect for the shared Validation enum and NewValidateInterceptor (one validate engine for both faces, zero duplication) and sdk/go/core for the ReplayStore interface and the request-id middleware. The dependency edge is one-directional (connectserver → connect → core); core and helpers stay Connect-free.

Import name clash (consumers: alias one)

This package imports the Connect-Go framework from "connectrpc.com/connect" aliased as `connectrpc "connectrpc.com/connect"`. A consumer that imports both this package and connectrpc.com/connect should alias one for clarity (as the SDK client binding's doc also notes).

Index

Constants

View Source
const DefaultMaxRequestBytes = 4 << 20 // 4 MiB

DefaultMaxRequestBytes caps what a handler will read from one request. Connect treats an unset cap as "any size" and decompresses every request, so without one a caller can spend the server's memory and CPU at a ratio it chooses: a gzip body inflates roughly a thousandfold, and the wire rules do NOT bound that work — protovalidate walks every element it is handed and collects every violation before any cardinality rule is reported, so an over-cap list is fully traversed on its way to being refused. This is the bound that models that cost, which is why it lives here and not on the fields.

It bounds the decompressed message but does NOT make decompression free. When a body inflates past the cap, Connect drains the rest of the stream to io.Discard to report the size it would have been, so the whole thing is inflated before it is refused. What keeps that finite is the RAW body bound below, not this one: measured, 4 MiB of compressed input inflates to 4.32 GB in ~850ms of CPU on a request that is then rejected. Memory stays flat, CPU does not. Lowering this constant lowers both, roughly linearly.

4 MiB is chosen against both ends, measured. A FULL-CARDINALITY push — 256 entries each carrying the full 32 terms, one restriction per axis, every field populated — is 0.81 MiB and validates in ~475ms, so a real catalog batch fits with better than fourfold headroom. The most EXPENSIVE conformant shape that still fits under this cap is 83 entries of 32 terms, each term carrying one restriction per axis with both token lists at their 64-item caps and every token a single character: 3.97 MiB and ~1.8s of validation, with one more entry exceeding the cap. That is what this constant buys — not a small cost, but a bounded one.

Note WHY that shape uses the shortest legal tokens, because it is the part that is easy to get backwards. Validation cost tracks the number of ELEMENTS walked, while size tracks their length, so under a byte cap the worst case is the shape that spends its bytes on count rather than on length. The same 83-entry structure with 64-character tokens is 88 MB and never reaches the validator; filled to the cap instead, it is 3 entries and ~132ms. Every point between is cheaper than the shape above, which is why that one is quoted.

This is a measurement of one shape, not a ceiling over all of them. The enumeration that would be needed for a ceiling — 256 entries, 32 terms, 8 restrictions, 64 tokens per list — leaves out quotas, obligations, scopes and attestations, which are equally capped and equally walked; and it reasons only about CONFORMANT pushes, while protovalidate collects every violation rather than stopping at the first, so a non-conformant one is checked just as thoroughly. Raising this cap raises the worst case roughly linearly; there is no value past which it stops mattering.

Note what that figure is and is not. It sizes a representative batch, NOT a ceiling on a conformant one: the caps in the contract bound how MANY entries, terms and attestations a push may carry, never how many bytes. Obligation.detail, the License strings and every ResourceAttestation member are length-free, so a conformant push can be arbitrarily large and this cap can refuse one. That is the intended trade — the bound models the cost of CHECKING a submission, and a deployment that must accept larger documents raises it.

Lower it if the deployment does not accept a 256-entry batch; raising it raises the worst case roughly linearly. Override per server with WithMaxRequestBytes.

Variables

View Source
var ErrReplayed = errors.New("connectserver: request replayed within window")

ErrReplayed is the verify-face sentinel for a nonce the injected ReplayStore reports as already seen. It maps to CodeUnauthenticated like any other verification failure. Exported so a WithOnReject observer can classify a replay rejection distinctly from a signature or chain failure (the reject error is otherwise opaque to the consumer).

Functions

func AsConnectError

func AsConnectError(code connectrpc.Code, detail *forav1.ErrorDetail) *connectrpc.Error

AsConnectError builds a *connect.Error of the given Code with detail attached as a typed error detail (the ADR-019 transport mechanism). The detail's Message becomes the error string. It lives in the SERVER binding (the emit direction: a server EMITS a typed error detail) — not the transport-neutral L1 helpers — so a non-Connect consumer of helpers/core compiles zero connectrpc; the neutral *forav1.ErrorDetail builders and Reason stay in helpers, and this is where the ErrorDetail meets the Connect transport. The read direction (ErrorDetailFrom) lives in the client binding sdk/go/connect.

func AttachDetail

func AttachDetail(cerr *connectrpc.Error, d *forav1.ErrorDetail) error

AttachDetail attaches the proto ErrorDetail d to cerr and returns it. The detail is best-effort: if it cannot be marshalled the bare cerr is returned rather than dropping the classified error. It keeps the NewErrorDetail/AddDetail dance in exactly one place for callers that build a typed-reason detail themselves (via the helpers.*Detail builders) — the shape AttachErrorDetail delegates to for the generic case.

func AttachErrorDetail

func AttachErrorDetail(
	cerr *connectrpc.Error,
	domain, message string,
	metadata map[string]string,
) error

AttachErrorDetail builds the ADR-019 ErrorDetail envelope (the non-authoritative developer message, the stable service domain, and any structured field metadata) and attaches it to an ALREADY-CLASSIFIED *connect.Error, returning that same error. It is the SDK-owned realisation of the ErrorDetail<->domain envelope build: every service that maps a domain error to a connect.Error and then stamps a generic (no typed reason) detail shares this one body instead of copying it.

The attach is best-effort: if the detail cannot be marshalled the bare cerr is returned rather than dropping the classified error. metadata is stamped only when non-empty, so the emitted proto shape matches a hand-built ErrorDetail{Domain, Message} for the metadata-free case (a nil map and an empty map are both absent on the wire, but keeping the field nil avoids allocating an empty map the caller never populated).

It sits in the SERVER binding — a server EMITS a typed detail — alongside AsConnectError, keeping connectrpc out of the transport-neutral L1 helpers. A caller needing a typed reason oneof builds the *forav1.ErrorDetail via the helpers.*Detail builders and attaches it with AttachDetail below.

func EmitUnpopulatedJSONCodec

func EmitUnpopulatedJSONCodec() connectrpc.Codec

EmitUnpopulatedJSONCodec replaces Connect-Go's default JSON codec so scalar fields with their zero value (e.g. a non-optional Cost.amount left as the empty decimal string on a subscription-covered offer) appear in the JSON wire output. Connect's default protojson.MarshalOptions{} omits zero-valued scalars, which loses an observable agents depend on: "the subscription-covered offer carries a zero Cost.amount" cannot be asserted when the field is omitted entirely from the response. A non-optional MESSAGE field and a Struct are not omitted when unset — protojson renders each as `null` under EmitUnpopulated, which is what a JSON client has to accept from a conformant FORA server. An unset MAP renders `{}`, and a field declared `optional` is omitted outright along with an unpopulated oneof member and an unset extension.

Field names are snake_case (UseProtoNames=true) — the FORA wire is snake_case proto-JSON everywhere (proto field names, corpus, generated clients, and this Connect codec).

Unmarshal discards unknown fields (a newer client may send fields this server's pin does not know) and rejects a zero-length payload.

Emit-unpopulated is a FORA-platform wire-policy choice, not a Connect universal, so the codec is OPT-IN: register it per handler via WithEmitUnpopulated (SDK-wrapped mounts) or pass connectrpc.WithCodec(EmitUnpopulatedJSONCodec()) directly to a raw generated handler. Both `json` and `json; charset=utf-8` content-types route here.

func IsBodyTooLarge

func IsBodyTooLarge(err error) bool

IsBodyTooLarge reports whether err is net/http's over-cap signal from the read the body bound wraps — the error http.MaxBytesHandler / http.MaxBytesReader produces once a body passes the cap.

It is exported because the classification has callers outside the response path: a middleware that must decide, before it can answer, whether the read it just failed was a size refusal or a malformed request. Only the size refusal is this package's to answer — WriteReject writes it; a malformed read is the caller's own verdict and its own response. What must not fork is the predicate the branch turns on, which is why it lives here rather than in a copy.

func NewBrokerServiceHandler

func NewBrokerServiceHandler(svc forav1connect.BrokerServiceHandler, opts ...ServerOption) (string, http.Handler)

NewBrokerServiceHandler builds the BrokerService HTTP handler wrapped by the SDK server face and returns the mount path and handler. It composes the same stack as NewExchangeServiceHandler — request-id outermost, verify at the http seam, validate/error-detail as connect interceptors — over the generated BrokerService handler. Broker relay routes outside the /fora. procedure prefix are the application's own http surface and never pass through this handler; they keep their bespoke verification.

func NewCatalogServiceHandler

func NewCatalogServiceHandler(svc forav1connect.CatalogServiceHandler, opts ...ServerOption) (string, http.Handler)

NewCatalogServiceHandler builds the CatalogService HTTP handler wrapped by the SDK server face and returns the mount path and handler — the exchange-operator role's starting point for the publisher-facing RPCs. It composes the same stack as NewExchangeServiceHandler over the generated CatalogService handler: request-id outermost, verify at the http seam (every /fora. procedure is gated, fail-closed, so an unsigned push never reaches the origin), validate and error-detail as connect interceptors.

What the binding gives is transport authentication, typed error emission, and — WHEN THE APPLICATION ASKS FOR IT with WithValidation(foraconnect.ValidationStrict) — the contract's wire tier. That option is not the default (see the package doc): the ResourceEntry envelope rules and the terms cap are refused at the boundary only on a mount that passes it, which is what the retirement of the terms-limit rejection reason assumes.

What stays the handler implementation's job is everything the contract leaves to the Exchange: that caller_id names the verified signer, that the caller is among the publisher's catalog_contributors, that tenant_id matches, the ingest-tier term checks (sdk/go/helpers), and the per-entry verdicts. An Exchange that resolves a contributor's key by caller_id narrows the seam with WithVerifyGate and verifies inside the handler, where the decoded request is in scope — WithKeyResolver cannot carry that policy, because KeyResolver.Resolve is handed the signature's keyid and never the message.

Two replay notes specific to this service. Its verbs carry no idempotency_key by design — an upsert and a delete are naturally idempotent — so the message-layer replay defence the rest of the contract relies on does not apply here, and an injected ReplayStore is the only replay control on this path. And RemoveResources is destructive, so a stateless-edge deployment acknowledging WithoutReplayStore is accepting replay of a delete within its signature window.

func NewErrorDetail

func NewErrorDetail(domain, message string, metadata map[string]string) *forav1.ErrorDetail

NewErrorDetail builds the ADR-019 ErrorDetail envelope — the stable service domain, the non-authoritative developer message, and structured field metadata stamped only when non-empty (a nil map and an empty map are both absent on the wire; keeping the field nil avoids allocating an empty map the caller never populated). It is the build half of AttachErrorDetail, exposed so a caller that owns a typed reason oneof can set it on the RETURNED (mutable) detail before attaching via AttachDetail — the envelope body then has exactly one source across every service, whichever attach path follows.

func NewExchangeServiceHandler

func NewExchangeServiceHandler(svc forav1connect.ExchangeServiceHandler, opts ...ServerOption) (string, http.Handler)

NewExchangeServiceHandler builds the ExchangeService HTTP handler wrapped by the SDK server face and returns the mount path and handler. The stack, from OUTERMOST in, is:

request-id  ·  verify (http-seam)  ·  [validate · error-detail  as connect interceptors]  ·  origin

Request-id is OUTERMOST: an auth-rejection response must still carry the stamped X-Request-ID so reject-path logs correlate — putting request-id inner to verify would regress that. verify is an http.Handler wrapper (body-bytes reason), NOT a connect.Interceptor; validate and error-detail ARE true connect.Interceptors composed onto the generated handler. KeyResolver and ReplayStore are injected by the application (ADR-020 §2/§3).

func RejectCode

func RejectCode(err error) connectrpc.Code

RejectCode maps a verify-face rejection sentinel to the Connect code returned to the client. Two rejections are resource/policy limits rather than authentication failures and say so: a hop-budget rejection (ErrTooManyHops), and a body past the read cap, which the buffering read reports as *http.MaxBytesError. Every other rejection (bad signature, replay, broken chain, expiry, missing headers) is an authentication failure. Classifying an over-size body as Unauthenticated would tell a correctly-signed caller its credentials were wrong, and would hide the one refusal a caller fixes by sending less. It is a pure, stateless error→code mapping — the budget and cap VALUES stay injected.

It answers the TRANSPORT question; ClassifyReject answers the AUDIT question over the same errors. The two do not agree on every input, and the doc on ClassifyReject says where: RejectReason has no value for a body past the read cap.

It is exported so a mount whose gate carries its OWN resource-limit sentinel — one this package cannot know about — answers that case itself and defers every other case here, instead of re-deriving the whole mapping. That is the composition WriteReject is shaped for.

func WriteReject

func WriteReject(w http.ResponseWriter, code connectrpc.Code, err error)

WriteReject emits a Connect-compatible error response so a Connect client sees a proper code (and matching HTTP status) instead of a raw status. The body shape mirrors Connect's unary error JSON: the two keys code and message, and nothing else — err's own text is the message, so a caller that rejects with a wrapped internal error publishes that text.

The code is a parameter rather than derived, so a mount whose gate carries a resource-limit sentinel this package cannot know about supplies its own verdict and still gets this status split and this body. A caller with no such sentinel passes RejectCode(err), which is what this package's own handlers do.

It answers the verify seam's two verdicts. ResourceExhausted is a resource or policy limit — 413 for a body past the read cap, 429 otherwise; Unauthenticated is 401. Any other Connect code is answered 401 as well, and the body still reports the code the caller passed: this is a REJECTION writer, not a code→status table, and it refuses rather than translating a verdict it does not model. connect-go keeps the canonical table unexported, so a copy of it here would be the second authority this function exists to remove; a caller holding a verdict outside those two — a malformed request, say — writes that response itself.

Types

type RejectReason

type RejectReason int

RejectReason is the classified cause of a verify-gate rejection — the SDK-owned single source of truth for WHY a request was rejected, so every consumer's audit log derives the same categories from the same sentinels instead of each re-deriving them. The four causes are the meaningful, distinct security outcomes an RFC 9421 gate produces.

const (
	// ReasonSignature is any signature-authenticity or freshness failure — a bad
	// signature, a missing/malformed Signature-Input, expiry, or future-created.
	// It is the default: an error the gate does not tag with a more specific
	// sentinel is a signature failure.
	ReasonSignature RejectReason = iota
	// ReasonReplay is a nonce the injected ReplayStore reported as already seen.
	ReasonReplay
	// ReasonBrokenChain is a multisig forwarding chain with a gap, reorder, or
	// missing link (a tampered relay chain).
	ReasonBrokenChain
	// ReasonHopBudget is a signature count exceeding the injected hop budget.
	ReasonHopBudget
)

func ClassifyReject

func ClassifyReject(err error) RejectReason

ClassifyReject maps a verify-gate rejection error to its RejectReason, keyed off the gate's own sentinels (ErrReplayed here, ErrTooManyHops and ErrBrokenSignatureChain in helpers). It is the classifier a WithOnReject observer uses to audit-log the outcome without re-deriving the mapping. An error carrying no known sentinel classifies as ReasonSignature.

One refusal the server binding answers is not an authentication outcome and has no value in this enum: a body past the read cap, which RejectCode answers resource_exhausted and this classifier reports as ReasonSignature, its default. A consumer auditing that refusal names it itself — reporting it as a signature failure would send an operator after a key rotation for a caller that sent too much.

func (RejectReason) String

func (r RejectReason) String() string

String returns the stable audit token for the reason. The tokens are stable wire values a consumer's audit log / dashboards key on; do not rename them.

type ServerOption

type ServerOption func(*serverConfig)

ServerOption configures the server verify face.

func WithEmitUnpopulated

func WithEmitUnpopulated() ServerOption

WithEmitUnpopulated registers EmitUnpopulatedJSONCodec on the handler — sugar over WithHandlerOptions(connectrpc.WithCodec(...)) so a service mount selects the FORA JSON wire policy without importing connectrpc.

func WithHandlerOptions

func WithHandlerOptions(opts ...connectrpc.HandlerOption) ServerOption

WithHandlerOptions appends raw connect handler options (e.g. a custom codec via connectrpc.WithCodec) to the generated handler. Interceptors belong in WithInterceptors; this is the escape hatch for the remaining handler-level knobs the SDK does not model.

func WithInterceptors

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

WithInterceptors appends application interceptors (tracing, metrics) to the generated handler's interceptor stack, inside the SDK validate / error-detail interceptors.

func WithKeyResolver

func WithKeyResolver(r helpers.KeyResolver) ServerOption

WithKeyResolver injects the request-signing KeyResolver the verify face resolves each signature's key through — the same interface the client resolves offer keys through (connect.WithKeyResolver). Custody and network policy stay with the app.

func WithMaxRequestBytes

func WithMaxRequestBytes(n int64) ServerOption

WithMaxRequestBytes overrides the per-request read cap (default DefaultMaxRequestBytes). It bounds TWO distinct quantities, because a caller can exhaust the server through either: the decompressed Connect message, refused as CodeResourceExhausted, and the raw HTTP body the verify face buffers before it can check a signature, refused as a 413. An unsigned caller reaches only the second, which is why the body bound cannot wait for authentication.

A non-positive value restores the default rather than disabling the cap: a server that reads without a bound is the state this option exists to prevent, and no accident should be able to select it.

func WithMaxSignatureAge

func WithMaxSignatureAge(d time.Duration) ServerOption

WithMaxSignatureAge clamps the accepted signature lifetime (expires − created). It bounds the replay window even when no ReplayStore is wired: without it a signer can set a far-future expires and replay the same bytes until then. 0 (default) is unbounded; a terminal Exchange sets it to its replay-window target (minutes). A longer-lived signature is rejected as a verify-gate failure.

func WithMaxSignatures

func WithMaxSignatures(n int) ServerOption

WithMaxSignatures injects the hop budget — the maximum number of signatures a multisig (relay) request may carry (= max_intermediary_hops + 1). It is an INJECTED value, never an SDK constant: the hop LIMIT is app policy, only the reject-reason→connect.Code mapping is SDK mechanics. 0 means unbounded.

func WithOnReject

func WithOnReject(fn func(*http.Request, error)) ServerOption

WithOnReject injects an observer the verify gate calls when it REJECTS a request, before the rejection response is written. It receives the request and the rejection error so a consumer can audit-log the outcome, classifying it via errors.Is against the exported sentinels (ErrTooManyHops → hop budget, ErrReplayed → replay, helpers.ErrBrokenSignatureChain → broken chain, else → signature). The observer is for observation only — it MUST NOT write to the response (the gate owns the fail-closed response) and runs on the reject path exclusively (a verified request never calls it). Omitting it keeps rejections silent (the pre-existing behavior).

func WithReplayStore

func WithReplayStore(s core.ReplayStore) ServerOption

WithReplayStore injects the nonce-dedup store the verify face calls as part of fail-closed verification. The SDK orchestrates the check; the app owns the store and its persistence. Omitting it disables the replay check (verify-only).

func WithReplayTTL

func WithReplayTTL(ttl time.Duration) ServerOption

WithReplayTTL overrides the nonce-retention window passed to the store's SeenOrAdd (default 5 minutes).

func WithRequestIDFunc

func WithRequestIDFunc(fn core.RequestIDFunc) ServerOption

WithRequestIDFunc overrides the server request-id source stamped outermost on every response (including the reject path).

func WithValidation

func WithValidation(v foraconnect.Validation) ServerOption

WithValidation sets protovalidate strictness for the server face. The default is ValidationOff; a server opts into bidirectional wire-shape enforcement (requests + responses + error details) with WithValidation(connect.ValidationStrict). The Validation enum is shared with the client (sdk/go/connect) so both faces select strictness with one type.

What Off means on a SERVER is worth stating plainly, because it is not symmetric with the client: the contract's own boundary rules do not run. Everything the protocol describes as refused before the handler — the field and message rules protovalidate carries, and the caps a rejection reason was retired against — simply is not checked, and the handler implementation sees the request whatever shape it arrived in. ValidationOff is also the enum's ZERO VALUE, so a mount that never names this option and one that explicitly opts out are the same request. A deployment that wants the wire tier passes ValidationStrict on every mount.

func WithVerifyGate

func WithVerifyGate(gate func(*http.Request) bool) ServerOption

WithVerifyGate overrides which requests the seam verifies. The DEFAULT gates every /fora. procedure unconditionally (fail-closed). A service whose handlers own the typed Unauthenticated fault for UNSIGNED requests narrows the gate to signature-presenting requests only:

WithVerifyGate(func(r *http.Request) bool {
	return r.Header.Get("Signature-Input") != ""
})

A request the gate declines is NOT rejected — it flows to the origin handler unverified (helpers.FromContext returns nil there), so the handler decides. The gate composes with the /fora. procedure check; it cannot widen the seam to non-procedure paths.

func WithoutReplayStore

func WithoutReplayStore() ServerOption

WithoutReplayStore is the explicit acknowledgement that this server face runs with no replay protection (e.g. a stateless edge that relies only on the MaxSignatureAge window). It silences the construction-time warning that a server handler otherwise emits when no ReplayStore is injected, so an ACCIDENTAL omission stays loud while a DELIBERATE one is quiet.

Jump to

Keyboard shortcuts

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