Help us shape the future of Go! Take the 2026 Go Developer Survey

httpapi

package
v0.10.0 Latest Latest
Warning

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

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

Documentation

Overview

Package httpapi serves Factory's public REST plane.

The interfaces here are the ones THIS package calls. They are declared here rather than in a shared dependency package so that widening what a handler needs is visible as a change to the handler's own contract, and so that no package acquires a dependency by importing a locator.

Index

Constants

View Source
const (
	// ErrorCodeUnauthenticated reports that no verified credential was
	// presented. It is 401 and a client recovers by authenticating.
	ErrorCodeUnauthenticated sessionwire.ErrorCode = "unauthenticated"
	// ErrorCodeNotAuthorized reports that a verified principal may not perform
	// the operation. It names no tenant, session, object or command: a denial
	// must not disclose whether the named resource exists elsewhere.
	ErrorCodeNotAuthorized sessionwire.ErrorCode = "not_authorized"
	// ErrorCodeRouteNotFound reports a path this version serves no route for.
	// It is deliberately NOT session_not_found: conflating "there is no such
	// endpoint" with "there is no such session" would make a client's retry
	// logic depend on which of the two Factory happened to mean.
	ErrorCodeRouteNotFound sessionwire.ErrorCode = "route_not_found"
	// ErrorCodeMethodNotAllowed reports a method this route does not serve.
	// The response carries an Allow header naming the ones it does.
	ErrorCodeMethodNotAllowed sessionwire.ErrorCode = "method_not_allowed"
	// ErrorCodeUnsupportedMediaType reports a request body that is not JSON.
	ErrorCodeUnsupportedMediaType sessionwire.ErrorCode = "unsupported_media_type"
	// ErrorCodePayloadTooLarge reports a request body past the configured
	// ceiling. The ceiling is applied while READING, so it bounds a body whose
	// Content-Length lied as well as one that declared its size honestly.
	ErrorCodePayloadTooLarge sessionwire.ErrorCode = "payload_too_large"
	// ErrorCodeNotImplemented reports a route this build serves no handler for.
	// Every occurrence is a later task in runbook 05; see routeTable.
	ErrorCodeNotImplemented sessionwire.ErrorCode = "not_implemented"
	// ErrorCodeInternal reports a fault inside Factory. Its message is fixed
	// text: a dependency's error string is diagnosis for an operator's log, not
	// for a caller's response body.
	ErrorCodeInternal sessionwire.ErrorCode = "internal_error"
	// ErrorCodeUnavailable reports a dependency Factory could not reach, which
	// is a different operational condition from a rejected request and is
	// retryable.
	ErrorCodeUnavailable sessionwire.ErrorCode = "unavailable"
	// ErrorCodeTimeout reports that the deadline this router imposed on the
	// request's work expired.
	ErrorCodeTimeout sessionwire.ErrorCode = "timeout"
)

Factory's public error codes are Core's ErrorCode vocabulary, extended.

Core v0.7.0 names nine codes: invalid_request, unsupported_version, session_not_found, command_rejected, gate_resolved, gate_not_resumable, gate_expired, gate_response_invalid, runtime_unavailable. Six of the nine name a gate or a command outcome, one names a missing session, and invalid_request and unsupported_version are general enough to answer a malformed request at any layer -- this package uses invalid_request for four pre-session conditions, so "they are all about a session or a command" would be false and this file would be the counterexample.

What is true, and what the extension rests on, is narrower: no Core ErrorCode CONSTANT names an unauthenticated caller, a denied operation, an unknown route, a refused method, an oversized body, an unserved route or a fault inside Factory. coreErrorCodes below is that constant set, enumerated through Core's own identifiers, and it is the measurement -- a grep for the words would not be, since "internal" occurs twenty-two times in non-test files at this version as part of internal_endpoint, and never as an error code.

So Factory declares those, and it declares them AS sessionwire.ErrorCode values rather than as a second type. Core's ErrorDetail.Validate requires only a non-empty code, and its unknown-member capture is explicitly a forward-compatibility mechanism, so an extended code travels in a Core envelope without a second body shape for a client to branch on. The whole point of "use Core stable error codes and JSON envelopes" is that a client reads ONE shape and switches on ONE member; inventing a parallel {"code":...} body for the pre-session conditions -- which is what this package's guard did before A2.1 -- defeats that more thoroughly than extending the vocabulary does.

A condition Core DOES name must be spelled with Core's constant. TestACodeCoreNamesIsSpelledWithCoresConstant holds the two sets disjoint, and states its own limit: it cannot see a code a later Core adds.

View Source
const CSRFHeaderName = "X-CSRF-Token"

CSRFHeaderName is the request header a client echoes an issued token back in.

A header rather than a form field or a cookie, and the reason is a condition rather than a property: a cross-site page can make a browser submit a form and can make it attach a cookie, but it cannot set a custom request header without a CORS preflight the SERVER approves. Nothing in this module answers a preflight -- no file writes an Access-Control- header at this revision -- so none is approved. A later task that adds a CORS policy must keep this header out of Access-Control-Allow-Headers, or this obstacle stops existing.

View Source
const RequestIDHeader = "X-Request-Id"

RequestIDHeader carries the identifier this router MINTS for each request.

It is minted, never echoed. A client may send this header, and a router that copied it would put caller-controlled bytes into every log line and durable record that later correlates on the value -- including bytes a naive log formatter would treat as a line break. The response carries the minted value so a caller can still quote it in a support request.

Variables

View Source
var ErrInvalidGuardConfig = errors.New("httpapi: invalid guard configuration")

ErrInvalidGuardConfig is the class of every NewGuard rejection.

View Source
var ErrInvalidRouterConfig = errors.New("httpapi: invalid router configuration")

ErrInvalidRouterConfig is the class of every NewRouter rejection.

Functions

This section is empty.

Types

type Authenticator

type Authenticator interface {
	// AuthenticateRequest returns the principal the request's verified
	// credentials name. It never reads a tenant from the body, query or path.
	AuthenticateRequest(ctx context.Context, r *http.Request) (identity.Principal, error)
}

Authenticator derives a principal from an inbound HTTP request.

Nothing in this package calls it, and that is deliberate rather than an oversight. Router takes the CONCRETE *internalidentity.Authenticator, for the reason GuardConfig.Credentials does: the operation context must record which credential authenticated the request, NewOperationContext is a method so that answer comes from the same derivation the authentication used, and an interface here would be a second place for an edge to state it wrongly -- a wrong answer there is a CSRF guard that skips.

What this declaration is FOR is the public seam. A deployer implements factory.Authenticator from outside the module, and TestPublicSeamsAreExactlyTheUnionOfTheirConsumers derives that seam from the narrow interfaces its consumers declare, so this is where AuthenticateRequest's membership in it comes from. Deleting it would silently narrow the public surface a composition (A9.1) has to satisfy.

type Authorizer

type Authorizer interface {
	// AuthorizeSessionList covers the tenant-scoped session list.
	AuthorizeSessionList(ctx context.Context, principal identity.Principal) error

	// AuthorizeSessionRead covers status, journal and gate reads.
	AuthorizeSessionRead(ctx context.Context, principal identity.Principal, session sessionwire.SessionID) error

	// AuthorizeObjectRead covers a session-scoped object body.
	AuthorizeObjectRead(ctx context.Context, principal identity.Principal, session sessionwire.SessionID, object sessionwire.ObjectReference) error

	// AuthorizeControl covers create, input, interrupt, restore and gate
	// response. Its signature is identical to the ClientLink authorizer's
	// because §8.1 makes the REST control routes and the ClientLink RPCs
	// alternatives obeying one admission contract, so one implementation
	// answers both.
	AuthorizeControl(ctx context.Context, principal identity.Principal, session sessionwire.SessionID, kind sessionstore.CommandKind) error
}

Authorizer decides every public REST operation. A nil error is the only grant; a denial wraps identity.ErrUnauthorized, and any other error is an authorization dependency fault.

type CommandDelivery

type CommandDelivery interface {
	Deliver(ctx context.Context, tenant sessionwire.TenantID, session sessionwire.SessionID, delivery sessionwire.HostLinkCommandDelivery) error
}

CommandDelivery wakes consumption of an already admitted command on whatever route this replica holds for the session.

Its signature is `internal/routing`.Bindings.Deliver's, stated in Core's vocabulary, so A9.1 composes the two without an adapter. What it is NOT is an acknowledgement path: the durable inbox record is the acknowledgement, this is a wake-up, and every failure it can produce -- no local subscriber, no route, a Host that refused -- leaves a correct durable record behind. See Router.deliverAdmitted.

type ControlAdmitter

ControlAdmitter is the durable command plane the control routes admit into.

It is `internal/admission`'s V1 surface, declared here as the narrow interface THIS package calls. Every method takes a Core request type and returns the authoritative SessionInbox record, so nothing about a URL, a header or a path value reaches the service.

AdmitCreate is PRESENT as of A3.1, and it was absent for two tasks before that because this composition could not author the immutable SessionBinding a V1 create reservation carries. It now can: WithSessionBinding supplies the two deployment members and admission derives the third from the create's own identity. Adding the method here and clearing the create's owner in routeTable were one change, as that absence note predicted.

Every method returns a DISPOSITION entry. A create chooses the session's protocol mode, disposition is the only choice a Host can take residency on, and every later command is admitted into the same family -- a command in the legacy inbox of a disposition session is one no Host can ever apply.

AdmitLegacyCreate is absent for a second, independent reason: it is a create.

The `bool` every method returns is admission's "this call is the one that accepted it". This edge deliberately does not read it -- see serveControl -- and the seam still declares it, because the seam's shape is the service's and an adapter would be a second place for the two edges to answer differently.

type Directory

type Directory interface {
	// Candidates returns one bounded page of the Hosts currently offering
	// capacity for one launch target, most free capacity first. A target
	// nothing advertises is an empty page rather than an error.
	//
	// # The error contract, which is part of the signature
	//
	// A failure that came from SessionStore must leave a
	// *sessionstore.HostTargetError reachable by errors.As. This package reads
	// the code to decide whether the condition is the deployment's -- a
	// backend outage is a retryable 503 -- or a fault in Factory's own
	// request, and anything it cannot classify is answered as an internal
	// fault by DEFAULT, because advertising an unknown condition as retryable
	// would tell every client to hammer it.
	//
	// The consequence for an implementer is the one that is easy to get wrong:
	// an adapter that wraps the store's error in a type of its own WITHOUT
	// keeping the original unwrappable turns every directory outage into
	// internal_error, silently, and no test in this package would see it --
	// the fake returns store errors directly. Wrap with %w or return the
	// store's error.
	Candidates(ctx context.Context, req sessionstore.ListCompatibleHostsRequest) (sessionstore.HostTargetPage, error)
}

Directory is the observed Host target directory.

It is how /v1/agents learns which Department launch targets are CURRENTLY advertised. The method takes and returns SessionStore's own types because that is the durable record's shape and Factory's public Directory seam already spells it that way; an adapter here would be a second vocabulary for one fact.

Reading it does not connect to a Host. The advertisements are bounded, expiring SessionStore OrderedIndex rows a Host publishes for itself, so a read of this directory is a durable read like every other one on this surface -- which is what lets /v1/agents obey "reads must not fan out across Hosts" while still describing what the fleet can launch.

type GateOwners added in v0.9.0

type GateOwners interface {
	GateResponsesAnswerable(ctx context.Context, tenant sessionwire.TenantID, session sessionwire.SessionID, record sessionstore.CatalogRecord) (bool, error)
}

GateOwners answers whether a gate response for a session would pass the gate_response write path's owner check now. *admission.Service satisfies it with the SAME check AdmitGateResponse makes, so the read and the write agree.

type Guard

type Guard struct {
	// contains filtered or unexported fields
}

Guard is the HTTP and WebSocket origin and CSRF guard.

Where it is mounted

Around the mux, and INSIDE authentication. Around the mux, because a rejected origin must not be able to learn which routes exist by comparing a 403 with a 404; the cost is that a rejected request is answered before routing, which is the intended answer rather than a lost 404.

Inside authentication, because a token is bound to the principal it was issued to and there is no principal before authentication has run. Mounting it the other way round does not silently disable it: a state-changing request then has no operation context and is rejected with ReasonUnauthenticated, so the mistake presents as every write failing rather than as the guard being skipped.

What each rule is for

The rules are three defences, not one repeated:

  • The HOST rule answers the case the Origin rule cannot see, which is DNS rebinding. A rebound page's requests are same-origin to the BROWSER, so a safe GET from one carries no Origin header for the Origin rule to examine. What it cannot change is the Host the server received, which net/http takes from the request line or the Host header.
  • The ORIGIN rule answers a cross-site request from a page that is honest about where it came from, which is every browser.
  • The TOKEN rule answers the case the Origin rule cannot see: a request a browser is willing to send with no Origin header. It is required only where it can do work -- see the ambient-credential note on Check.

What holds tokens

Nothing. Tokens are STATELESS: an issued token carries its own nonce and expiry and an HMAC over both and over the principal, keyed by CSRFConfig.SharedKey, which every replica is configured with. A Guard keeps no issued-token set, so a browser that mints a token on one Factory replica and posts to another is not rejected by the second one, and a replica that restarts does not invalidate every open tab. The alternative -- a per-process map -- is the failure this design exists to prevent: it works in a single process and fails intermittently the moment a deployment has two, in proportion to how well the load balancer happens to be pinning connections.

A stateless token MUST be bound to its principal, and that is a consequence rather than a nicety. An unbound stateless token can be minted by anyone with any account -- the attacker fetches one with their own credentials, embeds the literal string in the attacking page, and the victim's browser posts it alongside the victim's cookie. Binding is what makes a token the attacker can mint useless against a principal they are not.

func NewGuard

func NewGuard(cfg GuardConfig) (*Guard, error)

NewGuard validates a configuration and returns the guard. A rejection returns a nil Guard, so a caller that ignores the error cannot mount one built from a half-valid configuration.

func (*Guard) Check

func (g *Guard) Check(r *http.Request) (Reason, bool)

Check reports the single rule that rejects r, or ("", true) when every rule allows it.

The order below is the order of the rules. Every rejection except ReasonNoRequest -- which has no request for another rule to be satisfied by -- is reachable from a request every other rule allows, and TestGuardRulesAreEachSoleOnTheirOwnPath drives each of them that way from one accepted base. That is what makes a reason the rule that DECIDED rather than the rule that happened to run first.

The ambient-credential exemption

Every rule below the exemption -- the two that REQUIRE an Origin header, and the token rules -- applies to an AMBIENT credential, one a cross-site page can cause the browser to attach, and not to a bearer credential, which an application attaches deliberately to a request it made itself. The Origin-required rules are in that set for the same reason the token rules are: a browser cannot set an Authorization header on a WebSocket handshake or on a form submission, so a request that carries one is not a browser being used against its user.

The rules ABOVE the exemption -- host and Origin-if-present -- apply to every request, bearer included. A bearer credential says nothing about where the request was addressed.

The exemption is granted by NAMING the exempt source, which is what makes the empty source A1.1 records for an unreadable credential non-exempt: it is not on the list. The second result of CredentialSource is deliberately not consulted, because a branch on it could not change the answer -- the empty source is not the bearer source -- and a branch that cannot change an answer is a branch no test can hold.

func (*Guard) IssueToken

func (g *Guard) IssueToken(principal identity.Principal) (string, time.Time, error)

IssueToken mints a token bound to principal, valid for the configured TTL.

The nonce is fresh per call, so two calls return different tokens except with the probability of a collision in 128 bits of crypto/rand, and a client that mints per page load does not have to reason about reuse.

func (*Guard) TokenHandler

func (g *Guard) TokenHandler() http.Handler

TokenHandler serves a freshly issued token to the authenticated caller.

It is a GET, which stateChanging treats as safe, so a client holding no token can reach it. It is safe for it to be a GET for the reason a token endpoint generally is: the response is readable only same-origin, and the guard has already established that the request's Host is this deployment's.

func (*Guard) Wrap

func (g *Guard) Wrap(next http.Handler) http.Handler

Wrap answers a rejected request itself and passes an accepted one on.

type GuardConfig

type GuardConfig struct {
	// CSRF is the composed origin and CSRF configuration. It is validated
	// again here rather than assumed: a Guard built from an unvalidated
	// configuration is a guard with no trusted origins, which fails closed but
	// fails at every request rather than at composition.
	CSRF identity.CSRFConfig

	// Credentials answers which credential a request presents. It is the
	// concrete Authenticator rather than an interface, because the answer must
	// come from the one implementation of the precedence rule and a seam here
	// would be a second place to state it; see internalidentity.Authenticator's
	// CredentialSource.
	Credentials *internalidentity.Authenticator

	// Clock decides token expiry.
	Clock internalidentity.Clock
}

GuardConfig configures a Guard.

type IDSource

type IDSource interface {
	NewUUID() (string, error)
}

IDSource supplies the per-request identifier. Its shape is Factory's public UUIDSource, so the composition satisfies it with no adapter.

type LaunchTemplate

type LaunchTemplate struct {
	// Key is the launch target's identity: the triple SessionStore files an
	// advertisement under, so a configured template and an advertised one are
	// the same value rather than two spellings of one.
	Key sessionstore.HostTargetKey

	// Capabilities are the presentation strings this deployment publishes for
	// the agent. They are optional, and Core omits an empty list.
	Capabilities []string
}

LaunchTemplate is one launch target this deployment is configured to offer.

It carries IDENTITY and nothing else: the agent, the runtime compatibility boundary, where it runs, and the capability strings a picker renders. It deliberately holds no tool, model, role, prompt or capability DEFINITION -- specification section 7 makes rig.Rig the authority for those, and a launch target that restated them would be the "competing static agent catalogue" the specification forbids Factory to keep.

Why the deployment is asked at all

/v1/agents is specified as an aggregate of current Department advertisements plus configured dedicated launch templates, and the advertisement half cannot supply the MEMBERSHIP on its own. SessionStore files a target's rows under an ordering scope derived by DIGEST from the (AgentID, RuntimeCompatibilityID, Placement) triple -- see sessionstore's deriveHostTargetScope -- so a reader can ask "which Hosts serve THIS target" and cannot ask "which targets exist". Measured against the released sessionstore v0.1.0: the only listing entry points are ListCompatibleHosts, which requires a whole HostTargetKey, and ReconcileHostTargets, which walks the DUE index and therefore sees exactly the rows that have already lapsed. There is no enumeration of the key space.

So the deployment supplies the KEYS and the directory supplies the LIVENESS. That division is what keeps this from being a static catalogue: for a pooled target Factory never asserts launchability on its own say-so -- a configured pooled template that no Host currently advertises is not listed.

Specification section 7 sanctions the configured half explicitly: "Factory may discover Department membership and Rig capabilities from Hosts or deployment configuration".

func (LaunchTemplate) Validate

func (t LaunchTemplate) Validate() error

Validate reports why this template may not be used.

It is called from NewRouter, so a template naming no agent is a composition failure rather than a response this handler has to decide what to do with at request time. A malformed configured template that reached the handler could only be dropped silently or fail every /v1/agents request; refusing to start is the answer that reaches an operator.

type ObjectLimits

type ObjectLimits struct{ MaxPageBytes, MaxVerificationBytes uint64 }

ObjectLimits bounds retained response memory and whole-object verification I/O. Hard ceilings bound configuration mistakes as well as caller requests.

func DefaultObjectLimits

func DefaultObjectLimits() ObjectLimits

DefaultObjectLimits retains at most 1 MiB per request and verifies at most 64 MiB. Larger artifacts need a different protocol. Paging rereads the whole object on each request; the route's RequestTimeout also bounds this work.

func (ObjectLimits) Validate

func (l ObjectLimits) Validate() error

type ObjectPolicy

ObjectPolicy authorizes a reference using trusted committed session evidence. Metadata index existence is not that evidence. The returned kind comes from policy, never from a caller's reference syntax. Nil policy grants nothing. The catalog entry carries the immutable binding the evidence must belong to. A denial wraps identity.ErrUnauthorized; any other error is a policy fault. A9 must supply the production policy; this interface does not implement one.

type ObjectReader

ObjectReader is the neutral read capability a frozen binding may resolve to. Streams must honor cancellation, permit concurrent Close with Read, and report whole-object integrity at EOF, as released SessionStore does.

type Reason

type Reason string

Reason names the single rule that rejected a request. Every rejection answers 403, so the reason is what separates them: several ways through one status code is a guard whose checks cannot be told apart, and a check nothing can tell apart from another is a check nothing measures.

It is carried to the client as the "code" member of the rejection body, so a browser can distinguish the one rejection it can act on -- an expired token, which it recovers from by fetching a fresh one and retrying -- from the ones it cannot.

const (
	// ReasonNoRequest reports a nil request.
	ReasonNoRequest Reason = "no_request"
	// ReasonHostNotTrusted reports a Host naming no trusted origin's host. It
	// is the DNS-rebinding check: a rebound name reaches the server as the
	// Host it was reached by, which the page that rebound it cannot change.
	ReasonHostNotTrusted Reason = "host_not_trusted"
	// ReasonOriginNotTrusted reports an Origin header naming an origin that is
	// not trusted.
	ReasonOriginNotTrusted Reason = "origin_not_trusted"
	// ReasonUpgradeOriginMissing reports a WebSocket handshake carrying an
	// ambient credential and no Origin header.
	ReasonUpgradeOriginMissing Reason = "websocket_origin_missing"
	// ReasonOriginMissing reports a state-changing request carrying an ambient
	// credential and no Origin header.
	ReasonOriginMissing Reason = "origin_missing"
	// ReasonUnauthenticated reports a state-changing request with no operation
	// context, so there is no principal to verify a token against.
	ReasonUnauthenticated Reason = "unauthenticated"
	// ReasonCSRFMissing reports an absent CSRFHeaderName header.
	ReasonCSRFMissing Reason = "csrf_token_missing"
	// ReasonCSRFInvalid reports a token that is not this deployment's, or
	// is not this principal's. The two are deliberately one answer: telling
	// them apart would confirm that a presented token belongs to somebody.
	ReasonCSRFInvalid Reason = "csrf_token_invalid"
	// ReasonCSRFExpired reports a token this principal really was issued,
	// past its expiry. It is separate from invalid because it is the one
	// rejection a client recovers from without a human.
	ReasonCSRFExpired Reason = "csrf_token_expired"
)

type RouteLimits

type RouteLimits struct {
	// MaxRequestBytes is the inclusive ceiling on a request body, applied while
	// READING rather than by trusting Content-Length.
	MaxRequestBytes int64

	// RequestTimeout bounds the work a handler may do, as a deadline on the
	// request's context.
	//
	// It is a deadline and not a response guillotine, and the difference is
	// worth stating because it bounds the claim. Every dependency call this
	// package makes takes the context, so the deadline is what actually stops
	// the work; a guillotine that raced a handler already writing bytes could
	// not produce a JSON envelope, which is the property step 2 asks for.
	// Bounding the SOCKET -- a peer that never finishes sending a header, or
	// never reads a response -- is http.Server's ReadHeaderTimeout and
	// WriteTimeout, and belongs to the task that builds the server (A9.1).
	RequestTimeout time.Duration
}

RouteLimits bounds one request's body and one request's work.

func DefaultRouteLimits

func DefaultRouteLimits() RouteLimits

DefaultRouteLimits is what a composition naming no limits receives.

One mebibyte is generous for the V1 command envelopes this surface accepts -- identities plus a bounded prompt -- and an oversized private payload goes to SessionObjectStore by reference rather than through this ceiling (A3.1 step 5). Thirty seconds is longer than any durable read here should take and short enough that a stuck dependency does not accumulate handlers.

func (RouteLimits) Validate

func (l RouteLimits) Validate() error

Validate reports why these limits may not be used.

type Router

type Router struct {
	// contains filtered or unexported fields
}

Router is Factory's public HTTP surface.

func NewRouter

func NewRouter(cfg RouterConfig) (*Router, error)

NewRouter validates a composition and returns the router. A rejection returns a nil Router, so a caller that ignores the error cannot serve from one built from a half-valid composition.

func (*Router) Quiesce added in v0.7.0

func (rt *Router) Quiesce()

Quiesce refuses new public HTTP requests, including ClientLink upgrades. Requests that already passed this edge finish under the shared admission service's separate fence.

func (*Router) ServeHTTP

func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP splits the API surface, protected /ui/ application routes, and the optional single-page application.

The split is by path and it is made HERE, before authentication, because the SPA's assets are public and the API's routes are not. A configured /ui/ route also goes through authentication and the guard, then its own explicit authorizer. Everything under the version segment goes through the API chain; errors are JSON, and authorized object bodies are bounded binary responses; everything else is the SPA's, or a JSON route failure when no SPA is mounted.

The security headers are set in two places, and the split is exactly as wide as the argument for it.

setNeutralSecurityHeaders runs HERE, above the split, so it covers every response the router mounts, the SPA's included. apiSecurityHeaders runs inside Router.own, which is every response the router writes itself -- including the two routeNotFound answers dispatch produces before the API chain is reached. Both once sat inside the API chain, which left an unclean path such as /v1/../assets/app.js answering 404 with none of them: the exact request an attacker chooses.

The API-only half is TWO headers, not five, and each is withheld from the SPA for its own reason. Content-Security-Policy is default-src 'none', which would forbid a bundle its own scripts and styles, and no policy this package could write would suit a document it has never seen. Cache-Control: no-store is right for private session data and wrong for a hashed asset, where it costs a re-download of the whole bundle on every load. The three neutral headers have no such conflict -- a document's own policy has nothing to say about MIME sniffing, referrer leakage or framing -- so withholding them was a sentence about one header applied to five. See RouterConfig.UI for what a composer is left owing.

func (*Router) StopWakes added in v0.7.1

func (rt *Router) StopWakes(ctx context.Context) error

StopWakes cancels every Host wake this router started after an acknowledgement, refuses any later one, and waits for the running ones to return or for ctx to end. factory.Server.Stop calls it, including on a Server that never served, so an embedder mounting Handler on its own http.Server is covered by the Stop it already owes.

A wake that ignores its context is waited for until ctx ends, and then abandoned: the one goroutine left behind is the delivery seam's, bounded by whatever that seam honours.

func (*Router) WakesStopped added in v0.7.1

func (rt *Router) WakesStopped() bool

WakesStopped reports whether StopWakes has run. It exists so the composition root's own tests can hold that Server.Stop -- and not Quiesce -- is what stops the wakes, which no response can show: a stopped router is also a quiesced one and admits nothing to wake for.

type RouterConfig

type RouterConfig struct {
	// Credentials authenticates a request and derives its operation context.
	//
	// It is the CONCRETE authenticator rather than the Authenticator interface
	// this package declares, for the reason GuardConfig.Credentials is: the
	// operation context must record which credential authenticated the
	// request, NewOperationContext is a method so that answer comes from the
	// same derivation the authentication used, and a seam here would be a
	// second place for an edge to state it wrongly. A wrong answer there is a
	// CSRF guard that skips, silently.
	Credentials *internalidentity.Authenticator

	// Authorizer decides every public operation.
	Authorizer Authorizer

	// Reads is the durable read plane.
	Reads SessionReader
	// ObjectPolicy proves committed-reference permission; nil fails closed.
	ObjectPolicy ObjectPolicy
	// ResolveObjectStore consumes the full immutable catalog pin, including
	// RuntimeSessionID and ProtocolMode, and must refuse unknown configurations.
	// Reads stay in canonical authenticated tenant/session scope. Any runtime
	// namespace translation belongs to the resolved adapter, never this handler.
	// Production resolver/public server composition remains A9's obligation.
	ResolveObjectStore func(context.Context, sessionstore.SessionBinding) (ObjectReader, error)
	ObjectLimits       ObjectLimits

	// Directory is the observed Host target directory, read by /v1/agents to
	// learn which configured launch targets are currently advertised.
	Directory Directory

	// Admissions is the durable command plane the control routes admit into.
	//
	// A nil Admissions FAILS CLOSED rather than being rejected by NewRouter,
	// and that is ObjectPolicy's rule for ObjectPolicy's reason: a deployment
	// composed for durable reading alone -- which is what factory.New builds
	// today, and every test of the read plane -- is a supported composition,
	// and requiring a command plane it never calls would make the read surface
	// unbuildable. A control request against such a composition is answered
	// 503 unavailable, which says the deployment cannot carry it out now
	// rather than that the caller's command was refused.
	Admissions ControlAdmitter

	// GateOwners overlays the gates read's answerability with the gate
	// response write path's own owner check (see serveSessionGates). nil
	// leaves every stored answerability as it is; the composition always
	// supplies the admission service.
	GateOwners GateOwners

	// Realtime is the optional ClientLink entry point mounted at /v1/realtime.
	//
	// A nil Realtime FAILS CLOSED rather than being rejected by NewRouter, for
	// Admissions' reason and with Admissions' answer: a deployment composed
	// for durable reading alone is supported, and a realtime request against
	// one is answered 503 unavailable -- the deployment cannot carry it out
	// now, rather than the route not existing.
	//
	// It is a SUPPLIER and not a handler because the two halves have different
	// lifetimes. The router is built by factory.New and the ClientLink node is
	// RUN by factory.Server.Start -- clientlink.NewHandler runs it before it
	// returns, deliberately, so a composition cannot succeed and then refuse
	// every connection -- so a handler field would force New to acquire a
	// lifetime. A supplier answering nil until Start has run gives the same
	// 503, from the same line, with no second code path.
	Realtime func() http.Handler

	// Delivery is the optional local wake-up for an already admitted command.
	//
	// Nil means no attempt is made, which changes NO response: the durable
	// record is the acknowledgement and delivery is best effort either way.
	// An attempt runs AFTER the answer, off the request, and is owned by the
	// router until StopWakes. See Router.deliverAdmitted and wakes.
	Delivery CommandDelivery

	// Logger receives what the router recovers from and has no response to
	// carry it in: today, a delivery seam that panicked inside a wake. Nil
	// takes slog.Default.
	Logger *slog.Logger

	// Department is the launch targets this deployment is configured to offer.
	//
	// It may be EMPTY, and an empty Department is a supported composition
	// rather than a degenerate one: a Factory serving an existing tenant's
	// durable session history needs no launchable agent at all. It answers
	// /v1/agents with an empty list, which is the truthful answer.
	//
	// Every entry is validated by NewRouter. See LaunchTemplate for why the
	// deployment supplies these at all, and Router.serveAgents for why the
	// aggregate they produce is not tenant-scoped.
	Department []LaunchTemplate

	// Guard is the origin and CSRF guard, mounted around the mux and inside
	// authentication; see Guard's own documentation for both reasons.
	Guard *Guard

	// IDs mints the per-request identifier.
	IDs IDSource

	// UI is the optional single-page application. It is served only OUTSIDE
	// the API version segment; see Router.ServeHTTP.
	//
	// # What the router sets on its responses, and what it does not
	//
	// The router sets X-Content-Type-Options, Referrer-Policy and
	// X-Frame-Options before this handler runs, so a bundle server that sets
	// none of them is still covered. It does NOT set a
	// Content-Security-Policy: the API's is default-src 'none', which would
	// forbid a document its own scripts and styles, and there is no policy
	// this package can write for a bundle it has never seen.
	//
	// So a UI handler owes its own Content-Security-Policy, and this package
	// DECLINES to interpose rather than being unable to.
	//
	// The distinction matters because the earlier wording -- "nothing here can
	// check that" -- is false at the layer that has the reader. ServeHTTP does
	// not hand this handler the raw writer; it wraps it in a recordingWriter,
	// which already implements WriteHeader, so the PRESENCE of a policy this
	// handler set is observable at that seam with machinery that exists today.
	//
	// What no code here can judge is ADEQUACY. A present Content-Security-
	// Policy may be default-src * and worth nothing, and the policy a bundle
	// needs is a fact about the bundle's own scripts, styles, fonts and
	// connect targets -- which this package has never seen. A presence check
	// would therefore fail a correct deployment that sets its policy at the
	// edge, in a meta tag, or on the document alone rather than on every asset,
	// and pass one whose policy is empty of meaning. Refusing at run time is
	// worse still: it turns a header opinion into an outage.
	//
	// Enforcement at COMPOSITION is what would be worth having, and it is not
	// available -- there is nothing to inspect in an http.Handler before it
	// serves. So the obligation is stated here, on the field a composer
	// supplies, which is the earliest place a human reads it.
	//
	// Every header above is a DEFAULT rather than a floor. They are written to
	// the header map before this handler is invoked, and net/http's header map
	// stays mutable until WriteHeader, so a UI that must be embedded in a
	// parent application can Set or Del X-Frame-Options itself.
	UI http.Handler
	// UIRoutes serves application-owned /ui/ paths after authentication,
	// origin/CSRF checks and AuthorizeUIRoute. It does not handle public assets.
	UIRoutes         http.Handler
	AuthorizeUIRoute func(context.Context, identity.Principal, string, string) error

	// Limits bounds bodies and work. Its zero value takes DefaultRouteLimits.
	Limits RouteLimits
}

RouterConfig composes the public REST plane.

type SessionReader

SessionReader is the durable read plane the handlers use.

It is a SessionStore DOMAIN interface: no method names a github.com/looprig/storage primitive, so a handler cannot reach a Ledger, Leaser, KV or Blobs value and re-derive a record the aggregate owns.

Jump to

Keyboard shortcuts

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