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:

3 lines
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.

4 lines
⚠ --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).

AreaGenerated from the app specHand-written Go
Marketing pageshero, page_header, card, pricing, stat_row, callout, markdown, link_button blocks composed from framework/ui; auth-aware headerAny bespoke section not in the block catalog
AuthRegister/login/logout/me wiring, session middleware, login_form/signup_form screens, guest-only redirects, bootstrap adminCustom auth flows (SSO, MFA policy, email verification UX)
Dashboard stat/chart blocksstat_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 listentity_list block drawn by framework/entityui from the entity's display:: server-side search/sort/pagination as query params, one-click facets:, optional "New" buttonFiltering on computed/derived columns, range/date filters, multi-select facets
Entity recordentity_detail block: the record page holds the edit form and a button per states: move; create: synthesizes <list>/createMulti-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-officeFull CRUD admin over every entity (app.admin), role-gatedCustom admin actions beyond CRUD
Seed dataExplicit rows: + auto-generated count:/weights: demo rowsFixtures with complex relations / business invariants
Theme + dark modeColor + font tokens, dark sub-map, header theme togglePer-component theme overrides
FontsSelf-hosted webfonts fetched to static/fonts/ at generate timeSupplying .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, é, \U0001F600 and the rest of the table); \xNN is 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, TRUE and the three false spellings are booleans; null, Null, NULL and ~ 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. .inf and .nan are strings too: nothing in a spec reads one.
  • a value starting with ! is a tag, and tags are refused: the core !!str and a local !env HOME alike. 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:

7 lines
# Invalid: inline map
- {name: author_id, type: relation, to: users}

# Valid: indented map
- name: author_id
  type: relation
  to: users

Shape

124 lines
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 of gofastr.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:

KeyMeaning
idStable identifier; also the app spec's name for the hook
entityThe entity whose lifecycle the hook runs on
whenbefore_create, after_create, before_update, after_update, before_delete, after_delete, before_list, after_list
handlerThe Go func to write, registered via framework/hook
descriptionWhat 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 defines screenRegistrar{order int, fn func(fwApp *framework.App, site *app.App, db *sql.DB)}, a screenRegistrars slice, and mountGenerated(...), which sorts the registrars by order and runs them. Byte-identical for any screen/entity count. Add a screen by dropping in a new screen_<name>.go; never edit the seam.
  • screen_<snake>.go: one per authored non-CRUD screen (marketing pages, dashboards, auth forms): the screen struct, its Screen* methods, RenderCtx, a mount<Screen> func holding the site.Register/site.RegisterScreen call, and an init() that appends one screenRegistrars = 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 through appUI (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:

7 lines
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(), which main.go installs. It embeds locales/, loads every <tag>.json there with i18n.LoadJSONCatalog, and adds the en-XA pseudo-locale when GOFASTR_PSEUDO_LOCALE=1.
  • i18n_test.go: TestCatalogsMatchPhrases runs i18n.CheckCatalog over every locales/<tag>.json, so go test fails on a key no phrase defines or a translation that drops a {{placeholder}}. entity.* keys name the app's data and are skipped. Add i18n.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, which go:embed requires.

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.

2 lines
# 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:

2 lines
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 placeholder name field (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 its Render.

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.

7 lines
# 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"
1 lines
gofastr generate screen signup --from-a11y=signup.aria.yml

Each role becomes a block from the UI component catalog, so the screen file holds framework/ui calls and no CSS:

Snapshot roleBlock → 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
buttonaction_button
textbox, searchbox, option-less comboboxtext_field
spinbuttonnumber_field
checkbox / switchcheckbox / switch
combobox or listbox with optionsselect_field
radiogroup, a named group holding only radios, or adjacent radiosradio_group
form, or a run of adjacent controlscustom_form
table, griddata_table
listitem_list
navigationnav_links of its links
named region, article, group, dialog, …section
alertcallout (name as title, text as body)
separatordivider

Mapping rules worth knowing:

  • Controls next to each other (after unwrapping generic layout wrappers) become one custom_form. Help, hint and error text between them, and a named group (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: point action at your handler. Forms never nest: a form inside a form gives its fields to the outer one.
  • A text node 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.
  • banner and contentinfo are skipped: the app layout draws the site header and footer. img is 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 a navigation, links and buttons in table cells (cells become text), non-radio content in a radiogroup, and a textbox's current value.
  • A javascript: or other unsafe /url becomes #, 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:

1 lines
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.
3 lines
⚠ 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_list renders appUI.List("<entity>") with: fields: → .Columns(...), limit: → .PageSize(n), empty_text: → .Empty(...), mode: cards (or table) → .As(...), create: false → .NoCreate(), bulk: true → .Bulk() (row selection, the bulk bar and Export CSV; refused on any other block kind), and a text: 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 actual id column: 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_detail screen has no page at <list>/<id>, so its lists without create: true render with .NoLinks(): the rows draw as text with no row menu, instead of linking every row to a 404. A create: true list keeps its links (NoLinks also removes New), so give a writable entity a detail screen.
  • entity_detail renders appUI.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>/edit screen 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: true on a list synthesizes the create screen at <home list>/create (not /new), rendering appUI.Create("<entity>").Base(<home route>). It inherits the home screen's layout + access and is not added to nav.
  • entity_form renders 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 with primary_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 for int, 0.0001 for decimal and any for float) for forms not bound to one entity. The record form on the entityui screens is drawn from the entity's display: form:. Submitted control names use the default camelCase API wire keys (address_line_1 becomes addressLine1) so field refusals land on the matching input. Control IDs, label lookups and relationship targets keep their declaration names.
  • entity_form with mode: edit on 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(...). A text: 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 keyReplacement
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:

25 lines
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:

1 lines
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: true to an entity_list block → the list shows a "New <Singular>" button and the generator synthesizes a <route>/create screen rendering appUI.Create("<entity>") (the record form, empty).
  • Every entity_detail screen renders the record page with Delete and Duplicate actions and the edit form in the page (drawn from the entity's display: 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:

8 lines
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:

11 lines
  - 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.

KindComponentProps (required in bold)
custom_formui.Formaction (http(s) or root-relative), method (get\post), submit (submit label), hide_submit; child blocks are the fields
text_fieldui.TextFieldlabel, name, placeholder, help, required, disabled
number_fieldui.NumberFieldsame as text_field
checkbox / switchui.Checkbox / ui.Switchlabel, name, checked, disabled
select_fieldui.Selectlabel, name, options, required, disabled
radio_groupui.RadioGrouplegend, name, options, required
action_buttonui.Buttonlabel, variant (primary\secondary\ghost\danger), type (button\submit\reset, default button), disabled
data_tableui.DataTablecolumns (list of headers), rows (list of lists of cell text), caption, caption_hidden, id (needed when two tables share a caption)
item_listhtml.UnorderedListordered; each child block is one item
nav_linkshtml.Nav + ui.Clusterlabel (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.

25 lines
- 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.

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:

6 lines
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.

6 lines
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:

9 lines
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)

6 lines
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.SignOut control that POSTs to <base_path>/logout and 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.JWTSecret
  • DATABASE_URL → the DB DSN (a credentialed DSN is also stripped from the emitted dbURL constant and the getEnv fallback)
  • 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:

10 lines
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)

8 lines
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)

11 lines
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.description when 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.xml rooted at the APP_BASE_URL env var (fallback: app.base_url, then http://localhost:8080; set the env when deploying), /robots.txt keeping /__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 description emits the documented zero-value ScreenSEO opt-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 fails gofastr dev with a guided finding (first boot before any test run warns instead of failing). Dynamic screens (/orders/:id) need a concrete URL in the owned axeExtraPages slice, and StaticPaths on 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)

2 lines
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)

2 lines
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, omitting module: therefore also emits a main.go.
  • module: set and equal to the enclosing module → fine.
  • module: set but different → gofastr generate and gofastr validate fail with the expected value; generated code importing a module the enclosing go.mod does not declare cannot compile. Fix module: 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:.

10 lines
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:

1 lines
gofastr validate gofastr.yml      # or a directory of app spec files

gofastr 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.com is a protocol-relative link, /x/../admin is /admin to 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 /posts with api_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 no app: section (generate app --add) is not checked for it, since it does not carry the project's api_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 with RegisterGenerated). 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 entity name: 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 as order, group, default, values, limit, on, select, user or desc is fine: AutoMigrate and the CRUD routes write a keyword name quoted ("order") and every other name bare. Go words such as type, func and range are fine too.
  • two field names of one entity that become one Go struct field or one Postgres column (Title and title, _note and note): 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: Title is served and sent as Title, _note as Note, 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: Items or table: public.items and an entity named items): 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_modules and _gofastr_migrate_lock, bare or qualified by the default schema (public.auth_users, main.auth_sessions). An entity named auth_users was 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 reaches llm.md and /openapi.json verbatim, where it would reorder or hide text from the human reviewing what the agent authored
  • relation-typed fields (type: relation) without a to: target, or whose to: 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.module values 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 entity the app spec does not declare, two endpoints linking one handler, or a handler in a file generate app writes
  • 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, and app.admin.role each 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_path that is not an absolute path with no trailing slash (/auth/, auth, //host, /a//b and /.. are refused): the battery mounts its routes at it, and the generated forms post there
  • a login_form/signup_form action: that is not a safe link target (it must pass urlsafe.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:

5 lines
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:

11 lines
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 .exe suffix when runtime.GOOS == "windows" so exec.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 throwaway DATABASE_URL=file:<tmp>/e2e.db. A postgres app spec links only lib/pq, which cannot open that file DSN, so the test instead carves a disposable database from the env-provided TEST_POSTGRES_DSN admin DSN and points the child at it (DATABASE_URL + DB_DRIVER=postgres). When TEST_POSTGRES_DSN is 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". Set TEST_POSTGRES_DSN to a postgres:// DSN whose role can CREATE DATABASE / DROP DATABASE to 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_mode left at its default. Omitting app.auth.dev_mode means true: an HTTP-friendly session cookie and a per-process JWT secret that invalidates bearer tokens on every restart. Before deploying, set DevMode: false and a real JWTSecret (from a secret manager) in the generated app.go auth.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, set dev_mode: false + jwt_secret in the app spec, but dev_mode: false without jwt_secret fails validation, and the generated app's auth battery would refuse to boot.)
  • Expecting app.auth: enabled to protect entity data. The session middleware is pass-through for anonymous requests. Only per-entity owner_field, access, or multi_tenant gate 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 in extensions.go): keep the generated builders and your appExtensions hooks 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/yaml rejects {name: x, type: relation}; every map must be indented key: value lines. Anchors, aliases, block scalars, and tabs are also out.
  • Setting module: to something other than the enclosing go.mod. gofastr validate and gofastr generate fail with the expected value, because the generated imports could never compile. Omit the key to derive it from go.mod automatically.
  • Declaring type: relation without to:. 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".