App specs
HTML block props use HTML boolean semantics: false omits presence attributes
such as disabled, checked, required, and multiple; true emits them.
ARIA and data booleans remain string values.
A link block renders its text before its child blocks; declaring text does
not discard child markup.
Prop names for renderer-owned attributes are case-sensitive. Case-variant
extras such as ID or Class cannot override the canonical id or class.
Generation rejects image blocks without src, fieldsets without legend,
text-only labels without for, and node-rendered links without text,
children, or a nonblank aria-label or aria-labelledby prop. A plain
{type: link, href: /docs} block uses its href as the label; adding kind,
props, actions, an island, or a widget selects the node renderer and requires
content or one of those accessible-name props. The low-level node renderer
degrades incomplete nodes instead of panicking:
an image is omitted, an empty-legend fieldset stays a fieldset, a label may
remain unassociated, and a link without href becomes text content.
With app.auth enabled, a screen cannot use {base_path}/me (including
a trailing slash): that GET route belongs to the auth JSON API. The
default auth base path is /auth; GET login pages remain available.
A gofastr.yml app spec is an optional way to scaffold a GoFastr app:
declare your entities, screens, nav, seed data, and endpoint/middleware
stubs in one file, then generate owned Go from it. The app spec is not
a source of truth: after scaffolding, the generated Go is canonical and
you can delete gofastr.yml. Generate with:
gofastr validate gofastr.yml gofastr generate app gofastr.yml gofastr generate app specs/ --dry-run --json
App specs are not runtime declarations: the CLI reads .yml, .yaml, or
.json app spec files (or a directory of them), validates them, and scaffolds
owned Go into an idiomatic, module-root layout by default: a flat
package main at the root (main.go, app.go, screens_register.go, one
screen_<name>.go per screen, and, when needed, a thin extensions.go or
stubs.go) plus the entities/ package (set --out=<dir> or
app.output_dir to scaffold into a subpackage instead).
generate app is one-shot: it refuses to overwrite an existing project
(pass --force), because the emitted code is yours to own. --add is the
additive alternative: it writes only the new files from a partial yml, never
overwriting. See Additive generation.
A directory of app spec files loads in file-name order and merges into one
spec. List sections (entities, screens, nav, seed, endpoints,
hooks, middleware, plugins, helpers) are concatenated. The app:
blocks merge key by key, so auth: can live in one file and theme: in
another; a key that two files both set (app.name, app.auth.enabled,
app.theme.primary) is an error naming both files.
--force overwrites everything, so it tells you what it is destroying.
Every file the generator writes in a format with comments carries a
provenance marker with a sum of its content (JSON and binary files such
as fonts carry none; see Codegen); before
writing, --force names every target whose content no longer matches its
sum, since a mismatch means someone edited that file after scaffolding. A
marked file you never touched is not listed, even when today's template
differs. A file with no marker (written before markers, or by hand) is
listed when it differs from what replaces it.
⚠ --force is overwriting 2 file(s) edited since they were generated: the edits are being discarded: ⚠ app.go ⚠ resource.go ⚠ Edits you never committed are gone. If this code is maintained by hand, restore the committed version with `git restore <file>` and do not regenerate it.
It warns rather than stops: you asked to overwrite. Regenerating an app you
have edited by hand is almost always a mistake; reach for --add instead, or
keep the app spec as a record and leave the code alone.
At runtime your app
registers the generated entity package (entities.RegisterAll(app)). There
is no file-based runtime loader. The app spec's entities: list uses the same
entity shape and field types documented in
Entity Declarations.
The app spec root keys are app, entities, screens, nav, seed,
endpoints, hooks, middleware, plugins, and helpers. Project
configuration (codegen, contracts, isolation) is not part of the app: it
lives in gofastr.settings.yml.
app is a scaffold generator, one of the kinds described in
Codegen: gofastr generate app [spec] runs it (the spec
defaults to gofastr.yml). Plain gofastr generate, which gofastr build
calls, never does. It runs the codegen: section of
gofastr.settings.yml, and that section lists
derived generators and extensions only, so a build cannot regenerate the app
over code you have edited.
What the app generator writes vs. what you hand-write
The app generator scaffolds a working app, then gets out of the way. This table is what it produces today; everything else is ordinary owned Go you write against the framework (the emitted code is the starting point, not a ceiling).
| Area | Generated from the app spec | Hand-written Go |
|---|---|---|
| Marketing pages | hero, page_header, card, pricing, stat_row, callout, markdown, link_button blocks composed from framework/ui; auth-aware header | Any bespoke section not in the block catalog |
| Auth | Register/login/logout/me wiring, session middleware, login_form/signup_form screens, guest-only redirects, bootstrap admin | Custom auth flows (SSO, MFA policy, email verification UX) |
| Dashboard stat/chart blocks | stat_card (count/sum), bar_chart/pie_chart/line_chart bound to an entity source: {entity, group_by} | Custom metrics, multi-entity joins, computed series |
| Entity list | entity_list block drawn by framework/entityui from the entity's display:: server-side search/sort/pagination as query params, one-click facets:, optional "New" button | Filtering on computed/derived columns, range/date filters, multi-select facets |
| Entity record | entity_detail block: the record page holds the edit form and a button per states: move; create: synthesizes <list>/create | Multi-step wizards, custom field widgets, cross-field validation UX |
| Child-collection islands | — (not generated) | Hand-written; see Interactive patterns for the island RPC recipe |
| Admin back-office | Full CRUD admin over every entity (app.admin), role-gated | Custom admin actions beyond CRUD |
| Seed data | Explicit rows: + auto-generated count:/weights: demo rows | Fixtures with complex relations / business invariants |
| Theme + dark mode | Color + font tokens, dark sub-map, header theme toggle | Per-component theme overrides |
| Fonts | Self-hosted webfonts fetched to static/fonts/ at generate time | Supplying .woff2 files when generating offline |
app.theme can override canonical color tokens for generated UI apps. Supported
keys are primary, primary-fg, secondary, background, surface,
surface-soft, text, text-muted, text-subtle, border,
border-strong, accent, success, warning, danger, and info.
It also accepts the font tokens font_heading, font_body, and
font_display: named Google Fonts families that drive the --font-*
tokens. An app.theme.dark sub-map overrides any of the same color
tokens for the dark scheme (the header's theme toggle flips to it).
The map is an overlay on the framework's complete dark palette
(framework/ui/theme), so a token it leaves out takes the framework's
dark value, not the light one. A fill it overrides without its ink
(primary without primary-fg, likewise danger) keeps the light
ink, so set the -fg token when the dark fill needs a different one.
A theme with only a dark: map is still emitted.
Generated apps call site.WithTheme(...), so the values are emitted
through /__gofastr/app.css as computed CSS custom properties.
A declared app.theme starts from the light-only style.DefaultTheme()
and has a dark scheme only when app.theme.dark declares one, so a
stock dark palette never replaces the brand. With no app.theme, a
marketing app starts from theme.Default() and follows the OS into
the framework's dark palette.
Fonts are self-hosted, not CDN-linked. The generated app ships a
strict Content-Security-Policy (default-src 'self') that deliberately
blocks the Google Fonts CDN, so a <link> to fonts.googleapis.com
would be refused at runtime. Instead, generate fetches each named
family's latin woff2 subset at generate time and writes it to
static/fonts/<slug>.woff2 (the <slug> is the lower-cased,
hyphenated family name, e.g. Bricolage Grotesque →
bricolage-grotesque.woff2). The emitted fontFaceCSS @font-face
rules point at /fonts/<slug>.woff2, which the app serves from that
static dir, so a named font just works in a fresh app with no manual
step. When no static_dir is set but the theme names a font, the
generator defaults it to static/.
Generation may run offline. If the fetch fails, the app is still emitted
(it falls back to system fonts) but generate prints a loud warning
naming the exact static/fonts/*.woff2 files you must supply yourself.
The generated main.go also boot-checks for those files and logs a
warning at startup if one is missing. There is no silent 404 path.
Entity APIs live under /api; screens own the bare path
app.api_prefix (default "api") is the URL prefix every entity's JSON
CRUD API mounts under, so GET /api/posts returns JSON while the bare
/posts path is free for an HTML screen. Without this, a screen routed at
/posts would collide with, and be shadowed by, the auto-generated CRUD
handler at the same path. MCP tools and the OpenAPI spec follow the prefix
automatically. Set api_prefix: "" to mount entity APIs at the bare
/<table> (the historical behavior); a screen must then not reuse an entity
path. The generated entity_list / entity_form / entity_detail blocks
fetch and submit against <api_prefix>/<table> accordingly, where the
table is the entity's table:` or, without one, its name in snake_case.
YAML subset
GoFastr ships its own small YAML parser in core/yaml; no external YAML parser
is required. Supported syntax:
- indentation-based maps and lists, including a mapping value whose list
dashes align with its key (
items:\n- a\n- b) - strings, quoted strings, booleans, numbers, and
null. A double-quoted string takes the YAML 1.2 escapes (\n,\t,\",\\,\/,\e,\xE9,é,\U0001F600and the rest of the table);\xNNis the character U+00NN, and an escape YAML does not define, such as Go's octal\101, is an error - inline scalar lists such as
[draft, published]; a trailing comma ([draft, published,]) adds no item, an empty item ([a,,b]) is an error, and an empty string is written"" - comments starting with
#outside quoted strings, at the start of a line or after a space or tab - a leading byte order mark, which is skipped
Plain (unquoted) scalars resolve by the YAML 1.2 core schema, so a file reads the same here as in any other YAML tool:
Constraint flags (fields[].required, fields[].unique, and
indices[].unique) accept only booleans or the quoted strings "true"
and "false". Values such as yes, on, 1, null, or a list are errors,
not silently disabled constraints. Omitted flags still default to false.
true,True,TRUEand the threefalsespellings are booleans;null,Null,NULLand~are null. Any other casing (tRuE) is a string.- a run of digits is an integer; a decimal with a fraction or exponent
(
1.5,.5,-2.5e3) is a float. A float too large for 64 bits (1e999) is an error, not infinity. Go-only spellings stay strings: a hex float (0x1.8p1), digit underscores (1_000.5),Infinity..infand.nanare strings too: nothing in a spec reads one. - a value starting with
!is a tag, and tags are refused: the core!!strand a local!env HOMEalike. Quote it ("!important") for the string.
Unsupported syntax fails with line/column errors. Inline maps, anchors, aliases,
block scalars, tabs for indentation, a nested list on its parent's dash line
(- - a), and mixed advanced YAML forms are not part of the app spec format.
No inline (flow-style) maps. {type: relation, to: users} is invalid;
every map must be written with indented key: value lines:
# Invalid: inline map - {name: author_id, type: relation, to: users} # Valid: indented map - name: author_id type: relation to: users
Shape
app: name: Demo description: Freight quotes in seconds. # site meta description (derived from entities when omitted) base_url: https://demo.example # sitemap origin fallback; APP_BASE_URL env wins module: example.com/demo # Explicitly expose /openapi.json. Omit this to keep schema discovery # authenticated by default. public_openapi: false theme: background: "#101820" primary: "#F2AA4C" text: "#F7F4EA" db: driver: sqlite url: file:demo.db entities: - name: users fields: - name: email type: string required: true unique: true - name: posts crud: true mcp: true access: read: posts:read create: posts:write update: posts:write delete: posts:admin cursor_field: id cursor_fields: [created_at, id] properties: label: Posts icon: newspaper indices: - name: idx_posts_status columns: [status] fields: - name: title type: string required: true max: 120 - name: status type: enum values: [draft, published] - name: author_id type: relation to: users relations: - type: belongs_to name: author entity: users foreign_key: author_id screens: - name: home route: / title: Home description: Demo homepage body: - type: heading level: 1 text: Demo - type: paragraph text: Generated from YAML. - type: link text: Docs href: /docs/ - kind: section props: class: details-section children: - kind: heading props: level: 3 text: Details - kind: div island: live_status props: class: live-status children: - kind: paragraph props: text: Island content - kind: button widget: save_button props: id: save-action text: Save actions: - name: save_click event: click client_js: "G.updateText('[data-action-result]', 'Saved')" - kind: paragraph props: text: Waiting data-action-result: true endpoints: - name: publish_post entity: posts method: POST path: "{id}/publish" handler: entities/posts_endpoints.go#PublishPost description: Publish a draft post. hooks: - id: posts_before_create_validate entity: posts when: before_create handler: postsBeforeCreateValidate description: reject posts with an empty title middleware: - request_logger plugins: - name: analytics helpers: - name: normalize_slug
Custom endpoints (endpoints)
Each entry is exactly an entry of the endpoints: section of
gofastr.settings.yml, the same keys with the same rules:
name, entity (required), method, path (relative to the entity's
route, so {id}/publish serves POST /api/posts/{id}/publish), handler
(a link, entities/<file>.go#<Name>), description, mcp, and public.
Endpoints has the full contract.
generate app writes, for each endpoint:
- the entry in the
endpoints:section ofgofastr.settings.yml, merged into the file so its other sections stay as they are; - the linked handler file, with an input struct holding the route's path values and a typed handler that answers 501 Not Implemented until you write it;
entities/endpoints.gen.go, which registers every endpoint, and a typed call for each in the generated client.
From then on the settings file is where the endpoints live: add, change or
remove one there and run gofastr generate. Two endpoints cannot link the
same handler, and a handler cannot live in a file generate app writes for
an entity (entities/posts.go); link a file of its own.
An entity does not list endpoints: under itself, and a path does not
start with /. A route that hangs under no entity (a health check, a
webhook) is not an app spec endpoint: register it in owned Go with
fwApp.Router().Handle.
Lifecycle hooks (hooks)
Each entry declares one entity lifecycle hook and the owned-Go func that implements it:
| Key | Meaning |
|---|---|
id | Stable identifier; also the app spec's name for the hook |
entity | The entity whose lifecycle the hook runs on |
when | before_create, after_create, before_update, after_update, before_delete, after_delete, before_list, after_list |
handler | The Go func to write, registered via framework/hook |
description | What the hook is for |
gofastr generate emits a stub for each handler in stubs.go (the
framework.HookFunc shape, func(ctx context.Context, data any) error)
and registers it in main.go on the entity's hook registry at the named
lifecycle point, so the surface exists the moment the app generates and
you fill in the action. Validation refuses a hook whose entity is not
declared, whose when is outside the list above, or whose handler is
missing, is not a Go identifier, or is shared with another hook: the
kiln preview refuses the same shapes, so a hook that ran in the preview
cannot silently vanish from the shipped app. See
Lifecycle hooks for the registration API.
kiln freeze emits this section from the world's hooks, so a validation
or audit hook the operator watched run in the live preview arrives in the
graduation app spec instead of disappearing.
Generated output
Entity declarations reuse the existing entities package generator, including
owner_field, cross_owner_read (role-based cross-owner read; requires
owner_field), search_fields (multi-field free-text ?q= search),
per-operation access RBAC, cursor settings, indices, and properties.
The access map (keys read, create, update, delete;
each value a permission string, or anyone) is emitted as Access:
framework.AccessControl{...} in the generated registration, with
anyone written as framework.Anyone. A write left out of a declared
access: block is refused to every caller; create: anyone is the
public contact-form shape. See
entity-declarations for the semantics and
access-control for wiring roles + policy. Custom
endpoints generate the files listed in
Custom endpoints.
When app.module is present, app spec generation also emits a
main.go: a runnable app entrypoint that opens the configured SQLite
database, registers generated entities, wires generated
screens/middleware/plugins through RegisterGenerated (in
app.go, same package main), mounts the UI host, and serves
app.static_dir through the generated UI host.
The generated app mounts MCP by default: framework.WithMCP() mounts
/mcp (POST JSON-RPC + GET SSE) plus the discovery well-knowns
(/mcp/server-card, /.well-known/mcp/server-card.json,
/.well-known/mcp/catalog.json, /.well-known/mcp.json), serving the per-entity CRUD tools
(mcp: true) alongside the framework.WithMCPIntrospection() set
(app_routes, app_readiness, app_config, …) and the
framework.WithMCPTools(mcptools.Register) set (framework_docs_list, framework_docs_get,
framework_docs_search over the embedded framework docs). Those tools
are read-only but let a caller read the app's routes and config. Remove
WithMCPIntrospection() from main.go if /mcp is reachable by
untrusted callers in production; drop framework.WithMCPTools(mcptools.Register) and its
import to leave the docs corpus out of the binary.
The generated main.go also registers battery/log with its canonical
zero config: per-app file sink, access log, panic recovery. Under
gofastr dev the framework additionally auto-enables the mutating
control tools (app_module_enable / app_module_disable), the log
debug tools (log_recent, log_filter, log_metrics,
log_set_level), and MCP data tools for every CRUD entity (even
without mcp: true), so a connected agent can read recent requests
and errors, read and write app data, and toggle modules during
development, none of which registers on a production boot. See
agent-ready and dev-livereload.
Generated screen files
Screens are emitted one file per screen, behind a fixed seam, never as one
aggregated screens.go:
screens_register.go: a fixed seam that names no screen, entity, or app. It definesscreenRegistrar{order int, fn func(fwApp *framework.App, site *app.App, db *sql.DB)}, ascreenRegistrarsslice, andmountGenerated(...), which sorts the registrars byorderand runs them. Byte-identical for any screen/entity count. Add a screen by dropping in a newscreen_<name>.go; never edit the seam.screen_<snake>.go: one per authored non-CRUD screen (marketing pages, dashboards, auth forms): the screen struct, itsScreen*methods,RenderCtx, amount<Screen>func holding thesite.Register/site.RegisterScreencall, and aninit()that appends onescreenRegistrars = append(screenRegistrars, screenRegistrar{order: N, fn: mount<Screen>}).screen_<entity>_crud.go: one per entity with CRUD screens (or referenced by a data source): that entity's list/detail/create screens and their mount funcs, each drawing throughappUI(appUI.List("<entity>"),appUI.Record,appUI.Create).
app.go still owns layouts, nav, theme, authPolicy/guestPolicy, auth,
toasts, and endpoints. appLayout/marketingLayout are package-level vars
assigned in RegisterGenerated, which calls mountGenerated(fwApp, site, db)
and names no screen type. The generated screen order is
recovered by gofastr pack from each file's screenRegistrar{order: …}; see
Packing.
Translations (i18n.go, locales/)
Every word the app spec writes for a reader (a title, a hero line, a
button label, a nav entry, a footer column, an auth error) is emitted as
an i18n.Phrase declared at the top of the file that says it, and the
screen reads it with the request's context:
var ( homeScreenTextSeeYourRevenue = i18n.Define("meridian.screens.home.see_your_revenue", "See your revenue.") ) func (s *HomeScreen) RenderCtx(ctx context.Context) render.HTML { return ui.Hero(ui.HeroConfig{Title: homeScreenTextSeeYourRevenue.Text(ctx)}) }
The key is <app>.screens.<screen>.<slug> in a screen file,
<app>.screens.<entity>_crud.<slug> in an entity's crud file,
<app>.chrome.<slug> in app.go and <app>.auth.<code> in
auth_hooks.go. The slug is the English's first four words; the same
English twice in one file is one phrase. <screen> is the name
lower-cased to ASCII letters and digits; when two screens fold to the
same one (PostsList and postslist), the later one in the spec gets
_2. Field labels go through
i18nui.FieldLabel and enum values through i18nui.FieldValueLabel, so
they read the entity.* keys the admin reads.
With app.module set the generator also writes:
i18n.go:translatorOption(), whichmain.goinstalls. It embedslocales/, loads every<tag>.jsonthere withi18n.LoadJSONCatalog, and adds theen-XApseudo-locale whenGOFASTR_PSEUDO_LOCALE=1.i18n_test.go:TestCatalogsMatchPhrasesrunsi18n.CheckCatalogover everylocales/<tag>.json, sogo testfails on a key no phrase defines or a translation that drops a{{placeholder}}.entity.*keys name the app's data and are skipped. Addi18n.Require("<app>.")to fail on a phrase a locale has not translated yet.locales/README.md: what goes in the directory. It also keeps the directory non-empty, whichgo:embedrequires.
The generator never writes a <tag>.json, so generate --force keeps
every translation. Add locales/es.json keyed like the phrases (dots
nest) and the browser's Accept-Language picks it. See
i18n for the catalog format and i18n.CheckCatalog.
Additive generation (--add)
gofastr generate app <yml> --add is additive: it reads any partial yml
(e.g. just two new entities, or a couple of new screens, not the canonical
gofastr.yml) and emits only
the new full-fidelity pieces into an existing project, never touching owned
files. It never overwrites anything: if a target file already exists, it is
skipped (and listed). In an empty directory, --add produces the same output
as plain generate.
# Add two entities to an existing project from a partial yml. gofastr generate app additions.yml --add
A fragment's endpoints: merge into the project's gofastr.settings.yml;
an endpoint whose name the project already has keeps the project's entry.
The derived files entities/endpoints.gen.go and entities/client/client.go
are rebuilt from the tree after the merge, not skipped. With --json the
report lists the written files under files, the files left alone under
skipped_existing, and the files already there that the run changed (the
settings file and the rebuilt derived files) under updated.
With --json, source, validation, write and rebuild failures return one
{"files":[],"errors":[{"message":"..."}]} document and exit nonzero.
Endpoint and client rebuild failures are both fatal. Earlier writes are
not rolled back; the error document is not an inventory of completed
writes. Correct the error and rerun before building the updated app.
Partial ymls are legal: an entities-only (or screens-only) fragment omits
app.module, so the module is derived from the enclosing go.mod exactly as
in a full generate. This means main.go, app.go, entities/register.go,
entities/events.go, and screens_register.go are still rendered (the generator needs them for a valid
package), but since they already exist in your project, --add skips them.
Every generated app ships both registration seams and their call sites
(entities.RegisterAll in main.go, mountGenerated in app.go) even when
it has no entities or screens yet, so a later --add registers and mounts its
pieces without editing any owned file. If you removed one of those calls,
--add warns with the exact line to restore.
Only the new files are written: entities/<name>.go for new entities,
screen_<name>.go for new authored screens, and screen_<entity>_crud.go for
an entity that gains CRUD screens.
The shared extensions.go seam (the appExtensions value appUI is built
from) is skipped like every existing owned file. A new entity's screens live
in their own new screen_<entity>_crud.go, so --add extends the app
without rewriting a shared file.
Entity order continuity. --add reads the existing entities/ directory
and assigns the new entities declaration orders that continue after the
existing set (e.g. if the project has entities at orders 0 and 1, the first
added entity gets order 2). This keeps RegisterAll's declaration order and
gofastr pack's order recovery coherent across the union.
Screen order continuity. --add reads the existing screen_<name>.go
files and assigns the new screens screenRegistrar{order: …} values that
continue after the project's existing max screen order, mirroring entity
order continuity, so mountGenerated's sort and gofastr pack's order
recovery stay coherent across the union.
Referencing existing entities. A screens-only fragment may reference an
entity that lives in the project rather than in the fragment, e.g. an
entity_list over an entity you generated last month. --add reads the
project's entities/ declarations and validates the fragment against the
union, so there is no need to re-declare the entity just to build UI on it.
Re-declaring an existing entity or screen is a no-op: the file already
exists, so it lands in the skipped list and the existing file is left
untouched. There is no diffing or merging: --add only writes files that
don't exist.
client.go follows the entities. entities/client/client.go is an
aggregate over all entities, so --add rebuilds it to cover the new ones.
It is derived: gofastr generate client, plain gofastr generate, and
gofastr build rebuild it from entities/*.go whenever it exists. Keep your
own client code in another file of package client. A client.go from
before v0.93.0 that you edited is left alone with a warning; move the edits
out, then run gofastr generate client --force once.
Pre-0.15 layouts. --add requires the per-piece layout on both sides: the
per-entity entities/ files and the per-screen screen_<name>.go files. If
entities/register.go holds the app.Entity calls inline (the aggregated
entity layout older generators emitted), or the project still uses one
aggregated screens.go instead of the per-screen files, --add refuses
rather than drop in a file the package can't compile with. Recover your
app spec with gofastr pack (which still reads both legacy layouts as a
fallback), merge the new pieces into it, and regenerate with --force.
Quick scaffolds (generate entity|screen)
For an Angular-schematics-style fast stub with no yml at all, use the
scaffold subcommands. They synthesize a minimal one-piece app spec fragment
in memory and route it through the same additive machinery as --add, so
skip-existing, entity/screen order continuity, union validation, the
legacy-layout refusal, the client.go staleness warning, and the
--dry-run/--json output shapes all hold for free:
gofastr generate entity posts # entities/posts.go + a placeholder field gofastr generate screen contact # screen_contact.go at /contact
Each flag accepts --out=DIR, --dry-run, and --json. --force and
--add are rejected: scaffolding is additive (it never overwrites), so
those flags have no meaning here. The stub is a deliberate starting point you
immediately edit:
generate entity <name>emits one entity with a single placeholdernamefield (a required string); rename it and add the rest. CRUD stays default, so the entity also gains a JSON API.generate screen <name>emits one screen at/<kebab-name>with a heading and a stub paragraph; replace itsRender.
The scaffolds and --add extend an app generated from an app spec (it has
app.go and per-screen screen_<name>.go files). A gofastr init project
(screens.go, main.go, entities/entities.go) is refused with the
hand-edit seams named: another app.Entity call in RegisterAll, and a
screen type registered with site.Register in main.go.
Screens from an accessibility tree (--from-a11y)
generate screen <name> --from-a11y=<file> builds the screen body from a
Playwright aria snapshot instead of the stub: the YAML accessibility tree
that locator.ariaSnapshot() returns and Playwright MCP's browser_snapshot
prints. --from-a11y=- reads it from stdin.
# signup.aria.yml - main: - heading "Create account" [level=1] - paragraph: Start your free trial. - textbox "Email" - checkbox "I agree to the terms" - button "Sign up"
gofastr generate screen signup --from-a11y=signup.aria.ymlEach role becomes a block from the UI component catalog,
so the screen file holds framework/ui calls and no CSS:
| Snapshot role | Block → component |
|---|---|
first heading [level=1] (+ a paragraph right under it) | page_header (title + subtitle) |
other heading (a later level 1 becomes level 2) | type: heading |
paragraph, text, blockquote, code, … | type: text |
link (+ /url) | type: link → ui.Link |
button | action_button |
textbox, searchbox, option-less combobox | text_field |
spinbutton | number_field |
checkbox / switch | checkbox / switch |
combobox or listbox with options | select_field |
radiogroup, a named group holding only radios, or adjacent radios | radio_group |
form, or a run of adjacent controls | custom_form |
table, grid | data_table |
list | item_list |
navigation | nav_links of its links |
named region, article, group, dialog, … | section |
alert | callout (name as title, text as body) |
separator | divider |
Mapping rules worth knowing:
- Controls next to each other (after unwrapping
genericlayout wrappers) become onecustom_form. Help, hint and error text between them, and a namedgroup(fieldset) holding a control, stay inside the same form. Its last button becomes the submit; with no button the submit is hidden. The form posts to the screen's own route, which has no POST handler yet: pointactionat your handler. Forms never nest: aforminside a form gives its fields to the outer one. - A
textnode that repeats the next control's name (its visible label) is dropped, since the generated field renders its own label. - Field names come from labels (
"E-mail"→e_mail), unique across the screen; two radios with the same label get distinct values. - A table with no name gets the caption "Table", hidden on screen; a
caption used twice gets an
id, so the two tables' ids stay distinct. bannerandcontentinfoare skipped: the app layout draws the site header and footer.imgis skipped because a snapshot carries no image source. A warning (to stderr under--json, with its snapshot line) names every skipped or inlined node and every piece of content a mapping leaves out: text and buttons inside anavigation, links and buttons in table cells (cells become text), non-radio content in aradiogroup, and a textbox's current value.- A
javascript:or other unsafe/urlbecomes#, with a warning. Control and bidi characters are stripped from names and text.
The snapshot is capped at 4 MiB, 50,000 entries and 128 levels of nesting; a malformed line fails the command with its line number and writes nothing.
Scaffolds and yml fragments are complementary, not competing: the stub is
basic scaffolding (one thing, fast); an app spec yml is full intention
(fields, relations, RBAC, theme, nav, seed, multi-screen layouts). For
anything beyond a single placeholder, write a partial yml and use --add.
generate theme <name> is intentionally not provided: theme tokens live in
the owned app.go (there is no new-file representation to scaffold).
Packing: gofastr pack (lossy snapshot of the app as an app spec)
gofastr pack [app-dir] reconstructs a best-effort gofastr.yml from a generated
app's Go source, a lossy snapshot, not a round-trip inverse of gofastr
generate. It reads the real artifacts via the
Go AST (it does not stash a manifest): the per-entity entities/<name>.go
files for entities (falling back to a legacy entities/register.go),
app.go for app config + theme + auth/admin + nav, stubs.go
for seed, and the per-screen screen_<name>.go files for screens (reading
each file's screenRegistrar{order: …} to recover authored order, with a
legacy aggregated screens.go fallback for apps older generators emitted),
reversing the emitted framework/ui grammar (ui.Hero → hero,
appUI.List("x")… → entity_list, and so on). The result
also preserves the control catalog: custom forms and their text, number,
toggle, select and radio controls; action buttons; static table columns
and cells; ordered/unordered item lists; and labeled navigation links.
Control properties round-trip in canonical form, not their original
spelling: action-button variants and types are case-insensitive and
lowercased while parsing. Default variant: primary, type: button,
and navigation-link gap: md values are omitted both when parsing and
packing, so a control gains no authored properties from generated defaults.
The result
prints to stdout, or to a file with -o:
gofastr pack examples/meridian -o recovered.yml-o/--out requires a nonempty filename, not another flag. An empty value
is refused rather than falling back to stdout. To deliberately write
a dash-prefixed filename, use an explicit relative path such as ./-out.yml.
Every string in the output comes from the app's source, and none reaches the terminal raw if it could drive it: a value holding a control or invisible character (an ESC, a C1 control, a bidi override, a zero-width space) is written double-quoted with that character escaped, and reads back as the same bytes. A map key holding one is refused, since keys cannot be quoted. The notes pack prints percent-encode the same characters in file names.
Pack refuses a schema.Field it cannot write back as a spec field,
naming the field and key, rather than drop it: a set WireName,
RawType or other key the spec has no field for, a Type with no
spec name, a Max or Min that is not a literal, or an unknown
AutoGenerate.
Pack reads a phrase as its English: homeScreenTextWelcome.Text(ctx)
becomes the text its i18n.Define in the same file gives, and an
i18nui.FieldLabel call becomes its English argument. The packed spec
holds the English only; translations stay in locales/.
Pack then checks its own result. It regenerates the app from the packed
spec in memory and compares each Go file at the app root and directly
under entities/ with the file on disk, ignoring
provenance markers, CRLF line endings
and a byte order mark. Files in other directories are not checked. It
reports three kinds of file, on stderr when the spec goes to stdout:
- Go files the spec does not generate and no marker claims: hand-written code, left out of the spec.
- Files it could not read, such as a symlink or a file without read permission. Pack left them out of the spec too.
- Files the packed spec generates differently, with the reason the marker gives: edited since it was generated, untouched but still different, no marker at all, marked but no longer generated, or generated by the spec but missing from the app. An untouched file another gofastr release wrote may differ because the template changed rather than because pack lost part of it, and the note says so. Each note names the part of the spec the file carries, such as the screens or the entities.
⚠ pack: 1 hand-written file(s) are not in the spec: helpers.go ⚠ pack: 1 file(s) generate differently from the packed spec: ⚠ entities/notes.go: edited since it was generated, and differs from what the packed spec generates (entities)
Tests and files another generator marked are skipped. The check writes nothing, fetches nothing, and leaves the exit code alone.
The JWT secret, the database URL and the admin seed password live in the
app's .env, not its source, and pack reads them back from there. A
checkout without that .env packs a spec with no seed_password, so the
spec seeds no admin. Pack says so before the check names app.go and
main.go; add seed_password under app.admin before generating from
it.
Pack also reads public_openapi, the UI host's application description,
and the APP_BASE_URL fallback from main.go. A deployment's environment
override is not substituted for that authored fallback; the default
http://localhost:8080 fallback is omitted from the packed spec.
Trailing / characters in app.base_url are removed while parsing:
the value is a base prefix for absolute sitemap URLs, so
https://slash.example/ and https://slash.example have the same
canonical form, as do base prefixes ending in a path. Pack retains that
canonical form. Descriptions equal to the generator's name/entity-derived
default are omitted rather than treated as authored metadata; custom
descriptions are retained. The generator and pack share this derivation
through appgen.AppSpecSiteDescription.
Admin paths and roles equal to the defaults (/admin and admin) are
also omitted; customized admin values are retained.
The synthesized /create screen, and the /login screen added for an app spec with auth and no login form, are dropped (they weren't authored). Generate and pack are a matched inverse pair for the declarative
pieces app, entities, screens, nav, and seed, so the invariant
parse(yml) ≡ parse(pack(generate(yml))) holds for an app spec of those
constructs (modulo comments + formatting). When you add a new construct to
that set, teach both the generator and pack, or the round-trip test
fails.
The serializer half of that invariant is stronger: parse(yml) ≡
parse(encode(parse(yml))) holds for every construct the grammar can
declare, including endpoints, middleware, plugins, and helpers. For
the list constructs (entities, screens,
blocks, nav, seed, endpoints, stubs, and their nested lists), the guards
also cover every field inside each element — the omission class this
catches (seed.count, seed.weights, entity renames) hides one level
down, where a set construct key already differs from the zero output and
the top-level checks pass anyway. A value that would re-parse as anything
but itself is quoted: " and backslash are escaped, invalid UTF-8 bytes
and non-printable runes as \xNN/\uNNNN, so a quoted value comes back
byte-for-byte (an apostrophe passes through verbatim inside the double
quotes; bare, 60' would merge two flow-list members into one). Every
committed example app spec gates this, not just Meridian, and
reflection-driven guards fail the day an AppSpec or construct field
exists that the serializer's key lists don't cover — the drift that let
middleware/plugins/helpers ship dropped, and seed.count,
seed.weights, and renames after them.
Note what that invariant does and does not say. It is about the
declarations: pack reads examples/meridian's Go and recovers the same
entities, screens, nav, and seed the app spec declares. It is not a claim
that re-running gofastr generate reproduces that app's source. Meridian has
been hand-edited since it was scaffolded, so regenerating it in place would
overwrite code the generator never wrote; examples/meridian/doc.go says so,
and --force there is a mistake, not a refresh. Two of its trees are the
exception: entities/ and cmd/meridian/ are generator-owned, and
TestGeneratedTreesMatchGenerator regenerates them into a scratch package
(the CLI through the cli entry in Meridian's gofastr.settings.yml) and
fails on any byte of drift (custom.go, the dev-owned CLI seam, is
excluded). examples/ecommerce regenerates in place as a whole, because its
app spec sets output_dir: app and the generator owns that directory
outright.
Pack recovers hooks, middleware, plugins, and helpers from their
wiring (the Use, RegisterPlugin and RegisterHook calls) and the names
and descriptions in stubs.go, so a stub whose body you have since written
still packs. It reads endpoints back from the endpoints: section of
gofastr.settings.yml. A linked handler file is yours once you write its
body, so the check counts it as hand-written rather than comparing it.
examples/ecommerce/app packs with nothing unrecovered, and a test holds it
there.
Data blocks (entity_list, entity_form, entity_detail)
A top-level entity_list or entity_detail makes its screen a
server-rendered (request-time) screen. The generated screen calls the
app's one entityui value — appUI, built once at boot from the
extensions.go seam — and every read passes the entity's own read gates
under the page's route policy. There is no island and no second route onto
the rows: sort, page, search and filter are query params on the page's own
URL, and links and GET forms navigate (the runtime fetches the screen
partial and keeps the shell).
entity_listrendersappUI.List("<entity>")with:fields:→.Columns(...),limit:→.PageSize(n),empty_text:→.Empty(...),mode: cards(ortable) →.As(...),create: false→.NoCreate(),bulk: true→.Bulk()(row selection, the bulk bar and Export CSV; refused on any other block kind), and atext:heading (level 2 when a block ahead of it on the screen renders the page's<h1>). One list per screen needs no key; two lists of different entities each get.Key("<entity>")so their query params stay apart, and two blocks for the same entity on one screen are refused at validate time.fields: [id]requires an actualidcolumn: natural and composite keys do not add one unless explicitly declared.- An entity listed on several screens has one home list: the list
screen its detail screen sits under, else the first one with
create: true, else the first one declared. A list on any other screen (a dashboard's recent rows) gets.Base(<home route>), so its record links and New reach the home's detail and create screens instead of<dashboard>/<id>. - An entity with no
entity_detailscreen has no page at<list>/<id>, so its lists withoutcreate: truerender with.NoLinks(): the rows draw as text with no row menu, instead of linking every row to a 404. Acreate: truelist keeps its links (NoLinksalso removes New), so give a writable entity a detail screen. entity_detailrendersappUI.Record("<entity>", id).Delete().Duplicate()from the screen's{id}route param. The record page holds the edit form and a button per declared move, so there is no/<detail>/editscreen any more. When the entity has list screens, one of them must sit at the detail route minus/{id}(enforced at validate time, since entityui links records at<base>/<id>), and the detail screen registers as a drawer over that list (app.InterceptFrom): navigating to the record from the list slides it over, while a hard load or a shared link renders the full page.create: trueon a list synthesizes the create screen at<home list>/create(not/new), renderingappUI.Create("<entity>").Base(<home route>). It inherits the home screen'slayout+accessand is not added tonav.entity_formrenders a plain<form data-cui-rpc="<api_prefix>/<table>">(enum →<select>of values; relation →<select>populated from the related entity's route, each option's value its key column, so a target withprimary_key: [currency_code]offers its codes; a target keyed on more than one column has no single value to offer and its select stays empty; a number input steps by 1 forint, 0.0001 fordecimalandanyforfloat) for forms not bound to one entity. The record form on the entityui screens is drawn from the entity'sdisplay: form:. Submitted control names use the default camelCase API wire keys (address_line_1becomesaddressLine1) so field refusals land on the matching input. Control IDs, label lookups and relationship targets keep their declaration names.entity_formwithmode: editon an{id}route renders the record page,appUI.Record("<entity>", id), without Delete or Duplicate. It loads the record into its form, keeps the key read-only, and Save PUTs it.fields:keeps the listed fields and passes the rest to.Omit(...). Atext:title is not drawn: the page heads itself with the record's title. On a route that continues past{id}(/posts/{id}/edit), the screen passes.Base(...)the route up to{id}, so Save lands on/posts/<id>.
Removed screen keys. filters:, transitions:, island:, widget:
and search: are no longer block keys; the decoder refuses each with the
error naming where the setting moved:
| Removed key | Replacement |
|---|---|
filters: | the entity's display: facets: (enum/bool/relation one-click filters) |
transitions: | the entity's states: (declared moves, drawn as buttons) |
search: | the entity's search_fields: (the list's search box queries these) |
island: / widget: | none — lists are query-param pages, no island |
Likewise entity_create and entity_edit are no longer block kinds: the
list's create: synthesizes the create screen, and the record page holds
the edit form.
display: and states: on the entity. The entity says what its
screens show; the app spec spells both under the entity with the same
snake_case keys as the rest of the YAML:
entities: - name: invoices crud: true search_fields: [number] fields: - {name: number, type: string, required: true} - {name: amount, type: decimal, required: true} - {name: status, type: enum, values: [draft, open, paid, void], default: draft} - {name: paid_on, type: date} states: field: status initial: [draft] transitions: - {key: mark_paid, label: Mark paid, from: [open], to: paid, stamp: paid_on, variant: primary} display: singular: Invoice plural: Invoices title_fields: [number] columns: [number, amount, status] facets: [status] form: main: - row: [number, amount] - {section: dates, items: [paid_on]} side: [status]
states: is a rule the CRUD handler enforces (a write may change the state
field or a stamp only through a declared move) unless advisory: true;
see States. The generator emits the seed runner under
an audited state override, so seeded rows may start at any state, and it
enables the audit log when an entity declares states: and the app spec
seeds it.
Dashboard data blocks read through the same value: stat_card calls
appUI.StatValue, the charts call appUI.GroupBars/GroupSlices/
LineChart, each resolving the entity at render time (a chart source
needs no screen and no registry entry).
appBaseCSS() (mounted ahead of static/app.css) is an owned,
empty-by-default extension point for app-specific base CSS: every
generated screen composes framework/ui components and core-ui/app
layouts that ship their own styling, so the generator emits zero bespoke
CSS.
Extending the screens (extensions.go)
The app's entityui code lands in the owned extensions.go seam:
var appExtensions = entityui.Extensions{}. Field kinds, view funcs
(a view whose filter depends on the caller, the tenant or the clock),
record tabs and actions each register there, keyed by entity. app.go
builds the app's UI from it once at boot:
appUI = fwApp.EntityUI(appExtensions)fwApp.EntityUI panics at boot on a name appExtensions uses that the
entity does not declare, the way App.Entity refuses a bad declaration.
The file ships even with zero entities, so a later generate app --add never
edits an owned file.
Writable app screens (create:, edit, delete)
App-side entity screens are read/write, not read-only:
- Add
create: trueto anentity_listblock → the list shows a "New <Singular>" button and the generator synthesizes a<route>/createscreen renderingappUI.Create("<entity>")(the record form, empty). - Every
entity_detailscreen renders the record page with Delete and Duplicate actions and the edit form in the page (drawn from the entity'sdisplay: form:, prefilled server-side). There is no separate edit route.
The save forms submit as form RPCs: data-cui-rpc POSTs/PUTs to the
entity's API and the answer re-fetches the page
(data-cui-rpc-navigate) with a toast. A refused save answers 422 with
each error beside its field. Delete is a DELETE behind a confirm. Moves
post the transition route (POST <api>/<entity>/<id>/transitions/<key>).
The synthesized create screen inherits the list screen's layout +
access and is not added to nav.
To add your own no-reload behavior to a hand-written screen, such as a
status <select> that swaps a badge or a comment form that appends to a
thread, follow
interactive-patterns.md, in particular "Writing
a hand-written island, end to end" (it covers the traps: the posted JSON
key is the input's name, the two route-param syntaxes, and manual route
registration) and "Themed confirmation (ui.ConfirmAction)".
Composite keys (primary_key)
primary_key: names an entity's key columns in order. Leave it out for
the usual generated id. A join table keys on the rows it joins:
entities: - name: enrollments crud: true primary_key: [student_id, course_id] fields: - {name: student_id, type: string, required: true} - {name: course_id, type: string, required: true} - {name: grade, type: string}
The rules are EntityConfig.PrimaryKey's
(Record ids). On a record screen
(entity_detail, or an entity_form with mode: edit), {id} stands
for the whole key: /enrollments/{id} mounts as
/enrollments/:student_id/:course_id, one segment per key column, the
path entityui links records at. The screen's SetParams joins the
values with framework.JoinKey. Validation refuses another route param
named after a key column; rename it. The generated e2e_test.go reads
every key column out of the create response to address the record, and
gofastr pack folds the expanded route back to {id}.
A relation to a composite-keyed entity names one foreign-key column per key column, in key order, as a list:
- name: lines crud: true fields: - {name: order_store, type: string} - {name: order_no, type: int} - {name: sku, type: string} relations: - type: belongs_to name: order entity: orders foreign_key: [order_store, order_no]
Each listed column is a declared field of the entity that holds it (the
belongs_to side here, the child for a has_many), typed like the key
column it references. Loading refuses a list whose length differs from
the referenced key, a repeated or undeclared column, a column typed
unlike its key column (an int column for a string or uuid key
column, say; the text types string, text, uuid, enum, image
and file all join each other), a column the entity's states
transitions write, and a
many_to_many with a composite end; link the last through a join entity
that belongs to both sides. A one-element list is the plain
foreign_key: order_no spelling. The rules are those of
Relation.ForeignKey.
RBAC for writable APIs
When any entity declares an access: block, its auto-CRUD API is permission-gated,
so the app must install a policy or every write 403s. The generator emits an
access.RolePolicy that grants the admin role (app.admin.role) the wildcard
(*) and an access.Middleware that resolves the signed-in user's roles, mounted
after the session middleware. The admin operator can therefore manage every entity
through its own gated API (the same API the back-office uses), for each operation
the access: block names; a write it leaves out is refused to the admin too. Add
finer per-role Grants in RegisterGenerated as you define more roles.
UI component blocks (the framework/ui catalog)
Any screen body can compose the framework's UI components directly via block
kinds; the generator emits the matching ui.X(...) call:
page_header · hero · section (with child blocks) · card · stack ·
cluster · grid · stat_row · stat_grid · stat_card · bar_chart ·
pie_chart · line_chart · link_button · callout · divider ·
markdown · pricing · custom_form · text_field · number_field ·
checkbox · switch · select_field · radio_group · action_button ·
data_table · item_list.
The layout blocks map directly to framework/ui: stack and cluster accept
semantic gap, align, and justify props (cluster also accepts
no_wrap). An unset align or justify keeps the component's default: a
stack stretches its children across the column, a cluster packs them at
the row's start and centres them vertically on its line. Set align: start to let a stack's children keep their natural
width. grid accepts min and gap; stat_grid is the dashboard grid
variant with a 12rem default minimum. Spacing must use the shared
none|xs|sm|md|lg|xl|2xl tokens; app specs do not create a second styling system.
Long-form content: markdown renders formatted prose (ui.Markdown) from a
text: string (headings, bold, lists, etc.) at a readable measure; the plain
type: heading / type: paragraph blocks are also typeset to a comfortable
column on marketing pages. Pricing: a pricing block takes
props: {plans: [{name, price, period, description, features: […], cta_text,
cta_href, featured}]} and renders a row of ui.PricingCards (featured plan
highlighted), so a pricing page reads like marketing, not an admin grid.
Controls, tables and lists: these kinds carry a form that is not bound
to an entity (for that, use entity_form). Validation rejects a block
missing a required prop, which the component would otherwise refuse at
render time.
| Kind | Component | Props (required in bold) | |||||
|---|---|---|---|---|---|---|---|
custom_form | ui.Form | action (http(s) or root-relative), method (get\ | post), submit (submit label), hide_submit; child blocks are the fields | ||||
text_field | ui.TextField | label, name, placeholder, help, required, disabled | |||||
number_field | ui.NumberField | same as text_field | |||||
checkbox / switch | ui.Checkbox / ui.Switch | label, name, checked, disabled | |||||
select_field | ui.Select | label, name, options, required, disabled | |||||
radio_group | ui.RadioGroup | legend, name, options, required | |||||
action_button | ui.Button | label, variant (primary\ | secondary\ | ghost\ | danger), type (button\ | submit\ | reset, default button), disabled |
data_table | ui.DataTable | columns (list of headers), rows (list of lists of cell text), caption, caption_hidden, id (needed when two tables share a caption) | |||||
item_list | html.UnorderedList | ordered; each child block is one item | |||||
nav_links | html.Nav + ui.Cluster | label (the landmark's name), gap (default md); child blocks are the links |
options is a list of strings, or of {label, value, selected} maps
(checked for a radio); a missing value is the label. variant, type
and method are matched without regard to case. A form's fields render
with the request context, so the form carries the CSRF token, and a
custom_form cannot hold another one.
- kind: custom_form props: action: /contact submit: Send children: - kind: text_field props: label: Email name: email required: true - kind: select_field props: label: Topic name: topic options: [Sales, Support] - kind: radio_group props: legend: Plan name: plan options: - label: Free value: free checked: true - label: Pro value: pro
The names differ from the raw node kinds button, form, select,
list, table and nav, which still render as plain elements through
the node renderer. That renderer draws only node kinds, so validation
refuses a catalog block nested under a node block (type: div holding a
custom_form): put it in a stack, cluster, card or section.
A raw textarea takes its text from props.value (or text, then
content), rendered as the element's content; children are not drawn.
A type: link block renders through ui.Link, the styled anchor;
validation refuses an unsafe href (javascript:, data:, //host).
Data-bound dashboard widgets: stat_card and the charts accept a source:
that computes a live metric server-side:
source: {entity: customers, agg: sum, field: mrr} (or agg: count with an
optional filter: status=active) for a stat_card, and
source: {entity: customers, group_by: status} for a chart. agg is
count (the default) or sum, spelled exactly, and a sum's field is an
int, float or decimal field; validation refuses anything else rather
than rendering "—". For the chart kinds source (with both entity and
group_by, targeting a declared entity) is required: validation
rejects a chart without one rather than letting the block silently vanish
from the page. A chart with a
title renders inside a ui.Card with that heading. A chart or card
block with no titled section or card above it emits
HeadingLevel: 2, so its title follows the page's h1 without skipping a
level; one inside a titled section keeps the default h3.
Layouts (screen.layout)
layout: marketing wraps the screen in the site shell: a header and a
footer the same generation writes as the app's own packages,
siteheader/ and sitefooter/ (for the public/front-of-house pages);
layout: app uses the sidebar shell (nav). Omitted → the default
(sidebar if nav is set).
The two chrome packages are the canonical ones gofastr generate
package copies — tests included, self-imports rewritten to the app's
module — not a separate app spec fork: plain core-ui/html elements
plus framework parts in Go (headless.Disclosure for the phone menu,
ui.ThemeToggle for the scheme switch), the look in an owned
siteheader.style.css / sitefooter.style.css beside them (every
dimension a theme token), and the class methods in the _style.gen.go
files the generator emits already current — the app compiles and
passes gofastr verify with no extra step. app.go fills in the
links (nav links, the Dashboard link
when signed in, the footer's columns, the theme toggle when a dark
scheme is declared) and appTheme() adds the header package's own
tokens with theme.Extend(siteheader.Tokens). app.go imports the
two packages by module path, so an app spec with a marketing screen
needs a module: app.module, or the enclosing go.mod it is derived
from. With neither, generation stops with an error saying so. To restyle the bar or
the colophon, edit the packages — they are yours; rerun
gofastr gen styles after editing a .style.css.
Navigation (nav)
nav is a list of sidebar entries, each {label, href}, with an optional
icon and nested items. The app shell renders them as the sidebar (and, at
narrow viewports, the hamburger drawer); the theme toggle lives in the sidebar
footer, so there is no separate app top bar.
A nav item may carry role: to restrict it to users holding that role:
nav: - label: Overview href: /app - label: Admin href: /admin role: admin # only signed-in admins see this link
Role-gated items are filtered by the signed-in user's roles at render time, on
both the desktop sidebar and the mobile drawer: a link a user can't use
never appears (and is never a dead end into a 403). Items without role: are
visible to everyone.
role: here is visibility, not access control. It hides the link; it does
not gate the route. Whether the destination is protected depends entirely on
what guards the destination: entity access: permissions gate the DATA a
screen renders (a caller without them sees the "Not available" notice in place
of rows), the screen's own access: block gates the route, and the admin
battery gates its own console. A generated screen with no access: block
sitting at a role-gated href answers 200 to anyone who types the URL, with
whatever ungated content it holds. Every screen path also appears in the route
manifest embedded in each page, so the href is discoverable regardless of
whether its nav link renders.
Put anything that must not be reached behind a gate on the destination: give
the screen an access: block, give the entities it reads an access:
permission, or mount it under the admin battery.
screens: - name: billing route: /app/billing access: auth: true # redirect an anonymous visitor to the login page role: admin # 403 a signed-in user without the role
The generated authPolicy composes auth.SessionPolicy and
auth.RolePolicy, so an access decider installed with
access.DeciderMiddleware binds the role check the same way it binds every
other battery/auth role gate (see
access control), and a
scoped API token is refused.
The real-estate example gates its agents, tours and inquiries screens with
auth: true under access: so anonymous visitors reach sign-in rather than a
page of refused entity reads. The entities retain their staff permissions:
signing in does not by itself authorize reading those records. Its public
properties catalog remains available without signing in.
Seed data
When the app spec declares seed:, generation emits seedData()
and main.go applies it via App.WithSeed after auto-migration. Seeding is
ordered (entities load in declared order, so a relation target is inserted
before the rows that reference it) and idempotent (an entity whose table
already has rows is skipped). Rows go through the CRUD CreateOne path, so
validation, id generation, and timestamps apply; decimal values are coerced
to the decimal-string form the validator expects. Seeding is fail-fast: a
row that fails validation (or an entity with no registered handler) aborts
startup with the entity named in the error. The idempotency check would
otherwise mark a partially-seeded entity as done, so a silently dropped row
would never retry; the app would report ready with bootstrap data missing.
A relation value written as "@currencies.label=Euro" is a reference: the
seed looks up the first currencies row whose label is Euro and stores
that row's key, the target's primary_key column when it declares one and
id otherwise. A relation's target has a one-column key, since boot
refuses a relation to an entity keyed on several columns (see
Relations and composite keys),
so a reference always stores one value.
When any entity declares owner_field (per-user scoping) and an admin
account is bootstrapped (app.admin.seed_email / seed_password), the seed
runs as that admin: the generated hook looks the admin up and stamps every
seeded owner column with their id. The demo data therefore belongs to the
admin, and a freshly registered user starts with an empty, owner-scoped
workspace they fill themselves. No other user's rows ever leak in. Without an
admin to attribute the rows to, an owner-scoped seed would have no valid owner
and fail closed.
Auto-generated rows (count / weights)
Hand-writing dozens of demo rows is tedious and tends to produce a flat
round-robin (open, in_progress, resolved, closed, open, …) that reads as
obviously fake. Instead, give a seed entry a count: and the generator
fabricates that many rows with deterministic, realistic-looking values:
seed: - entity: tickets count: 40 # generate 40 demo rows weights: # optional: skew an enum column status: open: 5 in_progress: 3 resolved: 8 closed: 2
Enum columns get a varied, non-uniform distribution: with weights
the values appear roughly in proportion; without them the generator derives a
deterministic skew seeded from the entity + column name, so demos never look
like an even split. Scalar columns are filled from naming conventions
(name/title → Ticket 1, email → ticket1@example.com, slug →
ticket-1, numeric/bool columns get a stable spread). Generation is
reproducible: there is no runtime randomness, so regenerating the app yields
the same rows and the diff never churns.
count fills scalar and enum columns only; it leaves relations and
system/auto-generated columns alone. For entities with a required relation,
use explicit rows: (with @entity.field=value refs) instead; a generated
row can't fabricate a valid foreign key. count and explicit rows: can be
combined: the explicit rows are seeded first, then the generated ones.
Generated rows never repeat a primary_key: an integer key column counts
up from 1001, or, when the column sets bounds, from min but never below
2 (the generated e2e test creates 1), and a uuid key takes one value per
row. A key made only of enums has as many keys as distinct value
combinations, less the one the e2e test creates. The generator refuses a
count that would run past those combinations or an integer key's bounds,
generate a key an explicit row already holds, or leave a key column empty:
a relation, file or image key, or a declared id without auto_generate,
takes explicit rows:. A key the server fills (auto_generate, or the
id the entity adds) is the server's to make unique.
Auth (app.auth)
app: auth: enabled: true dev_mode: true # optional, defaults to true: see below base_path: /auth # optional, defaults to /auth jwt_secret: ... # optional
With enabled: true the generated app wires the auth battery:
an AuthManager backed by entity stores over auto-created auth_users /
auth_sessions tables, with the core plugin's register/login/logout/me
endpoints mounted under base_path. Everything the generator emits that
calls those endpoints uses base_path too: the login_form and
signup_form default actions, the Sign out control, and the generated
e2e test's login. base_path must be an absolute path with no trailing
slash, such as /account; its segments hold letters, digits, -, _
and ., and none starts with a dot. The generated app also mounts
auth.SessionMiddleware on the router, so the session cookie issued at
login resolves to a user on every request; this is what makes
owner_field and access scoping work for logged-in users on the
generated CRUD and MCP endpoints. Without a valid session, owner-scoped
entities fail closed (401/403) for reads and writes alike.
The generated app also wires the auth UX so the session is reflected everywhere, not just enforced:
- Sign out: the app sidebar footer and (on marketing pages) the header
carry a
ui.SignOutcontrol that POSTs to<base_path>/logoutand returns home. - Auth-aware marketing header: a signed-in visitor sees a Dashboard link
+ Sign out instead of Sign in; the header re-renders per request from the
session (
app.NewContextComponent). - Guest-only auth screens: the login and signup screens redirect an already-signed-in visitor to the app instead of showing a form they're past.
The bootstrap admin (app.admin.seed_email / seed_password) is created on a
fresh database, and the auth battery creates its own auth_users /
auth_sessions tables in Init; the generated app ships no DDL. An
Init error stops the boot: with dev_mode: false and JWT_SECRET
unset, the app exits naming the missing key instead of serving its
screens with no auth routes.
Secrets never land in generated source
Generated Go reads every secret from the environment, so committing the generated app commits no credentials:
JWT_SECRET→auth.AuthConfig.JWTSecretDATABASE_URL→ the DB DSN (a credentialed DSN is also stripped from the emitteddbURLconstant and thegetEnvfallback)ADMIN_SEED_PASSWORD→ the bootstrap admin's password (no env var → no admin is seeded)
DSN redaction removes userinfo passwords, URL query password and
sslpassword parameters (including percent-encoded parameter names), and
libpq password/sslpassword pairs. Non-secret host/database settings are
retained; an unreadable URL query is removed entirely rather than leaked.
An at sign after a raw /, ?, or # authority delimiter can indicate
accidentally unescaped userinfo, even when a numeric password prefix parses
as a port. Such DSNs are treated as secret-bearing and redaction keeps only
the scheme prefix. Passwordless URLs with an at-sign-free query, such as
?sslmode=disable, remain unchanged.
Libpq assignments may have whitespace around = and single-quoted or
backslash-escaped values. Detection and redaction parse those assignments
together, so a spaced or quoted credential cannot become a source fallback.
When the app spec holds any of these values, the generator also emits a
.env carrying them (so the app runs without extra setup). The
.gitignore it always emits excludes .env and .env.local, .gofastr/,
bin/ (the gofastr build output) and the SQLite database files
(*.db, *.db-shm, *.db-wal). The generated main.go loads
.env.local/.env before opening the DB; a real process env var
always wins over the files. generate is one-shot and refuses to
overwrite an existing .env (or any other file) unless you pass --force.
dev_mode
dev_mode maps to auth.AuthConfig.DevMode and defaults to true
when omitted. The default is deliberate: a freshly generated app serves
plain HTTP, and the production cookie defaults (__Host-session name +
Secure flag) only round-trip over HTTPS. With dev_mode: false on
plain HTTP, register/login appear to succeed but the browser never sends
the session cookie back, so every authenticated request fails. Dev mode
uses an HTTP-friendly session_id cookie and mints a random per-process
JWT secret at startup (JWT bearer tokens invalidate on restart; cookie
sessions are DB-backed via auth_sessions and survive).
gofastr generate prints a warning whenever an auth-enabled app spec
generates in dev mode, and the generated wiring carries the same notice.
Before deploying, turn dev mode off with a real signing key: set
DevMode: false and a JWTSecret (sourced from a secret manager, not
committed) in the auth.AuthConfig in the generated app.go. That code
is yours to edit, so change it there rather than re-running the generator.
Serve over HTTPS. Unknown keys under app.auth are rejected, like every
other app spec section.
jwt_secret is required with dev_mode: false
dev_mode: false without jwt_secret is a validation error:
gofastr validate and gofastr generate both refuse the app spec.
The generated app could never boot anyway: the auth battery fails
closed at Init when DevMode=false and JWTSecret is empty, because
an empty signing key yields forgeable JWTs. The app spec check moves
that failure to generate time, with the remedy in the error: set
jwt_secret from your secret store, or set dev_mode: true for local
development.
CSRF
The generated app does not mount auth.CSRF. The generated API
is JSON-first (REST CRUD + /mcp), and the CSRF middleware rejects any
unsafe-method request that doesn't echo the CSRF cookie back as an
X-CSRF-Token header (or _csrf form field); plain JSON and MCP
clients don't, so mounting it would 403 the entire generated API for
non-browser clients. Two mitigations bound the exposure: session cookies
are issued SameSite=Strict, so modern browsers don't attach them to
cross-site form posts, and requests authenticated by Authorization /
X-API-Key headers aren't CSRF-able at all. If you add browser HTML
forms to a generated app, mount auth.CSRF on the routes that serve
them (every form then needs auth.CSRFInputFromCtx); see
auth for the pattern.
Login screen (login_form block)
A login_form screen block renders a plain HTML sign-in form that posts
(urlencoded, no JavaScript) to the auth battery's POST <action> handler,
which detects the form post, sets the session cookie, and 303-redirects to
?next=. Put it on a screen to give the app a real, clickable login:
screens: - name: login route: /login body: - kind: login_form text: Sign in # form heading props: action: /auth/login # default <base_path>/login next: /admin # where to land after login (default /) register_href: /signup # optional link
An app spec with app.auth.enabled: true and no login_form on any
screen gets one anyway: the generator adds a /login screen holding a
login_form with the defaults above (it links a signup_form screen's
route as register_href when there is one), the way a writable list gets
its <list>/create. Gated screens, the Sign in button and a failed form
login all point there, so none of them lands on a 404. It takes the
marketing layout when the app spec has marketing screens, and is not
added to nav. Write your own login_form screen (any route), or a
screen named login or routed /login, and nothing is added. gofastr
pack drops the added screen, as it drops a synthesized create screen.
Admin back-office (app.admin)
app: admin: enabled: true path: /admin # default /admin role: admin # required role (default "admin") login_path: /login # unauthenticated GET → redirect here seed_email: admin@you.com # bootstrap admin account (created if absent) seed_password: change-me
With enabled: true the generated app registers the admin battery,
an auto-generated HTML back-office at path: an overview and audit page,
and an editable CRUD screen for every registered entity at <path>/e/<table>
(View / Edit / Delete / Create), all proxying each entity's own CrudHandler so
validation, owner/tenant scope, hooks, and events apply. Access is gated by
role; an unauthenticated GET is redirected to login_path (pair it with a
login_form screen) instead of a bare 401, and a signed-in user without the
role gets 403. When seed_email/seed_password are set, the app bootstraps
that admin account on a fresh database (idempotent: created only when absent),
so the back-office is reachable on first boot. The generated main.go turns
on the audit log for every entity (fwApp.WithAuditLog, which creates
audit_log), so each admin write leaves a row and the audit page and the
dashboard's recent activity read them on a fresh database. Requires
app.auth.enabled.
The Queue navigation item appears only when the host explicitly supplies a
queue.Browsable backend to the admin battery; generated apps do not imply a
queue they have not configured.
Installable PWA (app.pwa)
app: pwa: enabled: true name: Meridian # optional: defaults to app.name at serve time short_name: Meridian # optional home-screen label description: ... # optional start_url: / # optional, default / scope: / # optional, default / display: standalone # optional: standalone | fullscreen | minimal-ui | browser theme_color: "#4f46e5" # optional; also drives the placeholder icon color background_color: "#ffffff"
With enabled: true the generated app passes uihost.WithPWA(...) to the
UI host: manifest, service worker, offline screen, registration script;
see the PWA guide for the full runtime behavior. It also
scaffolds three replaceable placeholder icons under
<static_dir>/icons/: icon-192.png, icon-512.png, and
icon-maskable.png (colored from theme_color, falling back to the
theme's primary token). A fresh app.pwa app is installable
immediately; swap the PNG files for real branding when you have it.
When no static_dir is set, the generator defaults it to static/ so
the icons have a home (same rule as self-hosted fonts). Only the fields
you declare are emitted into main.go; the framework applies defaults
at serve time. A custom api_prefix or auth base_path is emitted as
DenyPaths so the service worker's never-precache/never-intercept
guarantee follows the app's real mounts.
Strict SEO + a11y defaults (always on)
Generated apps ship uihost.WithStrict() plus a surface that passes
every strict check (see gofastr docs strict-mode):
- Site description:
app.descriptionwhen set, otherwise derived from the app name and entities ("Demo: manage products and orders."). Write a real one; the derivation is a floor, not copy. - Sitemap + robots:
/sitemap.xmlrooted at theAPP_BASE_URLenv var (fallback:app.base_url, thenhttp://localhost:8080; set the env when deploying),/robots.txtkeeping/__gofastr/and the admin back-office (when enabled) out of the index. The sitemap excludes the admin path too. - Per-screen SEO: every screen's title falls back to its name; a
screen without an app spec
descriptionemits the documented zero-valueScreenSEOopt-out instead of empty filler. Fill the description field for pages that should rank. - Axe gate (
axe_test.go): boots the built binary and runs axe-core over every sitemap page under both color schemes, in two passes: an anonymous browser for public and guest-only pages, and a separately-authenticated browser (seeded admin, logged in through the auth battery's HTTP endpoint so no login screen is required) for gated screens; one shared login would make guest pages redirect and cover the wrong screen. Concrete sitemap pages matching a gated dynamic pattern are routed to the authenticated pass too. Every scan asserts it landed on the intended route before auditing. Its scans record the axe-coverage manifest strict dev boots verify, so a hand-added screen without a scan failsgofastr devwith a guided finding (first boot before any test run warns instead of failing). Dynamic screens (/orders/:id) need a concrete URL in the ownedaxeExtraPagesslice, andStaticPathson the screen for strict mode to demand them.
The consequence to know: after adding a screen by hand, run
go test ./... once (needs Chrome) so the manifest covers it;
gofastr dev names the missing screen until then.
Public LLM markdown (app.llm_md)
app: llm_md: true
Emits uihost.WithPublicLLMMD(), which mounts the LLM-friendly page
documentation on the generated app: /llm-pages.md (an index of every
registered screen) and /<screen-path>/llm.md per screen (/ → /llm.md,
/docs/ → /docs/llm.md). Application-level and per-screen NoLLMMD
opt-outs keep working. Off by default because the documents enumerate every
screen and its data shape: useful for AI agents in trusted environments,
schema disclosure elsewhere. app.pwa and app.llm_md are independent:
enabling one never emits the other.
Locale in the URL (app.locale_prefixes)
app: locale_prefixes: true
Emits framework.WithLocalePrefixes(). English keeps the plain paths and
each locales/<tag>.json is served under /<tag>/: /es/pricing is the
pricing screen in Spanish, with hreflang alternates on every page, a sitemap
entry per locale, and robots rules repeated under each prefix. A marketing
footer gets ui.LanguageMenu in its Language slot, so a reader can switch
from any page. Off by default,
because it pins the plain paths to English: without it the browser's
Accept-Language picks the locale. The i18n guide
covers the routing rules and the language switcher.
app.module and the enclosing go.mod
Generated imports are <app.module>/<output-dir>. By default the output
directory is the module root (so the only project-local import is
<app.module>/entities, since the app files are a flat package main with no
importable subpackage); with --out=<dir> or app.output_dir set, it's
that subpackage path relative to the working directory. Either way app.module
must match the Go module you generate into:
module:omitted → it is derived from the go.mod enclosing the working directory (plus the relative path from the module root when generating in a subdirectory). Inside a module, omittingmodule:therefore also emits amain.go.module:set and equal to the enclosing module → fine.module:set but different →gofastr generateandgofastr validatefail with the expected value; generated code importing a module the enclosing go.mod does not declare cannot compile. Fixmodule:or remove it to derive automatically.- No enclosing go.mod → the declared value stands unchecked (you are generating a standalone tree).
gofastr validate anchors this check at the app spec file's directory;
gofastr generate anchors it at the working directory (where the output is
written).
Startup banner
The generated main.go registers its Server running at http://... banner
via framework.App.OnReady, which fires only after auto-migrate, seeds,
hooks, and the port bind all succeeded. A migrate failure exits non-zero
without ever printing a success banner.
Screen body blocks support the legacy sugar keys (type, text, level,
class, href) and property-based nodes through kind, props, and
children. Supported render kinds include structural tags, text tags, forms,
buttons, links, inputs, images, lists, tables, and raw nodes. island wraps a
block in island.NewIsland; widget wraps it in component.NewWidget.
actions generate GoFastr screen actions and add the required data-action
attributes to the rendered node.
Use kind: entity_list for a browser-refreshable table drawn by entityui
from the entity's own declaration. It requires an entity with crud: true
and known fields, accepts limit, empty_text and mode: cards. The
list's search box queries the entity's search_fields:, and its one-click
filters come from the entity's display: facets:.
screens: - name: home route: / body: - kind: entity_list text: Latest posts entity: posts fields: [title, status] limit: 5 empty_text: No posts yet.
facets: accepts only enum, bool, and relation columns; entityui renders
them as one-click filters and applies each as a server-side equality
filter that composes with search, sort, and pagination.
Supported UI action events are click, input, change, and submit.
client_js is copied into generated Go as a string and compiled by the normal
GoFastr action compiler. Use the public G runtime helper (window.__gofastr)
inside client_js; custom server behavior still belongs in Go. When one block
declares multiple actions, the first action uses the standard data-action
attribute and later event-specific actions use data-action-<event>. A block
can declare at most one action per event. If a click action is combined with
other action events on the same block, put the click action first so the
standard click delegate can reach it.
Custom behavior is intentionally not inferred from YAML. Auto-generated entity CRUD, OpenAPI, and MCP tools are real framework behavior. Endpoint handlers, middleware bodies, plugin internals, and helpers are emitted as Go stubs because the generator cannot safely invent application-specific implementation logic.
Validation
Run all validations without generating anything:
gofastr validate gofastr.yml # or a directory of app spec filesgofastr validate parses the app spec, runs every generate-time check
(including the app.module / go.mod consistency check and a full render
pass), and exits 0 when valid, 1 otherwise. Errors are written to be iterated
on: each names the app spec file (and the line, where the parser provides
one), what is wrong, and the remedy. gofastr generate runs the same checks
before writing any file.
The generator rejects:
- unknown top-level or section keys, except
x_/x-extension keys - duplicate entity names, routes, endpoints, middleware, plugins, or helpers, and a field name an entity declares twice
- a screen route a browser cannot ask for: one that does not start with
/, has an empty,.or..segment (//evil.comis a protocol-relative link,/x/../adminis/adminto a browser), holds?,#, a backslash, a space or a control character, or names one parameter twice (/x/{id}/{id}) - a screen route at or under an entity's CRUD mount (
/api/posts,/api/posts/{id}, or/postswithapi_prefix: ""): the framework refuses to boot on the mount itself, and under it the CRUD routes ({id},llm.md,_events) answer instead of the screen. A fragment with noapp:section (generate app --add) is not checked for it, since it does not carry the project'sapi_prefix - two screen routes that differ only in parameter names or syntax
(
/posts/{id}and/posts/{slug}or/posts/:id): the router tries dynamic routes in registration order, so the second would never render - two app spec items that would declare the same Go name in one generated
package, or a declaration that takes the name of a package the file
imports (a hook handler named
http, a helper whose stub collides withRegisterGenerated). The check runs over the whole package, not file by file, and the error names both files; rename one of the items - an entity
table:that is not letters, digits and underscores: the table name reaches two sinks that neither re-escape nor re-validate it: the generated typed client emits it into Go string literals, and the runtime interpolates it into DDL as a bare SQL identifier. The entityname:is already constrained to a Go identifier;table:was the way around that. - a table, field or index name SQL cannot carry, which would build and
then fail at boot or on its first query: a name with a hyphen, space or
dot (
first-name,last name), and a name over 63 bytes (Postgres keeps only the first 63, so two longer names that share them name one table or column). An SQL keyword such asorder,group,default,values,limit,on,select,userordescis fine: AutoMigrate and the CRUD routes write a keyword name quoted ("order") and every other name bare. Go words such astype,funcandrangeare fine too. - two field names of one entity that become one Go struct field or one
Postgres column (
Titleandtitle,_noteandnote): the generated code would not compile, and Postgres folds the unquoted names AutoMigrate writes into one column. A name that does not start with a lower-case letter is fine on its own:Titleis served and sent asTitle,_noteasNote, and the generated typed repo writes and reads both - two entities on one table, compared the way Postgres folds an unquoted
name and with the engine's default schema dropped (
table: Itemsortable: public.itemsand an entity nameditems): the second entity's routes collide with the first's, or both entities share rows - an entity on a table a battery of the generated app keeps for itself:
auth_users,auth_users_oauth_links,auth_sessions,audit_log,admin_saved_views,gofastr_modulesand_gofastr_migrate_lock, bare or qualified by the default schema (public.auth_users,main.auth_sessions). An entity namedauth_userswas migrated onto the accounts table and served it to every signed-in user through its CRUD routes. - enum
values:entries carrying a quote, backtick, backslash, or newline (they break the generated Go), or carrying a terminal-invisible character — a C1 control (U+0080–U+009F), a bidi override, or a zero-width/BOM rune: the value reachesllm.mdand/openapi.jsonverbatim, where it would reorder or hide text from the human reviewing what the agent authored - relation-typed fields (
type: relation) without ato:target, or whoseto:names an entity the app spec does not declare; without this check the built app would crash at startup with "auto-migrate: entity has BelongsTo to unknown entity" - relations, endpoint entity references, and entity list blocks that target unknown entities
app.modulevalues that conflict with the enclosing go.mod (see above)- entity list blocks that target non-CRUD entities or fields the entity does not define
- unsupported HTTP methods or screen types
- unsupported UI action events, duplicate action names, duplicate action events
on one block, unreachable combined click actions, or missing
client_js - endpoints whose
entitythe app spec does not declare, two endpoints linking one handler, or a handler in a filegenerate appwrites - an entity that lists
endpoints:under itself (move each to the top-level list) - scalar posture fields given a list or map shape:
access:permissions,owner_field,tenant_field,cross_owner_read,screens[].access.role, andapp.admin.roleeach name one value; a list or map there used to decode as the zero value and silently drop the gate it was configuring - an
app.auth.base_paththat is not an absolute path with no trailing slash (/auth/,auth,//host,/a//band/..are refused): the battery mounts its routes at it, and the generated forms post there - a
login_form/signup_formaction:that is not a safe link target (it must passurlsafe.Anchor: http(s), relative, or fragment schemes only — the form posts its credentials there) - unsafe output directories
- two declared names that meet in one generated identifier, such as
a field whose Go name matches a method the generator writes on the
same type (
UnmarshalJSON)
Middleware, plugin, helper, and screen name:s that are not valid
identifiers are skipped at emit: the generator refuses to interpolate
an ungated name into a Go declaration slot, and gofastr generate
(which validates first) is where the name is reported.
Unscoped entities (gofastr generate warning)
gofastr generate warns on every auto-exposed entity (crud
defaults on, or mcp: true) that sets none of owner_field, access
(with at least one non-blank permission), or multi_tenant, regardless
of what its fields are named. CRUD is secure-by-default, so such an
entity already requires an authenticated session (anonymous requests get
401); the residual risk the warning names is cross-user: every
signed-in caller can read, create, update, and delete every OTHER
user's row. Genuinely public data is a legitimate shape; declare it
with public: true to opt all the way out of the session gate, but for
per-user rows set owner_field, and gate by role with access:. The PII
rule below is the stricter subset that also fails gofastr validate.
Unscoped PII (gofastr validate error, gofastr generate warning)
Per-user data must be scoped before auto-CRUD/MCP exposure. When an entity
is auto-exposed (crud defaults on, or mcp: true), declares PII-shaped
fields (names containing tokens like email, phone, address, ssn,
password, token, secret, card, …), and sets none of owner_field,
access (with at least one non-blank permission), or multi_tenant,
every authenticated user could read and write every other user's row on
the generated API (the secure-by-default gate already stops anonymous
callers; the exposure here is cross-user). Enabling app.auth alone does
not suppress the rule: a session is necessary to reach the API but
not sufficient to scope it; only per-entity scoping does that.
gofastr validate reports this as an error (exit 1),
naming the entity, the matched fields, and the remedies; gofastr generate
prints the same finding as a warning and proceeds (suppressed under --json
to keep stdout machine-parseable). gofastr audit lint also reports it
(rule unscoped-pii) when a conventional gofastr.yml / gofastr.yaml /
gofastr.json sits at the audited root. Fix it with any of:
entities: - name: patients owner_field: user_id # per-user rows (see entity-declarations.md) # or: access: { read: patients:read, ... } # RBAC # or: multi_tenant: true # tenant scoping
The match is heuristic and name-based: relation-typed FK columns are
skipped (the target entity is checked on its own), and matching is
per-token (creditCard and credit_card both match card;
cardinality does not).
Owner-scoped uniqueness (gofastr validate and gofastr generate warning)
On an entity with owner_field, a field-level unique: true or a
unique index that leaves out the owner column makes the value unique
across every account. One user's row then blocks every other user's
create with the same value, and the 409 tells that user the value
exists in someone else's rows (an existence oracle on private data).
Both commands warn, naming the entity, the field or index, and the
composite to declare instead; neither fails. To scope uniqueness per
owner, drop the field's unique and add a composite index that leads
with the owner column:
entities: - name: customers owner_field: user_id indices: - name: idx_customers_owner_email columns: [user_id, email] # unique within one account only unique: true fields: - name: email type: string required: true # no field-level unique
Keep the global form only when the value is meant to be unique across
all accounts, such as a public handle. A read_only field is
server-assigned (an auto_generate: uuid order number), so no caller
can collide with it or probe it, and the warning skips it.
Testing contract
App spec changes should be proven with a generated-app E2E test: run the real CLI against an app spec, compile the generated app binary, start that generated binary as a separate process, exercise HTTP CRUD, OpenAPI, MCP tools, static assets, and drive generated UI in a real browser so islands, widgets, runtime actions, and DOM updates are covered together. Do not satisfy this with a test-only hand-written app shell that imports generated packages; that misses the app-generator boundary.
Generated e2e_test.go is driver- and OS-portable
The synthetic CRUD lifecycle chooses an entity whose required and key
fields it can fill. It skips required relations and entities with required
or key fields constrained by min, max or pattern, rather than sending
invalid sample values. Screen rendering checks still cover those screens.
Every generated app ships an e2e_test.go (gated by -short) that builds the
binary, boots it as a child process, and drives the real HTTP endpoints. Two
portability rules are built into that template:
- Windows binary name. The build target gets a
.exesuffix whenruntime.GOOS == "windows"soexec.Command(bin)resolves on Windows; the bare"app"the old template produced cannot be exec'd there. - Database bootstrap follows
db.driver. A SQLite/empty-driver app spec boots against a throwawayDATABASE_URL=file:<tmp>/e2e.db. Apostgresapp spec links onlylib/pq, which cannot open that file DSN, so the test instead carves a disposable database from the env-providedTEST_POSTGRES_DSNadmin DSN and points the child at it (DATABASE_URL+DB_DRIVER=postgres). WhenTEST_POSTGRES_DSNis unset or Postgres is unreachable the test skips (t.Skip). That keeps driverless CI green-by-skip instead of timing out with a misleading "server did not become ready". SetTEST_POSTGRES_DSNto apostgres://DSN whose role canCREATE DATABASE/DROP DATABASEto exercise the postgres path locally.
With gated screens and a seeded admin, the test signs in as that admin and
then requires each gated screen to answer 200. The admin is seeded with the
admin role and user, so a screen whose access.role names any other role
must answer it 403 instead. Its client does not follow a
redirect to the login screen's route, so a sign-in that left no session
fails with the redirect's 303 instead of passing on the login page.
Common mistakes
- Deploying with
dev_modeleft at its default. Omittingapp.auth.dev_modemeans true: an HTTP-friendly session cookie and a per-process JWT secret that invalidates bearer tokens on every restart. Before deploying, setDevMode: falseand a realJWTSecret(from a secret manager) in the generatedapp.goauth.AuthConfig; the emitted code is yours, so edit it rather than regenerating. Serve HTTPS. (If you'd rather set it before the one-shot generate, setdev_mode: false+jwt_secretin the app spec, butdev_mode: falsewithoutjwt_secretfails validation, and the generated app's auth battery would refuse to boot.) - Expecting
app.auth: enabledto protect entity data. The session middleware is pass-through for anonymous requests. Only per-entityowner_field,access, ormulti_tenantgate the generated CRUD and MCP endpoints; that's why the unscoped-PII check fires even with auth enabled. - Copying an entity-screen engine into a generated app. The screens
draw through
appUI(framework/entityui, built inextensions.go): keep the generated builders and yourappExtensionshooks there, and resist forking the drawing into the screen files. A private copy does not receive fixes when GoFastr is upgraded. - Writing flow-style inline maps.
core/yamlrejects{name: x, type: relation}; every map must be indentedkey: valuelines. Anchors, aliases, block scalars, and tabs are also out. - Setting
module:to something other than the enclosing go.mod.gofastr validateandgofastr generatefail with the expected value, because the generated imports could never compile. Omit the key to derive it from go.mod automatically. - Declaring
type: relationwithoutto:. Validation rejects it (and a missing target entity too); without the check, the built app would crash at startup with "auto-migrate: entity has BelongsTo to unknown entity".