embed

package
v0.62.0 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Overview

Package embed lets a GoFastr app hand out pieces of itself.

An app author marks a screen or island embeddable and names the exact origins allowed to frame it. Their customer pastes one <script> tag into an unrelated website and gets a live, themed, authenticated piece of the app.

Delivery

An iframe, loaded by a small script. Inside the frame GoFastr is same-origin with itself, so the runtime's origin guards, its same-origin fetches, and its ownership of the document all hold unchanged. That is the reason this shape is tractable: nothing about the runtime has to be relaxed to make it work.

Inside the frame the session cookie is never sent, even though the frame is same-origin with the app. SameSite is computed against the top-level browsing context and the full ancestor chain; the top level is the customer's site, so every request from inside the frame is a cross-site context. GoFastr's session cookie is SameSite=Strict and the CSRF cookie is Lax; neither is sent.

Identity can therefore only arrive explicitly, which makes CSRF against embed routes structurally impossible. Embed routes go further and REJECT cookies rather than merely not requiring them — a route that honours a cookie when one happens to be present would hand a signed-in user's full session to a third party's frame.

The credential

A single-use handshake nonce exchanged for a stateless grant.

The app author mints a nonce server-side for one specific viewer (Host.MintNonce) and renders it into the embed snippet. The nonce is an HMAC over (surface, subject, scopes, origin, nonce id, expiry) — nothing is stored at mint time, so minting scales like signing.

Only the exchange touches a store: the nonce id is INSERTed against a unique constraint, and the constraint violation is what "already used" means. That is atomic across replicas with no read-then-write race, the same shape as the migration and seed locks.

Single use exists to make a SHARED token impossible. The predictable customer failure with a time-window token is hardcoding one into a page template, so every visitor arrives as the same identity — and nothing about a TTL prevents that. Replay defence comes along for free.

A browser has several ways to fire the exchange twice (the customer's page prefetches the iframe, a dev double-mounts the loader, a user refreshes), so the exchange is POST-only and idempotent within the grant's lifetime: a repeat of the same nonce returns the same grant instead of failing. Without that, the feature surfaces as "the embed randomly doesn't load".

Origins

Exact origins only, no wildcards — every subdomain is listed separately. Origins are compared NORMALIZED, not as strings: https://acme.com, https://acme.com/, https://acme.com:443 and https://ACME.com are one origin and four strings, and a customer's trailing slash would otherwise silently never match.

The browser-enforced control is the embed document's CSP frame-ancestors directive, which lists every allowed origin. It has to list them all: no Origin header is sent on a navigation GET, so at the moment the header is written the server does not know who is framing it. Listing ten origins does not let an eleventh frame the page — the browser enforces against the real ancestor chain. The only cost is that the allowlist is public to anyone who fetches the embed URL.

Runtime

The frame gets its own runtime composition (kernel + rpc + signals + widgets-boot + boot + boot-embed) which omits the nav fragment. That is how SPA navigation is disabled inside frames: by absence, so no config mistake and no later refactor can re-enable it.

Index

Constants

View Source
const (
	DefaultNonceTTL = time.Minute
	DefaultGrantTTL = 15 * time.Minute
	// DefaultGrantMaxAge is how long a frame may keep refreshing before the
	// customer's page has to hand it a fresh nonce. It bounds an otherwise
	// immortal credential: a dashboard embed left open in a tab for a week
	// should not still be acting as its viewer.
	DefaultGrantMaxAge = 12 * time.Hour
)

Default token lifetimes.

The nonce window is short because the nonce is rendered into a page the app does not control and is spent by the very next request the browser makes. A minute absorbs a slow page load and a prefetch; it does not absorb a nonce pasted into a template and served for a week — which is the failure this design exists to prevent.

The grant window is what bounds how long a frame keeps working after its nonce is spent. It refreshes while the frame lives, so a short window costs one background request rather than a broken embed.

View Source
const (
	LoaderPath   = "/__gofastr/embed.js"
	RuntimePath  = "/__gofastr/embed-runtime.js"
	ExchangePath = "/__gofastr/embed-exchange"
	RefreshPath  = "/__gofastr/embed-refresh"
	// SurfacePrefix is the shell + content space: /__gofastr/embed/{surface}
	// and /__gofastr/embed/{surface}/content.
	SurfacePrefix = "/__gofastr/embed/"

	// GrantHeader carries the frame's grant on every credentialed request. A
	// header rather than a cookie, because a cookie would be ambient — and
	// ambient is exactly what an embedded surface must not have.
	GrantHeader = "X-Gofastr-Embed"
)

The embed HTTP surface. These are exported because more than one package has to agree on them: uihost mounts them, and the CSRF middleware has to know which ones are structurally exempt.

The two API endpoints sit OUTSIDE the /__gofastr/embed/{surface} space on purpose — a surface named "exchange" would otherwise shadow the exchange endpoint, and "which pattern wins" is not a question a security boundary should depend on.

View Source
const (
	// NoncePrefix marks a single-use handshake nonce — the credential the app
	// author renders into a customer's page.
	NoncePrefix = "emb_"
	// GrantPrefix marks the short-lived stateless grant the frame receives in
	// exchange for a nonce. It never leaves the frame.
	GrantPrefix = "emg_"
)

Token prefixes. They are load-bearing in exactly one way: they let a leaked string be identified at a glance (in a log, a bug report, a customer's page source) as a GoFastr embed credential rather than a session or an API token.

View Source
const (
	NoncePurpose = "gofastr/embed/nonce/v1"
	GrantPurpose = "gofastr/embed/grant/v1"
)

HKDF purposes. Domain separation means a nonce can never verify as a grant (or as a session token) even though all three are HMAC-SHA256 over JSON with keys derived from the same app secret. Versioned so the derivation can change without silently accepting the old form.

View Source
const DefaultBurnTable = "gofastr_embed_nonces"

DefaultBurnTable is the table NewSQLBurnStore creates unless overridden.

View Source
const DefaultMaxThemeVariants = 32

DefaultMaxThemeVariants caps how many distinct customer themes one surface may register.

Component CSS is content-addressed by theme, so every distinct theme is a cache miss plus a fresh render. Without a cap, a customer that varies a token per page view turns the theme registry into an unbounded map and the CSS cache into a miss generator.

View Source
const PruneGrace = 5 * time.Minute

PruneGrace is how far PAST a row's retention deadline Prune waits before deleting it.

Burn refuses a claim whose deadline has passed, using the calling replica's clock. Prune deletes using its own. Two replicas whose clocks differ can therefore disagree about whether a row is still live, and the dangerous direction is a fast pruner deleting a row a slow verifier is about to need — which un-burns the nonce and lets it mint a second grant.

A margin costs one extra row per spent nonce for its duration and removes the disagreement, which is the better trade: the rows are tiny and short-lived, and the alternative is a distributed clock assumption nothing enforces.

Variables

View Source
var (
	// ErrMalformed means the string is not a token of this kind at all.
	ErrMalformed = errors.New("embed: malformed token")
	// ErrBadSignature means the MAC did not verify under the given key.
	ErrBadSignature = errors.New("embed: bad token signature")
	// ErrExpired means the token verified but its expiry has passed.
	ErrExpired = errors.New("embed: token expired")
)

Errors returned by the verify path. They are distinguished so a handler can answer "this was used" differently from "this is not ours" — but note that handlers deliberately collapse them into one client-visible response, since telling a caller WHICH check failed is an oracle.

View Source
var ErrGrantExhausted = errors.New("embed: grant reached its absolute deadline")

ErrGrantExhausted means the credential reached its absolute deadline. The frame cannot recover on its own — the customer's page must load a new nonce.

View Source
var ErrSpent = errors.New("embed: nonce already used")

ErrSpent means the nonce was already exchanged and its grant window closed.

Functions

func CSRFExempt

func CSRFExempt(r *http.Request) bool

CSRFExempt reports whether r targets an embed endpoint that cannot be a CSRF target, and so must not be gated on a CSRF token.

This is not a convenience exemption. Double-submit CSRF works by pairing a cookie with a header, and no cookie is ever sent from inside an embed frame — SameSite is computed against the top-level browsing context, which is the customer's site. An app that installs CSRF middleware would therefore 403 every exchange with "missing cookie" and the feature would be dead, in exactly the configuration the framework recommends.

What makes the exemption safe is that these endpoints have no ambient credential to abuse. The exchange consumes a single-use nonce the caller must already possess; the refresh consumes a grant the caller must already possess. A cross-site page that could forge the request still has neither, so there is no confused deputy to exploit — which is the same reasoning behind middleware.SkipBearerAuth.

func IsNilValue added in v0.50.0

func IsNilValue(v any) bool

IsNilValue reports whether v is nil, including a non-nil interface wrapping a nil pointer.

Exported because framework/uihost's embed content route installs a resolved subject on its own — it builds a fresh context rather than going through Middleware — and needs the identical check. Two call sites disagreeing about what "no user" means is how the content route ended up installing typed nils after Middleware had stopped. A SubjectResolver written as `func(...) (*User, error)` and returning a nil *User produces exactly that: `user != nil` is true, the nil pointer is installed, and every "is a user present" gate downstream reports authenticated for a subject that does not exist.

func MintGrant

func MintGrant(key []byte, n Nonce, ttl time.Duration, deadline time.Time, now time.Time) (string, error)

MintGrant signs the frame credential a verified nonce is exchanged for. deadline caps the total life of this credential across every later refresh.

func MintNonce

func MintNonce(key []byte, surface, subject, origin string, scopes []string, ttl time.Duration, now time.Time) (string, error)

MintNonce signs a single-use handshake nonce. Nothing is stored — the nonce becomes "used" only when the exchange endpoint burns its id.

func NormalizeOrigin

func NormalizeOrigin(raw string) (string, error)

NormalizeOrigin reduces an origin string to its canonical scheme://host[:port] form, or reports why it is not an origin at all.

Normalization matters because an origin is a tuple and the customer types a string: https://acme.com, https://acme.com/, https://ACME.com and https://acme.com:443 are the same origin written four ways. Comparing the raw strings means a customer's trailing slash silently never matches and the embed "just doesn't work" with no diagnosable cause.

Rejected outright: anything carrying a path, query, fragment or userinfo. Those are not part of an origin, and accepting them would mean two configs that differ only in ignored bytes compare unequal.

func ResolveCustomerOrigins added in v0.50.0

func ResolveCustomerOrigins(ctx context.Context, src OriginSource, surface, customer string) ([]string, error)

ResolveCustomerOrigins is the single entry point a shell request uses to turn an OriginSource into the normalized, capped origin list for a CSP frame-ancestors directive.

Every failure returns a non-nil error so the caller can fail closed by serving frame-ancestors 'none'. The cases that fail closed are named deliberately:

  • no source (programming error by the caller),
  • empty customer id (the app opted into a source, so a request without one is a misconfigured snippet, not a wildcard),
  • an over-long customer id (the id is attacker-chosen and unauthenticated),
  • a source that errors (a store is not trusted to widen framing),
  • a source that returns no origins (a customer with no framers is a configuration mistake, not allow-everyone),
  • any origin that fails NormalizeOrigin (a store is not a trusted input),
  • a joined list over the per-response cap (proxies truncate or reject oversized response headers; failing this one customer closed is strictly better than the old boot-time refusal that broke every customer at once).

func RoutedPath

func RoutedPath(u *url.URL) (string, bool)

RoutedPath returns the path the ROUTER will dispatch on — decoded one segment at a time — or ok=false when the request must be refused before any authorization decision is made.

It exists because the gate and the router disagreed about what "the path" is, and the disagreement was exploitable in both directions.

MayReach used to decide on r.URL.Path, which is fully percent-decoded, and then cleaned it. net/http's ServeMux matches patterns against r.URL.EscapedPath(), where an encoded separator or an encoded dot segment is an ordinary byte sequence sitting INSIDE one segment. So a request for

GET /api/docs/%2e%2e/%2e%2e/reports

decoded to "/api/docs/../../reports", which path.Clean collapsed to "/reports" — inside the surface's own subtree, so the gate admitted it. The router collapsed nothing, matched the subtree pattern "/api/docs/", and ran a handler that reservedPrefixes exists to keep grants away from. The mirror image, "/__gofastr%2Fprivate", read as a runtime endpoint to the gate and as a single opaque segment to the router, reaching an app's own "/{slug}".

Cleaning cannot fix this. Normalising ONE of the two strings is precisely what opens the gap, so a stricter clean would only move it. The only correct move is to refuse any path whose segments do not survive decoding intact, and then compare on the same segments the router will see.

Nothing legitimate is lost: a path segment never needs to contain an encoded "/" or to spell "." or "..". Ordinary escapes (%20, %2B, a UTF-8 name) decode to themselves and pass.

func WithGrant

func WithGrant(ctx context.Context, g Grant) context.Context

WithGrant installs a verified grant on the context. Exported for the UI host, which verifies grants on its own routes before rendering.

Types

type BurnStore

type BurnStore interface {
	// Burn atomically claims nonceID for grant.
	//
	// First caller wins: it stores grant and returns (grant, false, nil).
	// A later caller arriving while the stored grant is still valid gets that
	// SAME grant back with replay=true — the exchange is idempotent, so a
	// prefetched iframe, a double-mounted loader or a page refresh does not
	// break the embed.
	// A caller arriving after the stored grant has expired gets
	// ("", true, nil): the nonce is spent and the idempotency window has
	// closed.
	Burn(ctx context.Context, nonceID, grant string, expires time.Time) (issued string, replay bool, err error)

	// Prune deletes rows past the retention deadline they were burned with.
	//
	// That deadline is the LATER of the grant's expiry and the nonce's: the
	// grant's, because replay has to keep returning it while it is valid; the
	// nonce's, because a row deleted while its nonce still verifies un-burns
	// it, and the next exchange mints a second, independent grant.
	Prune(ctx context.Context, now time.Time) error
}

BurnStore records which handshake nonces have been spent.

The whole contract is BurnStore.Burn, and its shape is chosen so that "already used" is decided by a unique constraint rather than by a read followed by a write. A read-then-write would race: two concurrent exchanges of the same nonce would both read "unused" and both mint a grant, which is precisely the shared-identity failure single-use exists to prevent.

type Config

type Config struct {
	// Surfaces is the closed set of embeddable surfaces. A name that is not
	// here does not exist — there is no index endpoint and unknown names 404
	// identically to unauthorized ones.
	Surfaces []Surface
	// BurnStore records spent nonces. Required. Use NewSQLBurnStore for
	// anything running more than one replica.
	BurnStore BurnStore
	// Resolve maps a grant subject to the current user. Optional: without it
	// an embed renders anonymously, which is the right shape for a public
	// pricing table or status widget.
	Resolve SubjectResolver
	// ResolveTenant maps a grant subject to its tenant id. Optional.
	//
	// Middleware clears the tenant along with every other ambient identity
	// value, because inheriting the COOKIE user's tenant is a cross-tenant
	// read. Without this hook it then had no way to install the right one, so
	// a multi-tenant entity behind an embed simply errored — the app's only
	// recourse was undocumented middleware of its own.
	//
	// The tenant comes from the app's lookup ON THE GRANT'S SUBJECT, never
	// from the request, so a stolen grant cannot choose its own tenant. That
	// is also why this is a resolver rather than a claim inside the token: a
	// claim has to be correct at mint time, in a credential living in a page
	// the app does not control, and a wrong one is a cross-tenant read.
	ResolveTenant TenantResolver
	// NonceTTL / GrantTTL / GrantMaxAge override the defaults above.
	NonceTTL    time.Duration
	GrantTTL    time.Duration
	GrantMaxAge time.Duration
	// OriginSource optionally supplies a surface's allowed origins per
	// customer at request time. When set, the embed shell serves only the
	// origins of the customer named in the request instead of the whole
	// static allowlist — see [OriginSource] and the Origins section of the
	// embed docs. Leave it nil to behave exactly as today (static
	// Surface.Origins on every shell response).
	OriginSource OriginSource
}

Config declares an app's embeddable surfaces.

type ExchangeResult

type ExchangeResult struct {
	// Grant is the frame credential.
	Grant string
	// Expires is when Grant stops verifying.
	Expires time.Time
	// Replay is true when this exchange returned a previously issued grant
	// rather than minting a new one. The caller answers a replay exactly as it
	// answers a first exchange — that is the whole point of idempotency — but
	// it should SAY something, because a run of replays is the only visible
	// symptom of a nonce baked into a cached customer page, which serves one
	// identity to every visitor of that copy.
	Replay bool
	// Surface names the surface the nonce was minted for, so a caller
	// reporting a replay can say which one.
	Surface string
}

ExchangeResult reports what an exchange produced.

type Grant

type Grant struct {
	Surface string
	Subject string
	Scopes  []string
	Origin  string
	Expires time.Time
	// Deadline is the absolute end of the credential's life. Refreshes move
	// Expires; they never move Deadline.
	Deadline time.Time
}

Grant is a verified frame credential.

func GrantFromContext

func GrantFromContext(ctx context.Context) (Grant, bool)

GrantFromContext returns the embed grant that authenticated this request.

A screen or handler uses it to read the scopes the app narrowed the embed to:

if g, ok := embed.GrantFromContext(ctx); ok && g.HasScope("comment") {
    return CommentForm()
}

ok is false on an ordinary first-party request, which is what a surface rendered outside a frame should see.

func VerifyGrant

func VerifyGrant(key []byte, token string, now time.Time) (Grant, error)

VerifyGrant checks a grant's signature and expiry.

func (Grant) HasScope

func (g Grant) HasScope(scope string) bool

HasScope reports whether the grant carries scope.

type Host

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

Host serves an app's embeddable surfaces. Construct with New.

func New

func New(cfg Config) (*Host, error)

New validates a config and returns the host.

Every failure here is a wiring mistake that would otherwise surface as a silently-open or silently-broken embed, so all of them are errors at boot.

func (*Host) AddReservedPrefixes

func (h *Host) AddReservedPrefixes(prefixes ...string) error

AddReservedPrefixes registers additional paths no surface may reach, and re-validates every surface already declared against them.

The framework calls this at mount time with each privileged battery's ACTUAL configured prefix, because the built-in list can only name defaults: an app that sets admin.Config.PathPrefix = "/back-office" would otherwise keep the protection on "/admin", which it no longer uses, and lose it on the prefix it does.

Re-validation is the point. Surfaces are declared before mount, so a Path or Reach that only becomes reserved once a battery is mounted has to be caught here or not at all. The error names both the surface and the prefix.

func (*Host) Exchange

func (h *Host) Exchange(ctx context.Context, nonceToken, framedOrigin string) (ExchangeResult, error)

Exchange verifies a handshake nonce, burns it, and returns the frame's grant.

The order is deliberate: verify first (so an unsigned string never reaches the store and cannot be used to probe or fill it), then burn, then return. The grant is minted BEFORE the burn because the burn stores it — that is what makes a repeat exchange idempotent instead of a failure.

framedOrigin is the browser-attested ancestor origin the frame reports. It is checked against the nonce when present, but it is NOT the control that stops another site from framing the surface: a non-browser caller can send anything here. The load-bearing control is the CSP frame-ancestors directive on the embed document, which the browser enforces against the real ancestor chain. This check is defence in depth and a much better error message.

func (*Host) GrantTTL

func (h *Host) GrantTTL() time.Duration

GrantTTL exposes the configured grant lifetime.

func (*Host) Lookup

func (h *Host) Lookup(name string) (*ResolvedSurface, bool)

Lookup returns a declared surface.

func (*Host) Middleware

func (h *Host) Middleware() func(http.Handler) http.Handler

Middleware authenticates requests that carry an embed grant.

Why an app needs this

The frame renders through the host's own embed routes, which verify the grant themselves. But everything the surface does AFTER first paint — every island RPC, every form post, every poll — targets an ordinary app route. Those routes know nothing about embeds. Without this middleware, an embedded surface paints as its viewer and then acts as nobody:

  • Cross-site framing: no cookie is sent and the grant is ignored, so the handler runs anonymously and the island silently swaps authenticated content for a logged-out render.
  • Same-site framing (app.acme.com inside www.acme.com): the cookie IS sent to the app route, so the surface's content renders as the grant's subject while its islands mutate as the cookie's user. That identity confusion is exactly what the embed routes strip cookies to prevent, one hop downstream.

Install it on the router (or on the group the embeddable surface's islands post to):

app.Use(embeds.Middleware())

What it does

A request with no grant header passes through untouched — this is not an authenticator for ordinary traffic.

A request carrying a grant header is an embed request, and is handled as one: the grant must verify (an invalid one is refused, never downgraded to anonymous), every ambient credential the request carries is discarded, and the grant plus its resolved subject are installed on the context.

Install it OUTERMOST

This middleware must run BEFORE any authentication middleware — outside session auth, bearer auth, API-token auth, and anything that derives a tenant from a credential:

app.Use(embeds.Middleware())   // first
app.Use(auth.Session(...))     // then everything else

It discards the credentials themselves (Cookie, Authorization, X-API-Key), so an authenticator running inside it finds nothing to authenticate and the grant's subject stands alone. Installed the other way round, an authenticator that already ran has written its own values onto the context, and this middleware cannot take them back: it does not know which keys another package used. The observable failures are a bearer token overwriting the grant's identity, and an API token's scopes surviving under the grant subject's name.

Scopes are not enforced here

The grant carries the scopes the surface declared, and installing the subject gives the handler that subject's FULL authority — the same as a first-party request from that user. Nothing about holding a "reports:read" grant stops it reaching an admin route the subject happens to be allowed to use, and a grant lives in a third party's page where anyone with devtools can read it.

Gate the routes an embed can reach with Host.RequireScope:

app.Use(embeds.Middleware())
reports := app.Group("/reports")
reports.Use(embeds.RequireScope("reports:read"))

func (*Host) MintNonce

func (h *Host) MintNonce(ctx context.Context, surfaceName, subject, origin string, scopes []string) (string, error)

func (*Host) Names

func (h *Host) Names() []string

Names returns the declared surface names, sorted. For diagnostics and tests only — it is never served, because an index endpoint would turn "which surfaces exist" into a public fact.

func (*Host) OriginSource added in v0.50.0

func (h *Host) OriginSource() OriginSource

OriginSource returns the configured per-customer origin source, or nil when the app serves only the static Surface.Origins allowlist. The embed shell uses this to decide whether to resolve a per-customer frame-ancestors directive; nil means "behave exactly as today".

func (*Host) Prune

func (h *Host) Prune(ctx context.Context) error

Prune drops burned nonces whose grants have expired. Wire it to a cron if the app runs long enough for the table to matter; nothing depends on it for correctness.

func (*Host) Ready

func (h *Host) Ready() bool

Ready reports whether the host has signing keys. A host without keys cannot mint or verify anything and its routes answer 503 rather than pretending.

func (*Host) Refresh

func (h *Host) Refresh(ctx context.Context, token string) (RefreshedGrant, error)

Refresh rolls a live grant forward without spending another nonce.

A frame someone leaves open outlives any sane grant window, and the nonce that created it is long burned, so the credential has to renew itself. What stops that from being an immortal credential is the deadline the grant has carried since it was issued: Refresh moves the expiry, never the deadline, and refuses once the deadline passes.

func (*Host) RequireScope

func (h *Host) RequireScope(scope string) func(http.Handler) http.Handler

RequireScope refuses any embed request whose grant does not carry scope.

Host.Middleware authenticates an embed request; this decides what that request is allowed to reach. The two are separate because only the app knows which of its routes correspond to which declared scope — the framework has no route-to-scope map and inventing one would mean guessing.

app.Use(embeds.Middleware())

reports := app.Group("/reports")
reports.Use(embeds.RequireScope("reports:read"))

// No surface declares "admin", so no embed reaches these routes.
admin := app.Group("/admin")
admin.Use(embeds.RequireScope("admin"))

Ordinary traffic passes

A request with no grant is not an embed request and is not this middleware's business, so it passes through. Gating first-party traffic is the app's existing auth middleware's job, and refusing here would break every ordinary visitor to the same route. The consequence is worth stating plainly: this narrows what an EMBED may do, and nothing else.

Why a grant needs narrowing at all

A grant is minted for a surface, handed to a third party's page, and readable by anyone with devtools on that page. The subject behind it may be an admin. Without this, holding a grant minted for a read-only reporting surface is enough to act as that admin anywhere the admin is allowed to act, for as long as the grant refreshes.

func (*Host) Resolver

func (h *Host) Resolver() SubjectResolver

Resolver returns the configured subject resolver, or nil.

func (*Host) SetKeys

func (h *Host) SetKeys(nonceKey, grantKey []byte)

SetKeys installs the HMAC keys for nonces and grants. The framework calls this at mount time with keys HKDF-derived from the app secret; tests and standalone uses may call it directly.

func (*Host) TenantResolver added in v0.50.0

func (h *Host) TenantResolver() TenantResolver

TenantResolver returns the configured tenant resolver, or nil.

The embed content route resolves identity itself — it builds a fresh context rather than passing through Middleware — so it needs this the same way it needs Resolver(). Without it, ResolveTenant reached every island RPC and not the first paint, so a multi-tenant surface rendered untenanted and then tenanted one swap later.

func (*Host) VerifyGrant

func (h *Host) VerifyGrant(ctx context.Context, token string) (Grant, error)

VerifyGrant checks a grant against the host's key and confirms the surface it names still exists and still allows the origin it was minted for.

type MemoryBurnStore

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

MemoryBurnStore is an in-process BurnStore.

Correct on one replica AND across one process lifetime. Two replicas each keep their own map, so the same nonce can be spent once per replica — and a restart forgets every burn, so a nonce still inside its (short) TTL becomes spendable again. NewSQLBurnStore is the answer to both. Kept for tests and single-process apps, and named so the limitation is visible at the call site.

func NewMemoryBurnStore

func NewMemoryBurnStore(opts ...MemoryBurnStoreOption) *MemoryBurnStore

NewMemoryBurnStore returns an in-process burn store that opportunistically prunes expired rows on writes past a high-water mark.

func (*MemoryBurnStore) Burn

func (s *MemoryBurnStore) Burn(_ context.Context, nonceID, grant string, expires time.Time) (string, bool, error)

Burn implements BurnStore.

func (*MemoryBurnStore) Prune

func (s *MemoryBurnStore) Prune(_ context.Context, now time.Time) error

Prune implements BurnStore. It remains the explicit, clock-injected sweep an external scheduler (or Host.Prune) can drive; Burn ALSO self-prunes opportunistically so the store stays bounded even without one.

type MemoryBurnStoreOption added in v0.55.0

type MemoryBurnStoreOption func(*MemoryBurnStore)

MemoryBurnStoreOption configures a MemoryBurnStore at construction.

func WithBurnPrunePolicy added in v0.55.0

func WithBurnPrunePolicy(threshold int, interval time.Duration) MemoryBurnStoreOption

WithBurnPrunePolicy overrides the opportunistic-prune high-water mark and minimum interval between sweeps. A threshold <= 0 keeps the default; an interval <= 0 disables the interval gate (sweep on every qualifying write).

type Nonce

type Nonce struct {
	Surface string
	Subject string
	Scopes  []string
	Origin  string
	ID      string
	Expires time.Time
}

Nonce is a verified handshake nonce.

func VerifyNonce

func VerifyNonce(key []byte, token string, now time.Time) (Nonce, error)

VerifyNonce checks a nonce's signature and expiry. It does NOT check whether the nonce has been used — that is the burn store's job, and the separation is deliberate: signature verification is pure and testable, burning is I/O.

type OriginSource added in v0.50.0

type OriginSource interface {
	// Origins returns the exact origins allowed to frame the named surface
	// for the named customer, in declaration order. The strings need not be
	// pre-normalized — ResolveCustomerOrigins normalizes them.
	//
	// An empty slice or an error fails the shell closed. The customer id is
	// attacker-chosen (it arrives on an unauthenticated navigation), so a
	// source must treat it as an untrusted key: parameterize any query and
	// bound its length server-side.
	Origins(ctx context.Context, surface, customer string) ([]string, error)

	// Allows reports whether origin may frame the named surface for ANY
	// customer. It is the grant path's question: MintNonce is handed an
	// origin, not a customer, so this is what decides whether a
	// source-managed origin can obtain a credential at all.
	//
	// It is on the hot path — VerifyGrant calls it on every embed request
	// whose origin is not in the static allowlist — so an implementation
	// must be cheap. Cache it; a table scan per request is not acceptable.
	//
	// An error fails closed. Origins are compared after NormalizeOrigin, so
	// an implementation receives the canonical form and should store the
	// same.
	Allows(ctx context.Context, surface, origin string) (bool, error)
}

OriginSource supplies a surface's allowed origins at request time, keyed by customer, so onboarding a customer is a row in the app's own table rather than a config change and a deploy.

It backs the per-customer shell response: when a Host is constructed with a source, the embed shell reads a customer id off the request and serves ONLY that customer's origins in the CSP frame-ancestors directive — instead of the whole static allowlist on every response. That changes the enumerability trade-off: a caller who guesses another customer's id learns THAT customer's origins, never the whole list, and gains no framing (the browser enforces against the real ancestor chain, and a grant stays bound to the origin it was minted for).

Two methods, because the two callers ask different questions. The shell needs the LIST of a customer's origins to build the directive. The grant path — MintNonce, Exchange, VerifyGrant — needs only a yes/no about one origin, and it does not know which customer is asking: the app calls MintNonce with an origin, not a customer id.

Without Allows, a customer added through the source could be framed but never granted: the shell would name their origin in frame-ancestors and MintNonce would then refuse it, so onboarding still needed a deploy. That is the whole point of the feature, so the second method earns its place.

A source is NOT a trusted input. Everything it returns goes through the same NormalizeOrigin validation as a boot-time origin (no wildcards, no userinfo, no paths, ports compared numerically), is de-duplicated, and is capped at response time. A source that errors, returns nothing, or returns an over-size list fails closed: the shell answers frame-ancestors 'none' rather than widening to everyone.

type RefreshedGrant

type RefreshedGrant struct {
	Token   string
	Expires time.Time
}

RefreshedGrant is a rolled-forward frame credential.

type ResolvedSurface

type ResolvedSurface struct {
	Surface
	// contains filtered or unexported fields
}

ResolvedSurface is a declared Surface with its origins normalized once at boot, so no request path ever re-parses an allowlist.

path is the screen's route resolved and normalized once at boot. MayReach and every other path comparison read it from here, so swapping Surface to carry a screen instead of a path string left the request-time matching logic unchanged.

func (*ResolvedSurface) AllowedOrigins

func (s *ResolvedSurface) AllowedOrigins() []string

AllowedOrigins returns the surface's normalized allowlist, in declaration order. This is the list that goes into the frame-ancestors directive.

func (*ResolvedSurface) AllowsOrigin

func (s *ResolvedSurface) AllowsOrigin(candidate string) bool

AllowsOrigin reports whether candidate may frame this surface.

func (*ResolvedSurface) MayReach

func (s *ResolvedSurface) MayReach(p string) bool

MayReach reports whether a grant for this surface may be used on p.

Three things are in reach: the surface's own Path subtree, the runtime's /__gofastr/* endpoints (which are already grant-aware and scoped per surface — the widget catalog substitutes the grant's own surface path rather than trusting the caller), and each declared Reach prefix.

Pass the result of RoutedPath, never a raw r.URL.Path. The cleaning below is a backstop for direct callers, and cleaning alone is NOT sufficient: it collapses dot segments that the router does not, which is the bug RoutedPath exists to close. It stays because for a caller that skips RoutedPath, cleaning fails closed on "/reports/../admin/users" while not cleaning admits it under the "/reports" prefix.

func (*ResolvedSurface) Path added in v0.50.0

func (s *ResolvedSurface) Path() string

Path returns the validated, normalized app route a grant for this surface may reach as its own subtree. It is resolved once at boot from the surface's screen. Callers that compared the old Surface.Path string read this instead.

type SQLBurnStore

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

SQLBurnStore is a BurnStore backed by the app's database. Correct across replicas: the nonce id is the primary key, so the second INSERT of a nonce loses to the constraint no matter which replica issues it.

func NewSQLBurnStore

func NewSQLBurnStore(database *sql.DB, opts ...SQLBurnStoreOption) (*SQLBurnStore, error)

NewSQLBurnStore creates the burn table if it does not exist and returns a store bound to it. The table is created here rather than through the entity migrator because it is framework plumbing, not app data — the same choice the auth battery's token tables make.

func (*SQLBurnStore) Burn

func (s *SQLBurnStore) Burn(ctx context.Context, nonceID, grant string, expires time.Time) (string, bool, error)

Burn implements BurnStore.

The INSERT is the claim. ON CONFLICT DO NOTHING makes a losing insert a zero-row success rather than an error, so the replay path is a plain follow-up SELECT instead of driver-specific constraint-error sniffing.

expires is the retention deadline, not the grant's expiry — see BurnStore.

func (*SQLBurnStore) Prune

func (s *SQLBurnStore) Prune(ctx context.Context, now time.Time) error

Prune implements BurnStore.

type SQLBurnStoreOption

type SQLBurnStoreOption func(*sqlBurnConfig)

SQLBurnStoreOption configures NewSQLBurnStore.

func WithBurnTable

func WithBurnTable(name string) SQLBurnStoreOption

WithBurnTable overrides the table name.

type Screen added in v0.50.0

type Screen interface {
	// RoutePath is the app route the screen is mounted at, e.g. "/reports".
	RoutePath() string
}

Screen is the minimal view of a core-ui/app.Screen this package needs: the app route the surface renders. *app.Screen satisfies it structurally, so this package never imports the UI layer — battery/auth imports this package, and dragging core-ui in would be a layering regression.

A Surface carries the screen value rather than a path string so the link from a surface to the component tree it renders is a Go value — followable by a human, a static analyzer, and the boot-time server-action walk — instead of a string resolved against a route table.

type SubjectResolver

type SubjectResolver func(ctx context.Context, subject string) (any, error)

SubjectResolver turns a grant's subject id into the value installed as the request's current user.

It exists so this package can stay out of the auth battery: the app supplies the lookup (usually the auth manager's FindByID) and gets whatever identity type its screens already expect. Returning an error fails the request closed.

type Surface

type Surface struct {
	// Name is the id a customer references in the embed snippet. It appears in
	// a URL, so it is restricted to lowercase letters, digits and dashes.
	Name string
	// Screen is the app screen rendered inside the frame. Required: a surface
	// renders a screen, not a path string, so the framework can follow a
	// surface to the component tree it renders without resolving strings.
	//
	// *app.Screen (core-ui/app) satisfies the Screen interface above. Pass the
	// same *app.Screen value you register with App.RegisterScreen to the
	// surface, so the link is a value identity rather than a re-typed path:
	//
	//	reports := app.NewScreen("/reports", &ReportsScreen{})
	//	application.RegisterScreen(reports, app.EmbedLayout())
	//	embed.Surface{Name: "reports", Screen: reports, Origins: ...}
	//
	// An island-only embed is a screen whose body is that island: the
	// chrome-less embed layout emits no header, nav or footer, so a
	// single-island screen renders as exactly that island and nothing else.
	// There is deliberately no second render path for islands — one path means
	// one set of security decisions.
	Screen Screen
	// Origins lists the exact origins allowed to frame this surface. Required;
	// there is no wildcard and no "allow any" spelling.
	Origins []string
	// Scopes is the capability set a nonce for this surface may carry.
	// MintNonce may narrow it but never widen it.
	Scopes []string
	// Theme restricts customer re-theming. Zero value means not re-themable.
	Theme ThemeConfig
	// Reach lists ADDITIONAL path prefixes a grant for this surface may reach,
	// beyond the surface's own Path subtree and the runtime's /__gofastr/*
	// endpoints. Anything else answers 403.
	//
	// The default is closed because a grant is delegated authority that lives
	// in a page the app does not control, and the alternative — reach
	// everything the subject can reach, unless the author remembers to gate it
	// — lost every time it was tried: the framework itself mounts /mcp,
	// {auth}/tokens and /admin/*, so "the author will gate it" was never a
	// property anybody could hold.
	//
	// A surface whose form posts to /api/orders declares that here:
	//
	//	Reach: []string{"/api/orders"}
	//
	// Prefixes match on segment boundaries, so "/api/orders" admits
	// "/api/orders" and "/api/orders/42" but not "/api/orders-archive".
	// Reach is per-surface: a grant for one surface never inherits another's.
	Reach []string
}

Surface is one embeddable piece of the app.

type TenantResolver added in v0.50.0

type TenantResolver func(ctx context.Context, subject string) (string, error)

TenantResolver turns a grant's subject into the tenant id its requests run under. Returning an error fails the request closed.

type ThemeConfig

type ThemeConfig struct {
	// AllowTokens names the style tokens a customer may override, using the
	// same names style.ThemeToTokens emits. Empty means the surface is not
	// re-themable at all — the safe default, since a token allowlist is the
	// only thing standing between "brand colour" and "restyle the confirm
	// button to look like the cancel button".
	AllowTokens []string
	// MaxVariants caps distinct registered themes for this surface.
	// Zero uses DefaultMaxThemeVariants.
	MaxVariants int
}

ThemeConfig restricts how far a customer may re-theme a surface.

Jump to

Keyboard shortcuts

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