wui

package module
v0.2.0 Latest Latest
Warning

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

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

README

wui

The reusable web user interface for looprig, and the browser counterpart to tui: a React 19 + Vite SPA built to a static bundle, //go:embed-ed in the consumer's binary, served next to harness's own pkg/serve routes. There is no backend-for-frontend — the process serving the UI is the process holding the rig.

Go API

func Assets() http.Handler                                       // the SPA alone
func Guard(next http.Handler, opts ...GuardOption) http.Handler  // Host/Origin guard
func Handler(api http.Handler, opts ...Option) http.Handler      // the composed default
func BundleProtocolVersion() (Bundle, error)                     // what the embedded bundle speaks
api := serve.Handler(rig, catalogreader.New(catalog, store))
h   := wui.Handler(api)   // /v1/ -> api, / -> SPA, Host/Origin guard over all of it

The serving surface is http.Handler in, http.Handler out, and no exported name here comes from another looprig module. BundleProtocolVersion is the one function that returns a wui type: Bundle is the embedded bundle's own self-description — which sessionwire version its JavaScript negotiates, which pinned Core its contract came from, which @looprig/protocol build is in it, and whether the release process produced the tree at all. A server composes the official bundle only after checking that marker against its own Core support, and a non-release or absent marker is refused. See bundle.go for why the claim is read out of the tree instead of declared as a Go constant.

github.com/looprig/core is in go.mod for the testscontract/ is a verbatim, version-pinned copy of that Core version's sessionwire/v1 schemas and fixtures and contract/contract_test.go is the drift guard — and no non-test file in this module imports it. Because nothing compiled imports it, go mod tidy drops the pin; use go get.

Layout

  • assets.goAssets(), the embedded SPA with a path-confined SPA-router fallback
  • guard.go, csrf.go, errors.go — browser guards (see Security below)
  • handler.goHandler(), composing api + assets + guards
  • bundle.goBundleProtocolVersion(), the embedded bundle's protocol marker
  • dist/ — the //go:embed all:dist target; index.html and looprig-bundle.json are always committed, the rest is force-added onto a release commit
  • contract/ — schemas and fixtures vendored from Core at a pinned version
  • packages/, app/ — the npm workspaces (protocol, React adapter, SPA)

Security

harness/pkg/serve has no Origin or Host check, and loopback binding alone does not stop DNS rebinding. Handler therefore wraps everything in a Host/Origin guard and applies a synchronizer-token CSRF check to the state-changing API routes only — never to the whole mux, which would turn every mutating request into a blanket 403 before routing resolved. GET /v1/csrf-token delivers the token.

Building

The Go module builds with no Node toolchain installed because a release bundle is committed, so the embed target always exists. That committed bundle is deliberately frozen between releases and may lag app/ source during development. make release-dist is the only release path: it performs a clean dependency install, builds into two isolated output directories, refuses byte-different manifests, rejects symlinks and non-regular output, then transactionally replaces and stages dist/ and runs the Go race/build gates against that embed. It requires a POSIX release host; native Windows is intentionally unsupported because safe rollback depends on stopping every descendant through release-owned negative process-group IDs. Run publication from a supported Unix host or POSIX CI runner. Any publication or gate failure, plus handled SIGHUP, SIGINT, or SIGTERM, restores the exact committed snapshot and index; signal exits retain their conventional nonzero status. Build and gate commands run in release-owned process groups: handled signals stop the complete group, escalating resistant descendants after a bounded grace period, before rollback or temporary-output cleanup begins. SIGKILL sent to the release process itself and power loss cannot run rollback. After either, run make dist-reset to remove interrupted output and restore the committed snapshot before retrying. The target refuses caller changes found under dist/ both before builds and immediately before publication; it does not claim to lock out editor writes. Use make dist-reset after an ordinary local app build to return to the committed release snapshot. Two consecutive isolated Vite builds from the same source must produce identical path-and-byte manifests; the target enforces that before it touches the committed snapshot or index.

make check                 # the full gate: fmt, vet, staticcheck, gosec, vuln, test, build
GOWORK=off go test ./...   # standalone verification against the pinned dependencies

Building the real SPA:

npm ci
npm run build -w app     # writes ../dist for local inspection
make dist-reset          # restore the committed release snapshot afterward

Licence

Apache 2.0.

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

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

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

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

func Assets() http.Handler

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

func Handler(api http.Handler, opts ...Option) http.Handler

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

func BundleProtocolVersion() (Bundle, error)

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

func NewCSRFGuard(ttl time.Duration) *CSRFGuard

NewCSRFGuard builds a CSRFGuard whose minted tokens are valid for ttl. A non-positive ttl falls back to DefaultCSRFTokenTTL.

func (*CSRFGuard) Mint

func (g *CSRFGuard) Mint() (string, error)

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

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

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

func WithCSRFTokenTTL(ttl time.Duration) Option

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.

Jump to

Keyboard shortcuts

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