wui

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Aug 28, 2026 License: Apache-2.0 Imports: 15 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
api := serve.Handler(rig, catalogreader.New(catalog, store))
h   := wui.Handler(api)   // /v1/ -> api, / -> SPA, Host/Origin guard over all of it

The exported Go surface names no looprig type: http.Handler in, http.Handler out. github.com/looprig/harness is in go.mod for the testscontract/ is a verbatim, version-pinned copy of that harness version's pkg/serve wire contract and contract/contract_test.go is the drift guard — and no non-test file in this module imports it.

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
  • dist/ — the //go:embed all:dist target; index.html is a committed placeholder
  • contract/ — schemas and fixtures vendored from harness 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: dist/index.html is committed so the embed target always exists.

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, overwriting everything but index.html

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 harness's own pkg/serve routes.

The exported Go surface names no looprig type. github.com/looprig/harness is pinned in go.mod for the test surface only — the contract drift guard and the fixture producers resolve it — and no non-test file in this module may import it. wui imports harness; harness never imports wui.

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

This section is empty.

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

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

Handler composes wui's complete 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().

Types

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