Documentation
¶
Overview ¶
Package wui is the reusable browser user interface for looprig: a React SPA built to a static bundle, //go:embed-ed into the consumer's binary, plus the handler and browser guards that serve it next to an API handler.
BundleProtocolVersion reports what that embedded bundle speaks: the sessionwire version its JavaScript negotiates, the pinned Core its contract came from, the @looprig/protocol build in it, and whether the release process produced the tree. A server composes the official bundle only after checking that marker against its own Core support.
The exported Go surface names no type from another looprig module; Bundle is this module's own. github.com/looprig/core is pinned in go.mod for the test surface only: the contract drift guard resolves its sessionwire/v1 schemas and fixtures. No non-test file imports core, and no module in the build list is harness; both are enforced, not merely asserted, by module_graph_test.go.
Index ¶
Constants ¶
const CSRFHeaderName = "X-CSRF-Token"
CSRFHeaderName is the request header the SPA must echo a minted token back in on every state-changing request. This is the wire convention CSRFGuard enforces.
const DefaultCSRFTokenTTL = 4 * time.Hour
DefaultCSRFTokenTTL is the bounded lifetime NewCSRFGuard falls back to when called with ttl <= 0. A few hours comfortably outlives a single working session in an open browser tab — this is a local control-plane UI, not a public site with walk-up users, so there's no pressure to expire aggressively — while still bounding how long a leaked or logged token stays exploitable, and keeping the in-memory token map's contents naturally bounded to "recent" tokens.
Variables ¶
var ErrNoBundleManifest = errors.New("wui: embedded bundle carries no manifest")
ErrNoBundleManifest reports that the embedded bundle carries no manifest at all -- the shape every wui build before this marker existed has, including the retracted v0.1.0.
It is deliberately distinguishable from a manifest that parses and declares itself non-release: one is an artefact that predates the marker, the other is a current artefact stating its own status. A consumer that cannot tell them apart cannot report which of the two it is refusing.
Functions ¶
func Assets ¶
Assets returns an http.Handler serving the embedded SPA build: a real asset under dist/ if the request path names one, otherwise dist/index.html (the SPA-router fallback).
Every request path is cleaned and confined to the embedded dist/ root before it is handed to embed.FS.Open -- see assetName and assets_test.go for the traversal cases this defends against, and for what was empirically verified about embed.FS's and net/http's own path handling.
func Guard ¶
func Guard(next http.Handler, opts ...GuardOption) http.Handler
Guard wraps next in the Host/Origin guard alone: no CSRF, no routing, no SPA. It is the escape hatch for a consumer assembling its own mux that still wants wui's DNS-rebinding defence. A nil option is ignored rather than dereferenced, so a mis-wired composition root is inert instead of panicking on construction.
func Handler
deprecated
Handler composes wui's deprecated legacy browser surface over api (a harness pkg/serve.Handler result): the SPA at /, api under /v1/, the CSRF token route, per-route CSRF on the five state-changing control routes, and the Host/Origin guard over all of it. See this file's package doc for the layering and why CSRF is per-route.
NOTE ON serve.Server: this wrapper is a plain http.Handler, so it does not carry harness's unexported auth-installed proof, and it cannot be made to — Go qualifies an unexported method name by its declaring package, so a wui.authInstalled method is a DIFFERENT identifier from serve.authInstalled and serve's own type assertion would still fail (harness's comment at server.go:116 is wrong on this point; it was verified empirically). The consequence is bounded: serve.Server refuses a bind only when it is non-loopback AND unauthenticated, so a loopback bind — carbon serve's default — is unaffected. A public bind of this handler needs serve.WithInsecurePublicBind().
Deprecated: compose Assets, Guard, and API-specific controls explicitly for new integrations. Handler's protected route set is frozen for compatibility.
Types ¶
type Bundle ¶ added in v0.2.0
type Bundle struct {
// CoreVersion is the github.com/looprig/core module version whose
// sessionwire/v1 schemas the bundle's client was built against. It is the
// same string contract/VERSION holds.
CoreVersion string `json:"core_version"`
// ProtocolVersion is the @looprig/protocol package version in the bundle.
// It is also the version string the Centrifuge connect frame advertises;
// app/scripts/write-bundle-manifest.test.ts drives the real handshake and
// asserts the two agree.
ProtocolVersion string `json:"protocol_version"`
// Release reports whether the release process produced this tree. A
// development build, and the tree committed so //go:embed all:dist compiles
// with no Node toolchain installed, are both false.
//
// An absent key decodes to false, which is the fail-closed direction: a
// manifest that does not say it is a release is not treated as one. The
// converse is checked rather than trusted: readBundleManifest refuses a
// true here over a tree that carries no build output.
Release bool `json:"release"`
// SessionwireVersion is the sessionwire protocol version the bundle's
// client negotiates -- the `const` in Core's
// version_negotiation_response.schema.json. This is the quantity a server
// compares against its own Core support.
SessionwireVersion int `json:"sessionwire_version"`
}
Bundle is the embedded SPA build's self-description.
It is a plain comparable struct of exported fields on purpose: a consumer (Factory's default command) has to be able to construct one to test its own acceptance rule against an old, empty or future marker without needing a second wui build to embed.
func BundleProtocolVersion ¶ added in v0.2.0
BundleProtocolVersion returns the embedded bundle's marker.
It returns an error rather than a zero Bundle for a missing or malformed manifest, because a zero Bundle is a value a caller could accidentally compare against something and pass. Callers should treat any error, and any Bundle whose Release is false, as "do not serve this as an official build".
type CSRFGuard ¶
type CSRFGuard struct {
// contains filtered or unexported fields
}
CSRFGuard mints and verifies per-page-load CSRF tokens for control-plane POSTs (and PUT/PATCH/DELETE, if this package ever adds them). See the package-level doc comment above for the full threat model, wire convention, and storage posture. The zero value is not usable; construct with NewCSRFGuard.
func NewCSRFGuard ¶
NewCSRFGuard builds a CSRFGuard whose minted tokens are valid for ttl. A non-positive ttl falls back to DefaultCSRFTokenTTL.
func (*CSRFGuard) Mint ¶
Mint generates a new token with crypto/rand, records its mint time, and returns it. Call this once per page load (TokenHandler below does exactly that over HTTP); the caller is responsible for delivering the result to the SPA (see the package doc comment). This is the one place lazy full-map maintenance happens: expired entries are pruned (evictExpiredLocked) and, if the live count still exceeds maxCSRFTokens, the oldest survivors are evicted too (evictOldestLocked) — verify (called on every control POST, far more often than Mint) does neither and stays a plain O(1) lookup.
func (*CSRFGuard) TokenHandler ¶
func (g *CSRFGuard) TokenHandler() http.HandlerFunc
TokenHandler builds the GET /v1/csrf-token handler that Handler (handler.go) registers. It mints a token via Mint — kept as the single source of mint logic so the handler and Verify/verify can never drift apart — and writes it as {"csrf_token": "..."}. The response is explicitly non-cacheable (Cache-Control: no-store): a CSRF token is a security credential, and any cached copy served back to a later, different page load would be stale and misleading at best. X-Content-Type-Options: nosniff pins the declared JSON content type against MIME sniffing.
This route is deliberately GET (never state-changing), so CSRFGuard.Wrap never demands a token to reach it — a client with no token yet must be able to fetch its first one.
func (*CSRFGuard) Wrap ¶
Wrap returns next wrapped so that GET, HEAD, and any other non-state-changing method pass straight through untouched, while POST, PUT, PATCH, and DELETE require a valid, unexpired token in the CSRFHeaderName header — missing, unknown, or expired all answer 403 with codeCSRFInvalid (see errors.go), matching HostOriginGuard's fail-secure, reject-fast-before-next convention (see guard.go). A rejected request never reaches next. codeCSRFInvalid is marked retryable: true — a client that clears its cached token, mints a fresh one from TokenHandler, and retries the identical request once is following the intended recovery path, not fighting the guard.
type GuardOption ¶
type GuardOption func(*config)
GuardOption configures the Host/Origin guard. It is a distinct type from Option because Guard takes only these — a caller wrapping an arbitrary handler has no CSRF store to configure.
func WithAllowedHosts ¶
func WithAllowedHosts(hosts ...string) GuardOption
WithAllowedHosts widens the Host/Origin allowlist by the named hosts. It is ADDITIVE, never a replacement: the three loopback forms (127.0.0.1, localhost, [::1]) are always allowed. It exists for the "public bind is opt-in" case, where a composition root deliberately serves a specific additional hostname.
type HostOriginGuard ¶
type HostOriginGuard struct {
// contains filtered or unexported fields
}
HostOriginGuard is standard net/http middleware — see NewHostOriginGuard — that rejects any request whose Host header, or whose Origin header when present, does not name one of its allowed hosts.
func NewHostOriginGuard ¶
func NewHostOriginGuard(extraAllowedHosts ...string) *HostOriginGuard
NewHostOriginGuard builds a HostOriginGuard accepting the three loopback host forms — 127.0.0.1, localhost, [::1] — on any port (or no port at all), plus any hosts listed in extraAllowedHosts. extraAllowedHosts is additive, never a replacement: it exists for the "public bind is opt-in" case, where a composition root deliberately widens the allowlist to a specific additional hostname. Calling it with no arguments — the common case — is loopback-only.
func (*HostOriginGuard) Wrap ¶
func (g *HostOriginGuard) Wrap(next http.Handler) http.Handler
Wrap returns next wrapped so the guard runs FIRST — reject-fast at the edge, before any auth or business logic in next ever sees the request. A rejected request never reaches next at all: fail secure means an unparseable Host or Origin is a rejection, not a pass-through, and every rejection answers 403 (the route exists; the request's origin doesn't).
type Option ¶
type Option func(*config)
Option configures Handler.
func WithCSRFTokenTTL ¶
WithCSRFTokenTTL sets the lifetime of minted CSRF tokens. A non-positive value falls back to DefaultCSRFTokenTTL.
func WithGuardOptions ¶
func WithGuardOptions(opts ...GuardOption) Option
WithGuardOptions applies guard options to a Handler. It exists so the one WithAllowedHosts spelling serves both entry points.