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.
The cookie fact ¶
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
- Variables
- func CSRFExempt(r *http.Request) bool
- func MintGrant(key []byte, n Nonce, ttl time.Duration, deadline time.Time, now time.Time) (string, error)
- func MintNonce(key []byte, surface, subject, origin string, scopes []string, ...) (string, error)
- func NormalizeOrigin(raw string) (string, error)
- func RoutedPath(u *url.URL) (string, bool)
- func WithGrant(ctx context.Context, g Grant) context.Context
- type BurnStore
- type Config
- type ExchangeResult
- type Grant
- type Host
- func (h *Host) AddReservedPrefixes(prefixes ...string) error
- func (h *Host) Exchange(ctx context.Context, nonceToken, framedOrigin string) (ExchangeResult, error)
- func (h *Host) GrantTTL() time.Duration
- func (h *Host) Lookup(name string) (*ResolvedSurface, bool)
- func (h *Host) Middleware() func(http.Handler) http.Handler
- func (h *Host) MintNonce(surfaceName, subject, origin string, scopes []string) (string, error)
- func (h *Host) Names() []string
- func (h *Host) Prune(ctx context.Context) error
- func (h *Host) Ready() bool
- func (h *Host) Refresh(token string) (RefreshedGrant, error)
- func (h *Host) RequireScope(scope string) func(http.Handler) http.Handler
- func (h *Host) Resolver() SubjectResolver
- func (h *Host) SetKeys(nonceKey, grantKey []byte)
- func (h *Host) VerifyGrant(token string) (Grant, error)
- type MemoryBurnStore
- type Nonce
- type RefreshedGrant
- type ResolvedSurface
- type SQLBurnStore
- type SQLBurnStoreOption
- type SubjectResolver
- type Surface
- type ThemeConfig
Constants ¶
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.
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.
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.
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.
const DefaultBurnTable = "gofastr_embed_nonces"
DefaultBurnTable is the table NewSQLBurnStore creates unless overridden.
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.
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 ¶
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.
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.
var ErrSpent = errors.New("embed: nonce already used")
ErrSpent means the nonce was already exchanged and its grant window closed.
Functions ¶
func CSRFExempt ¶
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 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 ¶
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 RoutedPath ¶
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.
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
// NonceTTL / GrantTTL / GrantMaxAge override the defaults above.
NonceTTL time.Duration
GrantTTL time.Duration
GrantMaxAge time.Duration
}
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 ¶
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 ¶
VerifyGrant checks a grant's signature and expiry.
type Host ¶
type Host struct {
// contains filtered or unexported fields
}
Host serves an app's embeddable surfaces. Construct with New.
func New ¶
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 ¶
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) Lookup ¶
func (h *Host) Lookup(name string) (*ResolvedSurface, bool)
Lookup returns a declared surface.
func (*Host) Middleware ¶
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 ¶
MintNonce signs a single-use handshake nonce for one viewer of one surface on one customer origin.
Call it from the app's own backend while rendering the customer's page — that is where the app knows WHICH viewer this embed is for. The returned string is safe to place in HTML: it is single-use, expires in a minute, and binds the origin it was minted for.
scopes narrows the surface's declared scopes. Passing nil grants the surface's full set; passing a scope the surface does not declare is an error rather than a silent drop, because a silent drop makes an over-broad call site look like it worked.
func (*Host) Names ¶
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) Prune ¶
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 ¶
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(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 ¶
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.
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() *MemoryBurnStore
NewMemoryBurnStore returns an in-process burn store.
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 ¶
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 RefreshedGrant ¶
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.
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.
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.
type SQLBurnStoreOption ¶
type SQLBurnStoreOption func(*sqlBurnConfig)
SQLBurnStoreOption configures NewSQLBurnStore.
func WithBurnTable ¶
func WithBurnTable(name string) SQLBurnStoreOption
WithBurnTable overrides the table name.
type SubjectResolver ¶
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
// Path is the app route rendered inside the frame.
//
// 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.
Path string
// 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 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.