view

package
v0.26.21 Latest Latest
Warning

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

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

README

view (core)

The SDK's server-side rendering domain (ADR 0058): the Renderer port, the Factory registry that resolves one engine by name, and the single trust type through which a caller can deliberately bypass escaping. The one engine that ships lives in internal/service/app/view.

type Renderer interface {
	Render(ctx context.Context, name string, data any) ([]byte, error)
	ContentType() string
}

Render returns bytes and never takes an io.Writer: template execution can fail halfway, and handed an http.ResponseWriter it would already have flushed the status line, the headers and a plausible prefix of the page before the failure is detectable — at which point 500 is no longer sendable.

TrustedHTML / TrustHTML are the ONE bypass and the only spelling the SDK offers. The other six html/template trust types — CSS, HTMLAttr, JS, JSStr, URL, Srcset — are refused in render data and have no spelling here at all. Read CLAUDE.md §Why this shape and ADR 0058 §D4 for what that buys and, more importantly, what it does not: a Trusted the caller built wrongly is an XSS the SDK cannot see.

Error ranges 0.2.27.*, and 0.3.57.* — the engine's construction failures, declared here since ADR 0160. Maintainer rationale lives in CLAUDE.md.

The ports — Factory and Renderer — are generated from design/app/view.yaml into design_gen.go (ADR 0163): a port changes in the design, then kit gen.

Documentation

Overview

Package view — ranges 0.2.27.* (ADR 0058 core/app/view block) and 0.3.57.* (ADR 0058 service/app/view block, declared here since ADR 0160).

Package view — the engine-neutral construction parameters.

Package view — declares the sentinel *errs.Error outcomes of the domain: the port's, and the html/template engine's construction failures. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

No Public string in this package names a template, a path, a line, a data key or a fragment of template source. A Public is written to third parties, and everything a template engine handles is either the application's internal structure or somebody's data. What an operator needs travels in Fields and in Private; what a browser gets is one sentence with nothing in it.

Package view — the plug-in contract an engine implements to enter the registry.

Package view — holds the process-wide Factory registry. Engine packages register themselves through package-level var initialisers when imported (no init()), mirroring core/data/codec and core/observe/logger/writer.

Package view — the one trust type this domain admits, and the only spelling the SDK offers for it.

Package view declares the SDK's server-side rendering DOMAIN: the Renderer port that turns a named template plus a data value into bytes, the Engine registry that resolves one implementation by name, and the single trust type through which a caller can deliberately bypass escaping. A core sibling admitted by ADR 0058.

The domain is HTML, and that is a security boundary rather than a scope note

html/template's entire value is that it escapes ACCORDING TO CONTEXT: the same value is escaped one way between two tags, another way inside an attribute, another inside a URL, and another inside a <script> block. text/template does none of that — it is string concatenation with a syntax — and the two packages are API-compatible, so swapping one import for the other compiles, passes every test, and ships stored XSS.

This domain therefore has no text/template representation at all. Not an Engine name, not a Factory, not a constructor, not an import: a named AST audit in internal/service/app/view fails the build if the identifier appears anywhere in the domain's three packages. The distinction is structural because a documented one is a distinction that survives exactly until the afternoon someone needs to render an email body.

Plain-text templating is out of scope, by name. If it is ever wanted it arrives as its own domain with its own port — never as a second Engine in this registry, because a registry exists to make its members interchangeable and an escaping engine is not interchangeable with a non-escaping one.

Rendering is all-or-nothing, and the signature is what enforces it

Renderer.Render returns a []byte. It does NOT take an io.Writer, and that is the decision this port is shaped by.

Template execution writes incrementally and can fail halfway: a nil pointer in a field chain, a method returning an error, a range over the wrong type. Handed an http.ResponseWriter, html/template will have flushed the status line, the headers and part of the body before it reports the failure — measured at seventeen bytes of a valid-looking page for a template failing on its second action — and at that point 500 is no longer sendable and the browser renders a truncated document. A caller cannot avoid this by being careful; it can only avoid it by not being given the opportunity.

So the engine renders into a bounded buffer it owns and hands back the complete bytes or nothing at all. The caller's destination is never touched on a failed render, because the caller's destination is never passed in.

Nothing here writes anywhere, which is how ADR 0030 is satisfied

ADR 0030 forbids an SDK default that writes to os.Stdout, because stdout may be the process's protocol channel. This domain does not satisfy that rule by choosing stderr; it satisfies it by having no destination in the port at all. A Renderer holds no writer, opens no file and knows no stream.

Where this domain stops

The SDK ships the extension point and one implementation of it. It does not ship a template LANGUAGE: layouts, block inheritance, helper libraries, in-template i18n, asset pipelines and distributed compilation caches are all framework opinions, and a domain that held them would be a rendering framework wearing a port's name. Template composition is whatever the chosen engine's own syntax already provides — for html/template that is {{define}} and {{template}}, which this package neither extends nor conventionalises.

Index

Constants

View Source
const CodeDuplicateEngine errs.Code = 0x00_02_1B_08 // 0.2.27.8

CodeDuplicateEngine identifies two distinct factories claiming one Engine name. It panics for the same boot-time reason as CodeEngineInvalid, and it matters more here than in a codec registry: silently letting the second registration win could swap the engine that escapes for one that does not.

View Source
const CodeEngineInvalid errs.Code = 0x00_02_1B_07 // 0.2.27.7

CodeEngineInvalid identifies a boot-time Register of a nil Factory or one claiming the empty Engine name. It panics: a broken registration is a programming error that must be visible at import, not a value to thread through a call chain nobody is executing yet.

View Source
const CodeEngineUnknown errs.Code = 0x00_02_1B_06 // 0.2.27.6

CodeEngineUnknown identifies an Open for an Engine no factory has registered — usually a name resolved from configuration with a typo in it, or an engine whose package was never imported.

View Source
const CodeRenderFailed errs.Code = 0x00_02_1B_03 // 0.2.27.3

CodeRenderFailed identifies a template that started executing and stopped — a nil pointer in a field chain, a method that returned an error, a range over the wrong type.

The engine's diagnostic for this is the single most leak-prone string in the domain. html/template renders it as, verbatim, `template: /srv/app/web/admin/page.html:1:7: executing "…" at <.User.Name>: nil pointer evaluating interface {}.Name` — an absolute filesystem path, a line and column, a fragment of the template's own source, and a Go type name. All of it is reconnaissance, all of it is Private.

View Source
const CodeRenderTooLarge errs.Code = 0x00_02_1B_04 // 0.2.27.4

CodeRenderTooLarge identifies a render stopped at Config.MaxBytes.

It is a denial-of-service guard, not a formatting complaint. A {{range}} over a collection an attacker can grow has no recursion depth, so the stdlib's own 100000-frame limit never fires; the byte cap is the only thing between that template and the machine's memory. The partial output is discarded, never returned.

View Source
const CodeTemplateNotFound errs.Code = 0x00_02_1B_02 // 0.2.27.2

CodeTemplateNotFound identifies a Render for a name the engine does not hold.

It is a normal, expected verdict rather than a broken renderer: a Renderer built over an empty tree is legitimate and refuses everything by name (ADR 0031). The name asked for is a Field, never the Public — a template name is part of the application's routing surface.

View Source
const CodeTemplateParseFailed errs.Code = 0x00_03_39_02 // 0.3.57.2

CodeTemplateParseFailed identifies a template file html/template refused to parse, or one it could not resolve a contextual escaping plan for.

Both are construction-time on purpose. html/template resolves escaping lazily, at a template's first execution, so an {{if}} whose branches end in different contexts or a {{template}} naming something absent would otherwise surface as a 500 on whichever request first reached that page.

View Source
const CodeTemplateSourceFailed errs.Code = 0x00_03_39_01 // 0.3.57.1

CodeTemplateSourceFailed identifies a template tree that could not be READ: a walk that failed, a file that could not be opened.

It is kept distinct from a parse failure because the two have different operators and different fixes. A source failure is usually a packaging bug — an embed pattern that matched nothing, an os.DirFS pointed at a path that does not exist in the container — and it is answered by looking at the build, not at the template.

View Source
const CodeUnsafeValue errs.Code = 0x00_02_1B_05 // 0.2.27.5

CodeUnsafeValue identifies render data carrying one of the six html/template trust types the domain refuses: CSS, HTMLAttr, JS, JSStr, URL or Srcset.

Each of them disables contextual escaping for the value it wraps, and each has a working exploit that plain strings do not: measured, template.URL("javascript:alert(1)") reaches the browser as href="javascript:alert%281%29" and executes, while the same string unconverted is rewritten to the inert "#ZgotmplZ".

View Source
const CodeViewMisconfigured errs.Code = 0x00_02_1B_01 // 0.2.27.1

CodeViewMisconfigured identifies a Renderer refused AT CONSTRUCTION because its Config cannot be honoured — most importantly a nil FS.

Every permanent fault a template tree can carry is discovered here, on purpose: a tree that does not parse, or a template html/template cannot contextually escape, will fail identically on every request forever, and a deploy that refuses to come up is a cheaper way to learn that than a 500 on the one page nobody smoke-tests.

View Source
const ContentTypeHTML string = "text/html; charset=utf-8"

ContentTypeHTML is the value an HTML Renderer reports from ContentType.

The charset is not decoration. A response whose Content-Type carries no charset is sniffed by the browser, and a document sniffed as UTF-7 or as a legacy multi-byte encoding can smuggle markup past an escaper that judged the bytes as UTF-8.

View Source
const DefaultMaxBytes int = 8 << 20

DefaultMaxBytes is the ceiling Config.MaxBytes clamps to when it is not positive: 8 MiB.

A rendered HTML document larger than this is a defect or an attack. The value is generous by an order of magnitude for a page a browser is meant to parse, and it is a CLAMP rather than a refusal because — unlike a lock's TTL, whose two readings are opposites (ADR 0031, ADR 0052) — there is a defensible universal answer here, and refusing view.Config{FS: templates} would make the obvious spelling unusable for no safety gain.

View Source
const MaxPooledBytes int = 1 << 20

MaxPooledBytes is the largest scratch buffer the engine keeps for reuse.

A single 8 MiB render must not leave 8 MiB pinned in a pool for the life of the process; anything above this is returned to the collector instead.

Variables

View Source
var (
	// ViewMisconfigured is returned by a [Factory]'s New for a [Config] it
	// cannot honour: a nil FS, a template tree that does not parse, or a
	// template html/template cannot contextually escape.
	//
	// The escaping half is the one worth naming. html/template resolves a
	// template's escaping context lazily, at its FIRST execution, and reports
	// three classes of failure there — a template that ends inside an
	// unterminated tag, an {{if}} whose branches end in different contexts, and
	// a {{template}} naming something that does not exist. Left alone, all
	// three are 500s on a live request. The engine therefore forces escaping at
	// construction and turns them into this.
	ViewMisconfigured = errs.Static(CodeViewMisconfigured, "VIEW_MISCONFIGURED",
		"The view renderer cannot be built from the given configuration",
		"core/app/view: a Factory received a nil FS, a template that does not parse, or one html/template cannot contextually escape; the fields name the template and the private message carries the engine's own diagnostic",
		0,
		78)

	// TemplateNotFound is returned by Render for a name the engine does not
	// hold, including the empty name.
	//
	// It is deliberately not a construction-time concern: a Renderer over an
	// empty tree is legitimate and refuses everything by name. That is ADR
	// 0031's shape — the configuration nobody filled in produces a loud,
	// typed refusal on every call rather than an inert renderer that returns
	// empty pages.
	TemplateNotFound = errs.Static(CodeTemplateNotFound, "TEMPLATE_NOT_FOUND",
		"The requested view does not exist",
		"core/app/view: Render was called with a name absent from the parsed set; the fields carry the name asked for and the number of templates the renderer holds",
		500,
		0)

	// RenderFailed is returned when execution started and could not finish.
	//
	// Nothing is returned alongside it. The bytes produced before the failure
	// are discarded inside the engine, which is the whole reason Render returns
	// a slice instead of taking a writer: handed an http.ResponseWriter, the
	// same failure would have flushed the status line, the headers and a
	// plausible-looking prefix of the page before it was detectable — measured
	// at 17 bytes for a template that fails on its second action.
	RenderFailed = errs.Static(CodeRenderFailed, "RENDER_FAILED",
		"The page could not be rendered",
		"core/app/view: template execution failed after it began; the private message carries html/template's own diagnostic, which names the file path, the line, the column and a fragment of template source and must never be shown to a caller",
		500,
		70)

	// RenderTooLarge is returned when a render reached [Config.MaxBytes].
	//
	// The engine stops at the ceiling rather than after it: the limit is
	// enforced by the writer html/template is executing into, so the bytes are
	// never accumulated in the first place and the process does not have to
	// survive the allocation in order to reject it.
	RenderTooLarge = errs.Static(CodeRenderTooLarge, "RENDER_TOO_LARGE",
		"The page could not be rendered",
		"core/app/view: rendering reached Config.MaxBytes and was stopped at the ceiling; the fields carry the limit and the template name",
		500,
		0)

	// UnsafeValue is returned when render data carries one of the six refused
	// html/template trust types.
	//
	// It is a programming error surfaced as a runtime verdict, and it is
	// refused BEFORE the template runs so no partial page exists to reason
	// about. The field naming the location is a path — data.Items[3].Body,
	// built from GO field names because that is the vocabulary a template
	// author reads and a developer greps — because "an unsafe value is
	// somewhere in your model" is not something a developer can act on at
	// three in the morning.
	UnsafeValue = errs.Static(CodeUnsafeValue, "UNSAFE_VALUE",
		"The page could not be rendered",
		"core/app/view: render data carries an html/template trust type that disables contextual escaping (CSS, HTMLAttr, JS, JSStr, URL or Srcset); the fields name the path and the type, and view.TrustHTML is the only trusted spelling the SDK offers",
		500,
		70)

	// EngineUnknown is returned by [Open] for a name no factory claims.
	//
	// It carries the registered names in a Field so the usual cause — an engine
	// package that was never imported, so its registration never ran — is
	// answerable from one log line.
	EngineUnknown = errs.Static(CodeEngineUnknown, "ENGINE_UNKNOWN",
		"No view engine is registered under that name",
		"core/app/view: Open received an Engine no Factory has registered; the fields carry the requested name and the registered ones, and the usual cause is an unimported engine package",
		0,
		78)

	// EngineInvalid is the boot-time panic for a nil Factory or an empty
	// [Engine] name.
	EngineInvalid = errs.Static(CodeEngineInvalid, "ENGINE_INVALID",
		"The view engine registration is not usable",
		"core/app/view: Register received a nil Factory or one claiming the empty Engine name; the empty name is what an uninitialised variable holds and is refused at boot so it can never reach Lookup",
		0,
		78)

	// DuplicateEngine is the boot-time panic for two distinct factories under
	// one [Engine] name.
	//
	// Re-registering the SAME factory is idempotent and silent; two different
	// ones are a hard conflict, because last-write-wins in THIS registry can
	// mean the engine that escapes was replaced by one that does not, at import
	// time, with no call site to blame.
	DuplicateEngine = errs.Static(CodeDuplicateEngine, "DUPLICATE_ENGINE",
		"Two view engines claim the same name",
		"core/app/view: a second, distinct Factory registered under an Engine name already taken; refused at boot rather than resolved, since one of the two would silently decide how every value in the program is escaped",
		0,
		78)

	// TemplateSourceFailed is returned by the constructor when the template FS
	// could not be read.
	TemplateSourceFailed = errs.Static(CodeTemplateSourceFailed, "TEMPLATE_SOURCE_FAILED",
		"The view templates could not be read",
		"service/app/view: walking or reading Config.FS failed; the fields carry the path that failed and the underlying error, and the usual cause is an embed pattern that matched nothing",
		0,
		78)

	// TemplateParseFailed is returned by the constructor for a file
	// html/template refused to parse or could not build an escaping plan for.
	TemplateParseFailed = errs.Static(CodeTemplateParseFailed, "TEMPLATE_PARSE_FAILED",
		"A view template is not valid",
		"service/app/view: html/template refused a template at parse time or could not resolve its contextual escaping context; the fields carry the template name and the engine's own diagnostic",
		0,
		78)
)

Functions

This section is empty.

Types

type Config

type Config struct {
	// FS is the tree the templates are read from. Required.
	//
	// It is an fs.FS rather than a directory path so that embed.FS, os.DirFS
	// and fstest.MapFS are all first-class, and so that the domain never
	// touches the filesystem API. It also removes path traversal from the
	// problem statement: fs.ValidPath rejects "..", so no template name can
	// address a file outside the tree the caller handed over.
	//
	// Every regular file under FS is parsed, and each template is named by its
	// SLASH PATH relative to the root — "admin/page.html", not "page.html".
	// See [Factory] for why that is not a cosmetic choice.
	FS fs.FS

	// Ext optionally restricts which files are parsed, by extension including
	// the dot (".html", ".gohtml").
	//
	// Empty means every regular file in FS, which is the honest default: the
	// caller already chose the tree, and second-guessing its contents is the
	// SDK inventing a naming convention. Set it when templates share a
	// directory with assets.
	Ext []string

	// MaxBytes caps a single render's output. Not positive clamps to
	// [DefaultMaxBytes]; there is deliberately no spelling for "unlimited".
	//
	// The cap is the only bound that exists. text/template's Execute takes no
	// context, so a render cannot be cancelled, and the stdlib's own guard is a
	// recursion DEPTH limit (100000 frames) that says nothing about output
	// size: a {{range}} over an attacker-influenced collection has no depth at
	// all and will write until the machine stops. The same "no unbounded
	// setting" reasoning applies here as to the WebSocket ceilings in ADR 0047.
	MaxBytes int
}

Config parameterises every Factory. It is deliberately small: this domain ships an extension point, not a template language.

type Engine

type Engine string

Engine names a registered template engine.

The zero value is the reserved invalid name: Register refuses it at boot, so it can never reach Lookup, Open or Available.

const HTML Engine = "html"

HTML is the Engine name of the stdlib html/template implementation in internal/service/app/view. It is the only engine the SDK ships.

func Available

func Available() []Engine

Available returns every registered Engine, sorted, so a caller can print what its imports actually wired up. It is nil before any Register.

type Factory

type Factory interface {
	// Engine returns the registry key this factory claims. It must be stable
	// and non-empty.
	Engine() Engine
	// New builds a Renderer from cfg, parsing the whole template tree eagerly.
	//
	// Eager parsing is the point: a template that cannot be parsed, or cannot
	// be contextually escaped, will fail identically forever, so the failure
	// belongs in the process's first second — a deploy that does not come up —
	// rather than in the first request that happens to reach that page.
	New(cfg Config) (Renderer, error)
}

Factory builds a Renderer of one engine from a Config.

Two methods, and the registry hands instances back to callers so each engine keeps its concrete type unexported — the stable contract is this interface.

What an engine undertakes by registering

Registration is not merely a name binding; it is a promise the security model rests on. An engine in this registry MUST:

  • Contextually escape every value it renders, for the media type its Renderer.ContentType reports. An engine that does not escape may not report an HTML content type.
  • Render into its own buffer and return complete bytes or none.
  • Honour Config.MaxBytes, clamping a non-positive value to DefaultMaxBytes.
  • Refuse at construction a Config it cannot honour, and refuse at render time — by name, with TemplateNotFound — a template it does not hold.
  • Name every template by its SLASH PATH within Config.FS.

That last one looks cosmetic and is not. html/template's own ParseFS names templates by filepath.Base, so a tree holding admin/page.html and user/page.html ends up with ONE template called "page.html" — measured, the second parse silently wins and every request for the admin page renders the user page. Full-path naming is the SDK refusing to inherit that.

func Lookup

func Lookup(name Engine) (factory Factory, found bool)

Lookup returns the Factory registered under name.

IFACE-PLUGIN: the registry hands plug-in Factory instances back to callers so each engine keeps its concrete type unexported; the stable contract is the Factory interface itself.

func Register

func Register(f Factory) Factory

Register inserts f under f.Engine() and returns it, so an engine package can bind the singleton to a typed package-level variable:

var Engine = view.Register(htmlFactory{})

It panics on a nil factory, on the empty Engine name, and when a DISTINCT factory already claims the name. Re-registering the same factory is a no-op.

IFACE-PLUGIN: the registry hands plug-in Factory instances back to callers so each engine keeps its concrete type unexported; the stable contract is the Factory interface itself.

"Nil" here means UNUSABLE, not only an untyped nil: a typed nil pointer and a plug-in whose type is not comparable both satisfy the port and neither can serve one call (see internal/kernel/plugin).

type Renderer

type Renderer interface {
	// Render executes the template registered under name against data and
	// returns the complete document.
	//
	// It returns ([]byte, error) rather than writing to an io.Writer so that a
	// failure mid-execution cannot leave a half-written response behind — see
	// the package documentation. On any error the returned slice is nil.
	//
	// ctx is checked before the work starts and is otherwise not consulted:
	// text/template's Execute takes no context and cannot be interrupted, so a
	// runaway template is bounded by BYTES, not by time. That is the reason
	// Config.MaxBytes exists and the reason it has no "unlimited" spelling.
	//
	// name is looked up exactly. A name the engine does not hold is
	// [TemplateNotFound] — a Renderer built over an empty template set is a
	// legitimate Renderer that refuses everything by name, which is a loud
	// behaviour rather than an inert one (ADR 0031).
	Render(ctx context.Context, name string, data any) ([]byte, error)
	// ContentType returns the media type the bytes from Render carry, ready to
	// be written as a Content-Type header — [ContentTypeHTML] for every engine
	// registered today.
	//
	// It is a method rather than a package constant because it is the promise
	// an engine makes about the escaping it performs. An engine that does not
	// contextually escape HTML may not report an HTML content type, so a
	// framework that writes this header verbatim (with X-Content-Type-Options:
	// nosniff, which is the framework's job and not the SDK's) never labels
	// unescaped output as a document the browser will parse as HTML.
	ContentType() string
}

Renderer turns a named template and a data value into a complete document.

The method set is FROZEN at two. This interface is aliased by pkg/v1/app/view, Go interfaces are structural, and a third method would break every downstream implementation at compile time with no deprecation window (ADR 0039). New capabilities arrive as SIBLING interfaces reached by type assertion — the model is codec's Appender (ADR 0037).

func Open

func Open(name Engine, cfg Config) (renderer Renderer, err error)

Open resolves name and builds a Renderer from cfg.

It is the config-driven entry point, and it fails loudly on a name nobody claims — EngineUnknown carrying the registered names — rather than falling back to a default. A fallback here would mean a typo in a configuration file silently choosing how every value in the program is escaped.

IFACE-PLUGIN: the engine's concrete Renderer stays unexported in internal/service/app/view; the stable contract is the Renderer interface.

type TrustedHTML

type TrustedHTML = template.HTML

TrustedHTML is an HTML fragment that the engine writes WITHOUT escaping.

It is deliberately an ALIAS of html/template.HTML rather than a defined type of its own. That is not a convenience: html/template recognises its trust types by an exact type switch, so a `type TrustedHTML string` would be escaped like any other string — measured, it renders <b>hi</b> as &lt;b&gt;hi&lt;/b&gt; — and a trust type that does not actually confer trust is worse than none, because callers stop looking for the reason their markup disappeared.

The alias has a consequence that is stated rather than hidden: because TrustedHTML and html/template.HTML are the same type, the engine cannot tell a fragment marked through TrustHTML from one a caller converted directly. What the SDK buys is therefore precise, and it is worth naming exactly:

  • Six of html/template's seven trust types — CSS, HTMLAttr, JS, JSStr, URL and Srcset — are REFUSED outright when they appear in render data, with the path that carries them named in the error. Those six have no SDK spelling at all.
  • The seventh has one canonical, greppable spelling, TrustHTML, so "where does this codebase decide to trust markup?" is one grep with one answer rather than a hunt through an import list.

A build-time ban on the raw conversion in CONSUMER code belongs in tools/sdkguard (ADR 0033), which already reads consumer source for exactly this class of rule. It is not shipped here.

func TrustHTML

func TrustHTML(s string) TrustedHTML

TrustHTML marks s as HTML that is already safe to write unescaped.

Every call is an assertion that s was produced by the server and sanitised — a Markdown renderer's output, a fragment from a template the same program authored. It is never correct to hand it a string that arrived from a request, a database field a user controls, or a third-party API.

The function exists because the conversion it performs needs a NAME. A reviewer scanning a diff for `template.HTML(` has to know that identifier matters; a reviewer scanning for `TrustHTML` is reading the word "trust" in the SDK's own vocabulary, at the call site where the decision was actually made.

Jump to

Keyboard shortcuts

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