Skip to content
Trilha
Chapters

Reference

Ctx

Everything a route function can do with the request context.

*trilha.Ctx wraps the request and the response. It is created per request and must not be used by another goroutine after the handler returns.

Request#

MethodDescription
Request() *http.Requestthe original request
SetContext(ctx)replaces the request context: a middleware passes values to code that only receives *http.Request
SetRequest(*http.Request)replaces the request (rewritten URL, wrapped body)
Context() context.Contextrequest context (cancellation)
Param(name) stringroute parameter (slug_ → "slug")
Pattern() stringthe template of the route that matched (/blog/{slug}), the aggregatable form of the path; "" for what the fallback answered (static file, 404, trailing-slash redirect)
Query(name) stringfirst value of the query parameter
Form(name) stringform field (parses on demand, with a size limit)
FormErr() errorform parse error: 400 invalid, 413 too large
BindJSON(&v) errordecodes the JSON body; unknown fields are an error (400); 413 above the limit
Cookie(name) (*http.Cookie, error)request cookie
Accepts(offers...) stringthe offer the client prefers (Accept, ranked by q), or ""; an absent or */* header picks the first offer
RequestID() stringreceived X-Request-ID (up to 128 of A-Za-z0-9._:-) or a generated id
Env() trilha.Envtrilha.Dev or trilha.Prod
Base() stringURL prefix (TRILHA_BASE_PATH), without trailing slash
App() *trilha.Appthe application
Fragment() stringid the client wants to swap (Trilha-Fragment header), or "" on a normal navigation (Interactivity)

Response#

MethodDescription
JSON(code, v) errorwrites JSON with the right Content-Type
Text(code, s) errorwrites plain text
HTML(code, node) errorwrites a node as a whole document, without layouts
Redirect(url) errorreturns the 303 redirect error (use with return)
IsPrefetch() boolthis GET is a prefetch (Purpose: prefetch from ui.Prefetch, or the browser's Sec-Purpose): skip side effects of reading; a reading that must always be recorded keeps its links out with ui.NoPrefetch
RedirectReload(url) errorRedirect for a destination with another frame (login, logout, organization): a client that follows redirects in place loads it whole
PushURL(path), ReplaceURL(path)on a fragment answer, the address that rebuilds what it drew: a new history entry, or the entry the trigger made; ReplaceURL("") leaves the bar alone. A path of this site, like Redirect; nothing on a full page
Status(code)status the next page render will use
Header(k, v)sets a response header
SetCookie(*http.Cookie)adds Set-Cookie
Flash(kind, text)queues a message for the next request, in a signed cookie: the news the redirect would eat. ui.Flashes(c) shows it. On a fragment answer it travels in the Trilha-Flash header instead, and ui.js shows it. Without TRILHA_SECRET nothing is written and the app says so once in the log
Flashes() []Flashthe messages left by the previous request plus the ones this one has not sent yet; reading them takes them, and reading twice gives the same list
Render(code, node) errorwrites the page with the route's layouts (like GET): for a POST to return the form with errors (422); on a fragment, without the layouts
Stream() *StreamServer-Sent Events response: Send(event, data), JSON(event, v), Comment(s), Flush(), Done(); disables the write timeout (AI and agents)
Writer() http.ResponseWriterdirect access (long downloads, WebSocket)
Written() boolwhether the response has started

HTTP cache#

MethodDescription
ETag(tag) boolwrites ETag (quoting it if needed) and reports whether the request already had it
LastModified(t) boolwrites Last-Modified and reports whether the copy is current
CacheControl(v)writes Cache-Control verbatim

true means the 304 is already written: return nil, nil and write nothing else. Only GET and HEAD answer 304; on other methods the headers are written and the answer is always false. An empty tag or a zero date writes nothing. When both are declared, If-None-Match decides and the date stays as metadata, as RFC 9110 asks. Files under static/ already carry an ETag: the content fingerprint that goes in ?v=.

Between page and layout#

MethodDescription
SetTitle(s) / Title() stringpage title, read by layouts
Set(key, v) / Get(key) anyper-request values (middleware → page → layout)

Islands#

func (c *Ctx) Island(src string, props any, children ...h.Node) h.Node

Renders <div data-trilha-island="…" data-trilha-props="…"> with the children as the server-rendered fallback. src is a module in public/ (addressed through Asset, so it carries the content hash) whose default export is the mount function, called once with (el, props, island). props is anything encoding/json serializes, or nil; it travels as an escaped attribute and is read back with JSON.parse, so it is data and never markup. Props that do not serialize warn once and leave the fallback alone. The loader is a single inline script with the request nonce, emitted with the first island of the response (Interactivity).

The way back: the island object#

The third argument is the channel to the server. It exists so an island does not have to rediscover the token, the headers and the error shape the rest of the framework already agreed on:

MemberWhat it does
island.csrf()the token of this response — the same one CSRFInput puts in every form
island.signalan AbortSignal, aborted when the element leaves the page
island.get(url)reads JSON
island.post(url, data)sends JSON, or sends Blob, File, FormData and ArrayBuffer as their native body, always with the token
island.send(method, url, data)the same, for PUT, PATCH and DELETE
island.swap(url, id)replaces a fragment, the way a link with a target does

A route that answers 422 comes back as an IslandInvalid whose .fields is the same object a form would have shown; any other failure is an IslandError with .status and .detail. A Trilha-Location on the response is followed as a navigation, so POST → redirect → GET works from an island too: the runtime sends Trilha-Island: 1, and a handler that answers c.Redirect to that request answers 204 + Trilha-Location, as it does for a fragment, instead of a 303 the fetch would follow in silence (0.156.0). The flash the handler left waits in the cookie for the page the browser goes to.

The double-submit cookie is HttpOnly, so the token reaches the island written into the element, as data-trilha-csrf. That is what island.post sends as X-CSRF-Token.

A route.go is an API, and an API client carries a bearer token, not a cookie — so a request with an Authorization header, or with no cookie at all (a webhook), is not asked for the token. A request that arrives with the browser's cookies and no Authorization is the page — an island, or another site's form riding on the session — and since 0.156.0 it is checked like a page's POST, with no middleware to remember. trilha.RequireCSRF still exists for a route that wants the check whatever the request carries.

The route stays an API, so its errors stay problem+json — which is what the island can read.

Modules that import modules#

c.Island hands the browser the versioned URL of the module (/game/island.js?v=…), so a deploy never runs a new page against an old module from the cache. A module that imports another by a relative path — import { step } from "./engine.js" — would lose that: the browser resolves ./engine.js against the module's URL and asks for it without the version. A page with islands therefore carries an import map, written before any script, that maps every module the islands reach through relative or root imports (./, ../, /) in public/ to its versioned URL. Nothing changes in the module; a bare import (import x from "preact") is left alone, and a trilha vendor file is imported by its path like any other. An island that arrives in a fragment on a page that had none gets no map — its own module is still versioned, its imports are not.

The types the module sees#

The script the page loads for all of this is trilha.IslandRuntime (/ui.island.js), written into public/ by trilha ui along with the rest of the kit; trilha check says so when a project mounts an island without it.

trilha gen writes public/islands.d.ts from the c.Island calls it finds in app/: one interface per props struct, plus the island object and the mount signature. The props are typed by the type of the expression — a literal, a variable or a parameter of the function, &props, a conversion, or a function of the app that returns the struct — so one struct can feed several islands chosen by a switch. Point the module at it and an editor checks both sides of the boundary:

/** @type {import("/islands.d.ts").IslandMount<"/editor.js">} */
export default function (el, props, island) { … }

A field whose type lives in another package of the same module — []api.Card, *contract.Move — is followed into that package, so the editor sees Card and its fields too; a type from outside the module (uuid.UUID) is unknown, and trilha gen names the field and the module it came from.

Props given as a map literal or a variable have no name to hang a type on: the island is still declared, typed unknown, and trilha gen says so. gen --check compares the file like it compares trilha_gen.go, and an app whose last island is gone loses the file.

Long connections and large bodies#

MethodDescription
AllowBody(n int64)body limit for this request, in place of Config.MaxBodyBytes
NoReadDeadline() errordrops this request's read deadline (a slow upload is not an error)
NoWriteDeadline() errordrops the write deadline (long download, SSE)
Hijack() (net.Conn, *bufio.ReadWriter, error)takes the connection over: deadlines cleared, and Trilha writes nothing more on it

The default limit belongs to the app; the exception belongs to the route. Raise it in the route's middleware.go, not in the handler — form CSRF reads the body before the handler runs, so the decision has to come first:

// app/anexos/middleware.go
func Middleware(c *trilha.Ctx, next trilha.Next) error {
	if c.Request().Method == "POST" {
		c.AllowBody(8 << 20) // this request only; every other route keeps the app's limit
		c.NoReadDeadline()
	}
	return next()
}

Going over the limit is still a 413 with the usual message, through FormErr, Bind* or a direct read of Request().Body.

Config.MaxBodyBytes is the request ceiling. Config.MaxFormMemory is a different limit: multipart parsing keeps at most that much file data in RAM (32 MiB by default) and spills the rest to temporary files. Raising an upload route to 200 MiB therefore does not silently turn every accepted upload into 200 MiB of heap.

c.Standalone() reports the browser's last declaration that the PWA is running in installed mode. The pwa recipe maintains the cookie it reads. Use it only to hide or change installation UI, never for authorization or device identity.

WebSocket#

Trilha has no WebSocket of its own, and that is a decision. The protocol is transport: it touches no route, no layout and no render. What it does need — fragmentation and continuation frames, control frames interleaved with a message, the close handshake with a deadline, UTF-8 validation, masking, size limits, concurrent writes, backpressure, permessage-deflate — is a few hundred lines that the Autobahn suite tests in 500+ cases. The asymmetry decides it: your app can add coder/websocket to its go.mod (principle II binds the framework, not the app), but it cannot take those lines out of the framework.

What was missing was the door, and Hijack is it:

func WS(c *trilha.Ctx) error {
	conn, _, err := c.Hijack() // read and write deadlines already cleared
	if err != nil {
		return err
	}
	defer conn.Close()
	return meuWebsocket.Serve(conn) // coder/websocket, gorilla, whatever you picked
}

After Hijack the connection is yours: the framework writes no header, no error page and no body on it, and the access log records 101.

Security#

MethodDescription
CSRFToken() stringthe request's token; creates the cookie on the first call
trilha.CSRFInput(c) h.Node<input type="hidden" name="_csrf"> for forms
trilha.CSRFTokenFrom(r) stringthe same token, for a renderer that only receives the *http.Request (html/template, templ, a handler of your own); "" outside a Trilha request
trilha.NonceFrom(r) stringthe CSP nonce of the request, same reason and same rule (Security)

The token is verified automatically on POST, PUT, PATCH and DELETE of page.go (and of route.go if Config.CSRFForAPI is on), through the _csrf field or the X-CSRF-Token header.

Bind#

Bind(v any) error fills a struct from the form (or from JSON, when the Content-Type is application/json). Fields match by the form:"name" tag, then by json:"name", then by the field name — a struct that came from an API carries json tags and no form tags, and a form that posted nome to a field the binder was calling Nome made required fire on a value somebody did type; types: string, []string, bool (on/true/1), int, int64, float64 (comma or dot), time.Time (2006-01-02 or 2006-01-02T15:04) and pointers (nil when absent). A nested struct is flattened, with the tag as prefix (Billing Address +"form:\"bill_\""+ reads bill_zip…). Values that do not convert become FieldErrors (message trilha.BindInvalid, adjustable) after every field has been tried. The validate:"..."` tag of each field is applied right after, in the same pass: see Validation.

File#

File(field string, rules FileRules) (*Upload, error) reads one file from a multipart form and only answers with it if it passes the rules.

SymbolRole
FileRules.MaxSize int64limit for this file, apart from Config.MaxBodyBytes; 0 leaves the body limit doing the work
FileRules.Accept []stringmedia types allowed, matched against the detected type: "image/png", "image/*", "*/*"; empty accepts anything
FileRules.Optional boolan absent field returns (nil, nil) instead of an error
FileRules.MaxFiles intceiling on how many files Files accepts in one request; 0 is no ceiling
Upload.Namesanitised name: no directory, no separator, no control character, at most 100 characters, never empty
Upload.MIME / Upload.Exttype detected in the first 512 bytes, and the extension that matches it
Upload.Size / Upload.Filesize in bytes, and the file itself positioned at the start
up.Save(dir) (string, error)writes inside dir (mode 0600) under a free name and returns the path
up.Close() errorcloses the file

A rule that fails is FieldErrors under the field's name, like Bind; anything else (a broken body, a full disk) comes back as itself. Messages come from ValidationMessages (required, filemax, filetype, filecount) — see Validation.

Files(field string, rules FileRules) ([]*Upload, error) reads every file the field carries, in the order the browser sent them, applying the same rules to each. An empty file input is not a file: it is dropped before the count, so Optional still means "nobody chose anything". A file that fails names its own position — files[2], not files — so a form with one line per file can put the message on the right line; the files that passed come back in the slice, and closing them is the caller's job. Over MaxFiles the whole request is refused under the field's own name, before a single byte is read.

Drafts#

Draft(name string) *Draft is a form in progress, kept between one request and the next. It is the answer to the only hard question a multi-step form asks: where does step one live while somebody is on step two.

SymbolRole
c.Draft(name)names the form — not the person; the draft travels in their own cookie
d.Save(v any, ttl time.Duration) errorwrites the draft and starts its clock
d.Load(v any) errorfills v, or ErrNoDraft — never saved, finished, expired, tampered with, or another browser
d.Clear()the draft became a record and stops existing
Config.Drafts DraftStorewhere a draft over 2 KB of JSON goes; nil means the cookie is all there is

Under 2 KB of JSON the draft is a signed cookie: nothing to configure, nothing to clean up, and it expires on its own. Above that it needs Config.Drafts — three methods over whatever the app already runs — and without one, Save returns an error naming that field rather than setting a cookie the browser would drop without a word. (The limit is 2 KB and not the 3 KB the issue proposed, because what goes in the cookie is base64 of the draft plus an expiry and a signature, and 3 KB of JSON crosses the browser's 4 KB.)

ErrNoDraft is an answer, not a failure: it is what sends somebody back to step one. A draft written by an older version of the struct answers the same way — the field was renamed between deploys, and starting over beats a 500 in the middle of somebody's form.

A draft is signed, so it cannot be edited by hand, and it is not secret: what is in a cookie travels to the browser and can be read there. Keep a price or somebody else's name behind Config.Drafts, with only the key in the cookie. Signing needs TRILHA_SECRET; without it Save says so instead of failing quietly.

ui.Steps draws the indicator — see A form in steps for the whole flow.

Spreadsheets#

CSV(name string, rows any) error writes a slice — or a receive-only channel — as a file the person can open, and BindCSV(r io.Reader, dst any, rules ...CSVRules) (CSVResult, error) reads one back saying which cell is wrong.

SymbolRole
c.CSV(name string, rows any) errordownload with a UTF-8 BOM, the locale's separator and CRLF lines
csv:"Heading"the column's heading; without a tag it is the field name, and csv:"-" leaves the field out
BindCSV(r io.Reader, dst any, rules ...CSVRules)reads the file into a *[]T, validating each row with its validate tags
CSVRules.MaxRows intceiling on the file; default 100,000, and above it an error rather than a truncation
CSVRules.Separator runeforces the delimiter; zero detects it from the header
CSVResult.Rows inthow many data lines were read, blank ones aside
CSVResult.Errors []CSVError{Line, Column, Message} in file order; the header is line 1
CSVResult.Warnings []stringwhat was odd and not fatal — a heading no field claims
res.OK() boolno errors: every row can be used

Config.Locale decides the separator (, in en, ; in pt-BR), the date (2006-01-02 15:04 against 02/01/2006 15:04), the decimal mark and the word for a boolean (yes/no, sim/não); Config.TimeZone decides which day a timestamp falls on. The BOM is not optional and not configurable: without it Excel reads every accent as mojibake, and that is the first thing anybody notices.

A text cell that starts with =, +, -, @, a tab or a CR goes out with an apostrophe in front ('=HYPERLINK(…)), so the spreadsheet reads text instead of running a formula someone typed into the app (CSV injection); BindCSV takes the apostrophe off on the way back in.

A channel is what a two-hundred-thousand-row export wants — rows are written as they arrive and the write deadline is cleared — but the producer is yours to end: select on c.Context().Done(), or a browser that closed the connection leaves a goroutine behind. (iter.Seq is not accepted: this module builds on Go 1.22.)

On the way in, the separator and the BOM are detected, the header matches by tag in any order ignoring case and surrounding space, a blank line is skipped, and a date is read as dd/mm/yyyy as well as ISO. Only rows that pass are appended, so after res.OK() the slice is the whole file. A required column missing from the header is one message at line 1 rather than the same message on every row; a heading no field claims is a warning, because a spreadsheet grows a column all the time. ui.CSVErrors renders the list — see CSV for the whole round trip.

Together with ui.Flashes(c) — the summary line, and the table of what to fix underneath — this is the answer an import screen gives without a person opening the spreadsheet to find one bad date by eye.

app/…/page.go
res, err := trilha.BindCSV(up, &rows)
if !res.OK() {
	return c.Render(422, page(ui.CSVErrors(c, res, ui.CSVErrorsOpts{
		Action: ui.ButtonLink("/import", h.Text("Choose another file")),
	})))
}
c.Flash(ui.FlashSuccess, "3 rows imported, 2 rejected")
result
3 rows imported, 2 rejected
LineColumnProblem
4due_datenot a valid date
9status"canceled" is not a known status
column "notes" is not used

Sending a file#

File receives; these six send. The name, the type and the two headers that keep a download from becoming a page of your origin are the whole of it.

SymbolRole
Attachment(name string, body io.Reader, ctype string) errordownload: Content-Disposition: attachment
Inline(name string, body io.Reader, ctype string) errorthe browser opens it in place (a PDF in an <iframe>, an image)
Send(name string, body io.Reader, ctype string, o SendOpts) errorthe same two, with the answers three arguments cannot give
AttachmentFile(path, ctype string) erroropens the file, sends it and closes it; absent is 404, a directory is an error
InlineFile(path, ctype string) errorthe same, opened in place
Pipe(res *http.Response) errorhands another service's answer to the browser and closes its body
SendOptsRole
Inline boolopen in place instead of downloading — the difference between the two methods above
Size int64the whole length of a body that cannot seek: the response promises Accept-Ranges and answers a Range with 206
ContentRange stringthe body already is the slice: the Content-Range of another service's 206
NeutralizeScript boolsend a document with script inline with the script turned off

The name is sanitised by the same function an upload's is: a path never becomes a filename, and nothing in it can add a second line to the header. It goes out twice — filename*=UTF-8'' percent-encoded for a browser that reads RFC 5987, and a quoted ASCII filename for one that does not — because a name with an accent breaks in half the browsers when it goes out once.

An empty ctype is detected from the first 512 bytes, the way File sniffs an upload, with the extension allowed to sharpen it inside the same family (text/plain to text/csv) and never to overrule it. X-Content-Type-Options: nosniff always goes out: a download the browser is free to re-interpret is a download that can become a page of this origin.

Inline only accepts what a viewer renders — application/pdf, image/* (not SVG), audio/*, video/*, text/plain, text/csv. HTML, SVG and XML come back as an error and not as a response: they are documents with script, served from your own origin. trilha.CanInline(ctype) answers the same question before the call, so a screen can offer the "view" link only where there is something to view.

The refusal is wrapped in trilha.ErrCannotInline, which is what lets a screen tell a content it cannot show from a bug in its own code:

if err := c.Inline(up.Name, body, up.MIME); errors.Is(err, trilha.ErrCannotInline) {
	return c.Render(http.StatusOK, noPreview(up)) // the screen draws its own fallback
}

The response says it may be framed by a page of this origin, and that is the half everybody gets wrong. The default hardening sends X-Frame-Options: DENY and frame-ancestors 'none' on every answer; a document carrying those cannot be shown in place, whatever the page around it declares. Adding frame-src to the framing page changes nothing — the browser is refusing on behalf of the framed answer, and it says so in the console. Inline relaxes those two headers on that one response, and leaves an app that wrote its own Security.CSP exactly as it is.

Drawing it is ui.Preview: the bar, the frame, the fallback for a type nobody can show, and an <img> instead of a frame for an image.

A body that is an io.ReadSeeker — a bytes.Reader, an os.File — is written by http.ServeContent, so Range, If-Range, 304 and HEAD come for free and Accept-Ranges: bytes is promised. Anything else is copied as a stream and promises nothing — unless you say how long it is, which is the next section. Every send clears the write deadline: a 50 MB file on a bad link is not a slow handler.

Range over a body that cannot seek#

The body of another service's answer never seeks, and that is exactly the app a Upstreams front end is: a Trilha screen in front of a backend that already exists. Without a 206 an <audio> or a <video> in Safari on iOS does not seek — "jump to minute 3" becomes downloading the whole file — and the app ends up writing the Range/Content-Range pair by hand, outside the envelope.

SendOpts.Size is the whole length of that body, the Content-Length the other service answered:

res, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer res.Body.Close()
return c.Send("episode.mp3", res.Body, res.Header.Get("Content-Type"), trilha.SendOpts{
	Inline: true,
	Size:   res.ContentLength,
})
RequestAnswer
no Range200 with Content-Length and Accept-Ranges: bytes — the answer that teaches the browser the size and lets it ask for a slice
Range: bytes=10-19206, Content-Range: bytes 10-19/size, Content-Length: 10, those ten bytes
bytes=900-, bytes=-10206 cut at either end
a Range outside the file, or one that does not parse416 with Content-Range: bytes */size
more than one range200 with the whole file — legal for any Range, and multipart/byteranges does not pay over a stream

The prefix is thrown away as it arrives (io.CopyN into io.Discard) and the slice stops at the last byte. Reading the file into memory to get an io.ReadSeeker would cost the whole audio on every request for 64 KB of the middle, which is the trade this exists to avoid — but the prefix does travel from the other service. When that service answers Range itself, forward the header and hand back its 206, and nothing travels twice:

// the Range of this request was forwarded to the other service
return c.Send(name, res.Body, res.Header.Get("Content-Type"), trilha.SendOpts{
	Inline:       true,
	ContentRange: res.Header.Get("Content-Range"), // "bytes 10-19/4096"
})

The value is checked before it becomes a header of ours — a Content-Range assembled wrong is a corrupt file in the browser — the status becomes 206, Content-Length is computed from the range, and nothing is cut. Size and ContentRange together are an error: they are two different claims about the same body. Without either one, a body that cannot seek is still sent whole with 200: the kit cannot invent the total of a Content-Range, and promising Range without knowing the size is worse than promising nothing.

An SVG served with the script turned off#

Refusing image/svg+xml inline is right — to the browser an SVG is a document with script, and serving script from your own origin is stored XSS by another name. But a white-label product accepts a logo in SVG, and that logo has to appear on the sign-in screen. NeutralizeScript is the explicit way to say "I know what an SVG is", without leaving the envelope:

return c.Send(logo.Name, body, logo.MIME, trilha.SendOpts{
	Inline:           true,
	NeutralizeScript: true,
})

The response goes out with the policy the kit imposes, the same for every app:

Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'

No script, no fetching anything, inline style (without it an SVG stops being a drawing), framed by this same origin. It is written over an app's own Security.CSP: here the policy is not hardening, it is the condition for the document to go out at all. And it opens the script door and no other — an application/zip inline stays refused, with or without the option, because what a viewer shows is a different list.

Pipe copies the status and a closed list of headers — Content-Type, Content-Disposition, Content-Length, Content-Encoding, Content-Range, Accept-Ranges, Cache-Control, ETag, Last-Modified, Expires, Vary, Age, X-Robots-Tag — plus the names in Config.PipeHeaders, for an upstream whose own header the app wants kept. Set-Cookie does not travel, listed or not, and neither do Content-Security-Policy and Strict-Transport-Security: the body of another service does not get to sit on this one's session or overrule its policy. For a whole prefix forwarded to another service, see Upstreams; Pipe is the one response you fetched yourself.

Language of the request#

CallWhat it answers
c.Locale() stringthe language of this request: "pt-BR", "ht", "fr"
c.SetLocale(l string)forces it for the rest of the request, writing no cookie
c.T(key string, args ...any) stringthe app's own message in that language

With Config.Locale alone it is one language for the whole process. With Config.Locales it is negotiated per request — the preference the app stored (Config.LocaleOf), ?lang=, the trilha_lang cookie it leaves behind, Accept-Language, the default — and resolved once: a page with fifty dates parses the header once. See Many languages for the order and the catalog.

func Page(c *trilha.Ctx) (h.Node, error) {
	c.SetTitle(c.T("atendimento.titulo"))
	return h.Div(
		h.P(h.Text(c.T("atendimento.bemvindo", nome))),
		h.P(h.Text(c.T("atendimento.protocolos", n))), // "1 protocolo pendente" / "4 pendentes"
		h.A(h.Href("?lang=ht"), h.Text("Kreyòl")),
	), nil
}

c.T looks the key up in c.Locale(), then in what Catalog.Fallback points at, then in the default locale. A key nobody defined comes back as the key and is logged once — it never comes back empty, and trilha check fails on it before a screen shows it. Without Config.Catalog, c.T returns the key: an app with one language needs no catalog to compile.

c.Link(name, opts) builds a signed URL that works with no session, and c.Claim(name) is what the route on the other side calls.

SymbolRole
c.Link(name, LinkOpts{...})the URL; name is the purpose, and a link for one purpose does not open another
LinkOpts.Datatravels inside the token: signed, not secret
LinkOpts.TTLzero is an hour; negative is an error, not a default
LinkOpts.Useszero is unlimited and needs no storage at all
c.Claim(name)checks signature, purpose, deadline and remaining uses
link.Consume()spends one use — after the work, never before
Config.Linksa trilha.LinkStore that counts the uses of limited links — one INCR compared against the limit, which is what makes Redis the natural one; nil counts in the process, which is honest about one replica and said once in the log
trilha.ErrNoLinkwhat Claim answers for a token that is invalid, not this link's, expired or used up — one error for all four on purpose

Every way a link can fail answers the same 404. Wrong signature, wrong purpose, expired, already spent: telling a stranger which of the four happened tells them how close they are. A wrong token also costs the address a point of a small budget, because guessing a token in a URL is brute force.

That budget is a refilling one and not a lockout, which is a deliberate difference from the issue that asked for this: an hour of blocking keyed by address turns one clumsy person behind an office NAT into an outage for everybody behind it, and the property that matters — guessing becomes infeasible — is the same either way.

With Uses: 0 there is no state. No row, no lookup, no cleanup: verifying is a signature check. That is the case of a verification code printed on a document, and it is why the common flow needs no table.

Reading is not spending. Claim checks that a use is left; only Consume takes one. A reload would otherwise burn the link somebody is still filling in, and a validation error would cost them the invitation.

See A public link for the whole flow.