Release History

Changelog

All notable changes to Grit are documented here. Each release includes new features, bug fixes, and any breaking changes you need to be aware of.

Releases by week

481 releases. Every line links to its full entry below.

September 28 to October 4, 2026

31 releases
  • v3.362.0An application you can read, that CI keeps honest
  • v3.361.0Traces, when you point them somewhere
  • v3.360.0Rules about one row
  • v3.359.0A runbook for the part of an upgrade that needs judgment
  • v3.358.0A generated resource now comes with its tests
  • v3.357.0What each route asks of a caller
  • v3.356.0The route table, in the browser
  • v3.355.0The saga runs screen
  • v3.354.0grit generate workflow: steps that finish or are undone
  • v3.353.0The upgrade machinery is on the site
  • v3.352.0The MCP server has ten tools and two modes
  • v3.351.0llms.txt, for the framework and for the API you build with it
  • v3.350.0/api/health answers in four states, and reports storage
  • v3.349.0A profile picture is cropped before it is uploaded
  • v3.348.0The audit tables have a read side
  • v3.347.0The auth handlers stop reading the users table themselves
  • v3.346.0One lockout, not one per factor
  • v3.345.0A public form stops losing submissions when two arrive at once
  • v3.344.0The access-review list was 4n queries, and dropped every error
  • v3.343.0SSO and SAML run no queries of their own
  • v3.342.0Two-factor has a service, and the races live in one place
  • v3.341.0The roles API has a service
  • v3.340.0The user endpoints have a service
  • v3.339.0Sign-in runs no queries of its own
  • v3.338.1The templates are LF on every machine
  • v3.338.0Templates as files, starting with the handlers
  • v3.337.1The docs now have to follow the rules the framework states
  • v3.336.1The security scan is green again
  • v3.336.0Scaling readiness, at the top of Observability
  • v3.335.0A post you could publish once and then never edit
  • v3.334.0grit scale: measure, then do one thing

September 21 to 27, 2026

41 releases
  • v3.333.0The production stack no longer asks for a subnet it does not need
  • v3.332.0Deploy to Railway in one command
  • v3.331.0A passkey could be registered and never used
  • v3.330.0The account screen was the one page in the admin with no way back
  • v3.329.0Grit’s slogan was in the browser tab of every app built with it
  • v3.328.0One line of CSS had killed every border colour in both apps
  • v3.327.0ADMIN could not be edited, and the page told you to use a control that did not exist
  • v3.326.0Verifying your own email in development, and a banner you can put away
  • v3.325.0A theme now repaints the dashboard, not just the sign-in page
  • v3.324.0The theme picker offers the themes
  • v3.323.1grit update worked once on Windows, then failed forever
  • v3.322.0Five more sign-in layouts
  • v3.321.0One prompt to confirm your email, not two
  • v3.320.0Sign in with a link, for the accounts that never had a password
  • v3.319.0One Account screen, under System → Security & Access
  • v3.318.0The admin, audited in a browser
  • v3.317.0One colour axis, different colours per product
  • v3.316.0grit plugin update: a plugin fix that reaches installed projects
  • v3.315.0A subscription now records what it charged
  • v3.314.0SQLite stops saying "database is locked"
  • v3.313.0Subscriptions, in the Stripe plugin
  • v3.312.0Stored images were not loading in development
  • v3.311.0Stripe payments: grit plugin add stripe
  • v3.310.0An Expo app with push notifications opens in a browser
  • v3.309.0A signed-in web page outlives its access token
  • v3.308.0Video plays in the browser
  • v3.307.0grit plugin add video: clips converted for every client
  • v3.306.0The security scan passes on a project with the Expo app
  • v3.305.0One React in a project with the Expo app
  • v3.304.0An upgraded project’s lint is green too
  • v3.303.0A duplicate value answers 409, and a new project’s CI passes on its first push
  • v3.302.0Tidier generated code, and date windows that are right on SQLite
  • v3.301.0The desktop app catches up: realtime channels, and generated screens that type-check
  • v3.300.0Push notifications: grit plugin add push
  • v3.299.0Generated Expo screens type-check
  • v3.298.0grit env: clone a project and run it
  • v3.297.0Realtime works in production Next.js apps
  • v3.296.0First names seed as first names, and a CRM built end to end
  • v3.295.0Seed a million rows in under a minute: batched, resumable seeding with grit seed --count
  • v3.294.0Ten new field types: email, url, domain, tel, country, color, percent, rating, time and json
  • v3.293.0Biome replaces ESLint and Prettier, and pnpm lint finally passes

September 14 to 20, 2026

38 releases
  • v3.292.0Less dead admin code, real types instead of any, and named upload components
  • v3.291.0No panics on a missing user, no leftovers, an allowlist read once, named ticket values, and SSO errors that keep their cause
  • v3.290.0A refresh that reloads only the page, lazy table images, a quiet idle timer, and sidebar groups that open themselves
  • v3.289.0One Redis connection for the jobs screen, a streaming GDPR export, fewer queries per write, and a faster notification bell
  • v3.288.0Forwarded headers only from trusted proxies, a tighter image policy, safe stored links, and a checked SSO redirect
  • v3.287.0Sign-in that reveals nothing, encrypted two-factor secrets, and a field encryption key in every new project
  • v3.286.0Tiptap 3, one editor that keeps your formatting, a paginated blog admin, and shared types for built-in models
  • v3.285.0No theme flash, a server-rendered shared form, one source for errors, a ticket service, and mail on the queue
  • v3.284.0Supported images with health checks, no guessable seed accounts, private dev storage, and a faster admin
  • v3.283.0GDPR erasure needs a reason, the API address lives in one place, and four admin screens use hooks
  • v3.282.0Race-free signups, working user filters, tickets that do not leak, honest dashboards, and errors.Is everywhere
  • v3.281.0Client events between browsers, a hub that reports itself, and no panic on a revoked session
  • v3.280.0Storage helpers, named disks, private files that stay private, and AWS S3 with no endpoint
  • v3.279.0Work on images in code, and one-sided resizes that no longer produce empty images
  • v3.278.0Mail: queued sending, grit generate mail, and a preview of the real templates
  • v3.277.0Realtime presence, and a crash when a socket closed mid-send
  • v3.276.0Storage behind a Disk interface, and a local driver
  • v3.275.0Mail drivers: SMTP, Resend, Mailgun, Postmark, SendGrid, Amazon SES, log and failover
  • v3.274.0Realtime channels with authorization
  • v3.273.0Realtime: the WebSocket refuses other origins, the module switch works, and connections are capped
  • v3.272.0Connection pools, the response cache, and a lighter admin panel
  • v3.271.0Background work: audit and activity writes, sync pushes, the outbox relay, cleanup jobs
  • v3.270.0Performance under load: request context, flags, API keys, dashboard stats, images
  • v3.269.0Deployment hardening: images, the production stack, CI and the admin gate
  • v3.268.0Account security: replays, provider sign-in, user records and profile changes
  • v3.267.1The upload codes from v3.267.0 are in the error catalogue
  • v3.267.0A user can no longer claim, or delete, a file someone else uploaded
  • v3.266.0Server errors reach the log, and their text stops reaching clients
  • v3.265.0A resource list is one request per view, per search and per save
  • v3.264.0Resource lists and the dashboard stop downloading spreadsheet and chart code up front
  • v3.263.0Admin pages start loading without waiting for the signed-in user
  • v3.262.0Removing the blog leaves an API that compiles
  • v3.261.0Two migrations in the same millisecond no longer collide
  • v3.260.0The home page and blog render their posts on the server
  • v3.259.0A project’s CI scans its real code, runs its tests, and releases
  • v3.258.0Sync pull stops losing rows, and works on MySQL
  • v3.257.0CSV imports over 1 MB work, take turns, and look each group up once
  • v3.256.0A list page on a million rows answers in 10 ms instead of 122

September 7 to 13, 2026

65 releases
  • v3.255.0An XLSX export no longer holds the whole table in memory
  • v3.254.0Streams stream, and gzip stops costing a megabyte a response
  • v3.253.0The health check no longer stalls Redis
  • v3.252.0Uploads over 10 MB work, and a slow export is no longer cut off
  • v3.251.0A failed database write no longer passes for a successful one
  • v3.250.0An image cannot claim enough pixels to take the API down
  • v3.249.0A generated API builds on Go 1.26.6, and govulncheck finds nothing
  • v3.248.0MinIO no longer runs on minioadmin, and side containers get only their own settings
  • v3.247.0A delegated role can no longer make itself ADMIN
  • v3.246.0Database backups are no longer in a public bucket
  • v3.245.0A two-factor code can be used once, and cannot be guessed
  • v3.244.0A refresh token is not an access token, and logging out ends the session
  • v3.243.0Production is the default: login limits that fire, no SQL console, no default passwords
  • v3.242.0Stored XSS: rich text is sanitised on its way into the database, and again in the browser
  • v3.241.0A security review of a scaffolded app: the three criticals, and the secrets in .env.example
  • v3.240.1--append-only broke grit migrate on ordinary MySQL
  • v3.240.0A double-entry ledger under load, and the three gaps it found
  • v3.239.0A two-app project on Vite had a panel that could never have run
  • v3.238.0The single gets the sign-in pages its auth library never had
  • v3.237.0A web app serves three kinds of page, and now has three layouts
  • v3.236.0A single gets the admin panel too, inside its SPA
  • v3.235.0A double gets the admin panel, at /admin
  • v3.234.0Say which database, and have it tested
  • v3.233.0Three bugs where tenancy, roles and impersonation meet
  • v3.232.0One code, one status: the error taxonomy, end to end
  • v3.231.0The build reads the docs
  • v3.230.0Down migrations, for a framework that has no migration files
  • v3.229.0A stability matrix per subsystem, and a weekly rollup over 343 releases
  • v3.228.0The verification that found the bugs now runs in CI, on three Postgres versions
  • v3.227.0grit doctor: the mistakes that do not announce themselves
  • v3.226.0Sentinel v2.5.0, GORM Studio v1.1.0 and Pulse v1.0.0, and grit upgrade raises them
  • v3.225.0The import, public and tree endpoints query through the service too
  • v3.224.0Generated handlers ran every query themselves, and the service beside them was never called
  • v3.223.0Feature flags could not target a business unit, or be checked at all
  • v3.222.1v3.222.0’s dashboard fix reached new projects only
  • v3.222.0--i18n projects did not compile, and the admin was English-only anyway
  • v3.221.0A workflow could change a status, and nothing else
  • v3.220.0A custom role could not reach a single admin endpoint
  • v3.219.0Two people saving the same record: the second silently won
  • v3.218.0A revoked permission kept working on the other replicas
  • v3.217.0A customer’s identity provider could sign in as your administrator
  • v3.216.0Who read a record: --audit-reads
  • v3.215.0The activity log never verified on Postgres or MySQL
  • v3.214.0Owned resources checked the owner on four doors out of eight
  • v3.213.0Erasing a user left the records they owned
  • v3.212.0Encrypted columns were stored in plaintext after the first edit
  • v3.210.0grit upgrade wrecked TanStack projects
  • v3.209.0Two projects on one machine were sharing a Redis
  • v3.208.0grit generate field reaches the API
  • v3.207.0Append-only records, and a ledger nobody can rewrite from a web page
  • v3.206.0A rule your model enforces now reaches the caller
  • v3.205.1A promised sidebar entry that was never written
  • v3.205.0Re-running a generate destroyed hand-written code
  • v3.204.0The production images did not build on a fresh project
  • v3.203.0You can write a plugin now
  • v3.202.0The offline sync engine has tests now
  • v3.201.0grit sync says which screens it could not update
  • v3.200.0Every client now uses the shared types
  • v3.199.0Generated hooks redeclared the type instead of sharing it
  • v3.198.0--tenant-owned
  • v3.197.0The multitenant plugin never turned its own scoping on
  • v3.196.0Realtime works with more than one API replica
  • v3.195.0The device-pairing plugin
  • v3.194.0--owned-by: per-user resources, generated
  • v3.193.0Resource events were broadcast to every connected user

August 31 to September 6, 2026

15 releases
  • v3.192.0A generated resource could not find its own definition
  • v3.191.0--single and --double grew an admin directory they do not have
  • v3.190.0The TanStack admin was missing files, a whole feature, and every plugin
  • v3.189.0Upgrade could half-migrate an app to Tailwind v4
  • v3.188.0grit.json reported the version you scaffolded with
  • v3.187.0A tutorial for writing your own endpoints
  • v3.186.0Cursor pagination, which had never worked
  • v3.185.0Every resource owns its routes file
  • v3.184.0A money field type
  • v3.183.0Tailwind v4 everywhere on the web
  • v3.182.0The toggle was a button with no name and no state
  • v3.181.0Passkeys
  • v3.180.0Form labels were not attached to their inputs
  • v3.179.0one_to_one
  • v3.178.0An account security page, and recovery contacts

August 24 to 30, 2026

7 releases
  • v3.177.0Every date field failed to save
  • v3.176.0Uploads were blocked in production, and nothing said so
  • v3.175.0A dropzone in the package itself
  • v3.174.0Every upload in the admin now optimises itself
  • v3.173.0Image optimisation moved to the client, and got better
  • v3.172.0A decompression bomb could exhaust the API’s memory
  • v3.171.0Image optimisation, on by default

August 17 to 23, 2026

20 releases

August 10 to 16, 2026

15 releases
  • v3.152.0A settings registry, so configuration is not a choice between a deploy and a code change.
  • v3.151.0Workflows: a status field can be a process, not just a column.
  • v3.150.0Domain events: webhooks and realtime now actually fire.
  • v3.149.0Offline behaviour is declared, enforced and diagnosable.
  • v3.148.0Offline sync is a property of a resource, not of the desktop app.
  • v3.147.0grit upgrade stops overwriting the files you have edited.
  • v3.146.0MySQL is a supported database.
  • v3.145.0The detail page is customisable the same way the list page is.
  • v3.144.0Filter presets as tabs, and query filters that actually filter.
  • v3.143.0One folder per resource.
  • v3.142.0Bulk actions: edit, archive, restore, export and delete.
  • v3.141.0Seven fixes found by building an app with the customisation feature instead of reading it.
  • v3.140.0Typed rows in resource customisations.
  • v3.139.0Custom tables, forms and pages, registered once and safe from the generator.
  • v3.138.0useResourceController() — the admin list page, minus the markup.

Earlier weeks are in the entries below, newest first.

v3.362.0October 4, 2026

An application you can read, that CI keeps honest

examples/ held six guides: the commands that build the same Job Portal in six architectures. Guides are useful and they have one weakness, which is that a guide cannot be wrong in a way anyone notices. It describes commands, and when a command's behaviour changes the guide goes quietly out of date while still reading as authoritative.

examples/library is an application instead. Authors, books, and the loans that connect a book to whoever has it. Generated by Grit, checked in, and built, vetted and tested by this repository's CI on every push.

One file in it was written by hand: apps/api/internal/policies/loan.go, which says that a loan that has been returned is history and cannot be edited or deleted. Neither of the other two authorization layers can express that. The permission catalogue decides whether somebody may touch loans at all, and a librarian holding every permission still should not be able to quietly change the dates on a closed loan. --owned-by decides whether a row belongs to the caller, and the borrower does own it. The rule is about the state of the record, which is what a policy is for. The test beside it makes the requests and checks the answers, because that claim is either true of the running code or it is marketing.

What it checks that nothing else did

The Live workflow already proves that today's templates produce a working application. This proves something different: that an application generated at an earlier version still builds and passes its own tests against today's libraries. That is the question every existing user has, and a fresh scaffold cannot ask it, because it is never old.

One checked-in application and not six. Six copies would be six to keep green against a framework that releases most days, and the day they fell behind they would teach the wrong thing while looking authoritative. The guides stay correct a different way: the commands in them are checked against the real command tree on every push, and a renamed flag fails the docs build.

v3.361.0October 4, 2026

Traces, when you point them somewhere

Grit already answered "what is my server doing" better than most: Pulse profiles every request, the activity log records who did what, and every response carries an X-Request-ID that ties a log line to a request. All of that stops at the edge of the process.

A trace does not. When this API calls a payments service, which calls a ledger, a trace is the one artifact that shows the whole thing as a single timeline with the slow span highlighted. You cannot reconstruct that from three sets of logs with three different request ids, which is what everybody tries first.

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318

That is the whole configuration, and it is OpenTelemetry's variable rather than one Grit invented. OTEL_SERVICE_NAME and OTEL_TRACES_SAMPLER_ARG work too. Anyone who has run a collector before already knows them; anyone who has not can paste a line from a vendor's quickstart and have it work.

Off until the endpoint is set, because a tracer with nowhere to send spans is a background goroutine and a growing buffer that pays for nothing. Everything is sampled in development and a tenth in production, because a developer looking for one request wants that request and not a tenth of it, and a trace per request at a thousand a second is a bill rather than a tool.

One id, not two

When a request is sampled, the request id becomes the trace id. A log line and a span that share an id are one search away from each other; two ids that do not match are two searches and a guess. X-Request-ID still goes out, so nothing that reads it breaks, and X-Trace-Id goes out beside it.

Four modules, not ten: the OTLP/HTTP exporter rather than both transports, and a hand-written Gin middleware rather than otelgin, because the span has to be named after the route pattern (/api/v1/widgets/:id, not one span per id) and carry this project's request id. Only a 5xx marks a span as an error: a 404 or a 422 is the API working, and a trace list where everything is red is one nobody reads.

v3.360.0October 4, 2026

Rules about one row

A Grit project could already answer two authorization questions. The route's middleware answers may this user touch widgets at all, from the permission catalogue. --owned-by scoping answers is this their row, from the owner column.

Neither can say anything conditional on the record itself. A post may be edited while it is a draft and not after it is published. An order may be approved by anyone except the person who raised it. Those were hand-written if statements at the top of a handler, which is where they stop being findable and start being forgotten in the second handler that needs them.

internal/policies is where they go now. A rule gets the actor and the row, and returns authz.Allow() or authz.Deny("..."). Three abilities are checked around every row the API loads: widgets.read before returning one, widgets.update before an update or a patch, and widgets.delete before a delete, so a rule can refuse a delete without refusing an edit.

The reason is the feature

A permission check can only say no, so a user who cannot do something is told that they cannot, and files a ticket. A denial here carries words, and they come back as the 403 body: this post was published on Tuesday, and published posts are edited through a revision. That is 403 and not the 404 ownership uses, deliberately: the caller is already allowed to see this row, so hiding it says nothing and withholds the reason, which is the thing the rule exists to give.

An ability with no rule allows

Deliberately, and it is the opposite of what a security layer usually wants. A gate is a narrowing: the route's permission has already said yes, and a rule decides whether this record is an exception. Failing closed would mean that delivering this to an existing project refused every write in it, which is not a safe default, it is an outage. So a rule is a restriction you add, never a permission you forget to grant.

grit upgrade adds the check to resources generated before this release, both halves in one pass: a project with forty resources would otherwise have to regenerate all forty to use one rule. The repaired service is byte-identical to a freshly generated one, comments included.

v3.359.0October 4, 2026

A runbook for the part of an upgrade that needs judgment

grit upgrade rewrites the project and will not touch a file you have edited since Grit wrote it. That protection is also the limit: what the new version would have changed in that file goes unapplied, and it was reported as one line of terminal output that scrolls away.

There is no mechanical answer to that, because the reason the file was skipped is that applying the change needs judgment about code you wrote. But a change that needs judgment is one a coding agent can carry out when it is told what the change is, where to look and how to check the result.

grit upgrade --plan writes UPGRADE-PLAN.md at your project root: which version you came from, every file left alone, the diff of what the new version does to it, and a command whose success means it worked. Paste the whole file into a coding agent, or work through it yourself.

Every entry is detect, change, verify. The third one is the point: an instruction with no verification step is one an agent will report as done whether or not it is. The command fits the file, because "the whole project builds" is slow enough that nobody runs it between edits, and a check nobody runs is not a check. A Go file gets gofmt and go build from its own module directory, a .tsx gets tsc --noEmit in its app, a compose file gets docker compose config.

The diff is what Grit would have written against what you have, and it is not a patch to apply blindly: your edits are in there for a reason, and the job is to carry the new behaviour into them rather than to replace one with the other. The file says so.

v3.358.0October 4, 2026

A generated resource now comes with its tests

grit generate resource wrote twelve files and no test. The only thing standing between a generator change and a broken resource was somebody scaffolding a project by hand and looking at it.

It writes a factory and four tests now. Not tests of the CRUD mechanics, which are the same code for every resource and already covered: tests of the wiring, which is what varies. That the route refuses an anonymous caller, because a resource injected into the wrong group is reachable by anyone and nothing else would notice. That a signed-in user without the permission gets a 403, which is the difference between a permission that is declared and one that is checked. That a created row survives the round trip through the model, the service, the handler and the serialiser. And that an unknown id is a 404 and not a 500, which is the most common shape of generated-handler bug.

The factory at internal/factory/<resource>.go is the one place that knows what a valid row looks like, so adding a required field is one edit rather than one per test. A belongs_to is deliberately left empty: the factory cannot know which parent you mean, and inserting one would make every test that touches the resource write rows in another table too. The doc comment says so and shows the override.

Run the same suite against Postgres

internal/testkit is the shared setup: a database, a config, a request client and the assertions. By default the database is SQLite in memory, which is fast and needs nothing installed, and is not what you deploy to. Postgres stores timestamps to microseconds and SQLite to nanoseconds, so a hash chain over a timestamp verifies locally and fails in production. JSONB, ILIKE and numeric precision all differ too.

docker compose up -d postgres
GRIT_TEST_DATABASE_URL=postgres://grit:grit@localhost:5432/grit go test ./...

Every call gets its own schema, created and dropped around the test, so they stay isolated and can run in parallel. Worth doing before a release even if you do not do it every day.

A failing assertion prints the body. "expected 200, got 422" sends you to add a print statement and run it again; the same line followed by the validation error is the answer.

v3.357.0October 4, 2026

What each route asks of a caller

grit routes was reading 108 of the 171 routes a fresh project registers, and it was calling /api/v1/auth/me public. Three separate faults, all of the same shape: each one produced a plausible answer rather than no answer.

A route with its guard written in front of it, which is how every permission is attached, matched nothing at all, so the 55 lines that name a permission were dropped. Authentication was detected by looking for .Use(middleware.Auth and the generated file writes .Use(middleware.APIKeyOrAuth(db, middleware.Auth(...))), so no group was ever recognised as protected and every authenticated route was reported public. And a path built from a constant came out as /api/"+APIVersion+"/health.

The parser does less guessing now: the group graph is built first and resolved afterwards, each group's middleware chain is accumulated rather than tracked as one running variable, and arguments are split with a paren-aware scanner instead of a pattern that assumed their shape. It reads the per-resource route files too, which is most of what a real project serves. 182 routes in a fresh project, against 108.

So grit routes has an ACCESS column, and so does /system/routes. v3.356.0 shipped that screen with a paragraph explaining why it could not have one: Gin's route table carries a route's last handler and nothing about the middleware in front of it. That was true about Gin and wrong about the problem. The answer is written in routes.go, so grit reads it there and generates internal/access/registry.go, which the screen and the MCP route tool both read.

There are four states, not two. A route the table does not cover reads unknown, never public: the reference, the profiler and the database browser register 125 routes this project's route files never saw, and calling those public would be the one wrong answer worth avoiding, because somebody reads this column as a security statement.

The table is rewritten by grit new, grit generate resource, grit remove and grit upgrade. Add a route by hand and grit doctor says so, which is the one case no command catches.

v3.356.0October 3, 2026

The route table, in the browser

grit routes has printed it in a terminal for a long time and remains the fuller answer, because the CLI parses routes.go and can report the middleware group and the permission each route wants. What nothing provided was a searchable version for somebody already in the admin, looking for the URL of an endpoint they are about to call.

/system/routes lists every registered route grouped by what it is about, filtered as you type, with a copy-as-curl on each row. The API's own endpoints come first and the mounted dashboards last: the reference, the profiler and the database browser are 130 of the 298 routes in a fresh project and they are somebody else's.

There is deliberately no access column. Gin's route table carries a route's last handler and nothing about the middleware in front of it, so the authorization level is not readable at runtime at all. A level guessed from the path would be wrong for /api/v1/auth/me and all six passkey endpoints, which sit under /auth/ and every one of them needs a signed-in user, and somebody would read that as a security statement. The page says so and points at the command that does know.

/llms-full.txt now orders its route listing the same way, since it was sorting the same 130 mounted routes to the top of the file.

v3.355.0October 3, 2026

The saga runs screen

v3.354.0 shipped the engine and no way to look at it. A run that goes stuck is one whose compensation failed past its attempts, so there is a charge, a reservation or a booking still standing that was supposed to be taken back, and the only way to find one was SQL against saga_runs. That means nobody finds one until a customer complains.

/system/sagas lists every run with its status, filtered by chips that carry the counts, and a banner at the top when anything is stuck. Opening a run shows its steps in order with the error that stopped each one, and a stuck run gets a Retry button: it resumes compensating from the step it stopped on, because the thing that could not be undone is still not undone.

Retry refuses a run that is not stuck, and says why. A running one needs no help and a finished one has nothing left to do, so retrying either would re-run work that already happened.

The status chips carry what each word means on hover, because compensated reads as a failure and is the opposite: a step failed and everything before it was undone, so the world is back where it started. See Sagas.

v3.354.0October 3, 2026

grit generate workflow: steps that finish or are undone

A transaction is the right tool when every write is in one database. It is no help when the steps are in four places: charge a card, reserve stock, book a courier, send the receipt. The card does not roll back when the courier refuses, and the process holding all of it in its head is exactly the thing that crashes, usually between the charge and the reservation.

Every step now gets a Do and an Undo. They run in order, and when one fails for good the completed ones are undone newest first. Where the run got to is a row in saga_runs, not a stack frame, so a process that dies resumes rather than losing the thread, and every replica runs a runner because a run is claimed before it is touched.

grit generate workflow Checkout --steps "charge,reserve_stock,book_courier,send_receipt"

Each generated step returns an error until you write it. A stub that returned nil would be a saga reporting success and doing nothing, and the first you would hear of it is a customer saying the parcel never came.

The parts that are easy to get wrong are the ones the engine takes a position on. r.IdempotencyKey() is stable across retries, because a crash between the charge and the record of it is not preventable and a provider that deduplicates on that key is what stops the second charge. What a step writes with r.Set survives even when that step then fails, because a charge id obtained just before a timeout is exactly what the refund needs. And a compensation that cannot complete leaves the run stuck rather than compensated: something happened that could not be taken back, and a status that reads as resolved would be a lie.

A step you cannot undo declares Undo: nil, which is legitimate and makes the ordering the design: anything after it that fails leaves it done. See Sagas. This is a different thing from workflows, which turn a status column into a state machine; most projects want both.

v3.353.0October 3, 2026

The upgrade machinery is on the site

The strongest thing this project does had no page. A scaffolder gives you code once and then watches it rot; this one keeps fixing the project it generated, and the only place that appeared was in changelog entries, which is to say nowhere a visitor looks.

Upgrading a project is the detail: the manifest that records a hash per generated file, the three answers it gives (unchanged, modified, untracked) and the three behaviours they lead to, the 53 repairs that rewrite the files you share with Grit by matching the exact text it wrote, and what travels on every upgrade rather than only at scaffold time.

Documentation only. Nothing in the framework changed.

The page also says the part that is easiest to miss: a warning from grit upgrade is the interesting half. Grit found code it did not write, so it did not touch it, and told you what the fix would have been and why.

v3.352.0October 3, 2026

The MCP server has ten tools and two modes

It had three, all of them read-only, and no way to say so structurally. It now has nine that answer questions and one that writes, and the default server does not contain the one that writes: grit mcp serve builds a map without it, so no token, no scope, no misconfigured client and no instruction hidden in a README can reach it. --mode write builds the one that has it.

The alternative, which is what most servers do, is to register everything and check a flag inside each handler. That works until somebody adds a tool and forgets the check, and the failure is silent and total: the tool simply works for everyone. Letting the mode decide what gets built removes the class of mistake.

The new tools are the ones an agent actually needs. grit_file_ownership reports which generated files are still exactly as Grit wrote them and which you have edited, which nothing else can answer and which decides both whether an upgrade will keep updating a file and where it will report a conflict. grit_list_permissions gives the real permission keys, so a guard is not written against one that matches nothing and fails silently. grit_list_resources finds what the generator made, by the marker it leaves rather than by guessing from filenames. grit_doctor returns the audit as data. grit_env_keys names the variables and whether .env sets them, and never returns a value from either file. grit_cli_reference reads the commands out of the running binary, so an agent proposes a flag that exists in the version you have.

grit_generate_resource is the write-mode tool, and it checks its arguments before anything runs: a name that is not a name, a field type this version does not have, or a shell metacharacter is an error the agent can read rather than a half-generated resource to clean up. See MCP Server.

v3.351.0October 3, 2026

llms.txt, for the framework and for the API you build with it

An agent pointed at Grit had to crawl 171 pages of rendered HTML or guess. gritframework.dev/llms.txt is the index instead: what Grit is, the one thing to understand about it, and every documentation page with its description, grouped by subject. gritframework.dev/llms-full.txt adds the working guide and the complete CLI reference, as one file.

Neither is written twice. The page list is the metadata the pages already use for their titles, the CLI reference is the catalogue /docs/cli renders, and the working guide is the file grit init writes into a project, so none of it can drift from the docs.

A scaffolded API serves its own pair. /llms.txt is the orientation an OpenAPI document does not carry: how versioning works, which header holds the token, what a response and an error look like, and the parameters every list endpoint takes. /llms-full.txt adds every route the router holds, grouped by what it is about, which is the one thing the spec cannot answer, because the spec documents the routes somebody wrote an override for and this is the router's own table.

Both are mounted behind the same condition as the API reference, since they describe the same surface: off in production unless API_DOCS_PUBLIC=true. The text lives in internal/llms and is yours to edit. See llms.txt.

v3.350.0October 3, 2026

/api/health answers in four states, and reports storage

Every component reported one boolean, and that boolean covered two opposite situations: Redis is down, and this deployment has no Redis. Nothing reading the response could tell them apart, so the admin page guessed, and guessed differently per component: Redis read ok === false ? down : ok ? ok : unknown, email read configured ? ok : unknown, and storage was not on the page at all.

Components now answer ok, degraded, off or unknown. Off is a dependency this deployment never asked for and unknown is a probe that could not find out, so neither lowers the overall status. Only degraded does, and that rule lives in one function rather than in a condition that named Redis and therefore quietly excluded the mailer and the object store.

Storage is reported, which is the gap that prompted this: an upload answering 503 came with a System Health page that had nothing to say, because no object store was configured in that environment at all.

A missing cache is the one probe that asks which environment it is in. REDIS_URL= turned it off on purpose, and that is off. Anything else means the API dialled Redis at boot, was refused, and carried on with caching, jobs and cron disabled: on a laptop that is still off, with a detail saying so, and in production it is degraded, because there it is an incident.

Anything else reports itself through health.Register(name, probe), from a plugin or from your own main.go, and appears beside the framework's own components under the same rule. A probe that panics is reported as unknown rather than taking the endpoint down with it. See Health checks.

The ok field is still sent, and is exactly state === "ok", so load balancers, uptime probes and the desktop heartbeat are unaffected. grit upgrade rewrites the handler of a project that still has the one Grit wrote, and reports a handler you have edited rather than overwriting it.

v3.349.0October 2, 2026

A profile picture is cropped before it is uploaded

A profile picture is square and the pictures people have are not. Picking a file uploaded it as it came, and the browser did the cropping on display, where object-cover takes the middle: a photo of two people became a photo of somebody's shoulder, and the only fix was to go and edit the file.

Both places that change a picture, the account screen and the profile page, now open a cropper first. The whole picture stays visible with a round selection over it, which moves and resizes, and what is uploaded is a square cut from exactly that selection, scaled to 512 pixels. A PNG stays a PNG, because flattening transparency to JPEG turns it black.

Arrow keys move the selection and plus and minus resize it, so the cropper works without a pointer. A cropper that only works by dragging is one a keyboard user cannot use at all, and the alternative there is no picture.

No cropping library: the whole thing is a scale and an offset applied twice, once to the preview and once to the canvas, and those two agreeing is the only hard part. Verified against a running admin, which is how the one real bug was found: Tailwind preflight's img { max-width: 100% } was shrinking the picture below the size everything else was measured in.

v3.348.0October 2, 2026

The audit tables have a read side

Three handlers read them: the request log, the semantic activity feed, and the OCSF export a SIEM collector polls. Each built its own query, and the whitelists that decide which columns a client may sort or filter by were written three times. They are services.ActivityService now. Writing to those tables stays with the LogX functions: somebody logging an action should not have to build a service for it.

The severity chips above the activity dashboard dropped the error from their count, so a database that was down drew the same zeros as a quiet day. It is reported now. The window is still chosen by the endpoint, because 24 hours is that endpoint's promise; what the service owns is that the cutoff is bound as a value rather than written as NOW() - INTERVAL, which is Postgres-only syntax and used to return zeros on every SQLite project.

The export's cursor is in one place too: since is a wall-clock floor for a collector's first poll, after is the exact position for every poll after that, and an after that no longer exists falls through to since rather than erroring, so a collector that lost its place still makes progress instead of wedging.

All three handlers travel on upgrade now. Twenty of the framework's handlers have had their queries moved; twelve files still hold 26 sites between them, three of which only hand the connection to a service rather than building a query.

v3.347.0October 2, 2026

The auth handlers stop reading the users table themselves

Four handlers, ten queries: the provider callback, the password reset, the verification resend and the recovery contacts. Each one was a read or a write of a single account, and each one is now a call to a service.

Reads during a sign-in go through AuthService, which is where Login already gets them, so an unknown address is answered the same way whichever endpoint was asked. Writes to an account go through UserService, which owns them. The recovery handler builds its own, because it holds no AuthService and a lookup should not work or panic depending on how the handler was wired.

A provider sign-in for an address nobody has registered now inserts through services.CreateUser, so two callbacks for one new address in the same instant are settled by the unique index rather than by which read finished first. That is the third endpoint to pick this up: registration and the SSO provisioning path were the others.

All four travel on upgrade now, and recovery.go came out of a Go string literal into internal/scaffold/templates/, verified byte-for-byte. The repair that hardens an older OAuth handler still writes what that handler can compile, so its test runs against a fixture of the file it actually meets rather than against the current template.

v3.346.0October 2, 2026

One lockout, not one per factor

A wrong password and a wrong two-factor code are counted against the same account, and each had its own copy of the three writes that do it: count, read the count back, lock past the threshold. Written months apart, with the same trap in both, which is that the count a request arrived with is already stale and the decision has to be made from what the database holds.

They are AuthService.CountLoginFailure and AuthService.LockAccount now, beside the ClearLoginFailures that was already there, and both handlers call them. The threshold and the window stay with each caller, because one reads this project's configuration and the other reads the two-factor constants. The admin unlock went to UserService: it is an action on a user rather than part of signing in.

The offline sync protocol has a row store

services.SyncService takes the destination the handler built rather than a model, because a project syncs whatever it registered and the type is the registry's. The reflection stays in the handler with the protocol; what moved is the part that is a decision about data: that a pull is keyset-paginated on (updated_at, id) so rows sharing a timestamp are not lost at a page boundary, that it reads soft-deleted rows because those are the tombstones a client needs, and that a save from a phone touches neither associations nor created_at.

Both handlers travel on upgrade now, so an existing project gets the single lockout rather than keeping its own copy of it.

v3.345.0October 1, 2026

A public form stops losing submissions when two arrive at once

Every submission to a shared form bumped a counter by writing share.SubmissionCount + 1 from the row the request had read. Two visitors submitting in the same moment both wrote the same number, so the count lost one of them. A public form is exactly where that happens: nobody is taking turns. The increment is in SQL now.

Uploads list through paginate, and their stats report failures

The files list clamped the page and did the offset arithmetic itself, which is why it answered none of the search, sort or ?counts= parameters the rest of the API does. It goes through paginate like every other list, with the same response envelope it already returned.

Two of the three queries behind the storage stats discarded their errors, so a database that was down drew a page of zeros: indistinguishable from an empty bucket. All three report now.

Three more services

FormShareService, UploadService and WebhookEventService. The webhook one carries the claim that decides which of two redeliveries of one event may run its handler, and that claim has tests for the first time: one for a failed event taken over once, one for a pending event left alone until the process holding it must be gone.

Uploads keep their scoping in the handler, because who may see everybody's files reads the request; the handler says whose, and the service scopes every query by it.

All three handlers now travel on upgrade. Public form sharing was written once at scaffold time, so the lost-submission fix would have reached new projects only. 1,378 more lines came out of Go string literals into internal/scaffold/templates/, verified byte-for-byte.

v3.344.0October 1, 2026

The access-review list was 4n queries, and dropped every error

Drawing the recertification campaigns ran four counts per campaign, so a year of monthly reviews was forty-nine queries for one page. Each of those counts discarded its error, which meant a count that failed drew a campaign as having no items at all. In an access review that reads as nothing left to certify, which is the one wrong answer that matters.

It is two queries now, whatever the number of campaigns, and a failure is reported. The total is the sum of every decision rather than a fourth count, so a decision a later release adds is included instead of silently missing from the total.

The list also ordered by created_at alone, and two campaigns opened in the same second came back in whichever order the database felt like, which an operator sees as a list that reorders itself between refreshes. It breaks the tie on the id, which Grit issues in time order.

Feature flags have a service, and exposures count people

The flag rows and their exposure counts are services.FeatureFlagService. The engine in internal/flags still decides what a flag answers for a given user and when its cache is stale, which is the one thing a service cannot know. The exposure query counts distinct users, as it did before, and now has a test that spends forty checks by one person to prove it.

Both handlers moved into the framework-owned set, so an upgrade delivers them and the 4n list stops being something only new projects escape. Four more files came out of Go string literals into internal/scaffold/templates/.

v3.343.0October 1, 2026

SSO and SAML run no queries of their own

The connection and identity tables are now services.SSOService, and both sign-in flows read accounts through UserService. The handlers keep what they are for: which domains a customer's identity provider may vouch for, what a refused sign-in says, which fields an admin request may set.

The group-role mapping is the part worth reading. Its transaction was a second copy of the one behind the admin's role picker, written months apart, and both had to agree about the legacy users.role column, because that column is what the JWT carries: a token that disagrees with the grants is worse than either being wrong. One implementation now, in RoleService.ReplaceUserRoles, which the SSO mapping calls.

A just-in-time provisioned account goes through services.CreateUser, so two assertions for one new address in the same instant are decided by the unique index rather than by which read finished first.

The connection store has its own tests for the first time: one domain claimed twice, a disabled connection still answering a callback, a SAML assertion arriving for a connection switched to OIDC, and the subject match a returning sign-in depends on. The SSO service also moved out of a Go string literal into internal/scaffold/templates/, verified byte-for-byte.

v3.342.0October 1, 2026

Two-factor has a service, and the races live in one place

internal/handlers/totp.go was 870 lines and thirty-five queries, the largest handler Grit ships. They are now services.TwoFactorService: the config a user enrols, the pending token a half-finished sign-in carries, and the devices allowed to skip the prompt.

Three of those methods are the reason this one mattered most. A TOTP code is valid for its whole time step, a backup code sits in a list, and a pending token is a row anybody holding it can present: each has to be spent by one request and not by a second one arriving in the same instant. That is a compare-and-set, and whether this request won is a question only the write can answer, which makes it the service's and not a handler's. The guarantees were already there; they were spread across three handler methods, and nothing tested them. They have tests now, each one playing the second request.

The lockout decision reads the failure count the database holds rather than the one the request arrived with, which is what several wrong codes in flight at once needed all along. The "trusted devices" count on the status endpoint had its error dropped, so a database that was down read as a user with no trusted devices; it is reported now. User lookups go through AuthService, which this handler already held.

An upgrade delivers the handler and the service together, as it already delivered this handler: one without the other would not compile. 852 lines also came out of a Go string literal into internal/scaffold/templates/, verified byte-for-byte against the previous release.

v3.341.0October 1, 2026

The roles API has a service

Seventeen queries out of internal/handlers/role.go and into services.RoleService. The handler still decides who may ask: only an ADMIN changes a built-in role, a role hands out no more than its author holds, an unknown permission key is a 400. What a role is, how its holders are counted and which writes have to land together is the service's, and a job or a console command reaches the same rules.

Two things the move turned up. The holder count behind the roles list had its error dropped, so a failed count drew every role as held by nobody; it is reported now. And assigning roles counted the ids, then fetched the rows, where one read answers both.

"Role is assigned to N user(s)" now counts users

A role reaches a user two ways: the user_roles join table, and the legacy users.role string the admin's user form writes. The delete guard counted rows, so somebody holding a role both ways counted twice, and the number in that message is what an operator is told to go and reassign before they can delete the role. It counts people now, and a deleted account is not one of them, so a role held only by deleted users can be removed.

role.go travels on upgrade, and is a file again

The handler and its test were written once, when the project was scaffolded, so a fix to either reached new projects and no existing one. They are framework-owned now, which means an upgrade delivers them, manifest-guarded as always: a role.go you have edited is reported as a conflict and kept.

Both also moved out of Go string literals into internal/scaffold/templates/, 591 lines of Go that a parser can now see. Verified byte-for-byte: the same project name scaffolded with both binaries produced identical files.

The extractor that moves a batch of templates now refuses to overwrite a template that exists. Re-running it over a moved batch used to be a no-op, and stopped being one the moment templates carried repair markers: it wrote the stripped output back and deleted every marker in the file.

v3.340.0October 1, 2026

The user endpoints have a service

internal/handlers/user.go ran fourteen queries of its own: the row behind an id, the row behind a session, the address check, the two writes an update makes, the soft deletes, and the paging config for the list. Every generated resource was moved onto its service in v3.224, so the handler a developer is most likely to copy from was the one breaking the rule.

They are now services.UserService: List, GetByID, Update, Delete, EmailTakenByAnother and SyncRoleAssignment. The handler reads the request, calls one of them and writes the answer. A job that deactivates accounts and a console command that fixes a role now go through the same rules as the HTTP route, which is the point: a rule written in a handler applies to the handler.

The role assignment and the user row still share one transaction, and that claim now has a test: it forces the second write to fail and checks the promotion did not survive. Nothing tested it before, and it is the kind of thing that only shows up as a user who kept the permissions of a role they were never given.

handlers/user.go travels on upgrade

It used to be written once, when the project was scaffolded. A fix to it reached new projects and no existing one, which is why the hand-rolled users list that read none of the admin's filters had to be repaired into place with text substitutions, three separate times.

It is now one of the framework-owned files, so an upgrade delivers it. That delivery is manifest-guarded: a user.go you have edited is reported as a conflict and kept exactly as it is, and the repairs are still there for it. Verified both ways, on a copy of a real project: pristine takes the new handler and its service, edited keeps its edit and still builds.

handlers/user_role_sync_test.go is removed on upgrade, because its two cases moved to services/user_test.go beside the code they are about. Only when your handler no longer declares what it calls, and only when you have not edited the test yourself: either way you are told.

v3.339.0October 1, 2026

Sign-in runs no queries of its own

internal/handlers/auth.go read five things from the database by itself: the account behind an address, the account behind a refresh token, an enabled second factor, the row that records a half-finished sign-in, and the reset of the lockout counters. Every generated resource was moved onto its service in v3.224, and grit doctor reports a handler that queries, so the first file a developer opens was breaking the rule the generator enforces.

They are now five AuthService methods, and each one is a decision about data rather than about HTTP: which columns of a two-factor row are safe to load (the id and the method, never the secret, because a secret that cannot be decrypted would otherwise read as this account has no second factor), what an unknown address answers, when a failure count is cleared. The handler calls them and formats the answer.

An existing project gets the methods before the sign-in repair runs, which is the part that had to be built rather than noticed: grit upgrade rewrites an older Login to what the template says today, and what it says today calls h.AuthService.UserByEmail. Writing that into a project whose service has no such method is an upgrade that reports success and leaves a project that does not compile.

What an existing project gets: the five service methods, and, if its sign-in predates v3.286.0, a Login that calls them. An auth handler that already holds the current sign-in keeps its own queries, because internal/handlers/auth.go is a file developers edit and the upgrade changes it only where it still reads as Grit wrote it. It compiles and behaves the same either way.

The repair blocks are taken from the templates, not copied out of them

A repair needs the text the generator writes today, and it used to keep its own transcription: 229 lines of the auth handler in one constant, spliced with a second copy from another file. Editing the template without editing both copies left an upgraded project holding code the generator no longer writes, and nothing caught it, because the two halves are joined by a Go + that no search for the text can follow. That is how this release found two of them already out of step.

The templates now mark the parts a repair needs, the repair takes a slice of the one source, and the markers are stripped on the way out so no generated project sees them. Four blocks, no second copy. Proven by changing a string in the template alone and watching the repair tests follow it without being touched.

v3.338.1October 1, 2026

The templates are LF on every machine

v3.338.0 moved the handler templates out of Go string literals and into files. Files are subject to a thing string literals were not: git converts text on checkout, and this repository had no .gitattributes to stop it. A clone on Windows got all 29 templates with CRLF, the CLI embedded CRLF, and the repair constants that mirror those templates stopped matching them.

Confirmed by cloning the released tag: 29 of 29. It was invisible because the checks run on Linux, where no conversion happens, so all six workflows were green on a release that behaved differently on the machine it was written on. The same shape as v3.333, where the compose repairs had never once run on a Windows checkout.

.gitattributes now pins internal/scaffold/templates/** to eol=lf, and a test reads every embedded template back and fails on a CRLF, because an attributes file is easy to lose in a merge and the failure it prevents cannot be seen from Linux.

If you cloned at v3.338.0 on Windows, git pull then git add --renormalize . restores the templates; nothing in a generated project needs changing.

v3.338.0October 1, 2026

Templates as files, starting with the handlers

Nothing in this release changes a generated project. Every one of the 633 files a --triple --next scaffold produces is byte-for-byte what v3.337.1 produced. This is about the framework being maintainable by somebody other than its author.

internal/scaffold is 174,000 lines, and most of it is Go, TypeScript, CSS and YAML living inside Go string literals, with one file at 10,437 lines. An editor cannot highlight any of it, a parser cannot see it, and finding the template that produces a given file means grepping. A product review scored the maintainability of the framework itself 4 out of 10 for exactly this, and it is the reason a colour utility that compiled to nothing and a hook after an early return both shipped.

The 29 handler templates now live under internal/scaffold/templates/api/handlers/ as ordinary files, embedded with go:embed. api_files.go drops from 10,437 lines to 8,521. A test parses every Go template with go/parser, so a missing brace is now a unit-test failure naming the line (api/handlers/auth.go:524:47) in under a second, rather than a scaffold-and-build in CI.

What this does not do is type-check them. These templates import packages that exist only in a generated project, so the compiler cannot see them from here, and that stays the live suite's job. Syntax and findability are what moved, and syntax is where the bugs came from.

Two things that were tried and deliberately not shipped

Running gofmt over the templates looked like an obvious win and is wrong twice. The scaffolder already formats Go on the way out, so a generated project is formatted whatever the template looks like. And formatting a template means substituting a stand-in for {{MODULE}} first, which sorts to a different place in the import block than a real module path does: four handlers came out with their imports reordered. A tidy-up of the source was quietly changing what every project receives. The reasoning is recorded in the test file so the next person does not spend the afternoon rediscovering it.

The other is a note about verification. Comparing two scaffolded projects to prove nothing changed only works if they have the same name: the module path is part of the import block, and v3361b sorts after "time" while pilot3 sorts before it. Comparing differently named projects shows four false differences and hides real ones.

v3.337.1October 1, 2026

v3.337.0 was tagged with an empty commit: a git add that failed silently staged nothing, so that release carries no changes. Everything below is in this one. Nothing was wrong with v3.337.0 beyond its being identical to v3.336.1.

The docs now have to follow the rules the framework states

Every command the documentation shows has been checked against the real command tree for a long time: a renamed flag fails the build. The Go code on the same pages was checked against nothing, and it had drifted badly.

A product review found four things on the homepage that the agent skill tells an agent never to do: a handler running its own query, a float64 price, a swallowed bind error, and a hand-built error envelope with no code in it. The homepage is the page an agent reads before the skill, so it was teaching the wrong pattern from the opening screen.

There is now a check for it, with four narrow rules taken from what Grit already says in its README, its skill and its stability page. It found 49 violations across the site, not four, and all 49 are fixed: 29 money columns moved off float64, including an invoice Amount and a point-of-sale Price; five swallowed bind errors; nine handler queries.

The worst of them was not on the homepage. A lesson on building a public catalogue carried three samples labelled "(already generated)" that showed handlers building their own GORM queries. The generator stopped writing that shape at v3.224, around 110 releases ago, so the page was not teaching a bad habit: it was making a false claim about what the tool produces, and then telling the reader to add more of the same. It now shows what the generator writes, with the paginate.Config allowlist in the service where it lives, which is the half worth reading and was buried in a handler.

A page that needs to show what a rule forbids says so by name: docscheck:allow float-money with a reason on the same line. The Go language primer uses one, because float64 there is the subject being taught. A blanket skip-this-file switch would have been the easier design and would have stopped covering the pages that drifted most.

MySQL is supported, and the homepage said it was not

The connector has picked MySQL off the DSN prefix since v3.146, the live suite scaffolds an app and drives it over HTTP against MySQL 8 on every push, and the database section of the homepage carried a footnote saying there was no third dialector. Anyone evaluating Grit with MySQL in the building read that and left. It is now a provider card beside Postgres and SQLite.

Smaller things from the same review

  • The themes heading said four and listed nine. A version badge in one graphic had been frozen at v3.23 since roughly release 23 of 450; it reads the same source as everything else now.
  • The Wails desktop stack was a release train behind every other mode, on Gin 1.10 and GORM 1.25 while the rest was on 1.11 and 1.31. Raised.
  • examples/ said "Example Projects" and held no code. It now says what it is, which is a set of build guides, and points at demo/ for an application you can actually clone and run.
  • A compiled Python cache file had been committed to the docs folder.
v3.336.1September 28, 2026

The security scan is green again

A generated project's gosec run had eight findings, all of them from Grit's own recent work and none of them a real vulnerability: the template.CSS calls the email themes need (without them html/template writes ZgotmplZ and the mail arrives unstyled), the template.HTML that puts an already-escaped fragment into the layout shell, and the math/rand jitter that spreads cache expiry.

Each now carries a #nosec with the reasoning written out rather than a bare annotation, because an unexplained finding and a suppressed one look identical to the next person reading the file. Nothing behaves differently; the scan in your project's own CI passes again.

v3.336.0September 28, 2026

Scaling readiness, at the top of Observability

grit scale answered "what should I do next" from a terminal. The question people ask first is the other one: what is already handled here? The admin's Observability page now opens with all ten scaling stages and the state of each on this deployment, under the verdict.

Every line is measured off the running app rather than read from a list of features. A tick against Stage 4 means this deployment was observed keeping sessions in the database and uploads in object storage, with the driver named beside it. Stage 2 reports the cores it can see. Stage 9 reports the largest table, because that is the number that decides whether sharding is even a question, and the honest answer is almost always no.

The three measured stages go amber on the same constants the verdict uses, so the panel cannot show a green tick for the stage the verdict is calling out. Two things it will tell you about are the ones that are invisible until a second instance exists: STORAGE_DRIVER=local, which leaves uploads on one machine's disk, and SQLite, which makes every question after Stage 1 have the same answer.

A stage that is off reads "ready, not needed yet" rather than leaving a gap. That is the correct state for replicas and caching in almost every application, and a gap there looks like something missing when it is not. See Scaling.

A Grit app now says it is one

Nothing a Grit app served identified the framework, so a technology scanner could find Next.js, React and Tailwind and stop. Both frontend shapes now emit <meta name="generator" content="Grit">, the convention every static site generator and CMS uses for exactly this.

The name and not the version. Telling an unauthenticated visitor which release is running hands them that release's advisories, which is the same reason poweredByHeader is off in the Next config two files away and why the Go API still names nothing at all. One line in your own layout, so deleting it is the opt-out.

v3.335.0September 28, 2026

A post you could publish once and then never edit

Sentinel's firewall stepped aside for a richtext body at its public path and not at the /admin path the admin panel writes through. A body is inspected on the way in, so it is the writes that carry the markup: every PUT from the admin was answered 403 and logged as a critical threat, for a code block whose ../ reads as path traversal. Reported by Mark Cole Mukisa with the fix already in production (#90).

It was worse than the report: grit generate resource never touched the exclusion list at all, so every resource generated with a richtext field had the same fault at its own path. It now adds the resource when it emits one, and grit remove takes it back out. grit upgrade repairs a project already generated, finding its richtext resources by the sanitize:"html" tag on the models and reading their paths off the routes rather than guessing at a plural.

--public: the half that was missing

--public generated read-only endpoints behind an API key and stopped there. The hooks beside them still called the authenticated routes, and nothing in apps/web ever sent the publishable key the seeder had already written into its .env.local, so a storefront built the obvious way got 401s and the developer had to write the fetch layer by hand to find out why (#91).

There is now a read layer: apps/web/lib/<resource>-public.ts, calling the public routes with the key on every request, typed against the allowlist rather than the model, with the related and tree endpoints when the resource has them. Three more things that report found:

  • Only archived_at hid a row. An admin turning active off left the product on sale. Every public read now goes through one scope, and where the model has a column that says whether a row may be seen, that scope uses it.
  • Foreign keys were held back with the relation. Those are not the same thing, and a storefront could not link a product to its category from a response that had already agreed to return the product.
  • Forty products called Emily Gardner. The faker gave every string a person's name and every whole number 1 to 100. It now takes the resource into account, and a price gets a price.

See The public surface.

Deploying where the platform sets the rules

From a Grit triple shipped to Laravel Cloud, running in production (#92):

  • PORT is read before APP_PORT. Every platform that routes to a container injects it, and a deploy that binds the other one goes green with nothing answering.
  • A cross-site warning at boot. On a platform whose domain is on the Public Suffix List, two apps of one project are already cross-site, so the browser never sends the SameSite=Lax auth cookies and sign-in returns 200 followed by 401s with nothing logged. It now says so, and says what to do about it.
  • AWS_BUCKET and AWS_ENDPOINT_URL are accepted where S3_BUCKET and S3_ENDPOINT were, so attaching a managed bucket needs nothing configured.
  • A separate upload host in the CSP. A bucket that serves reads from its own CDN and signs writes for the underlying S3 endpoint had every upload signed by the API and then refused by the browser, reported as a console violation and never as an HTTP status. NEXT_PUBLIC_STORAGE_UPLOAD_URL goes into connect-src and nowhere else.

And a shop in Kampala

Duuka joins the showcase: one deployment serving many storefronts for East African businesses that sell over WhatsApp, live on Laravel Cloud. The showcase no longer requires table and model counts, because those are the author's to publish and a number nobody measured is worse than a card without one.

v3.334.0September 28, 2026

grit scale: measure, then do one thing

Point it at a running deployment and it reports the request percentiles, connection use against the ceiling, the slowest queries, the tables being read end to end and the cache hit rate, then names one next step. Most of the time that step is nothing, and that is the feature: a tool that nags you to add read replicas at forty requests a minute is a tool you learn to ignore.

It checks connection exhaustion before anything else, because that is the only failure here that arrives as errors rather than slowness, and it refuses to mention a replica while a large table is still being scanned end to end. An index is free; a bigger database is not.

The measuring happens in the API, at GET /api/v1/scale, admin only since it reports connection counts and query shapes. Percentiles measured on a laptop describe a laptop.

Read replicas, in one environment variable

DATABASE_REPLICA_URLS, comma separated, and reads go to the replicas on the next boot with no handler changes. Routing is per statement through GORM's dbresolver rather than per call site, which matters for the rule nobody writes by hand: a read inside a transaction goes to the primary, so a balance check inside the transaction that debits the balance cannot read a replica whatever the handler was written to do. database.Primary() and database.Replica() force either side for the reads that decide a write.

Read-your-own-writes is handled by a cookie rather than a shared store: it travels with the person who wrote, costs no lookup, and cannot itself be stale. The alternative puts Redis on every read and takes the read path down with it.

cache.Remember

Cache-aside with the three things a hand-written version misses. A cache read failure falls through to the loader, so Redis being down makes the app slower rather than broken. TTLs are jittered, so ten thousand keys written by one deploy do not expire in the same second. And when a hot key expires one caller rebuilds it while the rest wait briefly and read the result, instead of every concurrent request running the same expensive query. Plus a hit rate, which grit scale reads.

grit doctor does the connection arithmetic

Instances times DB_MAX_OPEN_CONNS has to stay under max_connections, and the number people get wrong is instances: a pool of 25 is comfortable on one machine and fatal on eight, and nothing else in the config mentions the other seven. Set APP_INSTANCES and doctor does the multiplication, before production does it for you.

And a page about all of it

Scaling maps the ten stages to what a Grit app already does. The honest count: Stages 1, 3, 4 and 8 ship on the first commit, Stage 2 does not apply because Go uses every core, and Stage 9 is the one thing Grit deliberately does nothing about.

v3.333.0September 26, 2026

The production stack no longer asks for a subnet it does not need

Docker hands out bridge-network subnets from 172.17.0.0/12 sliced into /16 blocks: about sixteen networks for the entire daemon. It does not return them when a deploy fails or a project is deleted. Run half a dozen Compose stacks on one host and the pool empties, and after that every deploy dies at the last step with all predefined address pools have been fully subnetted, after every image has already built. Nothing is said about disk, memory or CPU, because none of them are the problem.

docker-compose.prod.yml declared a named network and attached every service to it. Compose's implicit <project>_default gives the same service-name DNS and the same isolation, so the named one took a subnet and bought nothing. It is gone. So are the pinned container_names: names are global to the daemon, so a second copy of a stack, staging beside production, could not start while they were there.

grit upgrade applies both changes to an existing project, so the stacks already deployed converge rather than keeping the arrangement that causes this.

And a way out for a host whose pool is already empty

One fewer network per stack does not help a host with none left: even the implicit default fails to allocate. Every project now ships docker-compose.shared-network.yml, an overlay that joins a network which already exists and so asks for no subnet at all.

A shared network is shared, and the overlay says so: containers on it resolve each other by service name, so every service is given a project-prefixed alias and the API is pointed at those rather than the bare names. Two projects that both call a service postgres would otherwise resolve each other's, and a silent connection to somebody else's database is a worse day than a failed deploy.

The compose repairs had never run on Windows

Found while testing the above against real deployed projects: their compose files are CRLF, because git converts them on checkout, and every repair pattern was anchored on a bare \n. They matched nothing and reported no change, which is indistinguishable from a file that needed none. The Redis password repair in particular has been a silent no-op on every Windows checkout since it shipped. All of them are line-ending agnostic now, and a CRLF file stays CRLF.

v3.332.0September 25, 2026

Deploy to Railway in one command

grit deploy --railway pushes the variables a deploy actually needs, uploads the API and generates a URL. --provision adds Postgres and Redis on the way and points DATABASE_URL and REDIS_URL at them as Railway references rather than copies, so rotating a password does not need a redeploy and the password never sits in the app service's own settings.

The CLI does the upload rather than the GraphQL API, and that is not a preference: Railway's API has no endpoint that accepts local source. A service is built either from a connected repository or from an archive their CLI uploads, so every step Grit runs is a documented Railway command, printed before it runs.

It does not send your whole .env. A generated one has around a hundred entries and a real project sends about sixty. Empty placeholders for providers you do not use are held back, so is PORT, which Railway assigns, and so are the MinIO, Mailhog and compose-database settings, which describe containers on your laptop and would contradict the database you just provisioned. APP_ENV is forced to production whatever the file says.

--dry-run prints the whole plan, in order, and runs none of it. Values are masked, so the output is safe to paste into an issue.

Generated projects now carry apps/api/railway.json, which builds from the same Dockerfile docker compose uses rather than letting Nixpacks guess, and adds the health check and restart policy. The upload happens from apps/api rather than the repository root, because the Dockerfile copies go.mod from the context root and the Go module is apps/api.

The dashboard says what day it is

The weekday, date, month, year and time, beside the greeting. Rendered only after mount and deliberately so: a date formatted on the server and again in the browser disagrees on both the clock and the locale, and React's answer to a hydration mismatch is to throw away the subtree and rebuild it, which is a steep price for a decoration.

v3.331.0September 25, 2026

A passkey could be registered and never used

The account page invited you to add one and said "you can sign in on this device without a password". There was no way to do it. The API had /auth/passkeys/login/begin and /finish from the day passkeys shipped, and lib/webauthn.ts had toRequestOptions and encodeAssertion, written for exactly this call. Nothing in any frontend ever called them, so the whole sign-in half of the feature existed and was unreachable.

The sign-in page now has a Sign in with a passkey button, with a fingerprint on it, under the password form. It uses the discoverable-credential flow: the challenge carries no allowCredentials, so the browser offers whichever passkeys it holds and you are in with one touch, no email and no password. The server issues the same tokens a password sign-in issues and records the same session row, so the device appears in Active Sessions and can be revoked like any other.

The button appears only where the browser can actually produce a passkey, and that question is asked of the browser rather than the server: before anybody has identified themselves the server does not know whether this person has a passkey, and asking it would tell an attacker which addresses have accounts. Closing the system sheet or tapping Cancel is not reported as a failure, because telling somebody "passkey sign-in failed" when they changed their mind is how a button stops being trusted.

Proven end to end against a CDP virtual authenticator rather than reasoned about: register a passkey, clear the cookies, click the button, land on the dashboard.

v3.330.0September 25, 2026

The account screen was the one page in the admin with no way back

It wrote its own <h1> instead of using PageHeader, and PageHeader is what derives the "Back to System Hub" link for every /system/* route. So the one screen you reach from the hub and then need to leave was the one screen with no link out, and it was also missing the refresh, theme and notification controls every other page carries.

One page instead of four tabs

Six cards were hidden behind four tab labels, so answering "where am I signed in" meant knowing that devices were under Devices and not under Security. The page this replaced showed all of it at once, and six cards is a scroll rather than a navigation problem.

Now one column: profile, password, two-factor, passkeys, sign-in links, active sessions, and closing the account last, because it is the only thing on the page you cannot undo. It used to sit in the middle, since it lived inside the component that draws the first card; it is its own card now.

Every card shares one shell: a tinted icon chip, a title, a line of explanation and a rule under it. Profile and Password had the explanation beside the fields while the other four had it above, which is the kind of difference nobody can name and everybody notices. Active sessions has a heading for the first time: under a tab called Devices the tab said what the list was, and stacked it was an unlabelled column of device rows.

The five existing deep links that pointed at ?tab=security now point at #security, and the anchored sections carry a scroll margin so the sticky header does not cover the heading you were sent to.

v3.329.0September 25, 2026

Grit's slogan was in the browser tab of every app built with it

The generated Next.js app set its page title to "MyApp — Go + React. Built with Grit.", so every site anybody shipped carried the framework's marketing in the tab, in search results and in every link preview. The generated docs description did the same, and the generated README managed to say it twice: Built with Grit — Go + React. Built with Grit.

A title is the project's, not the framework's. The tab now says the project name and nothing else, the docs description says what it documents, and the README credit reads once, as a link. The starter landing page keeps a short "Built with Grit" badge, which is a credit rather than a slogan.

A new tagline

Describe your data. Get the whole app. It replaces "Go + React. Built with Grit." in the CLI banner, the README and across the documentation site. The old one named the ingredients; this one says what happens.

v3.328.0September 25, 2026

One line of CSS had killed every border colour in both apps

globals.css set the themed border colour twice: once inside @layer base, which is right, and once more near the bottom outside any layer. Unlayered CSS beats every layered rule, so that second copy overrode border-accent, border-danger and even border-transparent, everywhere, in the admin and the web app, since the Tailwind v4 migration.

Measured rather than guessed: the active tab on the account screen, the one carrying border-b-2 border-accent, computed to the same grey as its three inactive neighbours. That is the report that the tabs do not show which one is selected, and it was every coloured border in the product.

Sixty-seven colour utilities that compiled to nothing

Tailwind v4 makes one utility per --color-* in the theme block, and the admin defines sixteen. text-text-primary, bg-card, text-muted-foreground, bg-bg-primary and the rest name nothing, so they produce no rule: no build error, no console warning, just an element keeping whatever it inherited. A page looks almost right, which is worse than looking broken.

All of them are now real tokens, and a test walks every file the scaffolder writes and checks each colour utility against the palette that app's own stylesheet defines.

The account screen, drawn one way

Six cards, built at different times, with two surfaces, three icon treatments, two heading sizes and one bordered header band nothing else had. They share a shell now: the same tinted icon chip, the same surface, the same heading scale. The active tab carries its colour and its weight as well as its underline, because an underline alone is a two-pixel line and it is the first signal somebody with low vision loses.

An authenticator on a drifted clock can now finish enrolling

v3.326.0 taught the server to say your clock is 60 seconds behind instead of "invalid code". Useful, and not enough: knowing why does not let you turn on two-factor, and "fix your clock" is not advice on a managed machine.

Enrolment now searches five minutes either way, records how far out the device is, and validates every later sign-in against that offset with the same one-step window as before. It does not widen what is accepted: the window is still 30 seconds either side, of the device's time rather than the server's. Each accepted code re-records the offset, which is the resynchronisation RFC 6238 describes, so a clock that loses a second a day is followed rather than locking the account out six months later.

Email that survives an inbox, in the project's colours

The six templates were full HTML documents with a <style> block each. That is the one part of an email that is not reliably delivered: Outlook on Windows renders through Word and drops most of it, and Gmail strips it when a message is clipped or forwarded. They arrived as unstyled serif text for a large share of recipients.

Now one table-based layout with every style inline, a preheader so the inbox preview says something, and colours from THEME: a project built with --theme emerald sends green email rather than the framework's purple. The two emails that were never in Mail Preview, the sign-in code and the magic link, are in it.

The theme paints the web app too

The five themes added in v3.322.0 went into the admin's stylesheet only, so grit new shop --theme emerald produced an emerald admin behind an Atlas marketing site. The web stylesheet now takes its palette from the admin's, and a test holds the two together. The desktop app was already fine: it reads the shared theme registry at runtime.

The audit log is documented

A new page under Security: what the hash chain is, what Verify chain actually does, why request bodies are stored as a digest rather than verbatim, what --audit-reads adds, how pruning re-anchors the chain, and what the whole thing does not defend against.

v3.327.0September 25, 2026

ADMIN could not be edited, and the page told you to use a control that did not exist

ADMIN holds the * grant, which means every permission there is, including any added by a later release. The roles editor read that off the role and treated it as settled: the permission grid was replaced by a paragraph saying "remove that grant to pick individual permissions", and nothing on the page could remove it. Saving sent * straight back. The first role in every project was therefore the one role nobody could change.

The wildcard is a checkbox now, sitting in the Permissions header next to the counter. Untick it and the grid appears with everything the role currently holds already ticked, so narrowing ADMIN means unticking what you do not want rather than rebuilding it from nothing. Tick it again and the role goes back to *. The server already allowed all of this; only the screen did not.

A padlock on a card that opens

Built-in roles were drawn with a padlock beside the name. They are not locked: their description and their permissions have always been editable, and the seeder never overwrites an edit. The padlock is gone, the BUILT-IN badge stays because it is worth knowing, and the notice inside the editor now says which parts are yours to change and why the name is not: route guards, the seeder and the legacy role column all resolve a built-in role by name.

Em dashes in the text a project shows its users

Fourteen of them, in the sign-in footer and the page description of every admin style and theme, plus two more in the roles screens. Replaced, and the roles screens now have a test that keeps them out.

v3.326.0September 24, 2026

Verifying your own email in development, and a banner you can put away

A new project has no mailer, so the "confirm your email address" banner sits at the top of every admin page with no way to act on it. The link exists, in the server log or as a file under storage/mail, and nobody goes and gets it. In development the send endpoint now returns the link with the response, and the banner offers it as something to click. The response body is guarded on APP_ENV: anywhere else it would hand a working verification link to whoever asked for one, which is the whole secret.

The banner also has a close button now. Dismissal lasts the browser session, not forever: an unconfirmed address means a password reset has nowhere to go, so the reminder comes back at the next sign-in. It is stored in sessionStorage behind a try/catch, because that throws in a private window and a banner is not worth a blank page.

"The authenticator is not working" is usually a clock

A TOTP code is valid for 30 seconds either side of the server's clock. A phone or a VM whose time has drifted past that produces correct codes that are refused, and the refusal said "Invalid verification code. Make sure your authenticator app is synced", which sends people to delete the entry and re-scan a QR that was never the problem.

The server can measure the drift, so it does. A refused code is searched five minutes either way, and if it matches there, the message says so: how many seconds out the device is, and in which direction. A code that is simply wrong gets its own message and is not told its clock is broken. The code is refused either way, the attempt is counted the same way, and the search skips the window the validator already tried so it can never widen what is accepted.

A refused password said why in a sentence nobody could read

The password rules are shown to the user as a checklist, and the API built its error by joining those checklist labels after "Your password needs ". Two of the four are phrased as negatives, so the result was: Your password needs not a password everyone tries first and nothing from your name or email. That is what the API has been returning.

Each rule now carries its own clause for the sentence, separate from its checklist label, and the clauses come out in rule order rather than the order they happened to fail, so the same two failures always read the same way.

v3.325.0September 24, 2026

A theme now repaints the dashboard, not just the sign-in page

The five themes added in v3.322.0 styled their auth screens and left the dashboard on the default palette: a coral project had a coral sign-in page and an Atlas-coloured admin behind it. The picker that offers them says a theme "drives auth layout, dashboard tokens, fonts and brand colours", and for those five the middle one was not true.

Each now has its [data-theme] block, with the accent matching the theme's primary, so the sign-in button and the dashboard's buttons are the same colour.

Aurora and Pulse had an invisible button label

Writing the test that holds the above turned up something older. Buttons are bg-accent text-accent-fg, and neither Aurora nor Pulse ever set --accent-fg. With that token undefined the label falls back to --text-primary: near-black text on Aurora's near-black button, which cannot be read at all, and near-black on Pulse's blue, which barely can.

This is the same fault v3.318.0 fixed for the light themes and missed for these two, and it has been shipping since. Both now name white explicitly, and the test walks every theme rather than checking the token appears somewhere in the file, which is what let one theme satisfy it for all of them.

v3.324.0September 24, 2026

The theme picker offers the themes

v3.322.0 added five sign-in layouts and the themes that select them. They were reachable with grit new app --theme emerald, listed in the docs, offered by the stack builder and the AI wizard, and accepted by the flag validator. The one place they were missing was the interactive picker that grit new shows when you do not pass a flag, which is what almost everybody sees. For them the layouts had not shipped.

Nothing failed, which is why it survived a release: a rejected flag says so, a missing option says nothing at all. The picker now lists all eight, and two tests hold it to ValidThemes in both directions, so a theme cannot be accepted by the flag without being offered, or offered without being accepted.

v3.323.1September 24, 2026

Shipped as v3.323.1: the v3.323.0 tag's release job failed on a test of mine that asserted differently on Linux than on Windows, so it published no binaries.

grit update worked once on Windows, then failed forever

A Windows executable is locked while it runs, so updating means moving the running binary aside before writing the new one. It moved to grit.exe.old, and the leftover was cleared on the next run. Except Windows does not always clear it: when anything still holds a handle, the file is marked delete-pending rather than removed, the name stays reserved, and the rename onto it fails with Access is denied. Nothing in that message names the leftover, so the fix nobody could guess was to delete a file by hand.

The binary now moves to a name carrying the process id when the plain one cannot be cleared, which nothing else can be holding, and every leftover is swept after a successful update rather than only the current one.

Both installers now prove they can update before they write

An install is also a decision about every update after it. A binary installed with sudo into a root-owned directory, or into Program Files, can never be replaced by grit updaterunning as you: it fails on permissions, long after the install that caused it, with an error that names neither the directory nor the reason.

So both installers now write and delete a probe file in the install directory before downloading anything, and stop with something you can act on if it fails: which directory refused, whether it is a system directory that would need elevation for every future update, and the one-line command to install somewhere you own instead. grit update runs the same check up front rather than discovering the problem halfway through replacing itself.

The advice is always to move the install, never to run as administrator. A CLI that needs elevation to update is a CLI that stops being updated.

v3.322.0September 24, 2026

Five more sign-in layouts

Grit shipped three themes with three sign-in screens. It now ships eight. The sign-in page is the first thing anybody changes in a generated app and close to the last thing they want to build, and three shapes was not enough to find one that fits.

  • Coral puts the form in a card floating over a blurred glimpse of the app, the shape a marketplace uses when signing in is an interruption rather than a destination.
  • Amber is a plain bordered box under a wordmark, with the legal line and footer links a storefront is obliged to carry. Deliberately unfashionable.
  • Sky is a top bar and one bold heading, with social sign-in above the password field, for products where most people arrive holding an identity already.
  • Mono is black and white on a fine grid with a panel of proof beside the form.
  • Emerald is a narrow form column with a customer quote filling the rest.

Pick one with grit new myapp --theme emerald, or switch later with THEME=emerald in .env. A theme still carries its colours, fonts and radius: the layout is one more thing it decides.

The form inside is the same component in all eight, so none of this touches validation, the second factor, or the sign-in call. A layout decides where things sit and nothing else. The eight shells are generated from one shared preamble and one token block rather than eight copies, because eight copies of a CSS-variable list is eight chances for one theme to quietly stop publishing --auth-radius and render inputs with no border.

Emerald's quote ships attributed to "Replace this with a real one", and a test keeps it that way. A scaffold that ships a plausible-sounding fake endorsement is a scaffold that puts a lie into production the first time somebody forgets to edit it.

The desktop app keeps its three shells and maps the new layouts onto the closest of them, which is written down rather than left to a default arm: a window is not a browser tab, and a modal over a blurred page or a footer of legal links both assume a page you scrolled to. A test checks every layout is named there, because falling through silently would render Atlas for somebody who asked for Emerald.

Also

  • The "Copy prompt to build with AI" button is no longer the same blue as "Get started" beside it. Two solid blue buttons side by side make neither one the obvious thing to press.
  • The home page link that read "Our philosophy" now reads "Why Grit".
  • The "Grit in action" section gained a seventh step for themes, because a generated admin looks like a look people assume they are stuck with.
v3.321.0September 24, 2026

One prompt to confirm your email, not two

v3.320.0 added a "confirm your email address" card to the dashboard, directly underneath the banner that has asked the same thing on every page since long before it. Two prompts for one job teach people to ignore both, and the next one that matters pays for it.

The banner stays, because it covers every page rather than only the dashboard. The dashboard card is gone, and the dashboard nudge now owns the one thing that had no home anywhere: turning on two-factor. A test keeps it that way.

Worth saying how this was found, because it is the whole argument for the next item: not by reading the code, which looked right, but by opening a screenshot of a running admin and seeing the two of them stacked.

The home page shows the table

The "Grit in action" section paired the grit generate resource command with a screenshot of a form. The list screen is what that command produces first and what people spend their day in, so it now shows that instead: statistic cards, search, a date filter, import and export, a column picker, and a sortable table of 500 seeded rows with bulk selection and row actions. It is a photograph of a real generated project, taken for this release.

The README gains it too, alongside the Account screen.

v3.320.0September 24, 2026

Sign in with a link, for the accounts that never had a password

The sign-in page now offers "Email me a sign-in link instead", under the password box and using the address already typed there. It matters most for the accounts that have no password at all: anybody who signed up through Google or GitHub, and anybody whose reset email keeps landing in a spam folder they cannot reach from their phone. WCAG 2.2 asks for a way past a cognitive test at sign-in (3.3.8). Remembering a password is one, and this is that way.

Three decisions, each of them the thing this feature is usually got wrong:

  • The form answers the same either way. "If that address has an account, a sign-in link is on its way", whether or not it does, and an address with no account does no work at all. Anything else turns the sign-in page into a way to find out who is registered here. The one exception is the rate limit, which is refused out loud, because the person is sitting in front of an inbox and silence would leave them waiting for an email that is not coming.
  • The token is spent by the page, not by the link. Opening the link loads a page that POSTs the token back; the server spends nothing on the GET. Corporate mail scanners follow every URL in a message before anybody reads it, and a token spent on the GET is one the scanner burns. The person then clicks their own link and is told it has already been used, with nothing in that message to suggest why.
  • A link replaces the password, not the second factor. An account with two-factor on gets the same challenge it always does. A link sitting in a mailbox is exactly what a second factor exists to survive.

Links live 15 minutes, work once, and are stored as a SHA-256 hash, so a database read is not a pile of working sign-ins. Spending one is a conditional update rather than a read followed by a write, so two tabs opening the same link cannot both get in. Signing in this way records a session and an activity-log entry like any other, so the device shows up under Devices and can be revoked.

The Security tab gains a Sign-in links card listing when a link was requested, from what address, and whether it was used, expired or is still valid. It exists because the request form answers identically to everybody by design, which also means it can never warn anybody: this is the only place a request you did not make becomes visible. It never shows a token.

The dashboard asks you to finish two things

An unverified email address means a password reset has nowhere to go, which is a bad thing to discover on the day you need it. An account with no second factor is one leaked password from gone. Both facts used to live on a settings page nobody had a reason to open, so the dashboard now says so once, with a button that does the thing: send the verification email, or open the two-factor setup.

Each card can be dismissed, and both disappear on their own once the underlying job is done, so nothing becomes permanent furniture. A banner that cannot be dismissed is one people learn to look past, which costs the next one its attention too. All four dashboard styles have them, which a test now enforces.

One account screen, not two

v3.319.0 put everything about your own login on /system/account and left the older /account/security showing the same cards. Two pages for one thing is the problem the Account screen was built to fix. The old address now redirects, so a bookmark still works, and the user menu points at the new one.

A skill and a prompt, so an agent gets Grit right the first time

Grit is a code generator, not a runtime library, and nearly every mistake an AI makes with it comes from not knowing that: it hand-writes the nine files that grit generate resource would have written, produces something that compiles, and cannot work out why none of it is reachable.

So there is now a proper skill, installable with npx skills add MUKE-coder/grit --skill grit and readable at /skill.md. It covers the mental model, choosing an architecture, the full field-type table, which layer code belongs in, the response format, the list of things already in the box that agents keep rebuilding, the verification commands, and the nine mistakes that cost the most time.

A Copy prompt to build with AI button now sits in the hero, in the install tabs beside Windows, macOS and Go, on the docs home, on Start Here, and on the Build with AI page. It copies a nine-step brief that installs the skill, links every concept the agent needs, and tells it to ask you what you are building before it scaffolds anything. It is also fetchable at /prompt. The nav item formerly called "AI Integration" is now "Build with AI".

Grit in action, on the home page

A new section directly under the hero: six commands on the left, typed in the order you actually run them, and on the right the screen each one produced. The rest of the page argues; this part demonstrates. The screenshots are real generated projects, the pairing is the point, and it pauses on hover or focus with every step reachable by keyboard.

Also

  • The desktop sign-in knew which second factor an account used and never read it, so every account using codes by email was told to open an authenticator app it had never set up. Only a generated project's type check caught it, as an unused variable. Fixed, and pinned by a test that checks the admin and the desktop together.
  • The README is rewritten for somebody with no time: install, sixty seconds, what ships, a real five-minute tutorial that ends in a deployed support desk, and everything else folded away. Its field-type table said belongs_to was a uint; it has been a UUID string for a long time.

grit upgrade carries all of it into an existing project: the routes, the model registry, the pages and the cards.

v3.319.0September 24, 2026

One Account screen, under System → Security & Access

Everything about your own login was scattered across three pages: the password sat on a page called Profile next to a job title and a bio, two-factor and passkeys were on a second page, and the list of devices on a third. Somebody trying to lock their account down had to know all three existed. There is now one screen with four tabs, Profile, Password, Security and Devices, and it composes the cards that already existed rather than copying them.

The tabs are links, not an ARIA tablist: a link is keyboard-operable, shareable and survives a refresh without any roving-focus code, and the hand-rolled version is the one that fails a keyboard user.

Password rules the server actually enforces

The password box now shows four rules while you type, and the reason to trust them is that the same four run on the server, in one package, on registering, changing and resetting alike. A checklist the server ignores is theatre: every item can be green and a weak password saves anyway.

At least 8 characters; letters and something else; not one of the few hundred passwords attackers try first; and nothing taken from your own name or email address, which only the server can check because only the server knows both. A refused save says which rule it failed rather than "invalid password".

A second factor by email, for people who will not install an app

Two-factor meant an authenticator app or nothing, and for a lot of people that meant nothing. The Security tab now offers codes by email beside it. An authenticator is still the stronger of the two, because the code never leaves the device, while an emailed code is only as safe as the mailbox, which is also where a password reset goes. It is here because the alternative for those people was no second factor at all.

Turning it on takes two steps on purpose: a code is sent first, and only when it comes back is the factor switched on, so nobody enables something they cannot receive. A deployment with no mailer is refused outright, and if mail stops working later the sign-in says so instead of leaving somebody at a code box waiting for a code that is never coming. Codes are six digits from crypto/rand, stored hashed, compared in constant time, spent on use, and counted against the same five attempts as an authenticator code. The email carries the digits and no link, because an email asking you to click to sign in is the shape of every phishing message ever written.

grit upgrade carries all of it into an existing project, including the two routes and the sign-in branch, which live in files a project owns and would otherwise arrive half-wired.

v3.318.0September 24, 2026

The admin, audited in a browser

Not reviewed by reading it: driven with a real browser against a generated project, measuring what the page actually rendered. Six things failed, and every scaffolded admin had inherited all of them.

The primary button was unreadable. White on the default dark theme's #60a5fa measures 2.5:1, against the 4.5:1 that AA asks for: a blue rectangle with a rumour of text on it. The brightness is the point of lifting an accent for a dark canvas, so the label moved rather than the colour. There is now an --accent-fg token, dark on the light-blue themes and white on the dark ones, and the twenty-nine places that hard-coded text-white use it.

Muted text sat at 3.1:1 on the dark canvas and 2.5:1 on the light one. It is the colour of every table column header, so the labels on the densest screen in the admin were the hardest to read. Both tokens moved to values that measure over 4.5:1.

A screen reader read a page of identical checkboxes. Every row's select box announced nothing at all, so ticking one was a guess. Each now names its row, taken from the first readable column rather than from an id nobody identifies a row by, and the search box has a name instead of a placeholder that vanishes the moment you type.

There was no way past the sidebar. Eight links, on every page, before the content: a skip link now comes first and the main landmark it targets is focusable, so the next Tab carries on from the content instead of the top. The auth pages gained a landmark too, so the sign-in form can be reached without walking the hero panel.

And several targets were under 24px (WCAG 2.5.8): the row checkboxes, the password eye, the Edit and Delete links in a row, the option library's delete buttons. The option library's three fields also had visible labels attached to nothing, which is the same defect this admin shipped twelve times once before.

Five tests hold each of these, written against the templates so they fail before a release rather than after one.

v3.317.0September 23, 2026

One colour axis, different colours per product

Options are shared by the whole shop, which is what keeps one spelling of Colour and lets a filter match across the catalogue. On its own that left a gap any real shop hits on its second product: offering Colour offered every colour in the shop, so a shirt in ecru and navy and a tee in black, sand and olive could not share the axis. The storefront blueprint worked around it with two Colour options, one per product, which is the duplication the shared library exists to prevent.

A product now offers some of an axis's values. The admin's picker shows each ticked axis with its values under it, and the API takes them as value_ids alongside option_ids. An axis with none of its values listed is offered whole, and an axis with every value ticked stores nothing, so both mean the same thing: a colour added to the shop next month appears on every product that said yes to the axis rather than being quietly left out.

Nothing had to be backfilled. A catalogue that has never narrowed anything behaves exactly as it did, because no rows for an option means all of its values. Narrowing changes which combinations exist, so it clears the matrix the way changing the axes does, and the admin asks first. Every read of a product's options goes through one function, so the matrix generator, the public payload and the picker all see the narrowed set without knowing about it. Four shipped tests cover the rules.

On an existing project: grit add variants --resource Product again, then grit migrate for the new table.

v3.316.0September 23, 2026

grit plugin update: a plugin fix that reaches installed projects

Yesterday the Stripe plugin learned to record what a renewal charged. Nobody who had already installed it could have that. grit plugin add refuses once a plugin is installed, grit upgrade never touched plugin files, and remove then add loses every local edit and re-inserts the patches at their markers, which moves them relative to code the app has written since. Doing exactly that to a shop built on Grit put the payment service below the code that constructs things from it, and the API stopped compiling.

grit plugin update stripe # the files it owns, as this CLI writes them
grit plugin update --all # every installed plugin
grit upgrade # does the same, for every plugin, on its way past

An update rewrites only what it can prove nobody has touched. Every file is fingerprinted when the plugin writes it, so one whose fingerprint still matches is replaced and one that differs is left exactly as it is and named. Files a release has added simply arrive, a patch already in place is never applied twice or moved, and nothing is ever deleted. A line ending is not an edit, which matters on Windows where git rewrites them on checkout.

Two refusals are deliberate. A file you have edited is never overwritten. And a project installed before fingerprints existed cannot be judged at all, so rather than update some files and not others, it changes nothing and says which files it cannot vouch for: the file a release adds is usually the one another file has to change to use, and half an update is a project that does not build. Both have the same way out, with git to read it afterwards:

grit plugin update stripe --overwrite # take the plugin's version of everything
git diff # and see exactly what that did
v3.315.0September 23, 2026

A subscription now records what it charged

A subscription row says what somebody is entitled to. It says nothing about what they paid, when, or whether last month's renewal went through, and that lived in Stripe and stayed there. So an app could show a customer as a member and hold no record of a single payment from them, which is a problem the first time anybody asks for a receipt, a refund, or the month's takings. A shop built on this had a sales page reporting nothing while subscriptions were selling.

grit plugin add stripe now records every paid invoice as a Payment, the same row a one-off purchase writes, with the reference subscription:<id>, and runs the app's OnSucceeded hook so a receipt is sent for a renewal without writing that twice. A failed renewal is recorded too, with what the bank said, because that is the one an app most needs to show. A redelivered invoice changes nothing, a late failure cannot unpay a paid invoice, and an invoice in Stripe's newer shape (where the subscription moved under parent) is read either way. Five shipped tests.

services.OnOAuthLogin: what the provider knew

A social login learns things only the provider knows: the GitHub login, the avatar, the locale. Grit used one field of the profile and dropped the rest, and the callback that had it is a file Grit rewrites, so an app that wanted the GitHub handle had to fork it. The shop asked people to type a GitHub username it had just been handed at sign-in, and a typed username is where a typo comes from: an invitation to somebody else, or to nobody, found out about when the repository never appears.

Register a hook at boot and it runs after every social login, with the account and the profile:

apps/api/internal/routes/routes.go
services.OnOAuthLogin(func(ctx context.Context, user *models.User, profile goth.User) error {
if profile.Provider != "github" || profile.NickName == "" {
return nil
}
// NickName is the provider's handle: the GitHub login, the Twitter @.
return db.WithContext(ctx).Model(user).Update("github_username", profile.NickName).Error
})

A hook that fails is logged and the rest still run, and the login still succeeds: refusing somebody a session because their avatar URL would not save is a worse failure than the one it reports. grit upgrade adds both the registry and the call site to an existing project.

v3.314.0September 23, 2026

SQLite stops saying "database is locked"

A SQLite file opened with GORM's defaults has no busy timeout, so the second writer fails instantly instead of waiting its turn, and the pool handed out twenty-five connections to compete over one file lock. Two writes a few milliseconds apart was enough. Building a shop on Grit, a subscription webhook lost its write to a background worker's tick and the customer's purchase granted nothing, with only SQLITE_BUSY in a table to say why.

Grit now opens SQLite with busy_timeout and WAL, and takes one connection for it, so two writers queue inside the process instead of colliding in the file. Postgres and MySQL keep the pool they were given. grit upgrade repairs an existing project.

A redelivered webhook that failed is run again

A provider retrying a delivery is the one thing standing between a handler that failed and a customer who paid for nothing, and the receiver answered every repeat "skipped: duplicate". That is right for an event already processed and wrong for the two cases that bring a provider back: a handler that failed, and an event a process died holding. Both were dropped, permanently, in a table nobody reads.

A redelivery now claims the stored event with a conditional update exactly one caller wins, and runs the handler again. An event still being handled is left alone, and one abandoned by a dead process is taken over once it is too old to be in flight. Four shipped tests hold the cases apart.

middleware.Identify, for the public pages that know you

A catalogue that marks what you already own, a pricing page that knows your plan, an article with your own comment on it: each has to be readable signed out, which means no guard, which used to mean the handler could not tell who was reading even when they were signed in. Every app ended up parsing the Authorization header by hand, and that is how the cookie flow gets forgotten.

middleware.Identify reads the session if there is one and lets the request through either way, setting exactly what middleware.Auth sets. It is not a guard: a missing, expired or revoked token is an anonymous request, not a 401, so anything that must not be served to a stranger still belongs behind Auth.

grit generate field on User

Adding a field to User failed on the admin markers the resource generator writes and the ones the scaffold writes not being the same markers, and a second run could duplicate what the first had added. It now anchors on either, warns instead of failing when a marker is missing, and checks for its own work inside the block it is editing rather than anywhere in the file.

v3.313.0September 23, 2026

Subscriptions, in the Stripe plugin

grit plugin add stripe took one-off payments and nothing else, so anything that renews meant building card updates, failed renewals, dunning, proration and cancellation by hand. It now does recurring plans through Stripe's hosted checkout and billing portal, which are already localised and already handle 3-D Secure. Your app writes two functions: OnActive to grant what the plan buys and OnEnded to take it away, both inside the transaction that records the change.

GET /subscriptions/me answers entitled, which is the only question the rest of an app has to ask. A failed renewal keeps access while Stripe retries the card, because past_due is usually an expired card and most of those retries succeed; access ends when Stripe gives up. Cancelling leaves the days already paid for. Subscription events are conditional updates like payments, so a redelivered or out-of-order event changes nothing the second time, and a checkout session belonging to somebody else grants nothing.

Return URLs are built from SITE_URL plus a path, never from a URL the browser sends. See the plugin's page. Ten shipped tests cover it, and it was driven against a running API: a created subscription grants access, a redelivery is skipped, past_due keeps it, canceled ends it, and a subscription for nobody is recorded against nobody.

v3.312.0September 22, 2026

Stored images were not loading in development

next/image refuses to fetch an image whose host resolves to a private IP, and in development every host is one: the API serves stored files from localhost, and MinIO is localhost too. The optimizer answered 400, the page showed alt text, and the reason was a line in the terminal. Every app with an upload looked broken while running locally, and nothing said why.

Each Next.js app now sets dangerouslyAllowLocalIP: isDev, which is scoped to development: in production the guard stays on, where storage is a real origin and it is worth having. grit upgrade adds it to an existing web app, admin and docs site. Found building the storefront blueprint, where every product is a photograph.

v3.311.0September 22, 2026

Stripe payments: grit plugin add stripe

Take money for an order without trusting the browser with the price. There is no route that starts a payment: your checkout handler works out the total and calls payments.Service.Create, and the browser receives a client secret for that one payment. Reloading the checkout reuses it; a changed basket cancels the old one at Stripe, so its secret cannot pay the old price.

Stripe's word marks a payment paid, never a redirect: the signed webhook at POST /webhooks/stripe, deduplicated by event id, or POST /payments/:id/refresh reading the intent back with the secret key, so the return page works on a laptop with no webhook. Every change is a conditional update, so a redelivered or late event changes nothing, and OnSucceeded marks your order paid once, inside the transaction that marks the payment. An intent for another amount is never marked paid. Refunds come from the admin, keyed so a double click makes one. The web app gets <StripeCheckout> and <PaymentResult> in its own colours.

No stripe-go: four REST calls with the API version pinned, because its webhook parser refuses any event whose API version differs from the library's. See the plugin's page. Found missing building the storefront blueprint.

The CSP has a list plugins add origins to

Every directive of the Next.js Content-Security-Policy was one fixed string, so a plugin that loads a third party's script had nowhere to say so, and the browser refused Stripe.js with nothing but a console message. next.config.ts now has pluginOrigins, written at a grit:csp-origins marker and taken out again on removal, and an explicit frame-src 'self', which is what default-src already allowed. grit upgrade adds both to an existing web app and admin, along with the two new error codes, PAYMENTS_UNAVAILABLE and PAYMENT_PROVIDER_ERROR.

Two plugins in one project stay gofmt-clean

Plugins inject their imports at the same marker, so the second one landed wherever the first left off: a project with both video and stripe installed was no longer gofmt-clean, which fails the formatting check in its own CI. Every Go file a plugin edits is now formatted on the way out, so no plugin has to know what another injected.

Variants: a customer could reprice a variant

grit add variants mounted its routes on the group every signed-in user reaches, while the resource's own routes ask for ADMIN or a permission. So any customer could set a variant's price override to a cent and check out at that price, or create and delete the shop's options. They now ask for the resource's view permission to read and its edit permission to change, and grit upgrade moves them in an existing project. If you added variants before this release, upgrade now.

Three more from the same command, which no check caught because none ran it on a new project and built what it wrote: the public variants handler called respond without importing it, so the API stopped compiling the moment variants were added (grit upgrade adds the import); a second resource with variants wrote a second copy of the same helpers into the same packages; and the handlers it wrote did not follow the request's context, which upgrade then had to fix. The release checks now add variants to two resources of a new project and build, lint and test it.

v3.310.0September 22, 2026

An Expo app with push notifications opens in a browser

grit plugin add push wrote an onNotificationTap that asked expo-notifications for the notification that opened the app. On the web there is none, and the library throws rather than saying so, so an Expo app with the push plugin crashed on its first screen when opened in a browser, which is where Expo web previews and screenshots are made. It now does nothing on the web, and grit upgrade puts the fix into an existingapps/expo/lib/push.ts. Found making mockups of the WhatsApp blueprint.

v3.309.0September 22, 2026

A signed-in web page outlives its access token

The web app refreshes an expired session. Its API client had no 401 retry; only the admin panel added one. So every signed-in page in the web app failed fifteen minutes after sign-in, when the access token expired, although the refresh cookie was still good for days. lib/api.ts now sends /api/auth/refresh and repeats the request. Requests that fail together share one refresh: the server rotates refresh tokens and treats a second use of the old one as theft, so ten refreshes would sign the person out everywhere. Sign-in, sign-up, refresh and sign-out are never retried. grit upgrade updates an unedited lib/api.ts.

The video plugin's Expo player plays on a phone in development. It handed expo-video the stored URL as it was, http://localhost:..., which a phone cannot reach; it now goes through resolveImageUrl like every other stored file in the Expo app. And the upload example on the plugin's page named the arguments wrong: uploader.upload(file, file.name, { accepts: ["video"] }).

Both found building the Instagram blueprint.

v3.308.0September 22, 2026

Video plays in the browser

The web and admin Content-Security-Policy had no media-src, so <video> and <audio> fell back to default-src 'self' and the browser refused every clip the API or storage served. Nothing played video before v3.307.0's video plugin, whose VideoPlayer was therefore blocked in every Next.js app. media-src now admits the same origins as img-src, in the Next.js and Vite configs and in the nginx config a built Vite frontend is served with. grit upgrade adds it to an existing project. Found building the Instagram blueprint.

v3.307.0September 22, 2026

grit plugin add video: clips converted for every client

A new plugin. grit plugin add video turns an uploaded clip into one H.264 MP4 that starts playing before it has downloaded, capped at 720 on the short side, plus a poster frame and the size and length a feed needs. Every browser, iOS and Android plays that, so there is no HLS to serve and no player to choose per platform. POST /videos takes the key of an upload you own and answers 202; GET /videos/:id reports pending, processing, ready or failed. VideoPlayer and useVideo come for the web app and, with expo-video, for the Expo app. See the plugin's page.

The table is the queue. A worker claims a pending video with a conditional update, so a conversion survives a restart and replicas share the work with or without Redis. One conversion at a time per replica, and a claim held by a replica that died is taken over. The owner hears video.ready or video.failed on their realtime channel.

A crafted file cannot read the server. ffmpeg follows what a file says it is, and a playlist posing as a video can name other files for it to open. Only MP4, MOV and WebM are converted, each read with a forced demuxer and local files only; a playlist is refused before ffmpeg takes it as input.

Presigned uploads allow a video up to 300 MB. The form-field path already did for a field that accepts video; the direct-to-bucket path capped every file at 50 MB, so a minute of phone video was refused, or deleted after it landed. /uploads/profiles reports the new ceiling as max_video_upload.

A place in the Dockerfile for plugins. The API image has a # grit:runtime-packages line where a plugin adds what it needs at runtime, which is how the video plugin gets ffmpeg into the image. grit upgrade adds it to an existing Dockerfile.

v3.306.0September 22, 2026

The security scan passes on a project with the Expo app

pnpm audit. A project with the Expo app failed its security workflow on four high advisories, all in Expo's bundler, which the audit counts because Expo is a production dependency of the app. pnpm-workspace.yaml now moves the bundler's PostCSS to 8.5.28, which fixes the four PostCSS advisories and still bundles the app. The two image-size advisories have no fixed 1.x release, only 2.x, which metro cannot take, and metro only reads the sizes of the project's own image assets at bundle time, never a file a user uploads, so they are listed underauditConfig.ignoreGhsas with that reason, to remove when metro moves on.

Run pnpm dedupe after the React pin, not pnpm install. v3.305.0 said install. It keeps a version the lockfile already holds when it still satisfies the range, so the upload package, which asks for "react": ">=18", kept its own 19.2.7 in an upgraded project and there were still two Reacts. pnpm dedupe re-resolves, and installs.

v3.305.0September 22, 2026

One React in a project with the Expo app

Web and admin tests pass in a project with the Expo app. Expo SDK 54 needs React 19.1.0 exactly, because React 19 checks that react and React Native's renderer are the same version, while the web apps pinned 19.2.7. With both in one hoisted node_modules, Expo's copy sat at the root and every other app got its own, so a test loaded React more than once. Testing Library'sact() flushed a different React from the one rendering, and every component test in a new --expo project failed with "Invalid hook call" or an empty render.

With the Expo app in the project, the web app, the admin, the desktop app and the docs site now pin Expo's React, and the monorepo holds one copy. Nothing Grit generates uses an API added in 19.2. Projects without Expo keep 19.2.7. grit upgradealigns an existing project; run pnpm install afterwards.

v3.304.0September 22, 2026

An upgraded project's lint is green too

v3.303.0 took golangci-lint to no findings on a new project. A project upgraded to it kept one: the realtime presence heartbeat, which grit upgrade does not rewrite once a project has it. The upgrade now adds the same note a new project gets, saying why each presence write makes its own bounded context, and golangci-lint reports nothing on either.

v3.303.0September 22, 2026

A duplicate value answers 409, and a new project's CI passes on its first push

A value a unique field already holds is a conflict, not a server error. Saving a second contact with the same code came back as a 500 "Failed to create contact", which tells the person filling in the form nothing they can fix. respond.WriteErrorrecognises a unique violation on SQLite, Postgres, MySQL and SQL Server and answers 409CONFLICT, with the field in details when the database says which: SQLite names the column, and the others name GORM's idx_ or uni_index, read against the route's table. Every generated handler already routes through it, so this reaches existing resources on grit upgrade. A soft-deleted row still holds its value, so re-creating one is a 409 too. internal/respond has tests now.

The security scan accepts the one advisory with no fix. GO-2026-6452 is in excelize, reached only through GORM Studio's Excel import, and no release fixes it, so every new project's security workflow failed. It now reads.github/govulncheck-allow.txt, the same list Grit's own scan keeps: each entry names why it is accepted and the issue tracking the fix, prints as a warning on every run, and anything not listed still fails.

golangci-lint starts green again. The shipped config promises no findings on a new project, and there were nine, which a first push reports because there is nothing to diff against. The real one: the encrypted-column backfill never checked rows.Err, so a read that failed partway looked like the last batch and left plaintext behind. The rest are fixed or, where a fresh context is deliberate, say why at the call.

The gzip test passes under -race. The race detector makessync.Pool drop a quarter of what is returned to it, so the pooled-compressor check failed every project's CI. The limit is now half of an unpooled compressor's cost, measured, rather than near zero.

Generated desktop lists pass Biome. The bulk delete and import callbacks returned a value from forEach, which Biome reports as an error.grit upgrade fixes existing lists.

v3.302.0September 21, 2026

Tidier generated code, and date windows that are right on SQLite

A project stays gofmt-clean after grit generate. Registering a model with the API reference or GORM Studio left a comma before the marker comment, which gofmt removes, sogofmt -l listed apidocs.go after the first generated resource. The file is formatted after each change now, and a second run of the same resource still finds it already registered.

Count windows and daily charts on SQLite. GORM writes created_atin local time, and SQLite compares times as text, so a "last 24 hours" count or a 30-day chart bounded by a UTC time was off by the machine's UTC offset. The bounds are now the same instant written in local time. Postgres compares instants and was never affected.

No empty sign-in folders in the web app. A new web app got five empty(auth) folders for pages that only grit add web-auth writes. They are created with the pages now.

v3.301.0September 21, 2026

The desktop app catches up: realtime channels, and generated screens that type-check

The desktop app gets the realtime client every other app has. It shipped its own older client with no channels, presence or client events, and nothing in the app used it. It now gets the same client and hooks as the web, admin and Expo apps (useChannel,usePresence, useWhisper, useRealtime), signing the socket in with the token from the OS keychain.

Generated desktop screens failed tsc. A form for a resource with a relation to a user imported a use-users hook that did not exist; a select field was held as a string where the model types it as its options; a file field used the desktop's looser file type; and the list screen wrote each related record's name over the relation itself, so its rows no longer typed as the model. The generator now writes the users hook, types the form's payload, and gives related names their own column key. Relation pickers and columns also show a person's name instead of their id.

No more stray vite.config.js. The desktop'stsconfig.node.json had no output directory, so every pnpm build wrotevite.config.js and vite.config.d.ts beside the source. They go tonode_modules now; delete any that were written before.

grit upgrade repairs all of it in an existing project. Found putting the WhatsApp blueprint on the desktop, where its chat now runs live with typing, online status and read receipts.

v3.300.0September 21, 2026

Push notifications: grit plugin add push

Grit had realtime, email and background jobs, and no way to reach a phone that was not open. The push plugin adds it, through Expo's push service, which relays to Apple and Google, so the API holds no APNs certificate or Firebase key. From Go it is one line,services.NewPush(db).Go(userIDs, msg), sent in the background so no request waits on it. The Expo app gets lib/push.ts, which asks permission, registers the device, tells you which notification was tapped, and unregisters on sign-out.

A token Expo reports as no longer registered is deleted on the spot, a phone that signs in as someone else stops getting the previous user's notifications, sends go 100 to a request, and each user keeps their ten newest devices. Checked against Expo's live service: a message went out through it, and a token no device owned came back as not registered and was removed.

Plugins can add npm packages. A plugin could declare npm dependencies, but the installer only wrote them to the lockfile. It now adds each to the package.json of the app it names, as text so the file keeps its formatting, and skips an app the project does not have. Push is the first plugin to need it, for expo-notifications. Found building the WhatsApp blueprint, whose plan listed push as already shipped.

v3.299.0September 21, 2026

Generated Expo screens type-check

Relations to a user broke the build. A resource with a belongs_to field got Expo screens that imported a use<Plural> hook for the related model and named it asrecord.name || record.title. For a relation to the built-in User there was nouse-users hook, and most models have neither field, so tsc failed as soon as such a resource was generated. The screens now call relationLabel(), which uses whichever of name, title, first and last name, label or email a record has, and the generator writes a use-users hook when a relation needs one.

Typed routes rejected every list screen. Expo Router's typed routes are on in every Grit Expo app, and they accept a template literal shaped like a route but not a plain string. The generated list screens and the roles screen navigated with "/items/" + id, so tsc failed the first time expo start wrote the route types. They use template literals now.

grit upgrade fixes screens it generated before, adds the missing hook, and adds.expo/ to .gitignore, where Expo's cache was being committed. Found running the WhatsApp blueprint's mobile app, which now type-checks cleanly with its route types in place.

v3.298.0September 21, 2026

grit env: clone a project and run it

grit new writes a .env with generated secrets and a.env.example with CHANGE_ME in their place, and only the example is committed. A teammate who cloned the project had no .env, and the API rightly refuses to start on a placeholder, so their first job was generating ten secrets by hand.grit env does what grit new did: it copies the example and generates everyCHANGE_ME in the shape that variable takes, 32 bytes of base64 forFIELD_ENCRYPTION_KEY and hex for the rest. It prints the names it filled and never a value. Run again, it fills only placeholders still left, such as one a pull added, and leaves every value you set alone.

A new FIELD_ENCRYPTION_KEY cannot read data encrypted with another one, sogrit env says so when it makes one: on a database you share, use the team's key. A new project's README now has a "Cloned this project?" section with the three commands. Found cloning the WhatsApp blueprint to check its own instructions.

Two Expo fixes: realtime connects, and users stay signed in

Realtime never connected in an Expo app. React Native has no cookie jar, so the socket passes the access token itself, through a getter that setRealtimeToken was meant to replace. Nothing called it: the getter returned null and the server refused every mobile socket. It now reads the token the API client already keeps, through the app's web-safe SecureStore wrapper, and setRealtimeToken stays for an app that stores it elsewhere.

Expo apps signed users out after 15 minutes. The API client refreshed once per failed request. A screen that loads two things after the access token expires sent the same refresh token twice, and the server, which rotates refresh tokens and treats a spent one as stolen, revoked the session. Requests that fail together now wait on one refresh, as the admin and desktop clients already did. grit upgrade applies both to an existing Expo app and warns instead where you have rewritten the file. Both found building the WhatsApp blueprint's mobile app.

v3.297.0September 21, 2026

Realtime works in production Next.js apps

The browser blocked the realtime socket. The Content-Security-Policy a Next.js web app or admin panel sends named the API as http:// or https:// inconnect-src, and a CSP source matches its scheme exactly, so the socket atws:// or wss:// on the same host was refused. Development hid it, because the development policy allows every socket for hot reload. Under next start, live notifications, presence, client events and every other realtime update were dead. The policy now names the API's socket origin too, and the nginx policy a Vite frontend is served with allows sockets. grit upgrade fixes an existing project's config, and warns instead when you have written your own policy. Found building the first Grit UI blueprint, a WhatsApp clone.

Build artefacts are ignored. A new project's .gitignore ignores*.tsbuildinfo and next-env.d.ts, which TypeScript and Next.js write on every build, so they stop turning up in the first commit. grit upgrade adds the two lines to an existing project once.

v3.296.0September 21, 2026

First names seed as first names, and a CRM built end to end

Name fields seed the right part of a name. A --faker seeder filledfirst_name and last_name with a full name each, so a contact came out as "Ada Nakato Brian Okello". A field named for a first name now seedsgofakeit.FirstName(), and one named for a last name or surname seedsgofakeit.LastName(). A plain name field still gets a full name. Regenerate a seeder with grit generate seeder Contact --faker to pick this up.

New on the blog: build a CRM with Grit. One project that uses this month's features together: phone numbers from any country with the tel type, nine other field types, a million seeded contacts with grit seed Contact --count 1000000, and a live "who else has this contact open" banner built on presence and client events. Every command in it was run while writing it, and every number is the one it printed.

v3.295.0September 21, 2026

Seed a million rows in under a minute: batched, resumable seeding with grit seed --count

Batched inserts. A --faker seeder inserted one row per statement, each in its own transaction. It now inserts in batches, one transaction per batch, sized to what the database accepts for the table's column count. Ten thousand rows went from 96.6 seconds to 3.7 on SQLite, and from 31.9 seconds to 2.4 on Postgres. A million rows took 3 minutes 14 seconds on SQLite and about 50 seconds on Postgres, where the old seeder would have taken nearly three hours and about 53 minutes.

grit seed Contact --count 1000000. Give grit seed a resource and a count and it tops that table up to exactly that many rows. It counts what is there and inserts only the rest, so a second run does nothing and a run that was stopped partway carries on from where it stopped. With no arguments grit seed runs every seeder, as before.

Flat memory, clear failures. Rows are built in chunks by a few goroutines and written as they are ready, so a million rows used about 15 MB of heap. Progress prints every two seconds with rows per second and time left. The first batch that fails stops the run with an error; before, a failed row was logged and skipped, and the run reported success over a half-empty table. The count query's error, which was ignored, is checked.

Unique columns stay unique. A column marked unique seeds from the row's number (SKU-0000001) instead of four random letters and four random digits, which collided about a hundred times in a million rows.

grit upgrade adds the shared seeding helper, internal/database/seedbatch.go, and the new seed entry point. Seeders generated before this release keep working withgrit seed; regenerate one with grit generate seeder Contact --faker to use--count.

v3.294.0September 21, 2026

Ten new field types: email, url, domain, tel, country, color, percent, rating, time and json

Fields that know what they hold. grit generate resource acceptsemail, url, domain, tel, country,color, percent, rating, time andjson. Before, email, url and phone were only guesses from a field's name, andtel was refused outright.

Checked and normalised on every write. A new internal/fieldtypes package checks each value on create, update, patch, bulk edit, CSV import and sync, and stores it in one form: lowercase email addresses, bare punycode domains, #rrggbb colours, HH:MM times and E.164 phone numbers. A local Ugandan number such as 0772 123456 is stored as+256772123456. A value the column cannot hold is a 422 that names the field, for example "Phone is not a valid phone number for UG".

Inputs built for each type. The admin gets a searchable country picker covering 245 calling countries with flags and dial codes, shared by tel and country; a phone input that formats as you type; a colour picker; keyboard-accessible stars; and a JSON editor that checks as you type. The phone and country inputs load only on forms that use them. Tables and detail pages show phone numbers as tel: links, emails as mailto: links and colours as swatches.

Options in the third position. phone:tel:UG sets a field's default country, country:country:UG its default value, and score:rating:10 its number of stars. Phone numbers are checked against libphonenumber's metadata in the API (github.com/nyaruka/phonenumbers, added only to projects with a tel field) and in the admin (libphonenumber-js), so both sides agree.

Realistic seed data. --faker seeds values every rule accepts: in a 1,200-row test every email, domain, URL, colour and phone number was distinct, and the phone numbers were valid mobiles from 20 countries. grit upgrade adds the checks and the inputs to existing projects. In a project with the admin panel inside the web app, upgrade now also adds any dependency the panel needs to apps/web/package.json when that file has been edited, matched by package name and leaving your own versions alone; before, an edited file never received the new inputs' libraries and the build failed on an unresolved import.

v3.293.0September 21, 2026

Biome replaces ESLint and Prettier, and pnpm lint finally passes

Linting works on a new project. Before this release pnpm lint failed on every new project: the Next.js apps called next lint, which Next.js 16 removed, and the Vite apps called ESLint, which was never installed. Generated projects now lint and format with Biome 2.5.14, andpnpm lint passes with 0 errors on triple, double and single projects with either frontend. CI runs it on every pull request.

One tool instead of three. A single biome.jsonc and one pinned devDependency replace .prettierrc, .prettierignore and two Prettier packages, so a new triple project has 30 devDependencies instead of 33. Every rule that is switched off carries its reason next to it in the config.

Seven template bugs it found are fixed. Among them: the export menu called a hook after an early return, which breaks React's hook order, and the two error pages were namedError, shadowing the global. Unused imports, isNaN, and callbacks that returned values from forEach are cleaned up too.

Formatting is on demand for now. pnpm format formats in the style the templates already use: double quotes, semicolons, 100 columns. pnpm lint checks rules only, because the templates are not yet Biome-formatted and a format check would fail every new project. Runpnpm format once in your project if you want to turn formatting checks on.

grit upgrade adds Biome to existing projects. It removes the Prettier files only if they are exactly what Grit wrote, and it keeps any ESLint or Prettier setup you customised, printing one line to say so.

v3.292.0September 18, 2026

Less dead admin code, real types instead of any, and named upload components

New projects stop carrying code nothing renders. The old sidebar, the old view modal, and the four dashboard widgets that only the modern, minimal and glass styles use were written into every admin, and a panel inside the web app got its own second realtime client instead of using the web app's. That is 6 fewer files and 583 fewer lines in a standalone admin, and 8 files and 1,059 lines in a web app with the panel inside it. grit upgrade deletes an existing project's copies only when Grit wrote them, nobody edited them and nothing imports them; an edited copy is kept and named in the upgrade output.

Real types instead of any. Form errors, the stepper trigger, the toast hook's callbacks, the passkey options, the observability cards, the backups page and the Vite compatibility shim now have real types. A standalone Next.js admin goes from 22 explicit anys to 1, a web app with the panel inside it from 26 to 1, and a single app from 27 to 1. The ones that remain, the realtime payload and React's own lazy-component bound, are explained where they stand.

Upload components with names. The dropzone took 17 props, including a five-wayvariant and a provider hint. It now comes as five named looks,AvatarDropzone, InlineDropzone, CompactDropzone,MinimalDropzone and BoxDropzone, each built from Dropzone.Root,Dropzone.Target, Dropzone.FileList and Dropzone.Progress, which you can also compose yourself. The image, images, video and videos form fields use them directly.<Dropzone variant="..."> still works with all 17 props, so existing code keeps working. The Next.js admin ships 9 tests covering every look.

v3.291.0September 18, 2026

No panics on a missing user, no leftovers, an allowlist read once, named ticket values, and SSO errors that keep their cause

A missing signed-in user is a 401, not a crash. Four handlers took the signed-in user out of the request with an assertion that panics when it is absent: two in the dashboard layout, one in uploads and one in two-factor setup. Mounted without the auth middleware, 4 of 6 test routes panicked, and the upload one did so after the file was already stored. All six now answer 401, and a fresh project has no unchecked assertion on a request value, down from 4.

Leftovers removed. Blank variables that only kept imports alive, a value set and never read, an unused reflection helper, three unused types, two dead initialisers and 13 doc comments written twice are gone. On a fresh project golangci-lint's unused count drops from 10 to 2, and ineffassign and wastedassign from 1 each to 0.

The upload allowlist is read once. It was an exported map that an init()changed from the environment. UPLOAD_ALLOWED_MIME is now read by config.Loadinto Config.UploadAllowedMIME, and the upload handler gets a list built from it at startup. Generated resource handlers take the app name for their PDF export from config instead of reading the environment on every request. grit upgrade adds the config field and the wiring, so a project that set UPLOAD_ALLOWED_MIME keeps its extra types.

Ticket values have names. Ticket statuses and priorities are constants in the model (TicketStatusOpen, TicketPriorityCritical and the rest), and the label cap of 8, the notification list limit of 50 and the 500-character user agent cap on form submissions are named too.

SSO refusals keep their cause. SSO sign-in returned user-facing sentences as errors. Its 9 refusals are now named errors, 3 of them wrap the database failure that caused them, and each refusal is logged with its cause. The login page shows the same wording as before, chosen in one place for both OIDC and SAML.

grit upgrade repairs the dashboard layout handler, adds the ticket constants and the upload allowlist wiring; the upload, two-factor and SSO handlers arrive whole.

v3.290.0September 18, 2026

A refresh that reloads only the page, lazy table images, a quiet idle timer, and sidebar groups that open themselves

The refresh button reloads only what the page shows. With no keys passed, the admin page header's refresh called invalidateQueries() with no filter, so one click refetched every query in the app, including the signed-in user, their permissions and the notification list. On a page with two data queries, one click made 5 requests; it now makes 2.

Table images load when they come into view. Table thumbnails and avatars now load lazily, decode off the main thread and carry a fixed size, so a table's images are no longer fetched before they scroll into view and the layout no longer shifts when they arrive. On the public site, blog covers get a size and the cards decode asynchronously, while a post's main cover still loads first. Covers stay a plain image, so a cover pasted from any website keeps working.

The idle timer stops churning. The session timeout warning cleared and set a timer on every mouse move and scroll: 2,000 events cost 4,000 timer calls. Activity now only records the time, and one check every 5 seconds decides whether to warn. The warning appears after the same idle time.

Sidebar groups open on their own pages. A group was meant to open when you were on one of its resource pages, but the check compared the wrong path and it never did. It now opens, and the open group is worked out while the sidebar draws instead of in a second pass, so a navigation renders the sidebar once instead of twice. A group you open or close by hand stays that way.

grit upgrade delivers all of this with the admin and web app files it already refreshes. An existing Vite admin keeps its current files, as before; new ones get the fixes.

v3.289.0September 17, 2026

One Redis connection for the jobs screen, a streaming GDPR export, fewer queries per write, and a faster notification bell

The jobs screen stops opening Redis connections. The admin jobs screen built a new Redis connection pool for every request and closed it again, so fifty refreshes of the queue stats opened fifty connections. The handler now builds one inspector the first time it is needed and keeps it: the same fifty refreshes use one connection.

The GDPR export streams. An export read a person's entire activity log into memory before writing a byte, ignored the error from every query, and kept running after the client went away. It now follows the request's context, returns every database error, and writes the activity log 500 rows at a time. If a page fails partway, the file will not parse, so it cannot pass for a complete export. The two-factor flag in the export now says whether two-factor is actually on, not merely set up.

Generated writes stop reading their row back. A generated service wrote a row and then read it back with its relations: a create took 5 statements and an update or patch 6. Resources whose relations are all belongs-to now write in one statement with RETURNING and read only the related rows, so a create takes 2 statements and an update or patch 3. A patch on a resource with no relations drops from 5 to 2. MySQL, which has no RETURNING, still reads the row back.

A faster notification bell. Notifications had only single-column indexes, so the unread count for a user with 20,000 notifications read all 20,000 and threw half away. New(user_id, created_at) and (user_id, read_at) indexes let the count read only the unread entries, and a light user's list reads 50 rows from the index instead of reading all of them and sorting.

The admin root redirects on the server. Opening the admin's root returned a page with 20 scripts that then asked the API who was signed in before redirecting. The server now answers with a redirect to the dashboard before any JavaScript loads.

grit upgrade patches the jobs and GDPR handlers, updates the GDPR service and the notification model (grit migrate builds the indexes, and grit migrate down removes them), and rewrites generated services that are still in the shape the generator wrote.

v3.288.0September 17, 2026

Forwarded headers only from trusted proxies, a tighter image policy, safe stored links, and a checked SSO redirect

Forwarded headers are trusted only from your proxies. The API believedX-Forwarded-For, X-Real-IP and X-Forwarded-Proto from any client, so a request could choose the IP address its sessions, audit rows and rate limits were recorded under, and claim HTTPS over plain HTTP. Those headers are now honoured only from the proxies in the newTRUSTED_PROXIES setting and removed from every other request. The default trusts loopback and the private ranges (10/8, 172.16/12, 192.168/16, fc00::/7), which covers Caddy on the same host and a proxy on the Docker network, so both documented deploys keep real client addresses. Sentinel follows the same list unless SENTINEL_TRUSTED_PROXIES is set; before, it saw only the proxy's address.

Images load only from hosts the app uses. The frontends' Content Security Policy let images load from any https host. img-src now names only the API, the storage origin and the Google and GitHub sign-in avatar hosts, with NEXT_PUBLIC_IMAGE_ORIGINS (orVITE_IMAGE_ORIGINS) for anything else. Inline scripts are still allowed: removing them in Next.js needs a nonce on every request, which would make every page render dynamically, and that trade is not one to make silently.

Stored links cannot run code. Links built from stored data, such as a URL column filled in by a public form or a notification link, were rendered as given. React stops javascript:links, but data:, vbscript: and //other-host links still worked. A new safeHref helper allows only http, https, mailto, tel and paths on the site; table link cells show anything else as plain text, and notification links fall back to nothing clickable.

The SSO redirect goes only to the API. SSO sign-in followed the server'sredirect_url by appending it to the API address, so a value such as@evil.example turned into a link to evil.example. The address is now resolved as a URL and followed only when it is a path on the API's own origin.

A locked account gives nothing away, even to the right password. Since v3.287.0 a temporarily locked account answers 401 INVALID_CREDENTIALS whether the password is right or wrong. Answering ACCOUNT_LOCKED only to the right password would let someone guessing keep going through the lockout and learn which guess was correct. The lock still holds: the right password is refused until it lifts.

grit upgrade adds the proxy middleware and setting, tightens the image policy, and applies the link and redirect checks in the admin, the web app and the embedded admin panel.

v3.287.0September 17, 2026

Sign-in that reveals nothing, encrypted two-factor secrets, and a field encryption key in every new project

Sign-in no longer tells anyone what state an account is in. A wrong password for a locked account got 429 ACCOUNT_LOCKED, a disabled one 403 ACCOUNT_DISABLED, and a social-login account 400 SOCIAL_AUTH_ONLY naming its provider, so anyone could learn which addresses held accounts and what state they were in. An unknown address also answered in under a millisecond, against about 56 ms for a real one. All of them now get the same 401INVALID_CREDENTIALS after the same bcrypt work. A disabled or unverified account is told why only once its password is right.

Two-factor secrets are encrypted at rest. Anyone who could read the two-factor table or a backup of it could generate every user's codes. Secrets are now encrypted withFIELD_ENCRYPTION_KEY. Secrets stored before the upgrade keep working: grit migrateencrypts them in place, and any that remain are encrypted the first time their code verifies.

A field encryption key in every new project. grit new writes 32 random bytes to FIELD_ENCRYPTION_KEY in .env, so two-factor secrets and encrypted columns are encrypted from the first sign-up instead of only when someone remembered to set a key..env.example carries CHANGE_ME, and an API started with that placeholder refuses to boot and names the variable. grit upgrade never writes a key into an existing project, because a key nobody knows they have is a key nobody backs up; it prints one line saying how to generate one, back it up and run grit migrate. The go-live checklist has a new section on backing the key up: losing it locks out every two-factor user, and changing the value is not a rotation.

A stricter trusted-device cookie. The cookie that lets a browser skip the two-factor code is now Secure on HTTPS and SameSite=Lax, like the sign-in cookies. It was neither.

A refused socket token is not explained to the caller. A bad token on the realtime socket got the token parser's own error back. It now gets "Invalid or expired token", and the reason goes to the server log.

Import jobs belong to whoever started them. An import job now records its starter, andGET /imports/:id answers only that user or an admin. Before, any signed-in user with a job id could read another user's import counts and row errors, which quote the file's contents.

grit upgrade applies the sign-in, socket and import fixes whether a project has the handler shapes from before v3.285.0 or after, adds the import job's owner column, and updates generated importers. The sign-in fix also reaches projects created before v3.283.0, which never received the release that split Login into smaller functions: upgrade recognises Login as any earlier release wrote it and replaces it. A Login someone has changed is left alone, with a message saying what to change.

v3.286.0September 16, 2026

Tiptap 3, one editor that keeps your formatting, a paginated blog admin, and shared types for built-in models

Tiptap 3, and the security advisory is gone. The admin moves from Tiptap 2.27.3 to 3.31.3, clearing GHSA-cp6q-959q-f8rh (prototype pollution through mergeAttributes()), sopnpm audit on a new project goes from 8 advisories to 7, and the 7 left are all development tooling. Every Tiptap package is pinned to exactly the same version, because each one requires its siblings at that version and a range could install two copies of the core. The package list shrinks from 15 to 10, since Link and Underline are now part of StarterKit and the table parts part of TableKit.

One editor, one content schema. The form field and the blog editor each built their own list of extensions: the field knew 13 kinds of content and the blog editor 22. Saving a post from the field silently dropped 9 of them, including tables, text colour, highlight, underline, images and alignment; a test post went from 954 bytes of HTML to 440. Both now uselib/tiptap-extensions.ts, and a test in every new project loads and saves a post with every kind of formatting and checks nothing is lost.

The blog admin runs on its resource definition. The list was a hand-written page that fetched one page of 100 posts, so post 101 could not be reached, and the columns, filters, bulk actions and export declared in blogs.ts did nothing. It is now ResourcePage, 20 posts a page with all of those working, the detail page is ResourceDetailPage, and posts are written on a full form page.

Shared types for the built-in models. New projects had no shared types for API keys, form shares, form submissions, notifications, SSO connections, tickets and ticket replies untilgrit sync first ran, so pages declared their own copies that drifted. New projects ship them, byte for byte what grit sync writes, along with the profile page's Zod schemas, and the support pages, ticket thread and notification hook import them.

One page header and a smaller resource controller. The admin had two page headers, one used only by the resource page; the stat cards moved into components/chrome/StatCards.tsx and the second header is gone. The 759-line resource controller with 19 pieces of state is now 583 lines with 1, built from three focused hooks, and returns exactly what it did before.

A version conflict names the current version as a number again. v3.285.0 changed the 409 for a stale If-Match write to send "current_version": "2"instead of 2, which broke any client comparing it with the version it holds. It is a number again, as it was through v3.284.0, and a test in every new project checks the shape.

grit upgrade adds the shared type files without overwriting existing ones, replaces the blog pages and removes the old header when they are unedited, and raises the Tiptap versions in any app whose editor has moved onto the shared extension list. An edited file gets a message saying what to change.

v3.285.0September 16, 2026

No theme flash, a server-rendered shared form, one source for errors, a ticket service, and mail on the queue

The admin applies its theme before the first paint. The stored theme was applied in an effect inside the dark mode toggle, which lives in the dashboard chrome and only mounts once/auth/me answers, so every load of a dark dashboard showed a light one first. A small script in the layout's head now sets the theme before anything paints, in the Next.js admin, the panel embedded in apps/web and the Vite SPA. The second theme system is gone:theme-provider.tsx stored a key nothing else read, and its only consumer was a navbar no layout has rendered since v3.29.

The public shared-form page renders on the server. It was a client component that fetched in a useEffect, so someone opening a link waited for the bundle, then a round trip, behind a spinner. The page now reads the share on the server with no caching, so a disabled link stops working at once and the form arrives rendered. The interactive half posts through the web app's own API client, and every input has a label tied to its field.

Error responses have one source of truth. In a project upgraded from v3.284.0, hand-built error envelopes fall from 306 to 167 and respond.Fail calls rise from 2 to 139; a new project has 25 envelopes and 281 calls. respond.Fail takes the status from the generated error catalogue, and the named helpers such as respond.NotFound are now one line over it instead of hardcoding a second copy of each status and code. The envelopes left either choose their status in a switch, carry a list in their details, or are text an upgrade repair still matches.concurrency.WriteConflict now sends current_version as a string, which is what its type always said.

Tickets have a service. The ticket handler held every ticket query and rule, 396 lines with 9 direct database calls, and there was no service. services.TicketService now holds Open, Query, Visible, Reply, SetStatus and Assign, each taking a context and the acting user. The visibility rule stays part of the query, so somebody else's ticket still answers 404 exactly like one that never existed. The handler, now 256 lines with no query of its own, binds, calls one method and responds. Services no longer need a gin context to learn about the request: a middleware records the client IP, user agent and request id on the request context, so a job or a test can callLogActivityCtx, CreateSessionCtx and RotateSessionCtx.

Mail no longer leaves a handler from a bare goroutine. Registration, resend verification, forgot password and new ticket each started one: nothing bounded them, nothing retried them, and a restart dropped whatever was in flight. The verification and new-ticket emails now go on the job queue, keyed so a replayed request does not send twice, and send inline with a 15 second timeout when the project runs without Redis. Forgot password keeps its work off the request path, so its response time still does not reveal whether an address has an account, but through a helper that caps concurrent tasks at 32, recovers panics, and runs the work inline rather than dropping it when the cap is reached.

grit upgrade adds the theme script, rewrites the shared-form page while it is still the old one, writes the ticket service together with its handler, and moves the auth handlers onto the queue by anchor, leaving all three auth files alone if any of them has been edited.

v3.284.0September 16, 2026

Supported images with health checks, no guessable seed accounts, private dev storage, and a faster admin

Supported, pinned base images with health checks. The API runtime moves from Alpine 3.19, which is out of support, to Alpine 3.24, Node is pinned to 22.23 and nginx moves to 1.30. All three images (API, web, admin) now carry a HEALTHCHECK, where none did; the API's calls /api/health. The Next.js runtime image starts from a clean Node image with npm, corepack and yarn removed, instead of inheriting pnpm from the build stage, and Dependabot opens pull requests for the base images.

One pnpm everywhere. The Dockerfiles installed pnpm 9.15.0 whilepackage.json named pnpm 10.0.0, so image builds ignoredonlyBuiltDependencies and ran every dependency's install script. Both now use pnpm 10.33.4. Not the newest 10.x on purpose: from 10.34.0 pnpm refuses a tarball dependency whose lockfile entry has no integrity hash, and pnpm 10 never records one for the SheetJS CDN tarball the web app installs xlsx from, so a fresh project could not install at all.

No guessable accounts outside development. grit seed createdadmin@example.com with the password admin123, plus four demo accounts, in any environment not spelled exactly "production", so a staging database got five guessable logins. Those accounts are now seeded only with APP_ENV=development. Anywhere else the seeder needs a SEED_ADMIN_PASSWORD of at least 12 characters and skips the demo users.

Maintained dependencies. The two-factor QR code now comes fromboombuler/barcode v1.1.0, which has no dependencies, in place ofskip2/go-qrcode, unreleased since 2020. gorilla/mux, which the social sign-in library still requests at v1.6.2 from 2018, is pinned to v1.8.1.

Development storage stays on your machine. Dev MinIO answered on every network interface, so anyone on the same network could reach the bucket. Its API now listens on 127.0.0.1 unless MINIO_BIND_ADDRESS says otherwise, and its console always does. Projects with the Expo app need the phone to reach it, so they get MINIO_BIND_ADDRESS=0.0.0.0, andgrit upgrade adds that line to an existing Expo project's .env so its images keep loading.

A faster admin. Tables checked every row against the whole selection and redrew every row when one box was ticked: on a 200-row, 5-column page that was 1,000 cells redrawn per tick, and it is now 5. usePermissions builds its permission set once per fetch and returns the same can function every render, so components depending on it stop re-running their effects. The upload dropzone releases the preview URLs it creates, where picking three files used to keep all three in memory for the life of the page. Each route now preloads one font file (48 KB) instead of two (80 KB), and the generated CSS has 13 font rules instead of 46.

No invented numbers on the dashboard. The "Activity, past 7 days" chart plotted Math.random(), because no API endpoint counts activity per day. It is gone, along with its dashboard setting, and "Recent activity" sits beside "Severity mix" in its place, both showing real data.

grit upgrade patches existing Dockerfiles, package.json, the compose file, the seeder and a single app's main.go, and swaps the QR library; the admin fixes arrive with the rest of the admin files.

v3.283.0September 16, 2026

GDPR erasure needs a reason, the API address lives in one place, and four admin screens use hooks

A GDPR erasure now needs a reason. The handler ignored its request body, so a call with no body at all erased an account and wrote an empty reason into the deletion journal. It now answers 422 unless the reason is 3 to 500 characters, identifies the caller withauthz.CurrentUserID, and writes its audit row throughservices.LogActivityErr: if that row cannot be written, the response says so instead of reporting a clean erasure. The admin's confirm button stays disabled until a reason is typed. An erase call that sends no reason, which used to succeed, is now refused.

The API's address is written in one place. A new lib/api-core.tsexports API_URL, apiUrl(), createApiClient() andgetApiErrorMessage(), and both frontends build their client from it. Copies of the environment expression went from 15 to 7, and hand-built URLs that skipped the /api/v1prefix from 8 to none: public forms, the OAuth buttons, SSO discovery, and the SAML URLs an admin pastes into their identity provider. /api/health now also answers at/api/v1/health, so the admin's System Health page no longer reads a 404 as degraded.

Four system screens go through hooks. Form shares, access reviews, SSO and API keys made 21 API calls inline from their pages; they now make none, through four new hook files. The observability and security screens poll with useQuery instead of asetInterval loop that also hid the browser's own fetch.

Less copy-pasted error handling. getApiErrorMessage(err, fallback)replaces the error cast in the resource hooks, the toasted-mutation hook, the roles page and the auth pages, taking the copies from 26 to 17, and the four near-identical resource mutations are one factory.

Three oversized functions got smaller. routes.Setup went from 966 lines to 703: the middleware chain, Sentinel, Studio, Pulse and the public auth routes are named functions. registerAPIDocs went from 567 lines to 40 plus seven section functions, andLogin from 189 to 111. The route groups with grit: markers stay inSetup, because plugins and generated resources insert code there that uses its variables.

grit doctor is quiet on a fresh project again. v3.282.0 moved the built-in users and ticket lists onto paginate, and doctor had been recognising a generated resource by its list config. So every project fresh from grit new reported User and Ticket as resources, with an error about encryption keys and a warning about ownership, neither of which applied. A generated resource is now recognised by its bulk request, which the generator has written since v3.142.0 and no built-in declares.

grit upgrade fixes the GDPR handler and registers the versioned health route in existing projects, and delivers the new frontend files with the rest of the frontend.

v3.282.0September 16, 2026

Race-free signups, working user filters, tickets that do not leak, honest dashboards, and errors.Is everywhere

A duplicate email is a conflict, not a race. Registration and the admin's Create User both looked the email up and inserted only when nothing came back. Two signups for the same address arriving together both passed that check, and the one that lost at the unique index got a 500. Both now insert through services.CreateUser, which lets the index decide and answers a duplicate with 409 EMAIL_EXISTS. Updating a user is one transaction: the role assignment used to be committed first, so a failed row update left the account with the new role's permissions while users.role still showed the old one. The two reloads that ignored their error now report it instead of returning the row as it was before the update.

The users list goes through paginate, and its filters work. 65 lines of hand-written paging are gone, including a Count whose error was thrown away, so a failed count showed a total of 0 above a full page of rows. The list now reads ?role=,?active= and ?provider=. Before, the admin's Role and Status filters had no effect, and the Active Users card showed the total number of users. The ticket list applies its latest-activity order only when the caller asks for no other sort, so ?sort_by=priorityno longer only breaks ties, and ticket search ignores case, so on Postgres "Billing" finds tickets filed as "billing".

One visibility rule for tickets, answering 404. Get, Reply, Assign and the close and reopen actions each had their own copy of the rule, and each answered someone else's ticket with a 403, which confirms the id exists. The owner check is now part of the query, so a ticket you cannot see looks exactly like one that does not exist. The notification scope was written out three times and missing from MarkRead, so any signed-in account could mark any notification as read by its id. One helper now covers all four paths and answers 404 for notifications that are not yours. Role checks in those handlers use models.RoleAdmin instead of the string "ADMIN".

The security and performance dashboards say when Sentinel or Pulse is down. Seven upstream calls threw their errors away, so a dead service rendered as a 200 full of zeros: no banned IPs, no threats, no errors, which reads as safe and fast. Each response now carries adegraded list naming the calls that failed, and the admin pages show it in a banner. When nothing answers at all, the endpoint returns 502 with SENTINEL_UNAVAILABLE orPULSE_UNAVAILABLE, both in the error code catalogue.

Sentinel errors are compared with errors.Is. A generated API had 27 comparisons against errors such as gorm.ErrRecordNotFound using ==, plus twoswitch err blocks. Each one stops matching the moment someone wraps the error with%w, which quietly turns a 404 into a 500, and wrapping is exactly what a careful developer does. All of them now use errors.Is, including the generated tests and the cache'sredis.Nil check, and .golangci.yml turns on errorlint's comparison check, which reports nothing on a fresh project.

No more false alarm about the realtime hub. On any project already on v3.281.0,grit upgrade warned that internal/realtime/hub.go "does not send the way Grit wrote it" and could panic, about a hub that was exactly as Grit wrote it. The check predated the counted sends v3.281.0 introduced and recognised neither form. It now accepts them, and nothing in the file changes.

grit upgrade patches the user and auth handlers where they still read as Grit wrote them, refreshes the ticket, notification, security and observability handlers (an edited copy is reported as a conflict, not overwritten), and rewrites sentinel comparisons across apps/api/internal, tests included.

v3.281.0September 16, 2026

Client events between browsers, a hub that reports itself, and no panic on a revoked session

Client events. A channel could only carry what the server published, so "Ada is typing" cost a POST per keystroke or was never built. A connection subscribed to a private or presence channel can now send a client event, and the hub relays it to the other subscribers of that channel on every replica, storing nothing:

{"type":"client-event","channel":"presence-rooms.1","event":"typing","payload":{"typing":true}}

The hub fills in user_id from the connection's token, so a receiver can trust who sent one. The rest is the sender's browser talking, so treat it as user input. The rules are the server's: private and presence channels only, whose subscribers passed an authorizer; only a channel this connection is subscribed to; ten a second per connection; a payload under 1 KB; and an event name of 1 to 64 characters. A refusal comes back on the sender's socket alone, and the rate limit answers once a second however many it drops. The socket read limit goes from 1024 to 2048 bytes to fit one. Every frontend gets whisper() and a useWhisper hook, and a handler keyed client-event:typing sees one kind and ignores the rest.

The numbers the hub had and never showed. A hub that dropped a message for a slow client said so in a log line and nowhere else. /api/health now carries arealtime object: this replica's open sockets, distinct users and channels, and counters for messages sent, messages dropped for slow clients, client events relayed, client events refused by the rate limit, and events that never reached the other replicas. The admin's System Health page shows it as a sixth card. The three counts are per process, so with several replicas each reports its share.

A revoked session no longer panics a connecting socket. Connect queued its "you are connected" greeting after the hub had taken the connection, so a session revoked in that instant closed the send channel underneath it and the send panicked, taking the API down. 300 sockets signed out as they were admitted panicked 7 times on v3.280.0 and 0 times now. The greeting is queued before the hub can reach the connection at all.

Docs and the chat course. The realtime page documents channels, presence, client events and the health numbers, and corrects three things that were wrong: the ping interval is 54 seconds, a browser authenticates with the grit_access cookie rather than a query string token, and the socket takes three kinds of message rather than none. The realtime chat course is rewritten on the scaffolded hub: it used to tell readers to install a separate plugin with its own routes, which is a second hub with a second connection and no share of the application's authentication. It now builds rooms as authorized channels, a member list from presence, typing indicators from client events and history from REST, with 12 challenges against a project that installs nothing.

v3.280.0September 15, 2026

Storage helpers, named disks, private files that stay private, and AWS S3 with no endpoint

Store and serve files in one call. storage.Store andstorage.StoreAs save a form upload under a generated<dir>/<yyyy>/<mm>/<uuid><ext> key. They sniff the real content type (HTML and SVG are always refused), enforce a size limit and return the key.storage.ServeFile streams a stored file through the API with Content-Type, Content-Length, Last-Modified, inline or attachment disposition, 304 and single-range 206 support; on S3 a range is one ranged GET. The upload handler now uses them, so the name a file arrived with never reaches a storage key, and the new GET /api/v1/uploads/:id/download serves any upload the caller can see, private files included, on every driver. A range outside the file answers 416 with the new RANGE_NOT_SATISFIABLE code, which is in the error code catalogue.

Named disks. STORAGE_DISKS=backups withSTORAGE_DISK_BACKUPS_BUCKET opens a second store, reachable throughstorage.Disks.Get("backups"). The Data & Backup page, scheduled backups and grit backup write archives there when it exists, and older archives on the default disk still download and prune. grit backup now opens the storage the API uses: withSTORAGE_DRIVER=local it used to exit withbucket "" not accessible, and now writes the archive.

Visibility. PutOptions.Visibility states whether a file is public or private, and Put refuses a key whose prefix says otherwise, so a private file cannot land where anyone can read it. STORAGE_PUBLIC_PREFIXES sets the public prefixes (default uploads/,thumbnails/) and the bucket policy follows them. Cloudflare R2 and Backblaze B2 have no bucket policies, so there private files belong in a private bucket, served with a temporary URL.

Upright thumbnails. Thumbnails for presigned uploads, and for images the upload pipeline skipped, now go through the media pipeline: EXIF orientation applied, metadata stripped, at 400px. A portrait phone photo used to get a sideways 300x300 thumbnail; it now gets an upright 400x400 one.

AWS S3 without an endpoint. STORAGE_DRIVER=s3 with noS3_ENDPOINT now connects to the AWS regional endpoint, and with no access key the SDK credential chain (an IAM role) is used. Before, the API silently skipped storage and every upload answered STORAGE_UNAVAILABLE.

A clean security scan for the image API. v3.279.0'sDominantColor tripped gosec in generated projects (an 8-bit conversion it could not prove safe, and an index it could not bound). It is rewritten so the scan passes, with the same result.

The file storage docs are rewritten to cover drivers, local development, the Disk interface, the helpers, named disks, visibility, the presign fallback, file lifecycle and the real endpoints.grit upgrade updates config.go, main.go, routes.go, cmd/backup and .env.example in existing projects.

v3.279.0September 15, 2026

Work on images in code, and one-sided resizes that no longer produce empty images

A chainable image API. The media package could only run an upload through a fixed profile, so a handler had no way to crop, rotate, blur or inspect an image itself.media.Open(r), media.FromDisk(ctx, disk, key) andmedia.FromURL(ctx, url) now return an image with Cover,Fit, Resize, Crop, Rotate,FlipH, FlipV, Grayscale, Blur,Sharpen and Orient. Width, Height,MIME, Extension and DominantColor inspect it, theTo... methods pick the output format, and Encode orStore(ctx, disk, "avatars", media.PublicFile) finishes it and returns the stored key.

Every step returns a new image, so one decode can feed several outputs, and an error anywhere comes out at the end. The pixel limit is checked on the header and after any step that can grow an image, and all pixel work shares the per-CPU limit uploads already use. FromURL goes through the safe fetch client with a 20 MB and 10 second default, so loopback and cloud metadata addresses are refused before a connection is made. Lossy WebP and AVIF need the libvips build; without it they return an error naming -tags vips instead of quietly writing a lossless file. No new dependencies, and no cgo in the default build.

One-sided sizes keep the aspect ratio. media.Fit(800, 0) on a 2000x1000 photo produced an empty 0x0 image with no error, and it would have been stored. It now gives 800x400, and Fill(800, 0) scales the same way, on both the pure Go and libvips backends.

The Image Optimisation docs gain a section on the chain, and grit upgrade delivers the new media files with the rest of the media package.

v3.278.0September 15, 2026

Mail: queued sending, grit generate mail, and a preview of the real templates

Queued mail. mail.Queue(ctx, jobs, message) sends any message through the background worker, which retries a provider that is briefly down. The job existed all along with nothing able to use it. Attachments over 256 KB in total are refused with an error telling you to store the file and send a link, because a queued message waits in Redis for every retry. In a fresh project, a queued message with an attachment reached the local inbox through the worker, and a 300 KB attachment was refused before it reached the queue.

grit generate mail. grit generate mail OrderShipped writes a typed email: a data struct, a body inside the shared mail layout, a plain-text part, andSendOrderShipped and QueueOrderShipped helpers. It registers itself for the admin's Mail Preview.

Mail Preview shows what the API sends. The admin page used hand-written copies of four templates. It now renders the real ones from GET /api/admin/mail/templates and/api/admin/mail/preview/:template, staff only. The preview matched the Go render byte for byte, generated templates appear on their own, and a non-staff account gets 403. System Health names the mail driver in use instead of saying Resend.

Two quiet failures fixed. Adding a recovery email claimed a code had been sent even when sending failed; it now answers 502 MAIL_FAILED. Turning off notification emails in Settings now stops the new-ticket email, which ignored the setting.

grit upgrade now brings the mail drivers, queueing and the preview to single-binary projects too; before, its API repairs only ran for projects with a separate API app. The email docs, the generated project docs and the AI skill file describe the drivers, queueing, the test fake and the generator, and no longer show APIs that do not exist. The cloud .env template lists the mail driver keys.

v3.277.0September 15, 2026

Realtime presence, and a crash when a socket closed mid-send

A message sent while a connection was closing could crash the API. The realtime hub picked its recipients under a lock and sent after releasing it, so a connection that closed in between turned the send into a "send on closed channel" panic, which stops the whole process. Sends now happen under the lock and never block. In a stress test of 50 rounds of 200 closing connections, the previous hub crashed 3 runs out of 3; the fixed one completed 2,000 sends against 10,000 closing connections with no panic. Revoking a user's sessions also no longer panics ten seconds later for a connection that has no socket.

Presence channels. Channels named presence-* now know who is in them. A subscriber receives the member list right after subscribing, and everyone else is told when a user joins or leaves, once per user however many tabs they have open. An authorizer can attach a name or avatar with c.SetInfo. With Redis, every replica sees the same list, and the members of a replica that dies leave on their own once their entry expires, 45 seconds by default. In a two-server test, a closed socket's member left in 17 ms and a dead server's member left in 3.9 seconds with a 4 second expiry. The React hook usePresence(channel) returns the list in the web app, the admin panel and the Expo app.

grit upgrade repairs the hub in existing projects and adds presence to projects that already have channels.

v3.276.0September 15, 2026

Storage behind a Disk interface, and a local driver

A Grit project without MinIO credentials answered every upload with 503STORAGE_UNAVAILABLE, so trying uploads in development meant running MinIO first. Storage now sits behind a Disk interface (put, get, exists, stat, delete, copy, move, list, URL and temporary URL) with two drivers: S3-compatible buckets andSTORAGE_DRIVER=local.

The local driver. Files live in STORAGE_LOCAL_ROOT (defaultstorage/app) and are written atomically. Keys that try to leave the root are refused. The API serves uploads and thumbnails from /files, with Range support, and serves any other file only through an HMAC-signed temporary URL, which answers 403 once it expires. Outside production, a project without MinIO credentials uses the local driver automatically; production refuses it unless ALLOW_LOCAL_STORAGE_IN_PRODUCTION=true.

Uploads keep working. When storage cannot presign a direct upload, the presign endpoint says so and the upload package sends the file to POST /uploads instead, so the upload UI works on either driver.

On a fresh project with no MinIO credentials, an upload went from 503 to 201, and the file was then served (200, and 206 for a range), served through a signed URL (200), refused once the URL expired (403) and deleted. The same checks pass against MinIO, and a project on MinIO behaves exactly as before. *storage.Storage keeps every method, so existing handlers and generated resources compile unchanged. grit upgrade adds the driver to existing projects.

v3.275.0September 15, 2026

Mail drivers: SMTP, Resend, Mailgun, Postmark, SendGrid, Amazon SES, log and failover

Email could only go through Resend, and a new project with no Resend key sent nothing at all: a password reset wrote its link to the log, and the Mailhog in docker compose received nothing. The mailer now sends through a driver picked by MAIL_MAILER: smtp,resend, mailgun (US or EU), postmark,sendgrid, ses, log or failover. They use the standard library only, with no vendor SDKs.

Development mail arrives in Mailhog. With no driver set outside production, mail goes to Mailhog over SMTP and falls back to the log and storage/mail when Mailhog is not running. In a fresh project, one password reset delivered 2 emails to the local inbox where it used to deliver none.

Nothing breaks for existing code. A project that only setsRESEND_API_KEY keeps sending through Resend, and mail.New,Send and SendRaw work unchanged. The new SendMessage adds several recipients, cc, bcc, reply-to, a text part, attachments and custom headers.

Safe by default. A driver named without its keys stops startup with a message naming the missing keys. HTTP drivers retry once on a network or server error and never on a rejected message, and a Resend retry cannot send twice. Production refuses the log driver unlessMAIL_ALLOW_LOG_IN_PRODUCTION=true. /api/health reports which driver is in use.

Testing mail. The new mailtest package gives tests a fake mailer that records messages, with AssertSent and AssertNothingSent. New projects use it to test the password reset and verification emails.

grit upgrade brings the drivers to projects with a separate API app. Single-binary projects keep working on Resend and get the drivers in new projects only.

v3.274.0September 15, 2026

Realtime channels with authorization

The realtime socket could send to one user or to everyone, so a page had no way to follow a single record, and an app could not reach whoever is allowed to read it. Clients can now subscribe to a named channel, and the server answers subscribed, subscription_error orunsubscribed.

Authorization. Register who may join a channel withrealtime.Channel("invoices.{id}", authorize). public-channels are open to any signed-in connection; private- and presence-channels admit only users the authorizer accepts, and a channel with no authorizer is refused. A connection may hold up to 100 channels.

Publishing across replicas. hub.Publish(channel, event) reaches subscribers on every API process through the Redis backplane, andservices.RealtimeChannels can route resource events to channels.SendToUsers and RealtimeAudience work as before.

Clients. The web, admin and Expo clients gainsubscribe(channel, handlers), which returns an unsubscribe function, and theuseChannel hook. After a reconnect they subscribe again on their own.

In a live check with two API processes sharing Redis, 5 of 5 channel events crossed replicas, an unauthorized user received none, delivery stopped after unsubscribe, and a client whose connection dropped got its channel back without calling subscribe again. grit upgrade adds channels to existing projects and warns instead of rewriting a hub that has been edited.

v3.273.0September 15, 2026

Realtime: the WebSocket refuses other origins, the module switch works, and connections are capped

Another page could open a signed-in user's socket. The WebSocket accepted a handshake from any origin while authenticating it with the grit_access cookie, which the browser sends whichever page opens the socket. A page on another origin could connect as the signed-in user and read their live events. A cookie handshake is now accepted only from an origin in CORS_ORIGINS (or the cors.origins setting) or from the API's own host, and refused with 403 otherwise. Clients that authenticate with ?token= or anAuthorization header, such as mobile and desktop apps, are unaffected, and the socket now accepts the Authorization header. On a test project, an attacker page opened the victim's socket before the fix and was refused after, while the admin panel's own origin still connected.

MODULE_REALTIME=false turns realtime off. The flag hid the UI, but/api/ws stayed mounted and the Redis backplane stayed subscribed. With the flag off,/api/ws now answers 404 and the API holds no realtime subscription.

Connections are capped. Each user may hold 10 sockets and each process 10,000, set with REALTIME_MAX_CONNECTIONS_PER_USER and REALTIME_MAX_CONNECTIONS. A socket past either cap is closed with code 1013 and logged. One user opening 12 sockets held 12 before and 10 after.

grit generate resource finds the realtime hub again. It looked for a line routes.go stopped containing when the hub gained options, so a project without the event bus got a warning instead of having it started.

grit upgrade adds the origin guard and repairs routes.go and the realtime handler in existing projects, and says so when those files were edited and need the change by hand.

v3.272.0September 15, 2026

Connection pools, the response cache, and a lighter admin panel

Five more findings from the contact-app review, each measured on a generated project before and after upgrading, and two bugs found while fixing them.

Upgrading a project with the admin panel inside its web app dropped your resources from the panel. Upgrade wrote the template over admin-panel/resources/index.ts, so every resource added with grit generate disappeared from the admin while its definition and pages stayed on disk. Upgrade now leaves resource definitions, their pages and the registry alone, in the web app's panel and in a single app's. If an earlier upgrade already dropped them, this one registers them again and says which.

A generated API could open more database connections than Postgres allows. Each process allowed 100, and Sentinel opened up to 10 more on the same database. Under load, one process held 96 connections and 153 requests failed when Postgres refused more. The app pool now defaults to 25 (DB_MAX_OPEN_CONNS) and Sentinel's to 5 (SENTINEL_DB_MAX_OPEN_CONNS): the same load held 31 connections with no errors, and three replicas held at most 91. In production the API now connects through pgbouncer, where three replicas used at most 20 Postgres connections. pgbouncer also listens on the port its health check probes; before, that check could never pass.

An expired cache entry sent every waiting request to the handler. Of 100 requests arriving together at a cold key, between 13 and 71 ran the handler. Now one does, and the rest share its response. Entries also stored the body as base64 inside JSON; they now store it as sent, so a 6,004-byte response takes 6,040 bytes in Redis instead of 8,081. Clients see the same status, headers and body on a hit.

The admin asked for the same data under different query keys. The dashboard's stat card and latest table each fetched a resource's stats, and the dashboard and the notification bell both polled notifications. A dashboard with five resources made 17 requests on load and 15 a minute while idle; it now makes 11 and 9, with no duplicates. Tab counts are queries that refresh after a create or delete, and a list with three count tabs loads with 4 requests instead of 7. Relationship pickers fetch their options when opened and search on the server, and the multi-select picker shows records created elsewhere instead of a list up to five minutes old.

The admin's React Query client was shared by every server render. It was created when its module loaded, which becomes a leak between users the moment anything prefetches. The admin now creates its client once per mount, in all three admin shapes, and admin pages inside the web app no longer build a second, unused client.

Every form loaded the rich text editor. Tiptap and ProseMirror came with any page that could open a form, and with the blog editor. They now load when the editor appears: a user's detail page went from 1,347 KB (418 KB gzipped) to 824 KB (260 KB), the blog editor from 1,228 KB to 815 KB, and the Vite admin's entry script from 1,503 KB to 978 KB. The editor still opens and saves.

The blog editor could not save. It saved, published and deleted through/api/blogs/:id, which the API does not serve, so each of those returned 404. It now uses /api/admin/blogs/:id, and grit upgrade fixes the editor page in existing projects.

v3.271.0September 15, 2026

Background work: audit and activity writes, sync pushes, the outbox relay, cleanup jobs

Five more findings from the contact-app review, each measured on a generated project before and after upgrading, against Postgres, MySQL and MinIO.

The audit writer inserted one entry at a time while holding the chain lock. Every replica's writer waits on that lock. A batch is now chained in memory and written as one multi-row insert: 2,560 entries took 69 statements and 3.8 seconds instead of 2,920 statements and 9.0 seconds, and the chain verified.

A sync push had no size limit and ran three queries per change. A push now carries at most 500 changes; a larger one is refused with 413 TOO_MANY_CHANGES. The rows it updates or deletes are read with one query per model rather than one per change: 500 updates ran 501 queries on the table instead of 1,000 (and took 5.7 seconds instead of 12.7, with 88 activity inserts instead of 500). The web and desktop sync clients now send their outbox in pushes of 500, and grit upgrade updates both. An app already installed on a device, built before this release, with more than 500 changes queued will be refused until it is rebuilt. Changes in a push are still applied without a shared transaction: each one stands on its own, and a savepoint per change overflows Postgres's subtransaction cache.

Every create, update and delete waited on an activity-feed insert. The row is still built in the request, where the IP address and user agent can be read, and is now written by a batching writer. Emitting 1,000 events took 0.02 ms each instead of 10 to 11 ms (and all 1,000 rows were stored within a quarter of a second). The server writes whatever is still queued when it shuts down. Sign-ins and other security events are still written before the request returns.

The outbox relay was never pruned, blocked other replicas, and could not be stopped. Claims now use SKIP LOCKED: on Postgres, a second relay claimed the free messages at once instead of waiting 3.2 seconds for the rows another relay held. On MySQL a claim's locking read covers every row it scans, so the second relay no longer waits but claims nothing until its next poll. SKIP LOCKED needs MySQL 8.0 or MariaDB 10.6. The relay now deletes delivered messages older than seven days, in chunks, about once an hour; 100 delivered messages a month old were all still there before and none after. On shutdown the server stops the relay, and messages it had claimed but not tried go back to the queue instead of waiting out the five-minute claim timeout on another replica.

Cleanup and scheduled jobs were unbounded and retried 25 times. The orphan upload cleanup now works 1,000 uploads at a time and deletes their files with one storage request per page: 2,500 orphans took 3.9 seconds, 7 statements and 3 storage requests instead of 48.6 seconds, 2,502 statements and 2,500 requests. The built-in scheduled jobs retry 3 times with a deadline, and so do jobs made with grit generate job --cron. The activity-log prune deletes 5,000 entries per transaction (pruning 100,001 of 200,000 entries took 1.2 seconds with no transaction longer than 132 ms, where the single DELETE it replaces took 210 ms and grows with the backlog).

Pruning the activity log broke its verification. Found while measuring the prune: it rewrote the hash of the oldest remaining entry, and the entry after it still pointed at the old hash, so every prune that deleted anything left a log that failed verification. Remaining entries now keep their hashes, and each prune appends a security entry naming where the log now starts. Verification accepts a log that starts after deleted entries only when a prune recorded it: after pruning 100,001 of 200,000 entries the chain verified, and deleting the 10 oldest remaining entries by hand was reported as a break.

v3.270.0September 14, 2026

Performance under load: request context, flags, API keys, dashboard stats, images

Five more findings from the contact-app review, each measured on a generated project before and after upgrading.

Database calls ignored the request's context. A request the client had abandoned kept its queries running and its connection held. Database calls in the handlers now carry the request's context: 157 calls across the handler templates, and in methods that take the request as their first argument. The auth middleware's user lookup and API key check do too, and the three background jobs that write use the job's context. grit upgrade applies the same change to the handlers Grit wrote. Thirteen calls sit in methods that are never given the request, such as the failed-login counter, and are left as they are.

Every feature flag check started a goroutine and an INSERT. Checking one flag 2,000 times for one user wrote 2,000 exposure rows. Exposures now go to one writer through a bounded queue, are written in batches, and are recorded once per user, flag and variant per day; the same 2,000 checks write one row. When the queue is full an exposure is dropped rather than slowing the request.

API key requests queried the key twice each. Fifty requests with one key ran fifty lookups and fifty last_used_at updates on the same row. A verified key is now trusted for 30 seconds, and its last use is recorded at most once a minute: the same fifty requests ran one of each. Revoking a key clears it on the replica that revoked it; another replica can accept it for up to those 30 seconds.

Dashboard stats loaded a month of rows to count them. The stats and chart widgets read the timestamp of every record created in the last 30 days and counted per day in the API. They now count per day in the database, for SQLite, Postgres and MySQL. With 50,009 users, the users widget went from 1,905 ms to 406 ms, and 208 ms once warm; its daily query went from loading 50,009 rows to returning 30. On all three databases the new counts and sums matched a count done row by row. Days are now UTC days.

Image uploads had no limit on concurrent work. Each image is decoded whole, about 200 MB at 50 megapixels, and uploads arriving together each decoded at once. Decoding now runs at most once per CPU at a time, and an image's extra sizes upload four at a time instead of one after another. The review also flagged the transparency check as reading every pixel slowly; for the images the decoder produces, Go's own check already reads the pixel bytes directly, so that was left unchanged.

v3.269.0September 14, 2026

Deployment hardening: images, the production stack, CI and the admin gate

Five more findings from the contact-app review, checked on a generated project before and after upgrading.

Docker images carried local state. The .dockerignore patterns only matched at the root of the build context, and the API builds from apps/api, which had none. Its build context was 179 MB and put the development database, the Sentinel database and three local binaries into the image. The patterns are recursive now, apps/api has its own file, and the same context is 862 KB with none of them in it.

The production stack ran on SQLite. The API took DB_PROVIDER=sqlite from the development .env, so the database lived inside the container and a redeploy started from nothing while the Postgres beside it sat unused. The production compose file now sets Postgres, and an API started with APP_ENV=production on SQLite refuses to start unless ALLOW_SQLITE_IN_PRODUCTION=true says the file is on a volume you back up. A deployment that runs SQLite in production on purpose needs that line after upgrading. Redis now takes a password, REDIS_PASSWORD, generated into .env for new projects; grit upgrade says to add one to an existing .env, and compose refuses to start the production stack without it. PgBouncer and MinIO run pinned versions, and MinIO now comes from quay.io: Docker Hub no longer serves the minio/minio image both compose files used.

CI could be changed from outside the project. Every action in the generated workflows is pinned to a commit, with its version beside it for Dependabot. The release workflow is read-only by default and only its publishing jobs can write or sign. Syft is installed from Anchore's pinned action instead of a script downloaded at release time, the Wails CLI has a version, and golangci-lint is pinned to v2.13.2 with an action that can run it: the shipped .golangci.yml is a version 2 config, which the previous action could not load. grit upgrade now also updates a ci.yml that Grit wrote.

The admin was guarded only in the browser. A visitor who never signed in loaded every admin page, and a user with no permissions could open System pages. The API now sets grit_signed_in, a marker with no secret in it, and the web app's middleware sends a visitor without it to the login page before any admin page loads. Browsers only share it when the web app and the API are on the same host, so on separate hosts the middleware stands aside and the page's own check applies. A user with no permissions is sent to their profile from any admin page. Before, /admin/system/roles answered 200 with no cookies; now it redirects to the login page.

SAML metadata skipped the SSRF guard. The metadata URL an administrator enters is now fetched through the same guard as every other server-side fetch, so it cannot reach loopback, private or cloud metadata addresses.

v3.268.0September 14, 2026

Account security: replays, provider sign-in, user records and profile changes

Five findings from the contact-app review, each reproduced on a generated project first.

An Idempotency-Key replayed another user's response. The cache that makes retries safe keyed each stored response on the method, the path and the key, and answered before authentication. Anyone who learned a key got the stored response: sending only a user's key, with no credential at all, replayed the 201 that created their API key, secret included. The key now includes a hash of the credential the request carries, a request with no credential is never replayed, and sign-in and API key routes are never cached. After upgrading, the same request gets 401.

Signing in with Google or GitHub could inherit a planted account. The callback linked the provider to any account with the same email and left its password working, so someone who registered the victim's address first kept password access after the victim signed in with the provider. Linking to an account whose email was never confirmed now clears its password and ends its sessions first, and a provider that shares no email is refused. The OAuth cookie store also has its own key, derived from the JWT secret rather than being it, and an HttpOnly, SameSite=Lax cookie that is Secure over https.

Any signed-in user could read any user's record, email, role and IP address included. GET /api/v1/users/:id now takes users.view, like the user list; your own record is GET /api/v1/profile. It went from 200 to 403.

A stolen session could change the password and the email. Changing either now takes the current password: without it the answer is 422, with a wrong one 401. A new email is checked against other accounts first, where it used to fail with a 500 from the database, is unconfirmed until confirmed, and signs out every other session. An account with no password, one made through a provider, changes these through a password reset instead. The admin profile pages and the web account page ask for the current password and show the API's reason when a change is refused. The old address is not yet notified of the change.

The fifth finding, the xlsx package with two known vulnerabilities, was already fixed in v3.259.0, which moved every template to the patched SheetJS build. grit upgrade applies the four fixes to the API files Grit wrote, and leaves a note with what to change in any it cannot recognise.

v3.267.1September 14, 2026

The upload codes from v3.267.0 are in the error catalogue

v3.267.0 added two error codes to the upload handler, UPLOAD_KEY_FORBIDDEN and UPLOAD_ALREADY_RECORDED, without adding them to Grit's error catalogue, the list every code a generated API can return has to appear in with its status and what a client should do. The test that holds the templates to the catalogue failed, so the v3.267.0 release was never published. Both codes are catalogued now, and appear on the error codes page. The upload fix itself is unchanged: see v3.267.0.

v3.267.0September 14, 2026

A user can no longer claim, or delete, a file someone else uploaded

A presigned upload finishes with POST /api/v1/uploads/complete, which records the file. That call recorded whatever object key it was given, for whoever asked. A signed-in user could name another user's upload, get a row for it, and delete that row, which deleted the other user's file from storage. Naming a key outside the uploads also told them whether that object existed, and the same upload could be recorded any number of times.

Presigned keys now sit under the caller's own prefix, uploads/<user_id>/, and completing an upload accepts only a key under that prefix. The key is checked before storage is asked about it, so a refused key reveals nothing, and a key that already has a row is refused too. Nothing is kept between the two calls, so it needs no Redis. Clients that send back the key the presign returned need no change. grit upgrade delivers the upload handler. This finishes the second half of the upload access fix; listing and reading were scoped to their owner in v3.241.0.

Checked live against MinIO, with a second account. Before, it recorded the admin's upload (201) and deleted it, and the admin's file was gone from storage; a key outside the uploads answered 404, and its own upload could be recorded twice. After upgrading, the admin's key and the outside key are refused with 403, its own upload is recorded once, and the second attempt gets 409.

v3.266.0September 14, 2026

Server errors reach the log, and their text stops reaching clients

Many of Grit's handlers answered a 500 with the error itself. A notification that could not be marked read came back as SQL logic error: no such table: notifications (1), which tells whoever sent the request about the schema. The public /api/health endpoint did the same for anonymous callers, reporting why Redis or the database was down. Meanwhile respond.Internal, which the role and SSO handlers call, threw away the error it was given, so those 500s left nothing in the log at all. Database errors did appear in GORM's own log at its default level, but with nothing to tie them to the request that failed.

A new respond.ServerError answers 500 with the error code and a message safe for the client, and logs the cause with the method, path and request id. The id is the X-Request-ID header the client received, so a reported failure leads straight to its log line. respond.Internal and respond.WriteError go through it. Every handler template that sent the error text with a 500 now calls it instead: 38 places, plus three 400s that passed a server error through (the dashboard chart, the resource stats widget, and the public shared form submission). The health endpoint logs a failed ping and reports only that the dependency is down. Sync push results log a failed write rather than returning it to the device. Messages about the request itself, such as validation errors, are unchanged.

grit upgrade delivers the new respond package and applies the same rewrite to Grit's handlers and the health endpoint in an existing project. It only rewrites responses in the exact shape Grit generated, and a test holds every template to the rewrite.

Checked against a copy of a project's database with its notifications table removed. Before, marking all notifications read answered 500 with the SQLite error; after upgrading, it answers 500 with "Internal server error", and the log has the SQLite error under the same request id the client was given. With Redis paused, /api/health went from returning the ping error to returning only that Redis is down, with the error in the log.

v3.265.0September 14, 2026

A resource list is one request per view, per search and per save

Opening a resource list in the admin sent five requests: the list, and one for each of the four stat cards above it. Typing in the search box sent a request per keystroke, and each one blanked the table to a skeleton until its answer arrived, in whatever order the answers came back. Saving a record sent the list and all four cards again. On the users and blog pages three of those cards were also wrong: their handlers never read the date windows the cards asked for, so This Week, This Month and Updated Recently all showed the grand total.

The search now waits until typing pauses, and a request the next keystroke replaces is cancelled. The rows stay on screen while the next page, sort or search loads, with a thin bar above them instead of a skeleton. The default stat cards read the list response: Total is the total the table already has, and the list asks for the three windows beside it with a new?counts=created_7d,created_30d,updated_7d parameter, which paginate.List answers for every generated resource and a new paginate.Counts answers for the users and blog handlers. The cards now describe the rows the table matches, search and filters included. Saving an existing record refreshes the list and the record, and leaves any custom stat cards to refresh the next time they are shown. Custom cards set in a resource definition work as before.

grit upgrade delivers the admin files and the pagination package, and adds the counts to the users and blog handlers when they are the files Grit wrote, or says what to change when they are not. A new test in the generated project covers the counts.

Measured on the users list of a project upgraded from v3.264.0, against its API log: a page view went from 5 requests to 1, typing "admin" from 5 requests and 5 skeleton flashes to 1 request and none, and a save from 6 requests to 2, the save itself and the list.

v3.264.0September 14, 2026

Resource lists and the dashboard stop downloading spreadsheet and chart code up front

The admin's spreadsheet helpers imported SheetJS at the top of their module, and every resource list page imports those helpers, so about 500 KB of SheetJS arrived with every list whether or not anyone exported or imported. A comment on the list page called it lazy; it was not. The dashboard and its resource stat cards imported recharts the same way, adding about 400 KB to the dashboard's first load.

SheetJS now loads the first time a spreadsheet is written or read. CSV export needs none of it and still downloads inside the click. The dashboard's two charts and the stat card's sparkline moved into modules of their own that load after the page renders, behind a placeholder, so the greeting, stat tiles and activity feed do not wait for them. This applies to the Next.js admin, the admin inside the web app and the Vite admin, where the charts sit behind Suspense. grit upgrade delivers the changed files and the two new chart modules to projects that have not edited them.

Measured on a fresh project, then again after upgrading it, by fetching each page and adding up the scripts it asks for. The users list went from 1,352 KB (432 KB gzipped) to 861 KB (270 KB), with SheetJS gone from it; the dashboard went from 1,241 KB (366 KB) to 836 KB (261 KB), with recharts gone from it. In the browser, SheetJS arrived only on clicking Excel export, which still produced a valid workbook, the import template still downloaded, and all four dashboard charts rendered.

v3.263.0September 14, 2026

Admin pages start loading without waiting for the signed-in user

The admin layout showed a spinner in place of the page until /auth/me answered, so no page request could start before it did. The permissions request lived in the sidebar, which also waited for the user. Every full load of an admin page paid for one whole round trip before its lists, stats and widgets were asked for.

The page now renders straight away behind a loading overlay, and the layout asks for permissions beside /auth/me instead of after it. The overlay stays until the user is known, and a signed-out visitor is still sent to the login page: the page's own requests are refused with a 401, and the API client makes one refresh attempt before it redirects. Pages render on the first pass in the browser rather than on the server: admin pages read browser state while rendering, and prerendering them failed the build. The route group layout is no longer marked "use client", since the admin layout is already the client boundary. grit upgrade delivers both files to projects that have not edited them.

Measured on a fresh project against a local API, then again after upgrading it. On the dashboard, the first page request started 376 ms after /auth/me finished; now all ten start within 30 ms of it starting, most before it finishes. On the users list, permissions now start together with /auth/me, and the list query starts 78 ms after it finishes instead of 372 ms. Against a slower API the saving grows by the length of that request.

v3.262.0September 14, 2026

Removing the blog leaves an API that compiles

The other half of what v3.260.0 found. After grit remove resource Blog, the web app built again, but the Go API did not: removal reported the API reference entries gone while six of the seven were still there, naming a model and two request types it had just deleted, and the blog's public route group stayed in routes.go with its routes taken out, an unused variable Go refuses to compile. The same failure happens with v3.257.0, so it is older than the pages that turned it up.

Removal matched an API reference entry only when its path was the resource's base or the base followed by /:id. The blog documents /:slug, and its admin endpoints sit under /api/v1/admin/blogs. It now removes every documented path under the resource's base, and under its admin base, and still stops at a path segment so removing order leaves orders alone. It also removes a route group declared on the versioned group, which is how the scaffold declares the blog's, together with its comment.

Checked on a fresh project: after removing the blog, no reference to it is left in the API reference or the routes, and the API builds, passes vet and passes its tests.

v3.261.0September 14, 2026

Two migrations in the same millisecond no longer collide

Found by CI rather than by the review. The live suite for v3.260.0 failed once in the migration history's own tests, with UNIQUE constraint failed: grit_migrations.id, and passed when the job ran again. A recorded run's ID was the time to the millisecond, so two runs recorded within the same millisecond had the same ID. The test records a run and then another straight after, and on a fast machine both could land in one millisecond. A test that recorded two runs back to back failed on the second one every time.

A run's ID is still the time it was recorded, in the same format, so a history from an earlier version reads and sorts as before. When that time is not later than the latest recorded run, as with two runs in one millisecond or a clock that stepped back, the new run takes the next millisecond after it, so IDs are unique and always in order. A test now records fifty runs back to back and checks each ID comes after the last.

grit upgrade delivers the fix and the test with the rest of internal/migrate.

v3.260.0September 14, 2026

The home page and blog render their posts on the server

The next finding from the review of a scaffolded app. The home page, the blog list and every blog post in a Next.js project were client components that fetched their posts after the JavaScript loaded. The HTML the server sent held loading skeletons and no posts, which is what a search engine indexed and what a reader saw first, and all three pages shared the site's one title. Checked against a running project: the seeded post's title appeared in the server HTML of none of the three pages.

They are server components now, reading through lib/blog-api.ts and refreshed every minute. The blog list pages with links (/blog?page=2), a post has its own title, description and share image, and a missing slug is a real 404. Server-side, DOMPurify has no page to work with, so the API's public blog endpoints sanitise the HTML they serve, which also covers posts stored before the API sanitised on write. In production Docker the web container reaches the API by service name through API_INTERNAL_URL. An API that cannot be reached, as during a CI build, leaves a list empty rather than failing the build. The same check afterwards: the post is in the server HTML of all three pages, each with its own title.

Checking grit remove resource Blog against the new pages found it broken before this release. It still looked for the home page at the app root, which moved into a route group in an earlier release, and it never deleted the blog pages, so removal left the home page and both blog pages importing files it had deleted, and the web app failed to build. It now cleans the home page where it is and deletes the blog pages. The API still fails to compile after removing Blog, because the API reference and the route group keep references to it; that is a separate bug, older than this release, and next on the list.

grit upgrade delivers the pages and lib/blog-api.ts where they are unedited, and adds the sanitising to the blog handler and API_INTERNAL_URL to the production compose file where those are still what Grit wrote. TanStack and single-app frontends are single-page apps with no server rendering, so they are unchanged.

v3.259.0September 14, 2026

A project's CI scans its real code, runs its tests, and releases

The next finding from the review of a scaffolded app. Every generated project gets Dependabot and a security workflow, and both were written for the single-app layout whatever the project was: a go.mod at the root and the frontend in frontend/. In a monorepo, govulncheck ran where there is no Go module, the audit ran in a directory that does not exist, and Dependabot watched neither. No workflow ran the tests on a pull request at all.

Checking every generated workflow found more. The release workflow gated its desktop job with hashFiles in a job-level condition, which GitHub does not allow there, so the whole workflow was rejected on the first tag; and it built from apps/api even in a single app. turbo.json had no test task, so pnpm test failed. And once the audit pointed at the right place it failed on a fresh project: xlsx 0.18.5, the last release SheetJS published to npm, has two high advisories and no patched npm version.

The workflows now follow the project's shape. Dependabot watches the Go module and the pnpm workspace. The security workflow scans the API with govulncheck (pinned, on the Go the API builds with), audits production dependencies at high severity, and runs CodeQL on Go and, when there is a frontend, JavaScript. A new ci.yml runs vet and race-enabled tests for the API and type-check, tests and build for the frontend on every pull request. A single app's jobs stub the frontend it embeds, so Go compiles. The release job is valid for every shape and has a desktop job only when there is a desktop app. xlsx comes from SheetJS's patched 0.20.3 build. Every workflow passes actionlint for single, double, triple, API-only and mobile projects, and the commands they run pass on a fresh project, the audit included.

grit upgrade rewrites Dependabot, the security, lint and release workflows where they are still what Grit wrote, adds ci.yml when the project has none, and adds the test task. package.json is yours, so where a frontend still takes xlsx from npm, upgrade prints the command that replaces it. There is no lint step yet: the generated frontends ship no ESLint configuration, and Next.js 16 removed next lint.

v3.258.0September 14, 2026

Sync pull stops losing rows, and works on MySQL

The next finding from the review of a scaffolded app, and the test for it found data loss. Offline clients page through /api/v1/sync/pull with a cursor. The cursor was a bare timestamp and the next page asked for rows changed after it, so rows that shared a timestamp and fell across a page boundary were skipped for good: of 1,200 contacts written in the same instant and pulled 500 at a time, 500 reached the client. Bulk writes and imports produce exactly that.

What the review found was the ordering. A soft delete set only deleted_at, so to carry deletes, pull sorted by the later of updated_at and deleted_at. No index serves that expression, so every pull sorted the whole table: about 60 ms a page on 300,000 Postgres rows. And on MySQL the expression was written with a two-argument MAX, which MySQL does not have, so pull failed with a SQL error on every request.

A soft delete now sets updated_at to the same instant as deleted_at, through a hook in internal/sync that database.go installs, and generated and blog models index updated_at. Pull pages on (updated_at, id) with a cursor of the form time~id, and still accepts a bare time from a client that stored one before. It runs with the request's context. Measured again: 16 ms a page on the same Postgres table, 30 ms on MySQL, all 1,200 tied rows arriving in three pages, and a delete made after a cursor still reaching the next pull.

grit upgrade delivers the sync handler and the hook, installs the hook in database.go, and indexes updated_at on generated and blog models where the line is still what Grit wrote. Run grit migrate: it builds the indexes and moves updated_at forward on rows deleted before this release, so their deletes are carried too. On a project made with v3.257.0 the upgraded handler, model and hook came out identical to a fresh project's.

v3.257.0September 14, 2026

CSV imports over 1 MB work, take turns, and look each group up once

The next finding from the review of a scaffolded app, and the test for it found a worse problem first. Every generated resource's CSV import sits behind Sentinel's WAF, whose 1 MB body cap refused any larger file with 413 WAF_BODY_TOO_LARGE before the importer ran. The 100 MB limit an import route has had since v3.252.0 was never reached: a 5 MB file of 100,000 contacts could not be imported at all.

What the review found was in the importer itself. A belongs_to column looked its record up once per row: 19,000 contacts in 1,000 groups was 19,000 lookups of the groups table. Any error from that lookup, not only "not found", was taken as a reason to create the group, and the create was not checked, so a failed create left the row with no group and a confusing error later. And nothing limited how many imports ran at once: four started together all ran together, each holding a database connection.

Imports are now excluded from the WAF's body inspection, like uploads: they still pass through auth, permissions and rate limits. The importer resolves each distinct value once and remembers it, creates a missing record and checks the create, and fails the row on any other error. Id and user lookups check their errors the same way. Imports take turns through internal/imports, two at a time by default (IMPORT_CONCURRENCY); a waiting job says so in its status. Measured on the same project: the 100,000 contact file imports in 18.7 s with every row in its group, and of four imports started together two run and two wait.

grit upgrade delivers internal/imports, adds the exclusion to routes.go, and rewrites the lookups and adds the turn-taking in every generated importer where the code is still what grit generate wrote. On a project made with v3.256.0 the upgraded importer came out identical to a freshly generated one. Upgrading it also found a bug in upgrade itself: the helper that adds an import took a map key named "errors" for the import and skipped it. It now looks only in the import block.

v3.256.0September 14, 2026

A list page on a million rows answers in 10 ms instead of 122

The next finding from the review of a scaffolded app. A generated list sorts by created_at, which had no index, counted the whole match on every page, and searched with LOWER(col) LIKE '%term%', which no ordinary index can serve. Measured against a running API on Postgres with a million contacts: page 1 took 122 ms, page 2 142 ms, page 500 159 ms, and a search 543 ms.

Three changes. Generated models, uploads and users index created_at, so a page reads the newest rows from the index. A list's total is reused across its pages until the table is written to through the API, or for at most 15 seconds, so paging no longer counts the table each time; paginate.Install wires it in when the database connects. And on Postgres, grit migrate gives every column a list searches (tagged search:"trigram") a pg_trgm index, built CONCURRENTLY and recorded in the migration history so a rollback drops it. Where the extension cannot be created the indexes are skipped with a message and search works as before.

The same API and data afterwards: page 1 in 10 ms, page 2 in 7 ms, page 500 in 37 ms, and a search for a distinctive term in 8 ms. A short, common term is still slow on the page itself (175 ms for "4242" across a million numbered names), because so many rows match that Postgres walks the date index instead. Deep pages still pay for their offset; ?mode=cursor does not. The first live run also caught a bug in the count cache before release: it was keyed on a value GORM copies for every request, so it never hit, and its test did not list the way a service does. Both are fixed.

grit upgrade delivers the paginate package and the migrate command, installs the cache in database.go, and adds the index and search tags to generated models, uploads and users where those lines are still what Grit wrote. The indexes are built the next time you run grit migrate.

v3.255.0September 13, 2026

An XLSX export no longer holds the whole table in memory

The next finding from the review of a scaffolded app. A generated resource's export read its rows in batches for CSV, but for XLSX it collected every matching row into one slice and then built the sheet cell by cell in excelize's in-memory workbook. The comment above it said excelize had no streaming writer; it does. Measured on a fresh project with 300,000 contacts: one export took the API from 80 MB to 1,328 MB. A few at once, from anyone allowed to export, would take the API down.

export.NewXLSXStream writes the header, takes rows a batch at a time through excelize's stream writer, which moves them to a temporary file past a few megabytes, and sends the finished workbook. The generated handler adds each batch as the service reads it. The same export of 300,000 contacts now peaks 122 MB above idle, with every row in the sheet. More rows than a worksheet holds (1,048,576) is refused with export.ErrTooManyRows rather than a truncated file; CSV has no such limit. --audit-reads resources count the rows with sheet.Written(). export.XLSX still takes a slice, for small in-memory exports, and now uses the stream too.

grit upgrade delivers the export package and its tests, and rewrites the XLSX branch of every generated handler that is still exactly what grit generate wrote. On the test project the upgraded handler came out identical to a freshly generated one. An edited branch is left alone, with a note saying what to change.

v3.254.0September 13, 2026

Streams stream, and gzip stops costing a megabyte a response

The next finding from the review of a scaffolded app, and the tests written for it found three more problems in the same thirty lines. The gzip middleware built a new compressor for every request: 1,198 KB allocated per compressed response. Its writer did not pass Flush through the gzip buffer, so a server-sent event stream, the AI stream among them, reached the client in one piece when the handler returned. It compressed zip, xlsx, PDF and image responses a second time. It sent a 204 with a 10 byte gzip body. A handler that declared its length, as DataFromReader does, had the compressed body sent under the uncompressed Content-Length, which a client reads as a truncated or corrupt response. And gzip;q=0, a client refusing gzip, got gzip.

The middleware now lives in middleware/gzip.go. Writers come from a pool. The decision waits for the handler's first write, when its status and headers are known: only text-like types are compressed, never an event stream, never a bodyless status, never a body declared under 1 KB, and a declared length is removed when the body is compressed. Flush flushes the compressor and then the connection. A test that ships with each project covers every case above, and failed six of its seven checks against the old middleware. Checked end to end with a fake AI gateway sending a chunk a second: through the old middleware /api/v1/ai/stream delivered all five chunks after five seconds; now each arrives when the gateway sends it.

grit upgrade removes the old middleware from middleware/logger.go when it is exactly what Grit wrote, and delivers the new file. When it has been edited, upgrade says what to change and leaves both files alone.

v3.253.0September 13, 2026

The health check no longer stalls Redis

The next finding from the review of a scaffolded app. /api/health counted asynq's keys with a Lua script that called KEYS asynq:* inside Redis on every request. Redis runs commands on one thread, so the scan blocks every other client while it runs: the cache, rate limits, the job queue, the realtime backplane. The 500 ms client timeout does not stop a script already running on the server, asynq keeps a key for every task, and anyone can call the endpoint. Reproduced with a million keys: the worst Redis round trip went from 3 ms to 1,261 ms while the health check ran, and the check itself took 813 ms.

The jobs probe now reports up when Redis answers its ping, and takes its queue counts from jobs.StatsCache: asynq's inspector, which reads each queue's list and set sizes, refreshed in the background at most every 30 seconds. A probe never waits for it. Against the same million keys the worst Redis round trip stayed at 20 ms and the health check answered in 5 ms. The response reports queued and active in place of queue_keys, and the admin's System Health page shows them.

grit upgrade delivers jobs/stats.go and the admin page, and replaces the probe in routes.go when it is still exactly the code Grit wrote; otherwise it says what to change.

v3.252.0September 13, 2026

Uploads over 10 MB work, and a slow export is no longer cut off

The next finding from the review of a scaffolded app. Every request passed through a 10 MB body cap before it reached a handler, so the upload handler's own 512 MB limit never applied: no upload over 10 MB could succeed, whatever a field advertised. The server's 15 second read and write timeouts were each one deadline for a whole request or response. Reproduced on a fresh project: a 20 MB upload was refused with 413, and a CSV export of 300,000 rows to a slow reader answered 200 and stopped after 3,935 of them, so the partial file looked like a success. The AI stream was cut at 15 seconds while its client allows 120.

middleware.RequestLimits replaces the global cap. It chooses the body limit and the deadlines by route. An ordinary route keeps 10 MB and the server's timeouts, now 10 seconds for headers, 30 to read and 60 to answer. Routes listed as transfers in routes.go get their own limit and 30 minutes to read or write, extended per request with http.ResponseController: uploads (512 MB), the AI endpoints, the GDPR and audit exports and backup downloads. Every generated resource's /export and /import (100 MB) is a transfer without being listed. The server timeouts stay short on purpose: they are what stops a slow client holding a connection open.

Checked against a running API with MinIO: a 20 MB upload succeeds, and so does one sent over 25 seconds; the 300,000 row export reaches a slow reader whole; an 11 MB JSON body to an ordinary route is still refused. tests/live/verify_limits.py runs those checks. grit upgrade delivers the middleware and its tests, and updates routes.go and cmd/server/main.go where they are still the files Grit wrote.

v3.251.0September 13, 2026

A failed database write no longer passes for a successful one

The next finding from the review of a scaffolded app. Writes in the two-factor, session and auth paths ran without checking their error, and three of them mattered. Two sign-ins presenting the same backup code at the same moment could both use it, because each read the list of codes and then saved the shorter one. Turning 2FA off ran three deletes outside a transaction and answered "disabled" whether or not they had run. And when a refresh token was replayed, the session it belonged to was revoked with an unchecked write, so a database error left a captured session live.

A backup code is now spent by an update that matches only while the list is still the one the request read. The request that loses the race gets a 401, which counts as a failed attempt. Checked against a running API: two concurrent sign-ins with one code return exactly one 200 and one 401. Disabling 2FA runs in one transaction and answers 500 if it fails. A replay whose revocation fails is refused with that error. A passkey ceremony is claimed by deleting it, so two requests presenting one challenge cannot both finish.

The other writes the review listed, and those a scan of a fresh project turned up, are checked too: the outbox relay, the passkey sign counter, API key last use, SSO, webhooks, form shares, GDPR requests, tickets, notifications, import job progress, device pairing and the thumbnail URL. Where failing the request would be wrong, the failure is logged. The thumbnail job returns it, so the job is retried.

CI had been hiding two problems. The step that runs a generated project's tests piped go test into tail and reported tail's exit code, so the module flag tests had failed since v3.243.0 made production the default. The workflows now set pipefail, and those tests run in development. A fresh project's own lint config also reported two findings, now fixed: the API key seeder treated any database error as "no admin yet", and the lockout window multiplied a duration by a duration.

grit upgrade delivers the 2FA handler, the session and passkey services, the outbox relay, SSO and GDPR, and fixes config/modules_test.go in place. Form shares, tickets, notifications, API keys, webhooks, device pairing, import handlers and the jobs worker are yours once written, so those changes reach new projects and newly generated resources.

v3.250.0September 13, 2026

An image cannot claim enough pixels to take the API down

The next finding from the review of a scaffolded app. The image pipeline behind a direct upload read an image's dimensions from its header and refused one too large to decode. The thumbnail job did not: a file uploaded through a presigned URL went straight to image.Decode, which reserves memory for every pixel the header claims before reading any of them. Reproduced with a 72-byte PNG that claims 8000x8000: decoding it allocated 244 MB before failing. At 30000x30000 the same file asks for 3.4 GB. The job runs inside the API process and was retried five times, so a file that size would crash the API, and crash it again on every retry.

The storage package's image helpers now read at most 50 MB, check the pixel count against the media profile's limit from the header, and only then decode. They return storage.ErrImageTooLarge or storage.ErrUnreadableImage, and the thumbnail job treats both as permanent (asynq.SkipRetry), so an image that cannot be processed is set aside instead of retried. The media pipeline's own pixel check now multiplies in 64 bits, so dimensions near the integer limit cannot wrap past it.

grit upgrade delivers the storage and media changes and updates the thumbnail job in jobs/workers.go. A test that ships with each project feeds the helpers a PNG claiming 30000x30000 and fails if refusing it allocates more than 64 MB. One thing this does not change: jobs still run inside the API process. For heavy image work, run the workers as a separate process.

v3.249.0September 13, 2026

A generated API builds on Go 1.26.6, and govulncheck finds nothing

The next finding from the review of a scaffolded app. A fresh project's go.mod said go 1.25.0, which is what its release workflow installs, and its lint and security workflows asked for Go 1.24. Run on this machine's Go 1.26.4, govulncheck reported eight standard library vulnerabilities that the API's own code reaches, among them net/http and the encoding/xml behind SAML sign-in, which anyone can reach before signing in. Two modules were also below their fixed releases: golang.org/x/crypto and filippo.io/edwards25519.

The API's go.mod now says go 1.26.6. That one line is what setup-go and the release workflow install, and an older Go downloads 1.26.6 on first build, so it raises the version everywhere at once. The Dockerfiles pin golang:1.26.6-alpine, the generated lint and security workflows pin 1.26.6, the desktop app follows, and the scaffold requires x/crypto v0.57.0 and edwards25519 v1.2.0. On a fresh project govulncheck ./... now reports no vulnerabilities.

grit upgrade raises an older go directive, raises both modules to their floors (so grit doctor reports a project left below them), and raises Go pins below 1.26.6 in your Dockerfiles and workflows, leaving a newer pin alone.

v3.248.0September 13, 2026

MinIO no longer runs on minioadmin, and side containers get only their own settings

The next finding from the review of a scaffolded app. MinIO's root credentials were minioadmin / minioadmin everywhere: in .env, as the fallback in the API's config, hard-coded in the development compose file, where MinIO's port is open to the local network so a phone can load images, and as the default in the production compose file, where presigned uploads put MinIO behind the public proxy. Whoever reached it owned the bucket. The production stack also gave Postgres and MinIO the whole .env: the JWT secret, the Sentinel keys and every dashboard password.

A new project's .env now carries MinIO credentials generated like its other secrets. Both compose files read them from there and refuse to start without them, as the production file does for the Postgres password, which used to fall back to grit. Postgres and MinIO in the production stack receive only the settings they read. And the production check from v3.243.0 now covers MinIO too: outside development, an API using MinIO refuses to start with a default or short MINIO_SECRET_KEY, and names it.

grit upgrade updates both compose files and the production check in config.go. It does not rewrite .env, which holds your secrets: if it still says minioadmin, the upgrade tells you, and a production server will not start until you set new MinIO credentials. MinIO takes them on its next start; files already stored are unaffected.

Verified on a fresh project and on one upgraded from v3.247.0: the rendered production stack gives Postgres, pgbouncer, Redis and MinIO none of the API's secrets, an empty MinIO secret stops the stack, and a production API refuses minioadmin. The CI live suite checks both on every push, and the docs no longer tell you to sign in to MinIO with minioadmin.

v3.247.0September 13, 2026

A delegated role can no longer make itself ADMIN

The next finding from the review of a scaffolded app, reproduced before it was fixed. An account given only users.edit and roles.edit, the kind of role you would hand a support lead, made itself ADMIN by setting its own role, made itself ADMIN again by assigning itself the ADMIN role, reset an administrator's password, deleted that administrator, wrote * into its own role, and created a role with a permission it did not have. Every one of those requests was answered 200.

There is now a grant ceiling, in internal/authz/ceiling.go. An ADMIN hands out anything. Anyone else hands out at most what they hold: a role they create or edit may grant only permissions they have, a role they assign may grant only permissions they have, and a wildcard counts as every permission it expands to. Only an ADMIN makes an ADMIN, changes or deletes an ADMIN account (the ADMIN role, or roles that together grant everything), or edits a built-in role. A delegate still edits ordinary accounts and creates roles within their own grants.

grit upgrade adds ceiling.go and puts the checks into your user and role handlers where they still read as Grit wrote them. The role tests that ship with the project now act as an ADMIN, which they always meant to, and gain a test of the ceiling.

Verified live on a fresh project and on one upgraded from v3.246.0: all six escalations are refused and the three legitimate actions still work. The CI live suite gains the same checks. Also fixed: since v3.242.0, grit upgrade added dompurify to the web app's package.json without recording that the edit was its own, so the next upgrade treated the file as yours and stopped updating it. It now records the edit; a file already marked that way stays marked, and grit upgrade --diff shows what it holds back.

v3.246.0September 13, 2026

Database backups are no longer in a public bucket

The next finding from the review of a scaffolded app. On every start the API set a bucket policy granting anyone s3:GetObject on every key, and backups are written to the same bucket under backups/. A backup's address, seen once in a proxy log, browser history or a Referer header, was a permanent anonymous download of the database: password hashes, 2FA secrets and every record. The fifteen-minute signed download link protected nothing, because the same URL without its signature worked too. The error from setting the policy was also thrown away. Reproduced against MinIO: backups/, originals/ and exports/ all answered 200 to an anonymous request.

The policy now covers uploads/ and thumbnails/ only, which is everything the API hands out a plain URL for, so images and download links keep working. Backups, the private originals kept by the image pipeline and every other key can only be read through a signed URL. Setting the policy replaces the one a bucket has, so an existing bucket is narrowed the first time an upgraded API starts, and a provider that refuses the policy is now reported in the log rather than ignored.

One case this cannot reach. Cloudflare R2 and Backblaze B2 have no bucket policies: public access is switched on for a whole bucket in their dashboards. If yours has a public domain, keep backups in a different bucket from uploads.

Verified against MinIO on a fresh project and on a project upgraded from v3.245.0 whose bucket had the old policy: an uploaded image still loads without a signature, and objects under backups/, originals/ and exports/ are refused. tests/live/verify_storage.py runs the check against any MinIO, and a test that ships into every project pins the policy to the two public prefixes.

v3.245.0September 13, 2026

A two-factor code can be used once, and cannot be guessed

The next finding from the review of a scaffolded app, reproduced before it was fixed: five of nine checks failed. Anyone who had the password could get past 2FA.

  • Unlimited guesses. A pending token took as many codes as could be sent in its five minutes, and a correct password cleared the account's failure count, so signing in again handed out a fresh supply. A code has a million values and three of them are valid at any moment.
  • Replay. A code signed in again for as long as it was valid, including the code used to turn 2FA on.
  • Silent re-enrolment. Enabling 2FA replaced a secret that was already enabled, so a stolen session could move the account to the attacker's authenticator.
  • Skipped checks. The second step did not refuse a disabled or locked account, which the password step does.

Now each pending token takes five wrong codes and is then spent. Every wrong code also counts against the account on the same counter as wrong passwords, which is cleared only when a sign-in completes, so ten wrong codes across any number of sign-ins lock the account for fifteen minutes (totp.MaxFailedAttempts, totp.LockoutDuration). The time step of each accepted code is stored and only a later one is accepted, through a conditional update so two requests cannot spend one code; the code that enabled 2FA is spent too. Codes are compared in constant time, the pending token is spent by exactly one request, enabling is refused while 2FA is on, and the second step checks for a disabled or locked account. A backup-code sign-in now sets the browser's cookies as well.

grit upgrade delivers the TOTP package, its models (two new columns, added by grit migrate) and the handler, and moves the failure-count reset in your login handler to after the 2FA step.

After the fix all nine checks pass, computing real codes against a fresh project and an upgraded copy of the reviewed app, and they run in the CI live suite.

v3.244.0September 13, 2026

A refresh token is not an access token, and logging out ends the session

The next finding from the review of a scaffolded app, reproduced on a fresh project before it was fixed. Seven of thirteen checks failed.

  • Refresh tokens worked as access tokens. The two were the same shape, so the seven-day refresh token, which the login response also returns in its body, was accepted as a bearer token on every route and on the WebSocket.
  • Revoking a session reached nothing already issued. The auth middleware never read the sessions table, so after logging out, signing out everywhere or changing a password, every access token kept working until it expired.
  • The refresh cookie never reached its routes. It was scoped to /api/auth, and refresh and logout are mounted under /api/v1/auth. A browser never sent it to either, so a web session could not refresh (the admin sent you back to the sign-in page when the access token expired) and logout revoked nothing.

Tokens now carry a type and the id of the session they belong to. ValidateAccessToken, which the auth middleware and the WebSocket handshake call, accepts only an access token whose session is live, checked against the database and trusted for 30 seconds. Revoking a session through this process takes effect at once, and through another replica within those 30 seconds./auth/refresh accepts only a refresh token and keeps the session id through rotation, and the cookie is scoped to the versioned auth routes. Two-factor sign-in and impersonation now record sessions, which they did not before. The JWT parser also insists on HS256 and an expiry.

grit upgrade delivers the new auth and session services and edits the middleware, the WebSocket handler, refresh, two-factor sign-in, the impersonation plugin and the service wiring where they still read as Grit wrote them. Existing sessions survive: an access token from before the upgrade is refused once, and the client refreshes with its old refresh token, which is still accepted. If you mint tokens yourself with GenerateTokenPair, record the session with services.CreateSession, or the access token is refused.

After the fix all thirteen checks pass, on a fresh project and on an upgraded copy of the reviewed app, and they run in the CI live suite on every push. The session tests that ship into each project cover token types, revocation and rotation.

v3.243.0September 13, 2026

Production is the default: login limits that fire, no SQL console, no default passwords

Two more findings from the review of a scaffolded app, both about what a server runs with when nobody configured it carefully.

The login rate limit had never fired

Sentinel matches the request path exactly. The five-per-fifteen-minutes login limit, the register limit, AuthShield and the CSRF exemptions for signing in were all written as /api/auth/login, while the router mounts /api/v1. Not one of them matched a request, so a password could be guessed as fast as the server answered. They are now built from the API version, and the forgot-password, reset-password and 2FA routes get limits of their own. Verified against a running server: five wrong passwords are refused as credentials, and the sixth is a 429.

A forgotten APP_ENV meant development

Without APP_ENV the API ran as development: no rate limits, the WAF only logging, password-reset links written to the log. And nothing outside development refused studio, sentinel or pulse as a dashboard password, or a short JWT secret. Unset now means production. Anywhere but development, the server refuses to start with a JWT secret under 32 characters or a dashboard password that is short or a known default, and names the settings to fix. A scaffolded .env says APP_ENV=development and has generated secrets, so nothing changes on your machine.

GORM Studio and /docs in production

GORM Studio, a browser SQL console with write access to every table, was on in production behind basic auth, and /docs published every route, admin, backup, SSO and GDPR ones included, with a console to call them. In production Studio is now off whatever GORM_STUDIO_ENABLED says, because .env files get copied to servers whole; GORM_STUDIO_IN_PRODUCTION=true turns it on there, read-only and with no SQL editor. /docs is served in production only with API_DOCS_PUBLIC=true. Development keeps both as they were.

Upgrading

grit upgrade applies all of it to config.go, routes.go, the CSRF middleware and the startup log, anchoring on the text Grit generated and naming anything it had to leave alone. Check one thing before you deploy: a server started without APP_ENV, or with a default dashboard password, will now stop with an error instead of starting. That is the point, but it is a change.

The CI live suite now starts the fixture a second time with APP_ENV=production: Studio and /docs answer 404, a sign-in with a stale session cookie is judged on its credentials rather than refused for CSRF, the sixth failed login is a 429, and a start with PULSE_PASSWORD=pulse exits naming it.

v3.242.0September 13, 2026

Stored XSS: rich text is sanitised on its way into the database, and again in the browser

The next finding from the review of a scaffolded app. The demo blog stored a post's HTML exactly as it was sent, and the web app rendered it with dangerouslySetInnerHTML on the same origin as the admin panel. There was no sanitiser in either tier. So an account allowed only to edit posts could save <img src=x onerror="...">, wait for an admin to read it, and let the script call the API as that admin: issue itself an API key, or give itself the ADMIN role. Any richtext field on a generated resource had the same problem the moment somebody rendered it.

Every API now has internal/sanitize, installed on the database handle when it connects. Every field tagged sanitize:"html" is cleaned on every write that has a model, whichever way the write arrives: create, PUT, PATCH, bulk edit, the CSV importer, sync push and GORM Studio's row editor. The generator tags richtext fields, and the blog's content is tagged. The policy keeps what the admin editor produces (headings, lists, links, images, tables, code blocks with their language, text alignment, colours and highlights) and removes scripts, event handlers, javascript: URLs, iframes and any other styling. The blog pages, in the Next app, the Vite app and the single-app SPA, also render through DOMPurify, which covers posts stored before this release.

grit upgrade adds bluemonday and the package, wires it into database.go, tags the blog's content and adds dompurify to the web app, so run pnpm install afterwards. It cannot tell a rich text column from a plain text one in a resource you already generated, so add sanitize:"html" to those fields' tags yourself. Rows already stored are not rewritten; saving one again cleans it. Raw SQL and db.Table without a model bypass the sanitiser.

Verified live on a fresh double: the attack is gone from the stored row and the public page after a create and an edit of a post, and from a generated richtext field after create, PUT, PATCH and bulk patch, while centred text survives. The sanitiser's own tests ship into the project and pass there and on an upgraded copy of the reviewed app, and the Next and SPA builds pass with DOMPurify. The CI live suite gains four checks.

v3.241.0September 13, 2026

A security review of a scaffolded app: the three criticals, and the secrets in .env.example

An independent review ran thirteen specialist skills over a double scaffolded with v3.239.0, a small address book, and reported 109 findings: 3 critical, 26 high, 44 medium and 36 low. Nearly all of them are framework code, so they are being fixed in the templates rather than in that app. This release is the set the review marked "before the first commit". Every one of them was in a project freshly scaffolded today.

Any account could make itself ADMIN through sync

The default sync registry held users and uploads, and /sync/push was a generic write: it loaded the row the client named, decoded the client's JSON over it and saved it. Register, push {"op":"update","model":"users","data":{"role":"ADMIN"}}, done. Pull returned every row of every synced table to any signed-in account.

Users and uploads are no longer synced; both have APIs with their own checks. Each pushed change now asks for <model>.create, .edit or .delete, or for the row to be the caller's own in a table whose rows have owners, and an owner cannot hand a row to somebody else by rewriting its owner field. The id, version and timestamps are never taken from the payload. Pull reads a table with <model>.view, and otherwise only the caller's own rows of an owned table. A row the caller may not touch answers NOT_FOUND, so its existence is not confirmed.

SQL injection and IDOR in the upload handlers

GET and DELETE /uploads/:id passed the path segment to First(&upload, id). With a string, GORM treats that as a SQL condition, not a primary key, so /uploads/1=1 matched a row and a longer expression was a blind oracle over the whole database. Neither checked who owned the file, and the list and stats covered every user's uploads. Lookups are now parameterised and scoped: an ADMIN or a holder of uploads.view (or uploads.delete) reaches every upload, anyone else only their own, and anything else is a 404.

Generated resources were open to any signed-in account

Only delete and bulk asked for a permission. List, read, export, import, create and update sat on the protected group, and on an app with open registration signed in means anybody: the review exported the whole address book from an account made a minute earlier. Each generated route now asks for the permission the roles screen already grants for its verb (view, create, edit), on the staff group, so an ADMIN holds them all and a role can grant them. The same applies to the workflow, append-only and --tree routes, where a move or a rebuild rewrites every row beneath a node.

A resource generated with --owned-by or --tenant-owned keeps its protected routes: its queries are already narrowed to the caller's own rows or organization. A project whose routes predate the staff group puts shared routes on the admin group instead.

Real secrets in .env.example

.env.example, the file meant to be committed, was a byte-for-byte copy of .env: the JWT secret, the database password and the dashboard passwords, in the repository from the first git add, and in production for anyone who deployed with cp .env.example .env. Generated secrets are now CHANGE_ME there, and *.db and *.sqlite files, which hold password hashes, sessions and API keys, are ignored.

Upgrading

grit upgrade applies all of it to an existing project. It moves generated routes behind their permissions, in the route files and the tree routes in routes.go, takes users and uploads out of the sync registry and the desktop app's sync list, delivers the new sync and upload handlers, replaces the secrets in .env.example and adds the ignores. Each edit anchors on the exact text Grit generated, so a route you wrote by hand is left alone, and is still open to every account if it is on m.Protected.

Two things it cannot do for you. If .env.example was ever committed or shared, rotate the secrets in .env, because they are the same values. And an ordinary account that used a shared resource before now needs a role that grants it: give <resource>.view and the rest in Roles & permissions.

Verified live on a fresh double (24 checks) and on a copy of the reviewed app after grit upgrade (26 checks): a new account gets 403 on shared resources and cannot push or pull them, cannot change its role through sync, and gets 404 for /uploads/1=1 and for another user's file, while ADMIN and owners keep working. The CI live suite gains the same checks. The rest of the review is tracked as Phase 8.

v3.240.1September 13, 2026

--append-only broke grit migrate on ordinary MySQL

v3.240.0 added the append-only resource to the live suite, and its MySQL job was the first time the MySQL trigger met a real MySQL. Run locally against MySQL 8.4, it showed what production would have met: with binary logging on, which is the image default and the norm on RDS, a user without SUPER cannot create a trigger. Error 1419. InstallTriggers returned it, so the whole migration failed, including the default roles seeded straight after it, which left authorization broken as well as the table unguarded. And because AutoMigrate had already created the table, a raw UPDATE on the "append-only" rows went straight through.

On that refusal, grit migrate now logs what is missing and how to add it, and carries on: the GORM guard still refuses every change that goes through the API, and the roles are seeded. With log_bin_trust_function_creators set, the trigger installs and MySQL refuses a raw UPDATE with error 1644. Verified both ways against MySQL 8.4.11. Any other trigger error still fails the migration, because only the privilege refusal has a safe fallback.

Also, the v3.240.0 append-only live checks reached only the Postgres fixture. The SQLite and MySQL fixture is built in a separate job and never generated the resource. It does now, and the MySQL job sets the variable so the trigger is installed and checked rather than skipped with a warning.

v3.240.0September 13, 2026

A double-entry ledger under load, and the three gaps it found

The second of the reviewer's stress projects: a ledger with multi-currency transfers, stock, an append-only journal and the transactional outbox, driven by 32 concurrent workers. 3,000 transfers and 300 orders later the invariants all held. Every transfer wrote exactly two lines, every same-currency pair moved the same amount both ways, every balance reconciled against its own journal to the minor unit, 609 currency conversions rounded without drift, the product sold exactly its 250 units and refused the 51st, and not one journal row was ever mutated: not through the API, not in SQL, not by TRUNCATE.

What it found was not in the arithmetic. It was in what the framework leaves to you without saying so.

Nothing delivered the outbox. The event bus runs a relay for its own event: topics and deliberately leaves the rest of the table alone, so an application that enqueues ledger.transfer needs a relay of its own. Nothing says so at run time: the write succeeds, the transaction commits, and the rows sit at pending with zero attempts. 2,200 of them piled up before anybody looked. grit doctor checks for it now, reading the topics your code enqueues and the relays your code starts, and the outbox finally has a documentation page rather than one comment in a changelog.

The taxonomy had no code for "not enough of it". Grit ships stock.Take and money.Sub and had nothing for what they refuse, so every application that sells something or moves money invented a code. INSUFFICIENT_STOCK and INSUFFICIENT_FUNDS are in the catalogue now, both 422, and the generated TypeScript union knows them.

An append-only resource had lost its bulk load. --append-only dropped the CSV import along with update and delete, and the import only inserts: CreateInBatches with OnConflict DoNothing. A ledger's history arrives as a file of rows that already happened, and there was no way to load it. The import is routed again, and the live suite now imports 2,000 rows into an append-only table while transfers run against it, then checks that the table still refuses an update.

The outbox itself came out of this well: 3,250 messages, delivered by a relay whose Deliver refused a third of the time, drained in ten seconds with 1,639 retries, every key delivered exactly once. The retry path, the claim, the backoff and the dedupe key all behaved.

v3.239.0September 13, 2026

A two-app project on Vite had a panel that could never have run

grit new x --double --vite wrote the Next.js panel into a TanStack Router app: 41 route files under an app/ directory a Vite app does not route, and 129 components importing next/link, which it cannot resolve. Nothing linked to any of it and nothing would have compiled if it had. The shape shipped that way since the panel was embedded at all.

The machinery for doing it properly already existed: it is what a single project's SPA uses, and the only single-specific thing about it was a hardcoded path. The panel now lands in src/routes/admin/ with its code under src/admin-panel/ behind the @admin alias, in the TanStack dialect, with the aliases and dependencies written into that app's own Vite config, tsconfig and package.json. grit generate resource writes a new resource's screens there and registers it, grit add web-auth writes the sign-in pages and the customer area there, and grit upgrade deletes the Next.js panel that could not compile before writing the one that can.

Six bugs fell out of building it, and five were not about this feature. Every Vite web app ever scaffolded shipped a navbar linking to the literal string {{ADMIN_HREF}}, because the substitution ran only on the Next.js writer. The realtime client wrote apps/web/hooks/use-realtime.ts into an app that keeps its code under src/, and the resource generator then followed that directory, putting every generated hook beside it. Four writers appended src to a path that already had one, or did not append it when it was needed, so the standalone Vite admin's dashboard widgets were landing outside the application.

The one that would have bitten hardest: the SPA's API client authenticates with a bearer token and never echoed the CSRF cookie. That is fine on its own, because the API exempts bearer requests, and wrong the moment the admin panel in the same app signs in with cookies. After that the browser holds a session cookie for the host and sends it with everything, so a customer signing up on the public site was refused with CSRF_INVALID. It echoes the token now, which fixes the same latent bug in every single-binary project.

Verified on a scaffolded --double --vite: the web app builds and typechecks clean, the panel signs in and renders its dashboard with live data at /admin/dashboard, a generated resource appears in its sidebar, a customer registers and lands on the account dashboard, and a project scaffolded before this upgrades, loses the panel it could not compile, and builds green.

v3.238.0September 12, 2026

The single gets the sign-in pages its auth library never had

A single project's SPA has shipped lib/auth.ts since it existed: login, register, refresh, logout, the two-factor challenge, and a session-expiry monitor watching a session nothing could start. There was no sign-in screen. grit add web-auth writes those screens for a Next.js web app and refused a single outright, because it looked for apps/web and found a binary.

It writes them now, in the dialect that app speaks: /login, /register, password reset, and the customer area at /account behind a route guard that turns away a visitor with no token before a screen renders. Same dashboard shape as the two-app and three-app projects got in v3.237.0, same account overview and profile form, and the navbar gains the account menu.

The SPA is four sections now, and the root route is none of them: it renders an outlet and nothing else. routes/_site draws the navbar and the footer, routes/admin the panel's sidebar, routes/_auth a full-bleed card and routes/account the customer shell. The names beginning with an underscore are pathless layout routes, TanStack's answer to a route group, so not one URL changed. The root used to decide by asking whether the path started with /admin, which is the hand-kept list the Next.js app stopped using yesterday, and it was two entries away from being wrong again.

Verified on a scaffolded single: 2,920 modules built with no type errors, a customer registers and lands on the account dashboard, the profile form saves (200 on PUT /api/v1/profile), signing out and returning to /account lands on /login, the landing page keeps its chrome and the panel keeps its own. grit upgrade moves an existing SPA's pages into the section, rewriting the id inside each one, and that project builds green too.

v3.237.0September 12, 2026

A web app serves three kinds of page, and now has three layouts

Somebody opened the admin panel in a two-app project and found the marketing navbar above its sidebar and the site footer under its tables. The root layout wrapped every page in that chrome, and a client component took it away again for a hand-kept list of path prefixes: /login, /register, /forms/. The list had never heard of /admin, and a list that has to be kept in step with the routing table by hand is going to be wrong eventually.

Next has route groups for exactly this, and they cost nothing at runtime. The root layout is the document now: fonts, theme, providers, no chrome. app/(marketing)/ is the public site and its layout draws the navbar and the footer. app/admin/ keeps its own. And app/(app)/ is the third thing a web app serves and never had a home: the pages a signed-in customer sees. No URL changes, because a group name in parentheses is not part of the path.

That customer area was a hole, not a nicety. The user menu has linked to /account since v3.31.42 and the middleware has protected /account for just as long, and the page did not exist: signing in and clicking your own name was a 404. grit add web-auth now writes it as a dashboard, sidebar and all: an overview of the account and a profile page that edits the name, email, job title and password through PUT /api/profile. Add a section by adding a line to one array.

And the Admin link in the navbar, which in a double still pointed at http://localhost:3001 where nothing runs. v3.235.0 fixed the value the app compiles in and left .env alone, and .env set NEXT_PUBLIC_ADMIN_URL to that port in every project ever scaffolded. The environment wins over the default, so the fix never showed. That line is written per architecture now: a URL for a triple, a commented-out override for a double, absent in a single. grit upgrade retires it in projects that already exist, moves their public pages into the group, and says which of your own pages it left behind for you to place.

Verified on a scaffolded double: the admin panel renders with only its own chrome, the landing page keeps the navbar and footer, a customer registers and lands on a working account dashboard whose profile form saves, a generated resource appears in the admin sidebar with a working list screen, and an upgraded project built before any of this builds green.

v3.236.0September 12, 2026

A single gets the admin panel too, inside its SPA

Yesterday a two-app project got the panel. A one-binary project had the same hole and a smaller excuse: the whole point of --single is that one binary is the entire product, and the thing you administer it with was not in it. It is now, as a section of the same SPA at /admin/dashboard: 35 screens, 201 files, the same dashboard, resource CRUD, system hub, settings and account security a three-app project has.

The source is the Vite admin rather than the Next one, because a single project's frontend is already a TanStack Router app. Its routes move into the SPA's tree under /admin and the id inside each route file is rewritten to match, since TanStack refuses a file route whose id is not its path; everything else moves to frontend/src/admin-panel/ behind an @admin alias. The SPA's root route yields: the panel draws its own sidebar and topbar, so the site navbar and footer step aside for anything under /admin, and nothing else changes. grit generate resource writes its screens there and registers it in the sidebar, and grit upgrade adds the panel to a single you already have: 212 files, plus the aliases, dependencies and dev-proxy entries inserted into your own config only where they were missing.

Most of what this turned up was not about the feature. The panel's api-client defaults to http://localhost:8080, which is right when the admin runs on its own port and wrong when the binary serving the page is the API: it asks the origin it was served from now, in dev through the Vite proxy and in production directly. That proxy only forwarded /api, so the panel's links to GORM Studio, Pulse and Sentinel landed on the dev server; it forwards those too. The api-client also read process.env.NODE_ENV, which Vite does not polyfill, so every Vite admin ever scaffolded carried a "process is not defined" in the browser that vite build could not catch, because esbuild does not typecheck.

Three more, all from asking the wrong question. Writers that wanted to know whether the panel is a TanStack app asked UseTanStack, which reads a field that is empty for a single project, so the account-security screen was written as a Next.js page into an apps/admin directory a single does not have: one orphan file, no package.json, nothing that compiles it. The generator had two copies of "where does admin code live" and only one learned about the new shape, so a generated resource was written and never registered, which means the screens existed and the sidebar did not know. And the shared schemas and types were written to packages/shared, which a single project does not have, so grit generate emitted a customisation overlay importing a type it had just skipped, and grit sync had nothing to sync. grit remove resource now finds the panel in all three shapes as well.

Also the Redis noise, in the single binary this time. v3.235.0 quietened apps/api's main; a single project has its own, and it still printed a wall of driver failures, then "Background worker started", then an asynq error every second or two forever. Two lines now, and the worker and cron start only when Redis answered.

Verified on a scaffolded single: 2,908 modules built with no type errors, the panel serves at /admin/dashboard with its own chrome, a generated resource appears in the sidebar and lists its records, and grit upgrade delivers all of it to a project scaffolded before this existed.

v3.235.0September 12, 2026

A double gets the admin panel, at /admin

Somebody scaffolded a two-app project, opened the web app, clicked Admin in the navbar, and arrived at http://localhost:3001 where nothing was running. The panel was built for three-app projects only: a double had the link and no panel behind it, which is worse than not offering it.

It is there now, as a route group inside the web app: 147 screens at /admin/dashboard with their own layout, the same dashboard, resource CRUD, system hub, settings and account security a triple has. Not a second copy of the code. The file map is the one the standalone app uses, put through a transform that moves the routes under app/admin, moves the components, lib and hooks under admin-panel/ behind their own @admin alias, and prefixes the internal links, so a fix to an admin screen reaches both shapes at once. grit upgrade adds it to a double you already have, 157 files, manifest-guarded so anything you edited is reported rather than overwritten.

Four bugs came out of building it, and three were not about this feature at all. @repo/upload ships raw TypeScript and neither the web app nor the admin app listed it in transpilePackages, so the standalone admin could not have built either: the failure reads "Module parse failed: Unexpected token" on a type-only import. Several admin screens are written by feature writers rather than by the file map, and those asked whether the project has an admin app when they meant whether it has a panel, so an embedded one arrived without its account-security page while the link to it sat in the user menu. Fourteen writers built the path apps/admin by hand rather than asking, which is the failure the path-helper comments already warned about. And grit upgrade assumed every project was a triple, because Normalize defaults to it and upgrade never set the architecture: upgrading a double wrote thirteen files into an apps/admin that does not exist, plus a components.json for an application that is not there. Upgrade now reads the shape off the directories.

The panel guards itself, as it always did: the layout redirects anybody who is not signed in and sends a plain USER role to their profile rather than the dashboard. grit add web-auth is unchanged and does not collide with it: its middleware matches /account, /login and /register, none of which are the panel's routes under /admin. It did need one fix, because it writes the navbar and would have left the literal placeholder for the admin link in it.

Separately, the startup noise on a project with no Redis. Four identical pool failures from inside the driver, a warning saying caching was disabled, then "Job queue connected" which was not true, then an asynq error every second or two forever because the worker polls regardless. The driver's own logger now collapses repeats, the job queue reports what actually happened, and the worker and cron scheduler only start when Redis answered. Two lines instead of a wall.

Single is next: its admin has to go inside the embedded Vite SPA rather than a Next.js route group, which is the same idea and different work.

v3.234.0September 12, 2026

Say which database, and have it tested

Somebody opened a fresh project's .env to point it at MySQL and found Postgres settings, a commented DATABASE_URL mentioning Postgres and SQLite, and nowhere to put MySQL credentials. They were right: the engine came from the scheme on DATABASE_URL and nothing in the file said so, for an engine that had been supported for months.

There is a setting now. DB_PROVIDER takes postgres, mysql, sqlite or memory, each with its own block of settings beside it, and grit new myapp --db mysql picks it at scaffold time. DATABASE_URL still wins when set, because a managed database's connection string carries options these parts do not model, and when the two disagree about the engine the app says so at boot instead of leaving you to wonder why the settings you edited do nothing.

memory is new, and it migrates itself. An in-memory database cannot outlive the process holding it, so a separate migrate command would build the schema and take it away again: the server migrates at boot when the provider is memory, which is safe precisely because there is never anything to lose. It also uses a shared cache rather than a bare :memory:, without which every pooled connection gets its own empty database and a freshly migrated app reports that its tables do not exist.

The part that mattered more than the setting: these engines are now tested. The live suite, 68 checks over a running app covering ownership, trees, the public surface, money, encryption at rest, CSV import and optimistic locking, runs on MySQL 8 and SQLite as well as Postgres 15, 16 and 17, with a smaller set on an in-memory database. It reads the database directly on each of them rather than only over HTTP, which needed one Postgres-only query fixed: || for concatenation, which MySQL reads as OR.

Building the first project on MySQL found a real bug within a minute. grit generate resource --tree produced a table MySQL refused to create: the materialised path was an indexed varchar(1024), and utf8mb4 counts four bytes a character, so the index was 4096 bytes against a 3072-byte limit. The path is 700 characters now, about eighteen levels of uuid, and the whole suite passes on MySQL. That is the value of running the thing: the support was claimed, the documentation was correct, and the first table with a deep index did not exist.

grit doctor gained one more check, for the failure this release makes easy to reach: DB_PROVIDER=memory with APP_ENV=production is an error, because the app works perfectly until it restarts and then every row is gone with nothing to recover from. SQLite in production is a warning that names the tradeoffs rather than a refusal.

v3.233.0September 12, 2026

Three bugs where tenancy, roles and impersonation meet

A reviewer proposed building applications that deliberately combine subsystems which have each had bugs on their own, on the theory that intersections are where the next ones live. The first of those is multitenancy with custom roles and impersonation, all at once. It found three, and the first is the kind of thing that makes a framework unusable for the case it advertises.

Every DELETE on a tenant-owned resource answered 500. The plugin mounted its organization resolver on the protected route group only. The staff group carries every delete and every bulk route, and the admin group every admin-only endpoint, so on those the request never resolved an organization at all: the scoping callback fails closed, as it should, and the caller got "Failed to delete deal". The resolver is now mounted on all three groups, and the routes file has markers for the other two so a plugin can reach them.

A role held through an organization membership granted nothing. The middleware read the membership, put its role id on the request context, and nothing ever read it: permissions came from platform roles alone, so somebody made an administrator of one organization was an administrator of every organization they belonged to. Per-organization roles now grant inside that organization, and only there. They add to platform roles rather than replacing them, so an organization can grant and cannot take away.

"No active organization" was a 500. It is now 400 NO_ORGANIZATION, with a message saying to send the header or join an organization. The mechanism is worth having on its own: an error can implement respond.Coded to say what it should look like on the wire, which is how a package the response layer cannot import gets a decent answer instead of an opaque 500.

Ordering turned out to matter as much as presence. The first fix put the resolver after the permission gate, so a member whose only permission came from their organization role still got a 403: the gate read their grants before the organization had been resolved. On every authenticated group it now runs after authentication and before authorization, and CI asserts that order rather than trusting it.

The application that found these is kept rather than thrown away, which was the other half of the reviewer's advice. A new CI job scaffolds it on every push, with both plugins and both resource shapes, and runs 26 checks across the seams. That also closes something the stability page had listed as untested: multitenancy was not in the live suite at all. And grit doctor gained two checks, because a project that installed the plugin before today still has the old wiring and grit upgrade does not rewrite routes.go: one names the missing mount with the line to add, the other reports that a user provisioned by SSO joins no organization, which is a policy decision the framework should not make quietly.

v3.232.0September 12, 2026

One code, one status: the error taxonomy, end to end

A client needs two things from an error: a code it can branch on, and the certainty that the code always arrives with the same status. Grit had the first and not the second. VALIDATION_ERROR came back as 422 from thirty-eight handlers and 400 from twenty-five. INVALID_TOKEN was both 401 and 400. An upload that did not exist answered 400. And no page listed the codes at all, so the only way to learn one was to trigger it.

There is now one catalogue, and three things are generated from it so they cannot disagree: internal/respond/codes.go in every project, with a typed constant and the status for each of the 105 codes; packages/shared/types/errors.ts beside it, so a TypeScript switch over codes is exhaustive and a client that forgets one fails to compile; and the error codes page, which says what each code means and what a caller should do about it.

The part with teeth is a test that walks every template: a code a handler returns that is not in the catalogue fails the build, and so does a code returned with a status other than its own. That test is what made the statuses consistent, by listing the 31 sites that disagreed. Two of those were worth more than a status change: a request whose body could not be read is not a validation failure, so it answers INVALID_BODY, and a one-time link that has expired is not a credential, so email verification and password reset answer INVALID_LINK (400) rather than borrowing a 401.

Generated handlers now write errors through respond.Fail, which takes the status from the catalogue rather than having one typed beside the code, which is how the split happened in the first place. And the API reference says, per route, which errors it can return: a 401 on every authenticated route, a 403 only on resources where ownership or a role makes it reachable, a 409 on an update that can conflict, and for an owned resource the note that somebody else's row answers 404 rather than 403, on purpose.

Three of the release's own bugs, each caught before it shipped and each the same shape: a generated file that compiles in a test and not in a project. codes.go was only on the upgrade path, so a new project had generated handlers calling a function that was not there. The category constant for "notfound" was generated as CategoryNotfound, which gofmt parses happily and the compiler does not. And the per-route error responses were emitted without the dot that continues a method chain. Each now has a test: the scaffold path as well as the upgrade path, every identifier the generated file refers to, and a build of a real project.

v3.231.0September 12, 2026

The build reads the docs

"The docs said to do X and X did not work" is the most repeated root cause on this page. A command had been renamed, a flag never existed, a field type the generator refuses was in a tutorial. Every one of those was found by a person following a page, which is the most expensive way to find anything, and nothing in the build knew the docs existed.

Now it does. Every grit command shown anywhere in the docs, 586 of them, is resolved against the real command tree: the command has to exist, and so does every flag it is given. Every --fields spec goes through the same parser the generator uses. It found two bugs on its first run: a page telling people to run grit migrate:fresh, a Laravel spelling Grit never had, and a tutorial whose status:select field the generator refuses because a select needs options. Both are fixed.

That catches a command that is wrong, not steps that are each valid and do not work in order. So a block can claim more by naming a flow, verify="migrate-rollback", and the new Docs workflow scaffolds a project per flow, migrates it and runs that flow's blocks in page order on a real Postgres. Three flows are covered: the rollback walkthrough, grit doctor reporting nothing on a correct project, and a generated resource compiling and vetting clean.

What is deliberately not claimed: most blocks are not marked, because they start a server, bring up docker compose, or carry a placeholder only the reader can fill in. Marking those would be pretending. They still get the command and field-spec check, which is the half that rots quietly.

The checker is conservative by design, and two of its own mistakes are worth recording. It read prose as commands, because a changelog sentence began "grit can keep itself current" and a heading read "grit sync: Manual Type Generation", so it now only reads inside code blocks. And it was missing the first command of every multi-line block, the one sharing a line with code={`, which was most of the commands in these docs. The test now fails if that count collapses again.

v3.230.0September 12, 2026

Down migrations, for a framework that has no migration files

Grit migrates with AutoMigrate, so there is no up script and nothing to write a down script against. What there is instead is a fact worth using: AutoMigrate only ever adds, and never drops a column. So the reverse of a run does not have to be written by hand, it can be computed. Each run now snapshots the schema before and after, records the difference in two tables in your own database, and grit migrate down drops exactly what that run added: indexes, then columns, then tables.

grit migrate status shows the history, newest first, with what each run changed and whether it has been rolled back. grit migrate down takes --steps N, --dry-run, and --yes for CI. Every statement is printed before anything runs, because this is the one part no rollback can undo: a dropped column takes the data written into it since. Each drop is skipped if the thing is already gone, so a rollback interrupted halfway can simply be run again.

Two things it refuses. The first run on an empty database is the one that built the schema, recorded as a baseline: rolling that back would drop the database rather than undo a change, so it points at grit migrate --fresh instead. And a project scaffolded before this existed gets a baseline on its next migrate, because the schema it finds was built by runs nobody recorded. grit upgrade delivers the package and the rewritten cmd/migrate together, since either alone is a project that does not compile.

The tests that prove it ship into the project rather than staying in the CLI, and one of them found the first bug before release: SQLite reports its own sqlite_sequence table, which the history's autoincrement id creates, so a snapshot of an empty database was not empty. On SQLite no run would have been recognised as the baseline, and the first rollback would have offered to drop the whole schema. The live suite now adds a real column with grit generate field, rolls it back on Postgres 15, 16 and 17, and checks the column is gone, the table is not, and the baseline is refused.

Writing that check found a second bug, in grit generate field: it wrote to packages/shared and apps/admin without looking, the way grit generate resource never has. On an API-only project it failed on the first missing file, after the model had already gained the field, so the command reported an error having half done the work. It now injects the frontend where there is one, including the case of an admin panel with no definition for that resource.

v3.229.0September 12, 2026

A stability matrix per subsystem, and a weekly rollup over 343 releases

Three of the worst entries on this page were docs asserting a guarantee the code did not provide. So the stability and hardening page has one rule: a claim under "Grit guarantees" names the test that would fail if it stopped being true, and anything without such a test is listed as your responsibility instead. Twenty subsystems, each with an honest status (stable, beta, new), what is guaranteed and what proves it, what stays yours, and what went wrong once. It also gathers the numbers: 492 test functions in the CLI, 256 tests shipped into every project, 57 live checks on Postgres 15, 16 and 17, 13 grit doctor checks, and the scanners.

It says what is not covered, too, because a matrix that lists only strengths is marketing: no independent security audit, the framework's own handlers still query from their handlers, multitenancy is not in the live suite and not at all in combination with --tree or --public, there is no LTS channel, no signed desktop release has been verified end to end, and there are no production case studies yet.

The security guide asserted exactly this kind of guarantee while showing code from before v3.224.0: authz.MustOwn inside a generated handler. It now shows where the check actually lives, scoped in SQL for a list or an export and checked after loading for anything by id, answering 404 rather than 403 so a wrong guess cannot be told from a right one, with authz.MustOwn kept as the path for a handler you wrote yourself and the caller travelling on the context. Its pre-launch checklist gained rows for FIELD_ENCRYPTION_KEY, the Studio password, SENTINEL_AUDIT_KEY, --owned-by and grit doctor, and no longer points at a workflow file that does not exist.

This page now opens with "Releases by week", generated from its own entries by scripts/changelog-rollup.py, which also gave all 343 entries an anchor to link to. The generator refuses to write a partial result: its first version required an h3 title and would have silently dropped the 160 older entries that title themselves differently, which is the class of quiet half-success this changelog keeps recording.

v3.228.0September 12, 2026

The verification that found the bugs now runs in CI, on three Postgres versions

Grit's worst bugs were never found by a unit test. An export that returned an empty file with a 200, a list that handed one account another account's rows, a save that silently overwrote a newer one, a replace that appended instead of replacing: each was found by building a real application and using it, then fixed and verified by hand, once. tests/live/verify.py is that verification kept. It drives a running generated application over HTTP and reads Postgres behind it, because for some of these the database is the only witness: whether a column is really ciphertext, whether a move rewrote a subtree. 57 checks, green on the first run against a real project.

A new workflow scaffolds a project, generates the resource shapes those bugs lived in (owned, tree, public, money, encrypted, a relation, line items), migrates, starts the server and runs the suite, on Postgres 15, 16 and 17. The matrix is there because these are database behaviours: RETURNING, a materialised-path rewrite with REPLACE, sliding-window limits, and the way FindInBatches pages.

Two scanners join it. gosec runs over the CLI and over a generated application, with the rules a file-writing program trips by construction excluded: writing files at paths built from its own inputs is what a scaffolder is, and those rules produced 500 findings that would have taught everyone to ignore the scanner. What is left is zero across 317 files, so the next finding is a new one. Trivy scans a generated project's dependencies, secrets and Dockerfiles.

Trivy earned its place on the first run. A freshly scaffolded project carried five HIGH CVEs that govulncheck had not reported, because none are reachable from generated code: an XML signature validation bypass in goxmldsig, which on the SAML assertion path is an authentication bypass, where the newest release of the library that pulls it in still asks for the vulnerable version; two parser denial-of-service issues in excelize, which is what the CSV and XLSX importer hands user uploads to; an allocation DoS in golang.org/x/image, which decodes user images; and token parsing in golang.org/x/oauth2. All four are pinned as floors now, so grit upgrade carries them into existing projects too.

gosec found one pattern worth changing: two formatting loops in generated code indexed bytes while iterating runes. Harmless for the digits they are given, and the exact shape that truncates the first multi-byte character somebody passes in, copied into two files. Three findings that are right to keep now carry their reason in the code instead: TOTP is HMAC-SHA1 because RFC 6238 says so, the backup's table name comes from the model registry, and safefetch checks the destination itself. The README now carries CI, live and scan badges.

v3.227.0September 12, 2026

grit doctor: the mistakes that do not announce themselves

A build catches a type error and a test catches a wrong answer. Neither catches an encrypted column with no key, a list that was never scoped to its owner, or a database browser whose password is still studio. Those work, which is the problem, and every one of them was found the hard way once. grit doctor turns that into 13 checks over a project's models, handlers, services, routes, .env and go.mod.

It reports an unset FIELD_ENCRYPTION_KEY against encrypted fields, an encrypted column in a search or sort whitelist, an owned resource nothing scopes or one method that lost its scoping while the others kept it, the owner accepted from a request body, a resource that references a user and is scoped to nobody, a resource with no tenant.Owned in a multitenant project, PII-shaped columns left in the clear, an append-only resource still mounting writes, Studio with no login or a default password, default dashboard credentials and weak JWT secrets, framework libraries below their floors, Sentinel counting rate limits per process while Redis is configured, and a public allowlist publishing a held-back column. Each finding names its check and a fix; errors exit non-zero, so CI can run it, and --json prints the report for a machine.

The first thing it found was in the scaffold: every project shipped GORM_STUDIO_PASSWORD=studio, a known password on a tool that browses and edits every table, while the Sentinel and Pulse passwords beside it were generated per project. That is fixed here, so a new project reports nothing at all.

A linter that cries wolf gets turned off, so quiet on a correct project is a tested property, and the framework's own tables are not audited as resources: asked the loose way it called four of them possibly-owned in every project, including one with no resources of its own. Verified live: a fresh project reports nothing; a project with ten resources reported one real error and five real warnings; and on a generated owned resource, deleting the scoping from one method was reported as that method, with exit code 1.

v3.226.0September 12, 2026

Sentinel v2.5.0, GORM Studio v1.1.0 and Pulse v1.0.0, and grit upgrade raises them

Every new project pinned Sentinel v2.2.1, and v2.2.2 is a security release. Behind a trusted proxy a client could choose its own IP through X-Forwarded-For and slip per-IP rate limits, blocks and lockouts; sort_by on the dashboard's threat listing reached ORDER BY as it was sent; and several SSRF bypasses were open. Existing projects were worse off. grit upgrade rewrote framework files and never go.mod, so nothing would ever have moved them.

New projects pin Sentinel v2.5.0, GORM Studio v1.1.0 (read-only SQL enforced on the read path, fail-closed imports, composite keys matched in full) and Pulse v1.0.0, the tagged release of the commit already pinned. grit upgrade now raises each of the three to at least that version, never lowers one, and says why. On a project scaffolded with the old pins it raised all three, and the project still built, vetted and passed its tests.

New projects also use what the new Sentinel offers. When Redis is configured, rate limits and AuthShield lockouts are counted there, so replicas share them: counted per process, N replicas gave a client N times every limit. Two production replicas sharing one Redis refused the sixth failed login on a replica that had itself seen only three. The audit log's hash chain is keyed by a generated SENTINEL_AUDIT_KEY, and a UserExtractor reads the caller from the context, without which anomaly detection sees nothing. Both replicas booted with no Sentinel config warnings.

An existing project's routes.go is its own, so the counters, the audit key and the extractor are not added to it; the Sentinel page has the lines to add.

v3.225.0September 12, 2026

The import, public and tree endpoints query through the service too

v3.224.0 moved a resource's CRUD into its service, and three generated surfaces still queried from their own handlers. The CSV import ran every lookup and insert from a goroutine in the handler, the --public endpoints read through h.DB, and the tree handler looked a node's parent up itself before a reorder, outside the move's transaction. None of them passed the request's context down, which the multitenant plugin needs: it refuses a tenant-owned query that carries no organization.

The import's handler now takes the upload and answers 202, and services/<name>_import.go reads the rows, resolves their relations and writes them in batches. It runs on context.WithoutCancel of the request's context, so it keeps the caller and the organization and is not cancelled when the response goes out; an owned resource's rows still belong to whoever imports them. The public reads are service methods, with the allowlist left in the public handler you edit and passed in; a public handler you already have is kept as it is. Every tree service method takes a context, and Move takes the parent as a pointer, where nil keeps the one the node has.

Verified on a fresh project with 29 live checks against Postgres: moves, reorders and a rebuild on a tree; the public list, slug lookup, related items, category tree and subtree ids behind an API key; a CSV import that finds or creates categories by name; and owned imports, where an ordinary account's file cannot name another owner and an admin's can. grit upgrade on that project left every handler and service untouched. The generator's tests now check every generated handler for a stray query and every generated file's imports against what it uses.

The checks turned up one thing that is by design: public responses are cached by URL for cache.public_ttl_seconds (60 by default), so an archived product can stay in a cached list for up to a minute. Still to move are the framework's own handlers: auth, two-factor, uploads and the rest.

v3.224.0September 11, 2026

Generated handlers ran every query themselves, and the service beside them was never called

Reported by a user: in a new Grit app every handler talked to the database. It was worse than a matter of style. A generated handler made 27 GORM calls, and the services/ file generated beside it had its own List, Create, Update and Delete that nothing called, so a job that wanted the resource's rules had no way to get them.

The service is now the resource's data layer: list, export, get, create, update, patch, delete and bulk, holding the list whitelists, the writable columns, the --owned-by scoping, the If-Match version check and every transaction. Each method takes a context.Context. The handler puts the caller on it with authz.WithActor, a job uses authz.AsSystem, and on an owned resource a context with no caller matches nothing. The handler binds the request, calls the service, answers its error with 409, 404, 422 or 500, and sets the ETag. It runs no query.

The move turned up two more bugs, both fixed. A many-to-many id that matched nothing was dropped, so a PUT whose only tag id was mistyped answered 200 and left the row with no tags; it is refused with 422 now, and nothing is written. And the first live build failed on an import cycle, the service reaching for the database package that already imports services; single-statement writes are methods on the service instead, and a new test checks every generated file's imports against what it uses.

Verified on a fresh project with ten resources covering relations, money, encrypted and file fields, line items, a tree, audited reads, a public API and an owned workflow. go build, go vet and the generated tests pass, and so do 58 live checks against Postgres. They include 20 simultaneous saves of one version, where one landed and 19 got 409, and a staff user with delete permission who still could not delete another user's row.

Existing resources keep their handlers until regenerated: grit upgrade does not rewrite API code. It does add internal/authz/actor.go and the new concurrency helpers, which grit generate also adds when they are missing. Not moved yet: the CSV import, public and tree endpoints, and the built-in handlers (auth, two-factor, uploads and the rest), which still query directly. They are next.

v3.223.0September 11, 2026

Feature flags could not target a business unit, or be checked at all

Found building an ERP whose configuration differs by business unit. A flag could target individual user IDs, a percentage and a date window, so "on for the EU unit" meant listing every EU employee by hand and keeping the list in step with hiring. Worse, the flags package documented flags.IsEnabled(c, ...) and flags.Variant(c, ...), and neither existed: the only engine was a local variable inside routes.Setup, so no handler, service or job could check a flag.

Flag rules take attributes now, such as {"business_unit": ["eu", "ke"]}, matched against the attributes flags.AttributesFor supplies for the request (the user's role by default; replace it with whatever your users carry). A subject without the attribute does not match, so the rule fails closed. flags.IsEnabled, Variant and IsEnabledFor work from anywhere, and answer false before the engine has started. Both files moved to the set grit upgrade refreshes; in the API's own file list, no flag fix had ever reached an existing project.

Every project gets internal/flags/flags_test.go, which checks a business-unit rule and the package-level API against a real engine; it passed in the ERP project after the upgrade. Flags are still managed through the API: the admin has no flags screen yet.

v3.222.1September 11, 2026

v3.222.0's dashboard fix reached new projects only

v3.222.0 made the dashboard's stat cards and custom charts ask the API for a resource by its own name rather than the admin slug, which had failed every resource with a two-word name. A project upgraded to it kept failing: the widgets are written by two writers of their own, outside the admin's file map, and grit upgrade ran neither, so no fix to those files had ever reached a project that already existed.

Upgrade now runs them, from the same list the scaffold uses, so the two cannot drift apart again. They are written through the same guard as every other framework file, so a widget you have edited is still reported rather than overwritten. Verified on the ERP project: the upgrade rewrote the stat cards and the chart card, and the dashboard's requests for its three two-word resources, which had each answered 400, answered 200.

v3.222.0September 11, 2026

--i18n projects did not compile, and the admin was English-only anyway

Found building an ERP for a multi-region workforce. A project scaffolded with --i18n failed its type check in both the admin and the web app: the language switcher imported a dropdown menu component neither app ships, and even with that fixed, every production build stopped with "Couldn't find next-intl config file": the scaffold pinned next-intl 3, which supports Next up to 15, on Next 16. It pins next-intl 4 now, and an upgrade moves an existing pin up. The switcher was also never mounted anywhere, and not one admin component read the catalogues that were written, so nothing a user could do changed the language. Every upgrade then reported the six files i18n edits as edited by you and never updated them again, because the edits were never recorded.

The switcher is a native select now, mounted in the admin's page header and the web app's navbar. The admin translates through a small t(key, fallback) in lib/i18n that needs no dependency and returns the English it always showed until i18n supplies a catalogue: the sidebar, toolbar, pagination, empty state, row actions and form buttons and headings, plus each resource's own name, column headers and field labels, keyed under resources.<slug> and falling back to the definition's label. grit add i18n records what it writes, adopting its edits only in files that were untouched, so a file you edited stays yours.

grit upgrade repairs an existing i18n project: it replaces the broken switcher, mounts it, feeds the admin its catalogue, and takes back the files that differ from its template only by its own i18n wiring. It does the same for a tsconfig.json that next build rewrote: Next 16 sets "jsx": "react-jsx" and adds its dev types on the first build, which made the file read as edited by you from then on. The templates carry those values now, and a rewritten file that means exactly what the template means is taken back. Catalogues are yours to edit and are still never replaced, but keys added in a later release are merged into them, so an upgraded project does not show English in the middle of a translated page.

Two more things the same project turned up. The dashboard's stat cards and custom charts asked the API for a resource by its admin slug (purchase-requests) while the API registers it under its own name (purchase_requests), so they failed for every resource with a two-word name; they ask by the API's name now. And a generated resource was labelled with its Go type name, so the sidebar read "InventoryItems"; new resources are labelled "Inventory Items".

Verified on an ERP project scaffolded with --i18n before this release: both apps failed their type check, one upgrade adopted all six files and re-wired them, both apps then type-checked clean, and a second upgrade left nothing alone.

v3.221.0September 11, 2026

A workflow could change a status, and nothing else

Found building an ERP, where approving a purchase request has to draw down the department's budget and reserve the stock, and fulfilling it has to reach the HR ledger. A generated transition was one UPDATE and an in-memory event. Work belonging to the move could only go in a subscriber, which ran after the approval had committed, could not refuse it, and, being async, was lost on a restart or a full queue and never retried. The outbox that would have made it reliable was scaffolded into every project, and nothing started its relay.

Transitions now run in a transaction. Hooks registered with workflow.OnTransition run inside it, after the status changes: what they write commits or rolls back with the move, and workflow.Refuse undoes it with a 422 carrying your reason. A new Durable delivery, with events.OnDurable for subscribers that write to the database, writes the event to the outbox in that same transaction, and a relay started at boot delivers it with retries, across restarts and replicas. events.On called from an init(), which used to be dropped silently because it ran before the bus existed, now takes effect. A transition that failed for any unrecognised reason answered 403; it is now a 500, with the detail in the log.

Verified on Postgres with a purchase-request workflow: an over-budget approval and one without stock were refused, the second rolling back its budget draw-down; ten simultaneous approvals of 100 against a budget of 600 approved six and left it at zero; twenty simultaneous approvals of one request drew it down once; seven fulfilments reached the ledger through a subscriber that failed its first two deliveries, once each; and one the ledger refused until the API was restarted was delivered by the restarted process. grit upgrade adds the files and starts the relay in an existing routes.go; regenerate a workflow resource to give its transitions the transaction.

v3.220.0September 11, 2026

A custom role could not reach a single admin endpoint

Found building a marketplace with a support team who should see users and nothing else. Every admin route sat in one group behind the ADMIN role, so the perm: guards written on some of them never ran: a role granted users.view was refused by GET /api/users, and the seeded EDITOR was shown admin pages the API then refused. The same was true of generated deletes: granting products.delete did nothing.

New projects split the admin API in two. A staff group admits anyone holding a permission, and every route on it names the one it needs: users, roles, access reviews, the audit log, jobs, backups, blogs, the Sentinel and Pulse summaries, and the per-resource dashboard stats (which ask for the view permission of the resource in the URL). The admin group stays ADMIN-only for routes that name no permission, such as SSO connections, form sharing and writing settings, so anything that forgets one, a plugin's route included, fails closed. Generated delete and bulk routes go on the staff group and ask for <resource>.delete. The sidebar shows the System Hub by permission instead of by role name.

Verified on a fresh project: the EDITOR lists users and is refused deleting one, backups, jobs, SSO and settings; a USER is refused at the gate; a custom role granted products.delete deletes a product and still cannot list users. grit upgrade does not restructure an existing routes.go; it says so, and the Roles & permissions page shows the change. Until then generated deletes on such a project stay ADMIN-only, as before.

v3.219.0September 11, 2026

Two people saving the same record: the second silently won

Found building a bidding marketplace. Every generated record carried a version that each update incremented, and nothing ever checked it, so two people who loaded a lot at the same version and both saved got last-write-wins with no word to the first. For a bid, that is a lost bid.

Generated PUT and PATCH routes now honour If-Match. Reads return the version as an ETag; a write carrying it lands only if the record is still at that version, and otherwise gets a 409 VERSION_CONFLICT naming the current one. The check is the WHERE clause of the update, so it holds under contention and across replicas. Without the header nothing changes. Verified with twenty simultaneous bids on version 1 split across two copies of the API: one landed, nineteen were refused, the stored bid was the winner's, and the same twenty without the header all went through as before.

Resources generated from this release on get it. grit upgrade adds the internal/concurrency package; regenerate an existing resource to give its routes the check.

v3.218.0September 11, 2026

A revoked permission kept working on the other replicas

Found running two copies of an API behind one database, as a load balancer would. Each copy caches who may do what, and a role change cleared the cache only in the copy that made it. Reproduced: a permission revoked through one copy was still granted by the other ten seconds later, and would have been until it restarted.

Copies now share a small cluster_generations table. A role change bumps it, and every copy checks it at most once a second and drops its cache when it moves. The same table carries SSO: a connection created through one copy was unknown to the others, and now every copy rebuilds its identity-provider connections when another saves one.

Every replica ran every scheduled job

asynq's schedulers do not coordinate, and every copy started one, so each copy ran the token sweep, the orphan-upload cleanup and the log prune. Only the copy holding a Redis lock runs the scheduler now. The lock is a 30-second lease renewed every 10, so if that copy dies another takes over.

Running more than one instance

A new page, Running more than one instance, covers what the copies must share, what is already shared between them, and what is still counted per copy. Realtime needed nothing: one event caused on one copy reached 1,000 sockets on another in 0.16 seconds, through the Redis backplane that was already there. Sentinel's per-IP rate limits are still per copy, which is filed as Sentinel #18.

grit upgrade adds the cluster package, updates the permission cache and the SSO service, wires both into routes.go, and patches the four generated lines of cron.go that start the scheduler. Verified with two copies after the upgrade: a revoked permission stopped working on the other copy within three seconds, where before it was still granted ten seconds later; a new SSO connection worked on the other copy within a few seconds; one copy held the cron lock, and when it was stopped the other took over 25 seconds later.

v3.217.0September 11, 2026

A customer's identity provider could sign in as your administrator

Found testing enterprise SSO end to end against a mock identity provider. After matching on the IdP's subject, sign-in fell back to linking any existing account with the same email address, and never checked that the address belonged to one of the connection's domains. The customer controls their IdP, so the customer's IdP admin could assert admin@yourapp.com: reproduced, it signed in as the app's ADMIN. The same gap let it provision accounts at any domain.

A connection's email domains are now the only addresses its provider is trusted for. Linking by email and provisioning both refuse any other address, and so does an existing link, so an identity an attacker linked before this release stops working too. A connection with no domains can sign nobody in. If you ran SSO before this release, look through user_identities for links to accounts outside their connection's domains.

Leaving a directory group did not revoke its role

The docs promised that removing someone from a mapped group revokes the role on their next sign-in. Moving to another mapped group did; leaving every mapped group kept the old role. They now drop to the connection's default role, or USER.

Six more

  • IdP-initiated SAML sign-in could not be turned off. GORM named the column allow_id_p_initiated and the update wrote allow_idp_initiated, so switching it off returned a 500 and left it on. The column name is now pinned.
  • Once it could be, it would have refused every login, including the ones that started here: responses were checked against no request ids at all. Each sign-in now remembers its request, and the response has to answer it.
  • Two connections could claim one email domain, and discovery sent the address to whichever row came first. A domain now belongs to one connection.
  • A transient SAML NameID was used as the identity, so every sign-in linked another one. Transient ids are now passed over for the email.
  • A partial update of a connection wiped the fields it did not send, the metadata URL among them, which took a SAML connection offline over an unrelated edit.
  • Every reload logged an error for each SAML connection, from the OIDC registry trying to build it.

grit upgrade carries all six to existing projects. Verified end to end against a mock OpenID Connect provider and a SimpleSAMLphp identity provider: provisioning, group mapping and revocation, an address renamed at the IdP, IdP- and SP-initiated SAML with IdP-initiated sign-in on and off, and the attempts to sign in as the administrator or outside the connection's domains, which are refused.

v3.216.0September 11, 2026

Who read a record: --audit-reads

Found building a patient-records app. The activity log recorded every write and skipped every read, so the first question an access review asks, who looked at this patient's chart, had no answer.

A resource generated with --audit-reads now records every read in the same tamper-evident chain as the writes. A list records the ids on the page it returned, a read by id and its PDF record that id, and an export records its row count. The query string is kept as a digest, because a name typed into a search box is personal data too, and only successful reads count. Writes now record the id of the record they changed, so the admin audit page's new Record id filter lists everyone who read or changed a record. Also in a YAML definition as audit_reads: true, and inherited by line items.

Off by default, since reads are most of all traffic. Refused with --public, whose reads are anonymous, and with --tree, whose endpoints return the whole hierarchy at once. A project from before this release is told to run grit upgrade first rather than getting handlers whose reads nothing would record. Verified on a live project: an ordinary account's list, search, record read, PDF and export each landed in the log with who, which rows and when, the search term was stored nowhere, a search by record id found the reads and the edit, and the chain still verified.

v3.215.0September 11, 2026

The activity log never verified on Postgres or MySQL

Found while adding read auditing to a patient-records app: the integrity check reported the chain broken at its very first entry, on two projects whose logs nobody had touched. The hash covers each entry's timestamp. The writer stamped it in nanoseconds and Postgres stores microseconds, so the row read back never hashes the way it was written. Proven by taking a stored row and finding the 400 nanoseconds that reproduce its stored hash exactly. MySQL keeps milliseconds and failed the same way; SQLite keeps what it is given, which is why no test ever saw it.

Entries are now stamped by the writer itself, at millisecond precision and strictly after the entry before, so verification walks them in the order they were written. Each batch also takes a database lock and reads the latest hash from the database. Before, every process kept the latest hash in memory, so two API replicas would each have chained off their own and forked the log.

Security events were written outside the chain

LogSecurityEvent inserted rows with no hash at all. Nothing in a stock project calls it yet, but the first call would have broken verification, and the unique index on the hash column would have refused every event after it. It now writes through the chain like everything else.

Logs written before this release

grit upgrade installs the new writer. Entries written before it can never verify, through no one's tampering, so the admin audit page now offers a reseal from the first bad entry. You type the entry's id to confirm; the reseal recomputes the chain from there and records itself in it, with who did it and a digest of every hash it replaced. A reseal trusts the rows as they stand, so it is never automatic. Verified on both projects: each chain failed at its first entry after the upgrade, verified after a reseal, and still verified after 50 concurrent writes.

v3.214.0September 11, 2026

Owned resources checked the owner on four doors out of eight

Found building a patient-records app. A resource generated with --owned-by scoped its list and checked ownership on read, update and delete. Everything else that reaches a row did not. Reproduced with two ordinary accounts: the second printed the first one's clinical note through the PDF endpoint and rewrote it with PATCH. Export had no scope, Bulk had none for a role allowed to reach it, a workflow transition moved anyone's row, and the CSV importer took the owner from a column, so a file could put records under somebody else's name.

All of them now check. PDF and PATCH answer 404 on somebody else's row, as read does; export and bulk only see the caller's rows; a transition checks the owner; imported rows belong to whoever imports them, and only an ADMIN may name another owner. --tree with --owned-by is refused, because the tree endpoints have no notion of an owner and would have handed the hierarchy to everyone.

Every CSV export was empty

The same session showed an export returning a 200 with no body, for every resource. GORM's FindInBatches hands its callback a fresh session with no query on it, and the handler re-read each batch through it, found nothing, and threw the error away. Export now reads each batch from the slice FindInBatches fills, writes the header even when nothing matches, and reports a failure instead of discarding it. It also stopped sorting ahead of the key it pages by, which would have repeated rows past the first thousand.

The importer created an account for every email it did not know

A belongs_to:User column was resolved by email, and an unknown one created a user with that email and nothing else. Any signed-in account could mint accounts around registration that way. A user must now exist, or the row fails with a message saying which one was not found.

owned_by in a --from file was thrown away

A resource defined in YAML with owned_by: user was generated with no owner scoping at all, and its create endpoint demanded a user_id in the body. The command-line flags were assigned over the file, so an unset --owned-by erased it, and tree, public, append_only and tenant_owned went the same way. Flags are now merged into the file: they can add, never remove.

A resource with a workflow did not compile

Its transition routes were injected into routes.go against a handler variable that only projects from before per-resource route files declare, so the API stopped building the moment the resource was generated. They now live in the resource's own route file, behind its role guard when it has one. Generating the resource again removes the broken lines from routes.go.

The next steps left out the migration

The API does not migrate on start, and the steps printed after grit generate resource said to build and restart, which leads straight to a 500 about a missing table. They now say to run grit migrate in between.

Existing projects

Upgrade does not regenerate resource code, so grit upgrade patches the export and ownership fixes into generated handlers, importers and workflow services wherever the generated text is still recognisable, and names anything too changed to patch. Verified on the project where this was found: after the upgrade, all 13 checks passed across two ordinary accounts, and a resource freshly generated from YAML with an owner and a workflow passed its own.

v3.213.0September 11, 2026

Erasing a user left the records they owned

Found building a patient-records app. Right-to-erasure deleted rows from a fixed list of nine framework tables and anonymized the user. A provider's notes, generated with --owned-by user, stayed exactly where they were, while the deletion journal recorded the erasure as complete.

What erasure deletes is now a registry. The framework's own tables register from one file, and every resource generated with --owned-by registers from its model file, so the rows a person owns go with them. Append-only resources are not registered: their rows cannot be deleted, and which of retention or erasure wins is left to the project rather than decided quietly. Verified on a live project: two notes owned by the erased user went, and another user's note stayed.

Restoring a backup brought erased people back

A backup taken before someone was erased still holds them, and restoring it put them back. Worse, it replaced the deletion journal with the archive's older copy, so the entry proving the erasure had happened disappeared too.

Restore now reads the journal before clearing anything, puts back the entries the archive is missing exactly as they were, and erases those people again. Verified by erasing a user and then restoring a backup taken while they were present: the restore reported one erasure re-applied, the user stayed anonymized with their records gone, and the journal still verified with its original hash. The journal lives in the same database, so a restore into an empty one has nothing to consult; the compliance page covers exporting it.

grit upgrade carries both to existing projects.

v3.212.0September 10, 2026

A note on v3.211.0. It was tagged in error on the v3.210.0 commit and published before that could be stopped. Its contents are exactly those of v3.210.0; everything described below arrives in this release.

Encrypted columns were stored in plaintext after the first edit

Found on a patient-records app. Creating a patient stored the notes as enc:v1: ciphertext. The next PUT stored PUT: diabetic in the column as plain text, and the next PATCH did the same. Nothing showed it: reads came back right, because a value without the prefix is passed straight through, and search even started finding the rows, which it only could because they were no longer encrypted.

Generated update, PATCH and bulk handlers write a map of plain strings, and GORM only runs a column type's encoder when the value already has that type. crypto.Install, now called when the API connects, wraps any string headed for an EncryptedString column, and a test that ships with every project checks the raw column after a map update. grit upgrade adds the call to existing projects. Grit's own encrypted fields, the profile bio and the SSO secrets, were always converted by hand and were not affected.

Values written through an update before this release are still plaintext. They are re-encrypted the next time they are saved; to find them, look for rows whose column does not start with enc:v1:.

An :encrypted field modifier

The security page said to adopt encryption by changing a field's type to crypto.EncryptedString, "that's it". On a generated resource that broke the build twice, in the handler and in the CSV importer, since a plain string does not convert into the type by itself. notes:text:encrypted now makes the type the field's own in the model, both request structs and the importer, keeps the column as text because ciphertext outgrows a varchar(255), and leaves it out of search, sorting and filters, where ciphertext could only ever be noise. It refuses :unique, which non-deterministic ciphertext could never enforce, and works with grit generate field too.

Backups could not be restored

Two ways, found by restoring real projects. Every project with line items took backups it could not restore: the dump writes tables in the order the models are registered, the generator registers a child before its parent, and replaying the lines before their journal entries failed on the foreign key. And every project using --append-only, added in v3.207.0, refused the restore outright, because restore begins by truncating and those tables refuse to be truncated.

Restore now replays in foreign-key order, worked out from the models' own relationships, which also rescues archives already written, and lets itself past the append-only triggers for its own transaction only. Verified on both projects after grit upgrade, which now carries the backup package: the line-item project restored 45 rows with no orphaned lines, and the append-only one restored 62 rows with all four triggers enabled again and a raw UPDATE still refused.

v3.210.0September 10, 2026

grit upgrade wrecked TanStack projects

Upgrade never read which frontend a project uses. It built its options from the name, the style and the version, and an unset frontend defaults to Next.js, so every project was upgraded as though its apps were Next apps. On a TanStack project that wrote the Next admin and web apps, package.json included, over the Vite ones: both came out depending on next, with vite and the router gone and two apps' worth of files in each directory.

Upgrade now reads the frontend from grit.json, falling back to a vite.config.ts on disk for projects older than that field. The refresh it performs is written for the Next layout, so on a Vite app it now leaves the app exactly as it is and says so; a Vite equivalent is still to come. The API, shared packages and the security and passkey pages, which already knew how to write for Vite once told, are upgraded as before. Verified both ways: on a fresh TanStack project both apps kept vite and gained no Next files, and a Next project was refreshed as it always was.

The Vite apps' tests could not start

Every Vite admin and Vite web app shipped with pnpm test failing before a single test ran. The shared setup file imports @testing-library/jest-dom, the tests use @testing-library/user-event in a jsdom environment, and none of the three was declared. Under that, vitest walked up the tree for a PostCSS config and found one at the repository root, in Tailwind v3 style, which no app used and which named autoprefixer, never installed.

Both Vite apps now declare their test libraries, the vitest configs pin an empty PostCSS setup, and the root config is no longer scaffolded. On a fresh TanStack project the admin's suite now runs and passes. An existing project can delete its root postcss.config.mjs: nothing reads it.

The web navbar test could not pass

The navbar test shipped to every web app asserted a "Components" link, left over from the components page removed in v3.31.78, and the navbar it renders has no such link. pnpm test in apps/web had failed on every fresh Next project since. A check now fails the build if the test asks for a link it does not render.

Read-only and computed admin fields

A form field can now be readOnly, or carry compute: (values) => ... to derive its value from the rest of the form, recomputed as it changes. Both are shown the way the table shows the column, so a money field reads as money, and both are left out of what the form submits, across the standard form, the step-by-step form, its per-step saves and grouped editing.

That second half is the fix as much as the feature. The only way to show a value was a disabled input, which is still sent, and over a column the server computes and the PATCH allow-list names, saving the form wrote the displayed figure back over the server's. Found on a ledger, where the journal form now shows debits minus credits as the lines are typed. See read-only and computed fields.

v3.209.0September 10, 2026

Two projects on one machine were sharing a Redis

v3.205.0 moved the host ports into .env so a second project could start, and said the service addresses would follow. Only Postgres did: its URL is built from POSTGRES_*. REDIS_URL and MINIO_ENDPOINT were written into .env with the default ports baked in, so a project that moved REDIS_PORT kept dialling 6380. With another Grit project running, that is the other project's Redis, and nothing fails, because it answers.

Found on a ledger whose API had been using a storefront's Redis and MinIO while its own containers sat idle. The two shared a cache, a bucket host, and a job queue whose queue names are the same in every project, so either project's worker could pick up the other's tasks.

Both addresses are now built from REDIS_PORT and MINIO_PORT unless you set them, and new projects leave them unset. An explicit localhost URL that disagrees with its port is named in a warning at startup. grit upgrade reads an existing .env and prints the exact line to set, without rewriting the file. If you moved either port on v3.205.0 or later, run grit upgrade or check those two lines by hand. Verified on a fresh project with every port moved: its own Redis went from empty to holding the worker's keys at startup.

grit generate job

The cron page said new scheduled tasks are added "when you use grit add cron". That command did not exist. A custom job was five hand edits across two framework files, and the worker's handler list had no marker to anchor on, so the step most likely to be missed was the one that made the job run.

grit generate job ReconcileLedger --cron "30 23 * * *" writes the job with its payload, a typed enqueue method and the handler, registers the handler with the worker, and schedules it where the admin's Cron page lists it. It never overwrites a job you have written, and on a project from before the marker it registers the handler anyway and leaves the marker behind.

Load-tested on the ledger as an end-of-day reconciliation: 1,000 tasks fanned out one per account, then the same 1,000 queued again. The second thousand were refused as duplicates by their idempotency keys, every account was reconciled exactly once, the queue drained in under nine seconds, and nothing was left retrying or archived. The jobs page now covers batches like this.

Converting between currencies

The money package had no conversion, and the improvised one is wrong twice. MulFloat keeps the currency's decimals, so 10.00 USD at 148.2 relabelled as yen is 148,200 yen, a hundred times too much. And a float cannot hold most rates: 1.005 is 1.00499999999999989 in binary, so a dollar at that rate comes out as 100 cents instead of 101.

money.Convert(to, rate) takes the rate as a decimal string, parses it as an exact fraction, moves the decimal point for each currency's minor unit, and rounds once, half away from zero. Its tests ship with every project.

v3.208.0September 10, 2026

grit generate field reaches the API

grit generate field Account notes:text added the column to the model, the Zod schemas, the TypeScript type and the admin, and stopped there. Generated handlers do not bind into the model: they bind into CreateAccountRequest and UpdateAccountRequest and copy fields across one by one, and PATCH writes only what an allow-list names. So the admin showed the new field and sent it, and the API threw it away:

RequestBefore
POST with notes201, notes came back empty
PUT200, ignored
PATCH422, not a writable field

with the column sitting empty in Postgres. The field now goes everywhere the generator would have put it: both request structs, the create literal, the update map, the PATCH and bulk-edit allow-lists, the list's sort and filter whitelists, and the CSV importer and its template. The rest of each file is left as you had it, and the command is safe to run twice. The rules are copies of the generator's, pinned to it by a test that fails the day either changes. Verified against Postgres: create, update, patch and a CSV import each stored the new value.

A date field broke the build

The same command put *jsontime.Date into a model that did not import jsontime, so adding a date or datetime field stopped the project building with undefined: jsontime. Both types were listed as supported. The import is added with the field now.

v3.205.0 pointed you the wrong way

When a regenerate refuses because you have edited a resource's files, the message said to add the field to the model by hand and run grit sync. That has the same hole: sync updates the types, the schemas and the admin, and never the handler, so the field was shown, submitted and dropped. The message and the generated-files page now send you to grit generate field, and say why the hand-edited route is not enough on its own.

v3.207.0September 10, 2026

Append-only records, and a ledger nobody can rewrite from a web page

Found building a double-entry ledger. The generator gave a journal entry PUT, PATCH, DELETE, bulk and import routes, a soft-delete column and an admin with Edit and Delete: the right shape for a CRUD resource and the wrong one for a posted accounting record. Hand-rolling the fix took a package of GORM callbacks and an edit to main.go the framework offered no place for.

And it still lost. The callbacks stopped the API, the importer and GORM Studio's row editor. Then UPDATE journal_lines SET debit_amount = 1, typed into the Studio SQL editor, returned 200 and unbalanced the books. That editor sends statements to db.Exec, and a raw statement never passes a GORM callback.

grit generate resource X --append-only now does all of it: read and create routes only, a GORM guard that answers 422 with the reason, a trigger on the table installed by grit migrate for everything that is not GORM, and an admin that offers create and view. An --items child is append-only with its parent. Against Postgres, the API, the Studio row editor, the Studio SQL editor and psql were each refused, and the row was unchanged.

New projects make the two calls that install the guard. grit upgrade adds them to older ones, and the generator does too the first time the flag is used, or stops and names the call if it cannot find where it goes. A model registering with a guard nobody installs would look protected and not be. See Append-only records.

GORM Studio's write switches are reachable

gorm-studio has always had ReadOnly and DisableSQL, and nothing in a Grit project could set them. They are GORM_STUDIO_READ_ONLY and GORM_STUDIO_DISABLE_SQL now, and the production environment template turns the SQL editor off.

The admin detail page ignored table.actions

The list page asks whether a resource allows edit and delete before offering them. The detail page never did, so a resource that removed both from its table still showed the buttons one click further in. It asks the same question now.

v3.206.0September 9, 2026

A rule your model enforces now reaches the caller

Every generated handler bound the error from a write and never used it:

if err := h.scoped(c).Create(&item).Error; err != nil { c.JSON(500, ... "Failed to create invoice") }

So a rule enforced in a GORM hook, which is where a rule has to live if GORM Studio and the CSV importer are to respect it as well as your service layer, reached neither the client nor the log. Found building a double-entry ledger, where USD does not balance: debits 100.00 USD, credits 99.99 USD arrived as Failed to create journalentry with status 500.

Errors from a write are mostly not for the caller: a driver failure or a constraint violation carries schema details and sometimes SQL, so the handler cannot simply echo what it gets. respond.Rule is how your code marks the ones that are. Return it from a hook, a callback or a service method and the caller gets 422 with that sentence. A missing row gives 404. Anything unmarked keeps the same opaque 500 it had before, and is now logged on the way, which it was not.

Documented under Invariants, and telling the caller why.

--items generated a handler that did not compile

The inline Items request struct is built from the child's fields and written into the parent's handler, so a money field on the child puts money.Money in that file. The import scan walked only the parent's fields, so nothing added the import and the project failed to build with undefined: money. The same hole covered jsontime, datatypes and time. The scan now covers both.

--items silently dropped the child's belongs_to

The builder skipped every belongs_to on the child, on the grounds that the foreign key back to the parent is set by the association and clients never send it per row. True of the parent's key. Not true of one pointing anywhere else, and those are exactly the ones the client has to send: the account a journal line posts to, the product an invoice line bills.

So the request had no way to say which account, the create loop set nothing, and every line was inserted with an empty account_id while the model declared the column required. No error, just wrong rows. Only the parent's own key is skipped now.

grit upgrade left a project unable to build

Upgrade does not regenerate API code in general, deliberately. But the generator writes calls into internal/respond, so a project carrying an older copy got freshly generated handlers referring to a helper it did not have. Upgrade reported "Updated 268 files" and success, and the build failed on five undefined references with nothing connecting them to the command that claimed to have handled it.

internal/respond now upgrades alongside internal/money, for the same reason: these are the packages the generator emits calls into. Both are manifest-guarded, so a project that edited them is reported as a conflict rather than overwritten.

v3.205.1September 9, 2026

A promised sidebar entry that was never written

A triple project always closed a generate with "the admin panel will show Widgets in the sidebar", including when there was no apps/admin/resources to write to. The resource definition and both screens were skipped, nothing said so, and the run still ended with a green tick. Found by renaming apps/admin to see how the CLI copes; the same path is reached by a bad merge or a partial checkout.

The message now says what was skipped and what was not. Everything else about that case was already fine: the API, the shared types and the web hooks are generated, and both grit sync and the Go build work with an app folder missing or renamed.

v3.205.0September 9, 2026

Re-running a generate destroyed hand-written code

Adding a field by re-running grit generate resource with one more entry in --fields is the obvious thing to do: you already know the command. It rewrote the model, the service and the handler from scratch, printed a green tick for each, and everything hand-written in them was gone. No prompt, no backup, and nothing in the output to say a file had been replaced rather than created. --force was not required; a plain re-run did it.

Which is the opposite of what this site promised. The generated-files page called apps/api/internal/services/<name>.go the place "your custom business logic goes here" and said of regeneration that it "never touches" it. So the file a newcomer was told was safe was the file that lost their work, and the reassurance that made re-running the command feel safe was in writing.

Grit already records who wrote every generated file and a hash of what it wrote. It now compares before regenerating: if you have edited any of a resource's files, the command stops, names them, and points at the non-destructive route, which is to add the field to the Go model and let grit sync carry it through the types, the Zod schemas and the admin columns and form. --force still overwrites, deliberately. The .custom.tsx overlay is exempt: editing it is what it is for, and generation never rewrote it.

A resource you have not customised regenerates exactly as before. The documentation now describes what the command does rather than what it was hoped to do.

grit migrate failed on Postgres

The documented default database, on the documented quick-start path:

Migration failed: migrating *models.Passkey: ERROR: type "blob" does not exist (SQLSTATE 42704)

Three []byte columns on the passkey model carried gorm:"type:blob", which is SQLite spelling. Nothing needed the override: GORM already maps []byte to bytea on Postgres and BLOB on SQLite, so removing it makes the model portable rather than changing it. The desktop sync store keeps blob, correctly, because its database is always local SQLite. Verified by migrating and seeding a fresh project against Postgres, where the column is now bytea.

A second project could not start

Every Grit project bound the same host ports, so docker compose up -d on the second one failed with Bind for 127.0.0.1:6380 failed: port is already allocated — at step two of the quick start, which anyone evaluating the framework reaches by the end of the first afternoon.

.env said above these values "single source of truth: edit ONLY the POSTGRES_* values below", and that was not true of the host port: compose hardcoded it, so editing .env moved what the API dialled and left the container bound where it was. Compose reads .env from the project directory on its own, and now does, for Postgres, Redis, Mailhog and MinIO. Defaults are unchanged, so nothing moves for an existing project.

The generator named a file it had not written

Resource definitions moved to a folder each, so a new project gets apps/admin/resources/posts/posts.ts. The success output still announced apps/admin/resources/posts.ts, which is the first thing anyone opens after a generate and the first thing that does not open. grit sync printed the same stale path in two more places.

The same block also wrote posts.custom.tsx, the overlay that is never regenerated and therefore the only safe place to customise, and said nothing about it at all. So the one file worth knowing about was hidden and the one it pointed at was not there. Both now report the path on disk, and the overlay is announced the first time it is created.

v3.204.0September 9, 2026

The production images did not build on a fresh project

Found by building an invoicing app and following the VPS guide as far as it goes without a server: scaffold, then docker compose -f docker-compose.prod.yml build. The API image was fine. Both Next images failed, for two reasons.

The Dockerfile did COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./, and grit new does not write a lockfile: pnpm does, on the first install. So the build stopped at the first COPY with "/pnpm-lock.yaml: not found". The lockfile is now a glob, and --frozen-lockfile is used only when there is one to freeze to, so a repo that commits its lock keeps reproducible installs and a fresh project still builds.

Past that, pnpm stopped with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND. The image copied two workspace manifests by name, and both apps depend on @repo/upload as well as @repo/shared. A list of workspace members maintained by hand in a Dockerfile goes stale the first time one is added, and had. It copies packages/ now, so whatever the workspace gains is in the image the day it lands.

Verified by building all three production images from a project that had never run pnpm install.

Line items were typed as plain text

An inline --items child had its own switch over field types, covering five and falling through to text for the rest. Money, toggles, selects, textareas and rich text all rendered as text inputs.

On an invoicing app the field it got wrong was unit_rate: type: "money" in the standalone InvoiceItem resource and type: "text" inline, in the same generation run. A currency amount in a plain text box, on the line the whole invoice multiplies. Line items now use FormFieldType(), the mapping the rest of the generator uses, and selects carry their options.

v3.203.0September 9, 2026

You can write a plugin now

The authoring page described a plugin as a plugin.Plugin value and ended by pointing at internal/plugin/multitenant.go, which is inside the CLI. So a reader who followed it had nowhere to put what they had written: the registry was compiled in, writing a plugin meant forking Grit and rebuilding the binary, and the page never said so.

grit plugin add ./plugins/product-reviews

A plugin directory holds a plugin.json manifest and the files it installs. It is the same shape as a built-in, so nothing about installing or removing changes: the same lockfile records what was written and grit plugin remove replays it backwards either way. {{MODULE}}, {{PROJECT}} and {{API_ROOT}} are substituted, so a plugin does not need to know the module path of the project installing it. A when clause limits a file or an injection by architecture and frontend.

Paths that climb out of the project are refused when the manifest loads, because a plugin is somebody else's code and "to": "../../.ssh/authorized_keys" is a file write rather than an install. Only an explicit path is read as a directory, so a folder cannot shadow a built-in by sharing its name.

Proven by writing one: a product-reviews plugin with two files and five injections, installed into a storefront, exercised end to end (public reads with an average, held writes, duplicate refused, admin approval, public read after approval), then removed, leaving only the lockfile behind.

The marker list on that page was also three releases stale. grit:imports, grit:middleware:protected, grit:nav:system and the two icon markers all exist and were added because plugins needed them.

The AI handlers were throwing the error away

Both took the error from the ai package and returned "Failed to generate completion". Not logged, not categorised, not returned. A rejected key, a rate limit, a model that does not exist and a DNS failure all looked identical, and the server log said nothing at all.

Met while testing product-description generation: Grit said "Failed to generate completion" while the gateway had said "Free tier users do not have access to this model". That second sentence is the entire answer. Failures now come back as AI_UNAUTHORIZED, AI_RATE_LIMITED, AI_MODEL_NOT_FOUND or AI_FORBIDDEN, and the gateway's own words go to the log rather than to the caller, since it is somebody else's error text and may quote the request back.

.env.example now says that the default model needs paid credits, which is the specific thing a free-tier key meets first.

v3.202.0September 9, 2026

The offline sync engine has tests now

It is the least testable-by-hand and most consequential part of the desktop app: it decides what happens to a change made on a plane, and what happens when the server moved underneath it. It shipped with no tests, so every one of those decisions had only ever been checked by using the app.

Five tests ship with every desktop project, running against an httptest server standing in for /api/sync: a local write is readable before it is pushed, a successful push clears the queue and records the server's version, a version conflict parks the change with the server's copy attached and the local edit intact, a conflicted row is not retried behind the user's back, and resolving one replays it at the version the user actually saw.

The engine passed all five on the first run. That is the useful result: the behaviour was already right, and now it stays right.

The desktop app did not build on a fresh project

apps/desktop is a second Go module, so tidying apps/api during grit new never reached it. go build ./... inside it failed on four missing go.sum entries, including the SQLite driver the offline engine is built on. grit new-desktop had always tidied the standalone project; the combinable --desktop path had not. It does now, and a failure there warns and prints the command rather than losing the whole scaffold.

v3.201.0September 9, 2026

grit sync says which screens it could not update

Found by running the multi-client project on an Android emulator: a column added to a Go model mid-project was in the API response and in the shared type, and the mobile create form still showed the original three fields. Nothing anywhere said so.

grit sync keeps three things current: the shared TypeScript type, the Zod schema, and the admin resource definition, which is patched through markers so customised entries survive. The mobile forms and screens and the desktop columns have no equivalent and are only written when the resource is generated. Neither does grit generate field, which updates the model, the schemas, the type and the admin, and stops there.

Sync now names the files and the fields they never saw, and is honest that the only way to rebuild them is regenerating the resource with its full field list, which discards edits to those screens. It does not report framework-owned columns like version and archived_at, which no screen shows, or resources the generator did not write, whose screens are hand-written and where a regeneration would be actively wrong advice.

This reports the drift rather than fixing it. Marker-based regeneration for the mobile and desktop screens, matching what the admin already has, is the actual fix and is filed separately.

v3.200.0September 9, 2026

Every client now uses the shared types

v3.199.0 fixed web and admin. Expo declared its own copy of the resource type, which went stale the moment the Go model changed, and desktop declared Record<string, unknown> & { id: string }, which is not drift but an absent type: task.titel typechecked.

Both import from @repo/shared/types now, so a column added to a Go model reaches all four clients on the next grit sync with nothing else to remember. Verified on a five-target project: added a field, ran sync, and web, admin, Expo and desktop all typecheck against it.

The desktop table rejected every real type

Typing the desktop client surfaced this. DataTable was constrained to T extends Record<string, unknown> & { id }, which reads as "any object" and is not: TypeScript gives implicit index signatures to type aliases and not to interfaces, so no declared interface satisfied it. It went unnoticed while the desktop resource types were themselves open records.

The constraint is { id } now, and the one thing the table needs beyond that, reading a column by a caller-supplied key, is a single field(row, key) helper rather than a cast at each of eight read sites.

Wired by alias, not by dependency

Worth recording, because the first attempt was wrong. Adding "@repo/shared": "workspace:*" to the desktop app works and also moves pnpm's hoisting: the app ended up with its own nested copy of vite, two vite type packages disagreed about Plugin, and vite.config.ts stopped typechecking in a project that had been clean.

The desktop app never needed the dependency. Its vite.config.ts already aliased @repo/shared and its tsconfig already mapped the path; only the generated hook was not using them. Expo now gets the same treatment, a Metro extraNodeModules alias plus tsconfig paths, so neither app adds an entry to the dependency graph.

v3.199.0September 9, 2026

Generated hooks redeclared the type instead of sharing it

Found by doing what the multi-client tutorial describes: add a column to a Go model mid-project, run grit sync, and see it reach the clients.

It reached one of them. The generated hook declared its own copy of the resource type, so grit sync rewrote packages/shared and could not reach the duplicate. The new field existed in the shared type and nowhere else. Nothing errored, because each file stayed internally consistent, and the web app simply did not know the field was there.

The scaffold's own hand-written apps/web/hooks/use-blogs.ts has always imported its type from @repo/shared/types. The generator wrote the same file a different way, which is how the two drifted. It now matches, so a schema change reaches the client on the next grit sync with nothing else to remember.

v3.198.0September 9, 2026

--tenant-owned

grit generate resource Contact --fields "name:string,email:string" --tenant-owned

Embeds tenant.Owned and imports the package, so a generated resource is scoped to the active organization: queries are filtered by it and OrgID is stamped on insert. The multitenant plugin asked you to do this by hand on every generated model, and again after any --force regeneration.

Off by default even with the plugin installed, for the same reason --owned-by is: a country list, a currency table and a plan catalogue are shared on purpose, and scoping one by accident makes it invisible to every query with no error to explain it.

The flag only helps someone who knows it exists, so when the plugin is installed and it was not passed, the generator now says so once: "multitenant is installed and Country is shared: every organization will see every row. Pass --tenant-owned to scope it." A note, not a refusal.

v3.197.0September 9, 2026

Found by building a multi-tenant CRM on the docs, following them exactly.

The multitenant plugin never turned its own scoping on

It installed a correct tenant package and a correct middleware, and wired neither. tenant.RegisterScoping(db) was never called, so the GORM callbacks that add the organization filter and stamp OrgID on insert were never installed. middleware.Tenant(db) was never mounted, so nothing put the active organization on the request context for them to read.

Following the documented steps produced a multi-tenant application with no isolation at all. Two organizations, one contact: the second organization listed it, and org_id came back empty on every insert. The plugin's own middleware comment reads "the whole isolation model rests on this check", and nothing called it.

Generated handlers never passed the request context to GORM

Every generated handler queried h.DB directly, so GORM saw context.Background() and nothing a middleware put on the request could reach a callback. That made the plugin unusable with generated resources even once wired: unscoped it returned every tenant's rows, and scoped every query failed closed with a 500.

Handlers now go through h.scoped(c). The swap is applied to the assembled handler source rather than at each of the dozen places that build a query, because the create call, the reload, the many-to-many association writes and the line-items writer are all computed separately and the next one would have been missed. This is worth having without tenancy: a cancelled request now cancels its query instead of holding a connection to build a response nobody will read.

Generating a resource could silently delete the audit log

grit generate resource Activity reported success and overwrote internal/services/activity.go, taking LogActivity, LogCreate, LogUpdate, LogDelete, DiffSummary and seven more with it. The build then failed in access_review.go and event_subscribers.go, two files the developer had never opened, with nothing connecting the error to the command.

The reserved-name list covers built-in models. This was a built-in file, and they are not the same set: the audit log's models are ActivityLog and UserActivity, both reserved, while the helpers that write to them live in a file Activity claims. Names like Chart, Blog, Recovery, Security, Sync and Jobs were in the same position.

The generator now asks the manifest, which already records every file the scaffold wrote, so whatever the scaffold gains next is protected on the day it lands rather than after someone loses a morning to it. It names the files, suggests a free name, and --force still overrides.

Also

  • // grit:middleware:protected and // grit:imports in routes.go: there was no way for a plugin to mount request middleware or add an import.
  • A failing grit generate resource no longer prints the flag list underneath the error, which buried the sentence saying what to do instead.
  • The multitenant docs now say that a generated resource is shared until you embed tenant.Owned in it yourself, and that nothing warns you.
v3.196.0September 9, 2026

Realtime works with more than one API replica

The Hub was an in-process registry, so a second replica silently halved realtime: a user connected to replica A never received an event published on replica B. The push succeeded, into a registry that did not contain them, and nothing errored or logged. Invisible in development, invisible on one instance, and first met as "messages sometimes do not arrive" during a rolling deploy where two versions overlap.

routes.Setup now wires a backplane whenever the project has Redis, which it already runs for cache and background jobs:

realtimeHub := realtime.NewHub(realtime.WithRedis(cfg.RedisURL, ""))

WithRedis is a no-op on an empty URL, so a project without Redis keeps exactly the behaviour it had rather than failing to start.

Every send takes both paths: this node's own clients first and unconditionally, then one publish for the others however large the audience. Local delivery never waits on Redis and still works while Redis is down, so an outage degrades realtime to a single replica instead of breaking it. A node ignores its own messages coming back, or every user would see their own events twice.

Revocation crosses the backplane too. Closing sockets is how "sign out of all devices" became true in v3.193.0, and a kick that stayed local would have signed out only the devices sharing a replica with the request while the UI reported success. Verified against two live replicas: a revoke issued on B closes a socket held on A.

Delivery between nodes is best effort and says so. A realtime event is a hint that something changed, not the record of it, and every client resyncs on its next REST call. A publish queued behind a backplane that has stopped answering is dropped rather than blocking the request that made it, which matters because SendToUser is reachable straight from a handler and not only through the async event bus.

Backplane is an interface, so Redis is the shipped implementation and not the only possible one. Six tests ship with every project, wiring two hubs through an in-memory backplane, so cross-node delivery, the origin check, broadcast, revocation and the non-blocking publish are all covered without needing Redis to run them.

v3.195.0September 9, 2026

The device-pairing plugin

grit plugin add device-pairing adds the WhatsApp Web flow: a browser shows a QR code, a device that is already signed in scans it and approves, and the browser is signed in. It is how Telegram Web, Discord, Steam and most TV apps onboard a second screen, and it needed about two hundred lines over pieces Grit already had.

Five endpoints, a /link page in the web app, and a System → Link a device screen in the admin. There is no Device model: approval creates an ordinary session and sets the same cookies a password login does, so a paired browser appears under Account → Security and is signed out from there. A parallel device table would be a second list of the same thing, drifting from the first.

The parts that look like extra work are the ones that matter, because a pairing code is a bearer credential for a whole account:

  • Approval is two steps. The approving device fetches the browser and IP behind the code and shows them before it asks. Approving an opaque code is not consent, and it is the difference between a QR somebody photographed across a room being useless and being a silent takeover. Deny sits next to it, because "that wasn't me" has to be one tap.
  • Single use is enforced by the database. The claim is a conditional UPDATE, not a read then a write, so two polls arriving together cannot both walk away with a token pair. The generated tests race eight approvals and assert exactly one wins.
  • Two minutes, and rate limited. Codes are 32 bytes from crypto/rand. The start endpoint is anonymous, so one address may hold five in flight.

An injection could be silently dropped

Found while building the plugin. injectBefore decided an injection was already applied by asking whether its code appeared anywhere in the target file, which is a different question and gets it wrong two ways.

A plugin legitimately injecting the same snippet at two markers loses the second, which is exactly what an icon needs: an import at the top and a map entry lower down. And a snippet that occurs naturally somewhere else in the file blocks the injection entirely. Both fail silently. The device-pairing sidebar entry rendered getIcon's FileText fallback because of it, with nothing anywhere reporting a problem.

The check now compares the lines directly above the marker being injected at, which answers the actual question and is still a no-op when you install twice.

The admin icon map takes injections

// grit:icons:import and // grit:icons:map in apps/admin/lib/icons.ts. A plugin adding a sidebar entry needs an icon and had no way to add one, and getIcon falls back to a document icon for a key it does not know rather than failing, so the wrong icon was the quiet outcome.

v3.194.0September 8, 2026

The rest of what building a chat app on a stock scaffold turned up. v3.193.0 closed the leaks; this closes the gaps that made them easy to hit and hard to fix.

--owned-by: per-user resources, generated

A generated resource was shared by default and had no option to be anything else. Its routes sit on the authenticated group, so any signed-in account could list, read, edit and delete every row. authz.MustOwn existed for exactly this and nothing in the generator ever called it.

grit generate resource Invoice --fields "number:string,total:money" --owned-by user

All four access paths are wired, because guarding three of them is the same as guarding none: the list is scoped to the caller, read, update and delete check ownership by id and answer 404 rather than 403 so a wrong guess cannot be told from a right one, and create stamps the owner from the session. The owner field is kept out of the request body entirely, so a caller cannot hand a row to somebody else. An ADMIN is exempt from all four, because the admin panel calls the same endpoints. The flag adds the belongs_to:User field when it is not already there.

Resources generated without the flag are untouched, which is right for a product catalogue and is why this is opt-in rather than a default that would have made every existing shared resource invisible.

A realtime client, in every frontend

The hub has shipped for a long time and nothing consumed it: a scaffolded project contained no WebSocket code at all. The docs offered a six-line snippet, which proves the endpoint answers and is not enough to ship.

It also could not be written in the app Grit generates. It reads the JWT out of storage, and the web app keeps its JWT in the HttpOnly grit_access cookie so that scripts cannot read it. The handshake now accepts that cookie, which is what makes a browser client possible; Expo and service clients still pass ?token=.

lib/realtime.ts and a useRealtime hook now ship in web, admin and Expo: one connection for the whole app rather than one per mounted component, reconnection with exponential backoff and jitter, and useLiveResource("invoices", ["invoices"]) to keep a React Query list in step with created, updated and deleted without polling.

Custom routes can reach the hub

Mount carried the DB, the config, the service bundle and the router groups, and neither the realtime hub nor the auth service. The hub is the one thing a route cannot work around: realtime.NewHub() returns a different registry holding no connections, so a handler that builds its own pushes into nothing and fails silently. Reaching the real one meant editing routes.go, which is the file the per-resource split exists to keep people out of. Both are on Mount now.

grit plugin list was showing a third of the catalogue

Five CLI plugins were listed. Ten more packages exist at grit-plugins and only webhooks overlaps by name, so the command read as a complete catalogue while omitting websockets, search, notifications, Stripe, OAuth, i18n, video, export and conference. They install with go get rather than grit plugin add, so they are listed under their own heading with the command that installs them.

An un-preloaded relation is now absent, not blank

belongs_to generated a value-typed association with no omitempty, so every response carried a complete zero-value object for any relation that had not been preloaded. message.sender.first_name was "" rather than undefined, and sender.active was false, which is a specific and wrong claim rather than "unknown". On a chat app's highest-volume endpoint most of the payload was that padding, and it rode the websocket push too. Now a pointer with omitempty, and the generated TypeScript says sender?: User, which is the truth. A client reading the blank object will need updating.

UPLOAD_ALLOWED_MIME

Adding the audio types last release meant editing framework code inside a scaffolded project, which the manifest guard may hold back on upgrade. Set UPLOAD_ALLOWED_MIME=audio/flac,image/avif to extend the allowlist without touching it.

v3.193.0September 8, 2026

Found by building a chat app on a stock scaffold and watching what the wire actually carried. Three of these are security fixes and all three were reproduced against a clean project before being changed.

Resource events were broadcast to every connected user

The realtime subscriber listened on "*" and called hub.Broadcast, so every create, update and delete of every resource went to every open socket in the process. The payload carries the row's label, which is its human-readable title.

Two accounts and one websocket were enough to show it. A user with no relationship to the record received {"type":"conversations.created","label":"Chemo results - family only"}, along with the actor's id and the timestamp. In a multi-tenant app that is every tenant's record titles, live, to everyone signed in, whatever the REST layer permits. There is no per-row authorization in the event bus for it to have leaned on.

Events now go to services.RealtimeAudience(e), which defaults to the actor alone: your own devices stay in sync and nobody learns about a row they may not be allowed to read. Widen it deliberately when the app knows who is entitled to look, for example a chat sending to a conversation's participants. Returning nil drops the event.

A WebSocket outlived the token that authorised it

The JWT is checked once, at the handshake, and never again. Nothing bounded the connection after that, so a socket opened with a 15 minute token kept delivering events indefinitely.

realtime.Client now carries the token's expiry and writePump closes the connection when it passes, which is the same bound REST already operates under.

"Sign out of all devices" did not sign out of all devices

Revoking a session marked a database row and stopped there. Verified before the fix: after a successful revoke-all from a second session, the revoked device's access token still returned 200 on REST, and its open websocket kept receiving message bodies. Combined with the broadcast above, a signed-out device kept a live feed of activity across the whole system.

Hub.DisconnectUser closes every connection a user holds, and both RevokeSession and RevokeAllUserSessions now call it through a services.OnSessionsRevoked hook that routes.Setup wires to the hub. The socket closes within seconds rather than never.

No audio file could be uploaded

AllowedMimeTypes had images, video, documents and archives, and no audio/* entry of any kind. Every voice note came back INVALID_FILE_TYPE.

The admin has had an audio accept group listing five audio types for a long time, and the storage dashboard already buckets uploads by mime_type LIKE 'audio/%', so a field declared accepts:"audio" offered an audio picker and then failed every file it produced. That is the same trap the archive types were added to close. The audio types are now in the fallback list.

The defenders' handbook claimed an ownership check that is not there

It said grit generate resource wires authz.MustOwn into the generated handler, and that "IDOR is closed by the generator, not by the developer remembering". Nothing outside the authz package calls MustOwn. Generated routes sit on the authenticated group, so any signed-in user can read and write every row of every generated resource.

The helper is good and the page now says what it actually is: something you call, with a warning about what the default is until you do. The generator cannot guess which resources are per-user and which are shared reference data, so this is a page correction rather than a code change.

v3.192.0September 6, 2026

A generated resource could not find its own definition

Reported against a Vite project: Cannot find module '@/resources/posts/posts' on both routes of every resource generated by grit generate resource.

The definition writer and the route writers disagreed about where the definition lives. The writer put it at src/resources/posts.ts, flat; both routes imported @/resources/posts/posts, the folder form. The folder form is the one that is right: it is what the Next generator writes, what the built-in blogs and users resources use, and what the .custom.tsx overlay needs so it can sit beside the definition it overlays.

The reporter suggested changing the import instead, which would work and would leave the TanStack admin as the only place in Grit using a different layout from everything around it. The routes now ask where the definition actually is rather than assuming, so a project still holding a flat file from before this keeps working instead of trading one broken import for another.

pnpm typecheck was red on a fresh Vite project

Found while confirming the fix. A freshly scaffolded TanStack admin reported 24 errors from its own typecheck script, none of which stopped a build, so nothing had ever surfaced them.

Ten were import.meta.env untyped: the admin was the only Vite app in the scaffold without a vite-env.d.ts. Five were process undeclared, needing @types/node, which the Next admin already had. The remaining nineteen were imports and locals nothing used, dead in both admins and quiet in the Next one only because its tsconfig does not set noUnusedLocals.

All fixed: pnpm typecheck now exits clean on a fresh Vite project with a generated resource, and both admins still build.

One thing worth knowing rather than reporting as a bug: run pnpm typecheck before ever starting the app and TanStack Router has not generated routeTree.gen.ts yet, so every createFileRoute call is a type error. One dev or build run generates it and they go.

v3.191.0September 5, 2026

--single and --double grew an admin directory they do not have

Found by sweeping the architectures that had gone untested after the TanStack bugs in v3.190.0. The account security writers put five files into apps/admin/ in every architecture, including the two that have no admin app: no package.json, no layout, nothing that builds. An orphan tree reads as something half-finished, which is worse than the feature simply not being there.

Both writers now skip when the project has no admin, which is what every other admin writer already did. The guard is inside the functions rather than at their six call sites, so a seventh cannot miss it. The API endpoints are unaffected and still exist in every architecture.

The sweep itself

Every architecture and frontend combination was scaffolded and checked: imports resolved against each app's own tsconfig alias, Go built, and each frontend built. --single, --double, --triple, --mobile, --desktop and --full, times Next and Vite where both apply. Plugins were installed on the architectures that have an admin and confirmed to install only their API halves on the ones that do not.

Two things that look like bugs and are not, recorded so the next sweep does not chase them: the desktop app's Go build fails until its frontend has been built, because Wails embeds frontend/dist and that is the documented build order; and the docs app imports @/.source/server, which fumadocs-mdx generates before every dev and build run.

v3.190.0September 5, 2026

The TanStack admin was missing files, a whole feature, and every plugin

Reported by somebody whose grit new --vite project would not start: Failed to resolve import "@/components/tables/table-tabs". Vite reports these one at a time, so that was the first of four.

All of it is one mistake repeated: writers that hardcode the Next.js admin layout. The Next admin keeps components, hooks and lib at the app root; the TanStack admin puts them under src/ and routes through src/routes/. Anything written to the Next shape in a TanStack project lands where the @/ alias does not point.

Four components were never registered for TanStack: table-tabs, bulk-action-bar, bulk-edit-modal and use-resource-detail-controller. That one is a build error, so it at least announced itself.

The account security page did not exist at all. Passkeys, recovery contacts, the WebAuthn helpers and the page itself were written to the Next paths, so a TanStack project got five files nothing imports and no security screen. No error, because unimported files are not a build failure.

Four of the five plugins could not be installed. command-palette, impersonate and saved-views wrote into the Next layout, their injections missed targets that live under src/, and the install reported success anyway. Worse, the stray file then counted as "already exists", so a second attempt refused outright. Only multitenant, which touches no admin files, worked.

Every writer now asks the project which layout it has, and plugin components go through the same Next-to-TanStack converter the scaffold uses, so a rule added there reaches plugins without being added twice. Verified by installing all five plugins into both a Next and a Vite project and building each: Go, admin and web.

Four tests now assert it, because none of these were build failures and the next one will not be either: no plugin may write outside the project's admin layout, in either direction; a page shipped under src/pages/ must have a route pointing at it; and no file written for TanStack may still carry "use client" or next/navigation. That last one caught a fifth bug while it was being written.

v3.189.0September 3, 2026

Upgrade could half-migrate an app to Tailwind v4

The v3 to v4 move spans three files that only work as a set: package.json names the engine, postcss.config.js names the plugin, and globals.css uses either the v3 @tailwind directives or the v4 @import. Upgrade writes all three, and the manifest guard holds back whichever ones you have edited, one at a time. Nobody edits all three or none of them.

So the reachable state was a v4 plugin parsing a v3 stylesheet, and it does not degrade: the first utility built from tailwind.config.ts becomes Cannot apply unknown utility and the app stops building. The upgrade did list the held-back files, but nothing connected "your package.json was left alone" to "your web app no longer compiles". Found on a real project upgrading from v3.176.0.

The PostCSS config now follows the stylesheet: an app still on the v3 directives keeps the v3 plugin, and the file it gets says how to migrate when you want to. A consistent v3 app keeps working, and the migration becomes something you do deliberately rather than something an upgrade does to two files out of three. New projects are unaffected and still scaffold fully on v4.

If you hit this already, the smallest fix is the one in that comment: move package.json to tailwindcss ^4 plus @tailwindcss/postcss, and replace the three @tailwind lines with @import "tailwindcss"; and @config "../tailwind.config.ts";. The @config line keeps your existing theme file working, so nothing has to move into an @theme block on day one.

v3.188.0September 3, 2026

grit.json reported the version you scaffolded with

The version field was written once by grit new and never touched again, so a project upgraded four times still named the version it was created with. That is the field people open to answer "what version am I on", and it was the wrong answer every time after the first upgrade.

grit upgrade stamps it now. Only that key changes: the file is decoded and re-encoded rather than rewritten from the template, so architecture, frontend, apps and anything you added yourself survive. The authoritative record was already correct in .grit/manifest.json; this is the human-facing copy of the same fact, and the two disagreeing was worse than either.

v3.187.0September 2, 2026

A tutorial for writing your own endpoints

Custom API endpoints, end to end: build a shop with three resources, see every endpoint Grit already generated, then write four of your own and call them from Next.js and TanStack Start. Handlers and services explained for someone who has used neither, a GORM cheat sheet, seeding, adding a column to a resource that already exists, and what a relationship actually changes.

It was written by building the project first and reading the files, which is how the four fixes below were found. Every command and response on the page came out of a running API.

PATCH silently ignored many-to-many

PATCH /campaigns/:id {"title": "Renamed", "product_ids": [...]} renamed the campaign, dropped the product ids on the floor, and answered 200. Nothing said no. Sending only product_ids was worse: no scalar survived the whitelist, so it came back 422 "No writable fields in request body" about a field PUT accepts.

PATCH now applies the association the way POST and PUT do. An empty list still means "empty it", because presence in the body is the instruction, not the length of the list.

Seeders wrote to columns the framework owns

grit generate seeder reads the model back and takes every field it recognises, which included archived_at, and for a --tree resource also path, depth and position. The tree columns are computed in BeforeCreate, so a literal there describes a hierarchy that does not exist. archived_at was worse: every seeded row would have been archived, and the admin's default list hides those, so the seeder looks like it did nothing.

It never got that far, because archived_at is a *time.Time and the seeder emitted time.Now(), so the generated file did not compile. That is the only reason this was noticed.

A generated service could not run its own search

The service's List built its WHERE from every field whose Go type is string, and a belongs_to qualifies, because the foreign key is a UUID string. So a --tree resource got LOWER(parent) LIKE ?: parent is the relation, the column is parent_id, and the query fails at the database. Latent rather than fatal, because nothing calls the generated service until you write the first line of business logic in it.

New fields landed above the primary key

grit generate field Category is_featured:toggle injected directly after the struct opening, so the new column appeared above ID and ahead of every field the resource was generated with. It compiled, and it read as though something had gone wrong. New fields now go where a person would have written them: after the declared fields, before the framework's block.

v3.186.0September 2, 2026

Cursor pagination, which had never worked

The keyset code was all there and unreachable: cursor mode was a compile-time Config field no generated handler set, and no query parameter turned it on. Every list endpoint was offset-only, which walks 100,000 rows to throw them away and shifts the window under anyone paging while rows arrive.

?mode=cursor asks for it, and sending a ?cursor=implies it, so a client following meta.next_cursor never says so twice. Offset stays the default because the admin table needs page numbers and a total.

Being unreachable is why it was broken. The cursor encodes the last row's sort value as text and handed it to the WHERE clause as text, so a timestamp column compared against '2026-09-02T09:18:39.7195481+03:00' matched every row in SQLite and page two came back identical to page one, forever. Values are bound at their own type now. Sorting on a money field was broken separately: the column is price_amount and there is no PriceAmount field to reflect on, so the cursor encoded nothing and the walk restarted from the top. Seven tests ship with every project.

A transactional outbox

"Save the row and tell the world" has no correct ordering. Publish then commit, and a failed commit leaves a webhook announcing an order that does not exist. Commit then publish, and a process killed in between leaves the order with nobody told, and nothing logged, because from the process's point of view nothing failed.

outbox.Enqueue(tx, topic, payload) writes the message in the same transaction as the data, and a relay delivers what committed. It refuses the root *gorm.DB outright: enqueueing outside a transaction is the bug it exists to prevent, and it is one that fails in production and never in a test. Claims, backoff, a dead-relay timeout so nothing is stranded, and Prune that will not delete a failed message, because that is a bug nobody has looked at yet.

Webhook deduplication was decoration

The model's comment described a unique index on (provider, external_id). The handler treated a duplicate-key error as "already processed" and returned 200. The index itself lived in an Indexes() method that nothing called, so the column had a plain index, the second INSERT succeeded, and every retried delivery ran the handler again. A Stripe retry is not an edge case; it is how Stripe works.

The constraint is now declared on the fields, so the migration creates it, and external_id is nullable so events from providers that send no id stay distinct rather than colliding on the empty string. Migrations convert existing empty strings to NULL first, because no database will build a unique index over repeated empty strings. Four tests, one of which asserts the index exists in the database rather than in a comment.

Taking stock without overselling

stock.Take(tx, model, id, "stock", qty) is one conditional UPDATE: SET stock = stock - ? WHERE id = ? AND stock >= ?. The read-modify-write it replaces lets two orders for the last item both read 1, both find it sufficient, and both write 0: one unit, two sales, and a row that looks fine afterwards. TakeMany sorts by id before locking, which is not tidiness: two orders touching the same two products in opposite order deadlock under exactly the load that makes it expensive. Eleven tests, including twenty goroutines racing for ten units.

Framework-owned files travel on upgrade

Each of the fixes above needed the same thing: a change to framework code reaching projects that already exist. The webhook model, its receiver and the dispatch package read the same columns and only work as a set, and they were split across the scaffold-only and upgraded sets, so half a fix landed and the project stopped compiling. They travel together now, alongside the packages generated code imports.

Base UI, for new primitives only

Recorded as a decision rather than shipped as a dependency. The admin has no primitive library at all: every component is hand-written against the design tokens. That is right for anything that is a styled element, and wrong the moment a component needs a focus trap, roving focus or listbox semantics, which is where hand-rolled components fail silently for keyboard users.

So: new primitives that need real interaction behaviour use Base UI, not Radix. Existing ones are not rewritten, and the dependency arrives with the first component that needs it rather than in every scaffold ahead of time.

v3.185.0September 2, 2026

Every resource owns its routes file

routes.go was the worst file in a generated project. Each resource edited it in four places, hundreds of lines apart: the handler construction, the protected block, the admin block and sometimes the public one. It passed a thousand lines with a handful of resources, so adding a route by hand meant working out which of four blocks it belonged in, and the generator had four chances to inject into the wrong one.

Each resource now gets internal/routes/<resource>_routes.go holding its handler and every path that reaches it. The file registers itself from an init(), so creating one mounts a resource and deleting one unmounts it. Three resources generated back to back grew routes.go by two lines total, and both were model names in a list rather than routes.

Nothing moves in an existing project. Routes already inline stay inline and keep working, because relocating them would silently drop any edit made to those lines. grit upgrade adds the registry, and resources generated after that get their own files alongside.

The auth handler, split by concern

handlers/auth.go was the second largest file at a thousand lines, covering sessions, password reset, email verification, OAuth and account lockout at once, and it is a file people customise. Changing the reset email meant scrolling past OAuth. It is 587 lines now, holding the session core, with auth_password_reset.go, auth_email_verification.go, auth_oauth.go and auth_lockout.go beside it. New projects only; an upgrade leaves an existing auth.go alone.

Everything else that grows with your resources adds a line or two to a file under thirty, so nothing else was worth splitting. The remaining large files are fixed-size framework code that does not grow as you build.

A new project failed its own test suite six times in ten

go test ./... straight after grit new failed on TestAuthHandler_Login_Success more often than it passed, with a 401 that reads as broken login. Every connection to SQLite's :memory: gets its own empty database, and the pool opened a second one as soon as two queries overlapped, which they did because registering writes its activity row on a goroutine. The login that followed landed on an empty database and found no user. The test helper pins the pool to one connection. Eight runs in a row now pass where five used to give three failures.

Existing projects can apply the same three lines to newTestDB in internal/handlers/auth_test.go: sqlDB, _ := db.DB() then sqlDB.SetMaxOpenConns(1).

Removing a resource left the project uncompilable

The API reference moved out of routes.go into apidocs.go in v3.154.0 and took its markers with it. The generator learned the new home; grit remove resource never did, so it left every docs.Route chain and the model entry behind and the project stopped compiling on undefined: models.X. It follows the same fallback the generator uses.

v3.184.0September 2, 2026

A money field type

price:money stores an integer count of a currency's smallest unit alongside its ISO 4217 code, in two columns: price_amount BIGINT and price_currency VARCHAR(3). The amount stays exact, the currency travels with it, and SUM(price_amount) GROUP BY price_currency is a query the database can answer.

It generates the Go type with the arithmetic that goes with it (currency-checked add and subtract, an Allocate that splits 10.00 three ways without evaporating a cent), the shared TypeScript type and formatting helpers, an admin form field with a currency picker, and a right-aligned table cell that formats in the row's own currency. Zero-decimal currencies work: UGX 50,000 is 50000, not 5000000, and it renders as 50,000 rather than 500.

Worth recording how the first version was wrong. The type implemented driver.Valuer and sql.Scanner, which seemed tidy. GORM treats anything with those as a scalar, so it ignored the embedded tag and made a single price INTEGER column: the amount survived and the currency was silently discarded. Nothing failed. It only showed up in PRAGMA table_info, and the fix was to delete the two methods, which is now a test.

The storefront blog used to say floats were fine at that scale. That advice is gone. See Money, which includes the migration for an existing float price column.

Upgrade stopped shipping half a project

grit upgrade refreshed seed.go but not the API key seeder it calls, nor the model, service, middleware and handler that seeder depends on. Every upgraded project failed to compile with undefined: SeedAPIKeys, and fixing that one file only moved the error to the next one along.

The underlying split was wrong. Upgrade deliberately does not regenerate API code, which is right for anything a person might have edited and wrong for a package that exists purely to be imported by code the generator writes tomorrow. Those packages, paginate, events, export, ids and pdf, now travel on upgrade, so a freshly generated handler is never compiled against a paginate.Config six versions older than itself. Files you have actually edited are still reported as conflicts rather than overwritten.

The money field's own frontend half gets the same treatment: the shared type, the admin cell and the form field are injected into existing projects rather than only appearing in new ones, which is the mistake media, recovery contacts and passkeys each shipped with.

v3.183.0September 2, 2026

Tailwind v4 everywhere on the web

The Next.js admin and web app were still on Tailwind v3 while the rest of the ecosystem, shadcn included, had moved to v4. Both are on v4 now, along with the TanStack and single-binary frontends.

The v4 port of Grit's design tokens already existed, but only in the TanStack admin, applied by string-replacing the v3 directives out of the shared stylesheet. It lives in that shared stylesheet now, so every frontend is v4 from one source and there is nothing left to patch.

The load-bearing detail is regular @theme, not @theme inline. The utilities compile to var(--color-*), whose values are var(--bg-*) indirections that re-resolve per element, which is what makes [data-theme] switching repaint at runtime. With @theme inline the values bake at build time and the theme switcher silently stops working.

tailwind.config.ts is no longer written: v4 does not read one unless a stylesheet asks with @config, and shipping an unread config is a file people edit expecting an effect. autoprefixer is gone too, since v4 prefixes for itself and re-processing its output can mangle its @property rules.

Expo and the Wails desktop client stay on v3 deliberately. NativeWind targets Tailwind 3.4, so moving it would break the mobile app rather than modernise it. Both remain internally consistent.

The admin would not load for some people

#77: Turbopack's Google-fonts loader fails to resolve its own @vercel/turbopack-next/internal/font/google/font module, and the app never renders. The scaffolded dev scripts pass --webpack until Turbopack ships the fix.

Worth being straight about: it does not reproduce here. A project on the exact reported version, 16.3.3, ran all day with the same font imports. So this is a machine-dependent upstream bug, the flag is a workaround rather than a correction, and it carries a comment saying so and when to remove it.

v3.182.0September 1, 2026

The toggle was a button with no name and no state

Finishing the form accessibility work from v3.180.0. The toggle rendered a bare <button>: no role, no aria-checked, no accessible name. A screen reader announced "button" and nothing else, so there was no way to know what it controlled or whether it was on.

It is a role="switch" now, with aria-checked that flips and aria-labelledby pointing at its label. The checkbox group gained role="group" so its boxes are announced as one labelled set rather than a run of unrelated checkboxes. The radio field already had radiogroup and needed nothing.

Two docs pages that did not exist

Passkeys covers what a passkey actually is, the two ceremonies, why sign-in is usernameless, why the challenge lives in a table rather than in memory, why a backwards sign counter is logged instead of blocked, and how to test it against a virtual authenticator. Including the three things it deliberately does not do.

Account Security covers the page and the recovery flow behind it: why every write takes the account password, why the address comes back masked even to the person reading it, the two addresses that are refused, and why phone recovery is a seam rather than a bundled provider.

grit sync warned on every run

The scaffold's own blog resource shipped without the grit:cols:auto and grit:fields:auto markers, so every sync in every new project printed a warning about a file the reader had never touched. The markers are in the scaffold now. An existing project still gets the warning, correctly: that file is yours to edit, so upgrade will not overwrite it, and the message says exactly what to do.

v3.181.0September 1, 2026

Passkeys

WebAuthn sign-in with a fingerprint, face or device PIN. The private key never leaves the authenticator and the server stores only the public half, so there is nothing here for a breach to leak and nothing for a phishing page to collect.

Pure Go, via go-webauthn, so the API still cross-compiles to a single static binary. That was the constraint the whole design was checked against before a line was written.

POST /api/v1/auth/passkeys/register/begin add one to the account you are in
POST /api/v1/auth/passkeys/register/finish
POST /api/v1/auth/passkeys/login/begin public: usernameless sign-in
POST /api/v1/auth/passkeys/login/finish
GET /api/v1/auth/passkeys list, rename, remove

Sign-in is usernameless, because that is the point of a passkey: the authenticator knows which account it holds, so asking for an email first buys nothing. It issues the same tokens a password sign-in does and records the same session, so the device appears in Active Sessions and can be revoked like any other.

Ceremonies are stored in a table rather than in memory, for the same reason refresh sessions are: the moment there are two API instances, an in-memory challenge is a coin flip on whether sign-in works. Single use, five-minute life, and deleted on read. The sign counter is kept and a counter that goes backwards is logged, because that is the signature of a cloned credential.

A card on /account/security manages them, and hides itself when the browser has no platform authenticator rather than offering a button that opens a dialog and fails.

Verified against a real authenticator, not a mock: Chrome's virtual authenticator over CDP, registering a passkey through the actual UI, confirming the credential exists on the device as a resident key, that the server verified the attestation and stored it, and that removal takes it away.

v3.180.0September 1, 2026

Form labels were not attached to their inputs

Twelve of the thirteen admin form fields rendered a bare <label> and an input with no id. The two were never connected, so clicking a label did not focus its field and a screen reader announced an unlabelled box. On every form, in every generated project.

Fixed for the single-control fields: text, textarea, number, select and date now pair htmlFor with a useId. The group fields, radio, checkbox-group and toggle, want role="group" with aria-labelledby rather than htmlFor, and are next.

The many-to-many picker is a real combobox

Tags on an article, categories on a product. The trigger was a <div onClick>: Tab could not reach it, a keyboard could not open it, there was no way to move through the options without a mouse, and a screen reader saw a pile of unlabelled buttons.

It is now role="combobox" over a role="listbox" with aria-multiselectable, options that report aria-selected, and aria-activedescendant so movement is announced without focus leaving the search box. Arrows move, Enter toggles, Escape closes and returns focus to the trigger, Home and End jump, and Backspace on an empty box removes the last chip. Every remove button has a name: "Remove React", not a bare X. Enter on a search with no matches opens the inline create dialog, because that is what somebody typing a tag that does not exist is trying to do.

Worth stating because it was asked: the relationship is optional. An article saves with no tags and gets them later, which is how a catalogue actually gets built. Verified in a browser.

v3.179.0September 1, 2026

one_to_one

A belongs_to whose foreign key is unique, declared on the side that holds the key: a passport declares its user, not the other way round, because that is the only side the constraint can live on.

grit g resource Passport --fields "user:one_to_one:User,number:string"

uniqueIndex is the entire difference. The column, the eager loading, the searchable picker, the CSV import are all identical to belongs_to, deliberately: it is the same relationship with a constraint, so it reuses the machinery rather than duplicating it. Verified against a real database, where the second row for one parent is refused.

The docs said a command did not exist

Code Generation claimed there is no add field command and told you to edit the Go model by hand. grit generate field has existed for some time and does the whole job: the column, both Zod schemas, the TypeScript type, and the admin table and form. The page now leads with it, and keeps the manual path for the four types the command declines.

grit g resource Profile produced a project that did not compile

Found while testing the above. It collides with the built-in UpdateProfileRequest in the users handler. Profile is reserved now, so the generator refuses it and suggests a name.

v3.178.0September 1, 2026

An account security page, and recovery contacts

Two-factor and active sessions already existed, on a page called "profile", next to a bio and an avatar. That is a page somebody opens to change their job title. Security decisions now have their own screen at /account/security, reachable from the user menu.

Note this is not /system/security, which is the operator's threat dashboard: blocked addresses, recent attacks, the state of the perimeter. That page is about other people. This one is about you, and merging them would put a password box next to a list of intrusion attempts.

Recovery email, and a seam for SMS

A verified second address that can get somebody back in when the primary is gone. Six-digit code, fifteen-minute expiry, hash-only storage, single use, and guesses capped at five, because a million possibilities with unlimited attempts is a formality rather than a secret.

Every write takes the account password. That is the whole security model: a recovery address is a second way in, so somebody holding a live session from a borrowed laptop could otherwise attach their own address and keep the account. Adding and removing both require it, and the overview returns the address masked, because whoever is reading that screen might be the problem.

Phone recovery ships as an interface rather than a provider. internal/sms defines the seam; nothing is registered by default, because the right provider depends on where your users are and baking one in would make everybody carry a dependency most cannot use. The page asks the server whether one exists and leaves the card out entirely when it does not.

Two things this release does not claim

Passkeys are still not implemented. There is no WebAuthn anywhere in Grit, and the page does not pretend otherwise. That is its own feature: credential storage, registration and authentication ceremonies, challenge state, and a client flow. The page is now the place to hold it.

Recovery contacts live in their own table rather than as columns on User. That was a correction: as user columns, an upgraded project got the handler that reads them and a model without them, which does not compile. grit upgrade does not rewrite the User model, and a half-delivered feature is worse than an undelivered one.

v3.177.0August 26, 2026

Two bugs reported by Mukisa Mark Cole (#75, #73), both reproduced exactly as described.

Every date field failed to save

A date field generated a Go *time.Time, whoseUnmarshalJSON accepts RFC3339 and nothing else. The admin's date picker sends "2001-08-06", deliberately: it builds the string from year, month and day numbers rather than from a Date object, because that is how a birthday ends up a day earlier for anyone west of Greenwich.

So the two halves of the scaffold disagreed about the wire format and every date field was unsaveable out of the box:

parsing time "2001-08-06" as "2006-01-02T15:04:05Z07:00": cannot parse "" as "T"

datetime was broken the same way and the report said so: datetime-local sends "2026-03-15T14:30", which has neither seconds nor a zone and is not RFC3339 either.

Fixed on the server, which is the side that was wrong. A new internal/jsontime package provides Date and DateTime, which accept what browsers actually send. A Date parses in UTC and truncates to the day, so it cannot drift with the server's zone, and marshals back as "2001-08-06", so a value survives a round trip unchanged. Nine tests ship into your project.

grit sync broke the login page

Sync rewrote each schema file with only the create and update pair, while the scaffold's schemas/index.ts re-exports <Name>Schema for every resource it ships. The barrel was left importing a member that no longer existed.

That is a TS2305 inside @repo/shared, so the admin and the web app both failed to type-check, including the login page. Nothing in the project imported the missing schema at all: the stale re-export was the entire fault, which is what made it so confusing to hit.

Sync now emits the entity schema alongside the pair. It is the useful one anyway: create and update describe what goes in, and this describes what comes back, id and timestamps included, which is what you validate a response against.

v3.176.0August 25, 2026

Uploads were blocked in production, and nothing said so

Found by running a real project end to end rather than by reading the templates. The frontend Content-Security-Policy builds its connect-src from NEXT_PUBLIC_STORAGE_URL, and the scaffolder never wrote that variable. It fell back to the default MinIO port, which is right for exactly one configuration: a local project that has not moved anything.

Move MinIO, or deploy to R2 or S3, and every presigned upload is refused by the browser. There is no server log, no failed request in the network tab worth noticing, and no error in the UI. The only trace is a CSP violation in the console.

The seeder now writes both origins into each frontend's .env.local, derived from the project's own storage config rather than assumed, with a comment saying what they are for.

Stored files are named by what they actually are

A JPEG optimised to WebP was stored as photo.jpg. The Content-Type was correct so browsers rendered it, but a key whose extension contradicts its bytes confuses everything that reads keys instead: CDN rules, lifecycle policies, and whoever is looking through the bucket later.

Verified end to end

A fresh project, real MinIO, real browser, no manual configuration: a 3.70 MB photograph reached storage as 50.6 KB across two objects, 75x smaller, both genuinely WebP by their magic bytes, and zero multipart uploads reached the API.

@repo/upload is already wired into the admin and web apps, so a plain pnpm install links it. Nothing to install, nothing to configure.

v3.175.0August 25, 2026

A dropzone in the package itself

@gritframework/upload was logic only, which meant every project rewrote the same drag handling, progress rows and object-URL cleanup, and most of them got the last one wrong. It now ships a component:

import { Dropzone } from "@gritframework/upload/ui";
import "@gritframework/upload/styles.css"; // optional
<Dropzone uploader={uploader} profile="product-image" onChange={setFiles} />

It ships unstyled, with stable class names under a grit-dz namespace and a classNames prop that replaces any of them, so it sits inside an existing design system without a fight. The stylesheet is optional and themed through custom properties, because a package that forces its own CSS on you is a package you fight.

Per-file progress rather than one bar for the batch, the saving shown as it happens, and a failed file that stays in the list with the reason instead of vanishing. The drop target is a label around a real file input, so keyboard activation and the native mobile picker come for free.

Using Tailwind and want a version to own rather than configure? The Grit UI block is the same idea with the styling baked in: npx shadcn@latest add https://ui.gritframework.dev/r/application-ui-file-upload-optimizing-dropzone.json

Eight component tests cover the parts that are easy to break: the real input behind a label, the saving appearing, a failed file staying put, the profile reaching the uploader, maxFiles disabling the input, removal releasing its object URL, and the progressbar exposing aria-valuenow. They stay in the library rather than being vendored into projects, because they need jsdom and a second copy of react-dom that pnpm cannot reconcile, and no project should inherit that to test a component it did not write.

v3.174.0August 25, 2026

Every upload in the admin now optimises itself

The dropzone, the avatar picker and every generated file field go through @repo/upload. Measured in the browser against a running API: a 2.53 MB photo dropped on a form asks to upload 64 KB plus a 3.5 KB thumbnail, both WebP, and the presign asks for exactly the byte count it then sends.

The public signature of uploadFile() is unchanged, so nothing that called it needed touching. What changed is that progress now covers the thumbnail too, and the returned ref carries the renditions.

The package is on npm

@gritframework/upload works in any React, Next.js or Expo app, with no dependency on Grit. It needs three endpoints from your API, and the README documents the shapes.

npm install @gritframework/upload

Inside a Grit monorepo nothing changes: it stays a workspace dependency resolved from disk, so there is no install and no registry in the loop.

One source, not two

The library moved out of Go string templates into packages/upload in the Grit repository, which the scaffolder embeds with go:embed. That directory is simultaneously the published npm package and the source written into every generated project, because the alternative is a package and a template that agree right up until somebody edits one of them.

v3.173.0August 25, 2026

Image optimisation moved to the client, and got better

Uploads go browser to storage through a presigned URL and never touch the API, so optimising on the server was always working on the wrong side of the wire. @repo/upload does it before the bytes leave the device. Measured in real Chromium on a 5 MB photograph:

pure-Go server backend 149.9 KB
libvips server backend 33.9 KB (needs cgo)
browser, client-side 35.0 KB <- 147x smaller, no server involved

The browser matches libvips because it has a lossy WebP encoder built in. That is precisely the thing pure Go could not do without cgo, and it was on the client the whole time. No server CPU, no server bandwidth, and on a phone the 5 MB never leaves the handset.

One package, three platforms. The optimiser is injected rather than imported, so nothing has to resolve a platform at build time:

import { createUploader, createAxiosTransport } from "@repo/upload";
import { optimizeImage } from "@repo/upload/web"; // or @repo/upload/expo
export const uploader = createUploader({
transport: createAxiosTransport(apiClient),
optimize: optimizeImage,
});

Expo uses expo-image-manipulator, since React Native has no canvas. React gets useUpload with per-file progress and describeSaving() for the "6.1 MB to 41 KB" label. Profiles are served from GET /api/v1/media/profiles so the client uses the server's numbers instead of a second copy that drifts.

The server now constrains and verifies instead

A presigned URL is a capability handed to a browser, so once the client does the optimising the server cannot guarantee what landed. Two holes closed.

The exact byte count is signed into the presigned URL. The client optimises first and therefore knows its size before asking, so S3 rejects a PUT of any other length. Previously the URL was an unbounded write capability: ask for two megabytes, send five gigabytes, and nothing on this side ever saw it.

Completion re-reads the object from storage rather than believing the reported size, which was a claim about bytes that never came through the API. Anything over the limit is deleted rather than recorded.

The server pipeline from v3.171.0 stays as the fallback for anything that does reach it, and for non-browser clients.

v3.172.0August 25, 2026

A decompression bomb could exhaust the API's memory

Shipped in v3.171.0 and closed here. A solid-colour PNG compresses to almost nothing whatever its dimensions, so an upload that passes every file-size check on the way in can still be enormous once decoded. Measured: a 165 KB file at 12000x12000 allocated 224 MB, and ten concurrent uploads of it would have been 2.2 GB. Dimensions are now read from the header before any pixels are allocated, and anything over MaxPixels (50 megapixels by default) is refused. A 48 MP professional camera frame still passes.

libvips, as an opt-in backend

The pipeline now has a swappable backend. The default is unchanged and needs no system libraries. Build with -tags vips and it uses libvips, which can write lossy WebP and AVIF. Measured on the same 6.08 MB photograph in the same container:

pure Go (default) libvips (-tags vips)
default profile 149.9 KB JPEG 1001ms 33.9 KB lossy WebP 1853ms
JPEG, forced 141.0 KB 950ms 123.7 KB 1366ms
AVIF downgrades to JPEG 79.9 KB 7544ms

About 4x smaller, and not faster. libvips is slower here on both the default path and a like-for-like JPEG comparison. The widely quoted 4-8x speedup is libvips against ImageMagick rather than against Go's native image package, and it did not reproduce. The reason to want it is bandwidth, not latency. AVIF costs 7.5 seconds for one image and on this photograph came out larger than lossy WebP, so it is opt-in per profile and not viable on a synchronous upload.

Opt in where the environment is controlled, because cgo cannot cross-compile and grit deploy builds for linux/amd64 from your machine with CGO_ENABLED=0:

docker build --build-arg IMAGE_BACKEND=vips -f Dockerfile.api .

The same profiles drive both. What changes is what Auto resolves to. A profile asking for AVIF on the pure-Go backend is downgraded rather than refused, so one binary still serves a project whose profiles assume libvips, and format on the ref records what was really produced. The active backend is named in the upload log line.

v3.171.0August 25, 2026

Image optimisation, on by default

Upload a 6 MB photograph and 147 KB gets stored: resized to fit 1600x1600, encoded at quality 0.82, EXIF oriented and then stripped, with a 400x400 thumbnail alongside and the original kept privately for reprocessing. No configuration required. See Image Optimisation.

The output format is decided per image rather than configured, because the answer is in the pixels: anything with real transparency becomes lossless WebP, everything else becomes JPEG. That makes the usual mistake, a transparent logo saved as JPEG and quietly gaining a black box, unrepresentable.

Define a profile when one field wants something else, in internal/media/profiles.go, which is written once and never regenerated:

media.Define("product-image", media.Profile{
Max: media.Fit(1000, 1000),
Quality: 0.8,
Renditions: map[string]media.Size{"thumb": media.Fill(300, 300)},
})
// POST /api/v1/uploads?profile=product-image

No lossy WebP and no AVIF, deliberately. Neither has a pure-Go encoder, and adding one means cgo, which costs the static cross-compiled binary. On a photograph the pure-Go lossless WebP encoder produced 778 KB where JPEG produced 35 KB, so it earns its place as a PNG replacement and nothing more.

Every thumbnail was being orphaned

The upload handler stored the original, queued a thumbnail job, and returned a file reference whose thumbnail field was still empty because the worker had not run yet. That reference is what got written into the record, and the worker wrote its result onto the separate uploads row, which nothing read back. Every thumbnail generated for a resource file field was produced, paid for, and referenced by nothing. Transforming inline fixes it: the record is only ever written with final URLs.

The WAF was blocking every upload over 1 MB

Sentinel's exclusion list named /api/uploads, /api/blogs and the rest, while the router mounts /api/ + APIVersion. Not one entry ever matched. Two things were broken by that and neither announced itself: an upload larger than the WAF's inspection cap was rejected with a 413 before the handler saw it, and richtext bodies were never actually stepped aside, so in production a blog post containing ordinary markup could be refused as an XSS payload. The list is built from APIVersion now, so a version bump cannot quietly disable all of it again.

Also

  • FileRef carries format, optimised, renditions and the original's key and size. The format is recorded rather than inferred from the URL, so a client never guesses what it received.
  • The admin dropzone shows the work: 5.3 MB -> 147 KB JPEG instead of just a filename.
  • grit upgrade now delivers the media package and the storage code it hooks into, so existing projects get this without rescaffolding.
  • GIF is left alone on purpose: decoding one keeps the first frame only, so optimising an animation would silently throw it away.
v3.170.0August 20, 2026

Variants also have a page of their own now: Product Variants covers the five tables, the three decisions behind them, why a price is resolved rather than stored, and every endpoint the command mounts.

Columns of your own on the variant matrix

A variant already stores its own photographs, and the matrix had no way to show them and no way for you to add one. It takes a columns prop now, keyed the way a resource definition's own column overrides are keyed: a built-in key patches that column, any other key adds one.

<VariantMatrix
{...props}
columns={{
images: { // add
label: "Photo",
after: "sku",
cell: (variant) => <Thumb src={variant.images?.[0]?.url} />,
},
sku: { label: "Barcode" }, // rename a built-in
override: { hidden: true }, // drop one you do not use
}}
/>

A cell renderer is handed the variant, its unsaved draft, a patch that feeds the same Save button the built-in cells feed, and the resolved price. So a column of your own is editable without being a second way to write: one click still sends one PATCH per row.

images is on the variant type now, too. The server was already sending it.

The detail page had no vertical rhythm

The Details card and anything below it sat flush against each other. The container had no spacing at all: the header carried its own bottom margin, the related tables carried their own top margin, and the Details card and any DetailAside carried nothing, so a custom slot touched the card above it. The spacing moved to the container, because a slot component is somebody else's and should not have to know this page's margins to sit correctly in it.

Adding an option value looked like a duplicate row

The add-value field on an option card used Black as its placeholder, and Black is also the first chip sitting directly above it. The row read as a copy of a value already there rather than an empty field waiting for input, and the submit button stays disabled until you type, so the whole thing looked inert. The fields are labelled now, and the placeholder is plainly an example of something not in the list.

Two 400s per dashboard load, per inline child

An inline --items child is hidden from the sidebar and has no page of its own, but the dashboard still built it a Total and a Latest widget. The server answers those from a whitelist the generator writes only for top-level resources, so every load asked twice for stats on a table nobody browses and got two 400s back. Hidden resources are now skipped by the dashboard and by the widget catalogue.

v3.169.1August 20, 2026

Every form with a richtext field logged a Tiptap SSR warning.

Tiptap Error: SSR has been detected, please set `immediatelyRender`
explicitly to `false` to avoid hydration mismatches.
(components/forms/fields/rich-text-field.tsx:31:27)

Tiptap builds its document on the server, React builds a different one on the client, and the two do not match. The warning is the polite half; the impolite half is a hydration mismatch that shows up as an editor dropping the first keystroke somebody types into it.

immediatelyRender: false defers the editor to an effect on the client, which is the documented fix and the one the blog editor already had. The form field did not, so every generated resource with a richtext column carried it. Both admin flavours share the source, so Next and TanStack are covered by the one change.

New projects get it from grit new. Existing ones get it from grit upgrade, verified on a project that had the old version: the field is rewritten, the warning goes, and typing into the editor keeps the first character.

A correction in the Command Explorer

The grit upgrade entry shipped yesterday described --diff as a preview, ending its sample output with "Run without --diff to apply". That is wrong, and wrong in the direction that costs you something.

--diff is not a dry run. It performs the upgrade exactly as it would without the flag, and additionally prints the diff for the files it skipped because you had edited them. There is no preview-only mode. The entry now says so, carries the real output, and the note tells you to commit first.

v3.169.0August 20, 2026

grit remove resource left the public handler behind. Regenerating the resource with different fields then failed to compile on a column the model no longer had, because that file is written only when absent and the stale allowlist survived. It now goes with everything else, along with the variant files when grit add variants had been run against the resource.

Hierarchies, documented properly

Relationships & Trees gains a section on --tree: the four columns, why a materialized path rather than a recursive CTE, and the question the docs did not answer before, which is how to render level-2 categories on a level-1 page.

A category page asks two things that look like one. "Which categories sit under this one" is answered by children from the tree endpoint, and it is what you render as tiles. "Which products belong here" is answered by descendant_ids from the detail endpoint, because the products are filed under Cameras and not under Electronics. Reaching for the wrong one fails quietly: render descendant_ids and you get a row of UUIDs.

The easy answer to both is one request. GET /api/v1/public/categories/tree returns the whole published hierarchy already nested, assembled server-side in a single query, and it is cached. The roots are your category index; each root's children are the tiles on that root's page. There is no per-node children endpoint and adding one would not help: a shop renders a nav menu on every page, so the tree is already in the cache before anybody clicks.

One trap gets its own paragraph, because it has the most annoying shape a bug can have. A leaf's children is null and not [], since Go marshals an empty slice that way, so node.children.map(...) works on Electronics and throws on Cameras. The helper in the docs guards it once.

The storefront guide gets the same recipe built against a real catalogue, in Step 4e: the tree hook, the depth-first lookup, and a category page that renders sub-category tiles and subtree products together.

Every response shape in both was captured from a running project with a real two-level tree, not written from the handler source.

v3.168.1August 20, 2026

A new Command Explorer: every CLI command, with a simulated run and the exact files it touches.

Press run on a command and it types itself and streams its real output. Underneath is the part a CLI reference usually leaves out: the list of files that command creates, modifies or deletes. grit generate resource writes thirteen files and edits twelve more, and knowing which twelve before you run it on a project you care about is the difference between a reference and a list of names.

None of it is written from memory. Every output block and every file list was captured from a real run against a freshly scaffolded project, with the file effects read out of git status rather than recalled. A reference that says a command touches nineteen files and then names eighteen is worse than none, because you meet the missing one at the worst moment.

Search covers the command, its summary, its use cases and its file paths, so "which command writes routes.go" is a query rather than a grep. It answers with six. Category filters narrow to Scaffold, Generate, Add, Run, Data, Ship or Meta, every command deep-links by hash, and / focuses the search box.

Commands that write nothing say so plainly rather than showing an empty panel. Twelve of the thirty-seven only read, run or report.

v3.168.0August 19, 2026

grit generate field and grit generate seeder could not find any resource whose name is more than one word.

grit generate field OrderItem variant_id:string
resource "OrderItem" not found (generate it first): no model found
for "OrderItem": generate the resource first (looked in
apps/api/internal/models and internal/models)

The model was sitting in that exact directory. Every generator in Grit writes a model to models/<snake>.go, so OrderItem lands in order_item.go, and these two commands looked it up as <lower>.go instead. For a one-word resource those are the same string, which is why Product worked and nothing looked wrong; for BlogPost, OrderItem, AccessReview or anything else with two words in it, the command reported a file that was plainly on disk as missing. Both now resolve the snake-cased name, and fall back to the flat one for a model somebody wrote by hand.

Variants in the storefront guide

Build a storefront with Grit gains a step for them, between the category pages and the cart. It covers why the schema is five tables rather than a colour column and a size column, the three decisions inside it that each have an obvious worse alternative, and why a variant's price is resolved rather than stored.

Then the parts you write: a hook on the public payload, the two functions that match a selection to a variant (both of which have a wrong version that sells the wrong thing), a picker that greys out the colour you cannot have in the size you picked, and what changes in the cart and the checkout. The cart line stops being about a product and starts being about a combination, and keying it on the product id alone merges the black shirt into the navy one.

The guide previously ended by suggesting you build all of that yourself, which was true when it was written and stopped being true in v3.167.0.

Blog headings are anchors now

A cross-reference written inside a post scrolled nowhere, because the markdown renderer emitted headings with no id. The invoice guide had carried a dead one since it was published. Headings now take the same slug the docs table of contents uses, so the same heading gets the same id whichever surface renders it.

v3.167.0August 19, 2026

Variants shipped with five tables, a price resolver, and no way to touch any of it. v3.166.0 installed the schema and the endpoints; there was no screen in the admin, nothing for a storefront to read, and no seed data, so the only way to see a variant was to write the SQL yourself. This release finishes the feature.

The matrix, on the product page

grit add variants now writes an editor into the admin and hangs it off the record's own detail page, because variants are a fact about one product and that is where a shop owner looks for them. Pick which axes the product offers, generate the combinations, then edit SKU, stock, price and active state inline. Edits collect into one Save.

The price column is the part worth reading twice. A variant's price is resolved and not stored, so the table shows what each combination costs and the override box's placeholder shows what it would cost with no override. Clearing that box is never a guess about what the price becomes: the number is already on screen. Typing a value back to what it already was is deliberately not a change, so a save never bumps the version of every row somebody clicked into.

The option library is shop-wide, so it is a sidebar entry rather than a panel on a product: Colour is Colour whether it is on a shirt or a phone case. Options can now be deleted, which they could not before, and the server refuses while anything is built on them.

A payload a storefront can render

GET /api/v1/public/products/:key/variants
{ "options": [ { "name": "Colour", "kind": "swatch", "values": [...] } ],
"variants": [ { "sku": "...", "price": 356.98, "in_stock": true,
"option_value_ids": [...] } ],
"price_range": { "low": 354.48, "high": 356.98, "single": false } }

One endpoint, because a picker needs one payload: the options to draw, the combinations to match a selection against, and the range a listing card needs for "from 49". It follows the same rules as the rest of the public surface. Stock goes out as a boolean and never as a count, inactive combinations do not go out at all, and a per-value price delta is zeroed unless its option declares that the axis affects price, so a picker can never label a swatch "+ 20" and then resolve to the base price.

A product with no variants gets empty lists and a range of its own price, which is what lets a storefront render one component whether or not variants were ever set up.

Seed data, so there is something to look at

grit seed now writes a Colour and Size library and a real matrix across the first few products, deterministically: Size affects price and XL costs 2.50 more, Colour does not, and one combination in seven is out of stock. That last one is on purpose. The disabled swatch is most of the work on a product page and the hardest state to remember to build, so the seed puts it on screen unasked.

Three bugs from v3.166.0

The tables were never migrated. The model registration looked for its marker in routes.go, where that marker does not live, so Option, OptionValue and both join tables were never added to AutoMigrate. Every variant request then failed on a relation that does not exist, which reads as a bug in Grit rather than as a migration nobody ran.

Changing a product's options left the old matrix behind. The handler said the existing combinations went with it and then deleted only the links, leaving variants describing choices the product no longer offered. It now clears them for real, tells you how many went, and does nothing at all when the option set has not actually changed. The soft deletes are unscoped, too: a soft-deleted link still occupies the unique index, so re-adding the same option used to fail on a row nobody could see.

A second resource with variants took the API down. Running the command twice mounted the shared /options routes twice, and gin panics at boot on two handlers for one method and path. The shared half is now mounted once, and the per-variant update moved from /variants/:id to /product-variants/:id so two resources cannot collide. Existing projects are migrated to the new layout by re-running the command.

Verified end to end on a fresh project: scaffold, generate a public Product, add variants, migrate, seed, then drive the admin in a browser through choosing options, generating a matrix, editing a row, setting an override and clearing it back to the resolved price.

v3.166.0August 19, 2026

Installing a Grit UI block into a scaffolded app started an interactive setup instead of installing anything.

cd apps/web
npx shadcn@latest add https://ui.gritframework.dev/r/ecommerce-product-grids-grid-with-ratings.json
? You need to create a components.json file to add components. Proceed?
? Select a component library > Base UI (Recommended) / React Aria / Radix UI

The shadcn CLI will not install into a project without a components.json, and no scaffolded frontend had one. So the first thing anybody does with Grit UI is answer four questions about a project that already has every answer, and one of those questions offers a component library that is wrong here: Grit is built on Radix through shadcn/ui, and picking the recommended Base UI produces components importing packages the project does not have.

Every frontend now ships one, with values read from the project rather than guessed: the tailwind config and css paths the scaffold actually wrote, the cn() in lib/utils.ts, and lucide as the icon library because it is already a dependency. Next.js apps declare rsc: true, Vite apps declare false and point at src/globals.css.

Existing projects get one from grit upgrade, created only when missing. That runs as its own step because there is no upgrade path for apps/web at all, and a config file is safe to create in a way that rewriting a page never is.

Verified end to end on a fresh project: scaffold, install a block with no prompts, typecheck, start the server, and render it in a browser.

Grit UI in the storefront guide

Three blocks are now covered, each wired to the real public endpoints and clicked in a browser before being written down: the product grid, the product detail page with its gallery and variant pickers, and the circular category rail.

With one warning that took a browser to notice. Every prop on these blocks has a sample default, and a prop you do not pass keeps it. Leave out rating and your page states 4.8 from 246 reviews about a product nobody has reviewed. Leave out colours and it offers a Midnight Blue that does not exist. Nothing errors and nothing looks broken, which is exactly why it is worth saying out loud: a block is furnished by default, and furnishing is not data.

v3.165.0August 19, 2026

The storefront guide’s cart file: wrong package, wrong type, and printed after the component that imports it.

Three problems in one file, all reported by readers following the guide in order.

A package that does not exist

import type { Product } from "@shopfront/shared"; // no such package

The workspace package a scaffolded project actually has is @repo/shared. It appeared twice, in the cart and in the add-to-cart button.

A type the storefront never holds

addToCart took the full Product from the shared package. A storefront only ever has the narrower struct the public endpoint publishes, and passing one to the other is:

TS2739: Type 'CatalogueProduct' is missing the following properties
from type 'Product': stock, category_id, active, created_at, updated_at

Which is the allowlist working exactly as designed, and the guide typing against the wrong side of it. Both the cart and the button take CatalogueProduct now.

Printed after it was used

lib/cart.ts lived in Step 5, and Step 4c’s ProductCard imports it. Anybody building in order hit a missing module and a red editor. The file now appears where it is first needed, and Step 5 keeps what it was actually there to teach: why a client-side cart, why Simple Store rather than Context, why the store starts empty on the server, and why derived values are plain functions.

Verified by extracting the guide’s cart file verbatim into a real project, typechecking it against the guide’s own ProductCard, and clicking Add to cart in a browser: the badge went 0 to 1 and the line persisted with its name, price and image.

v3.164.0August 19, 2026

A second tree resource in one project did not compile, and a self-referential type broke tsc for the whole workspace.

Generated helpers collided

Four helpers introduced with --tree were emitted at package level, once per resource, into packages every resource shares. One tree resource was fine. Two gave you:

internal/services/department_tree.go:27:6: parentOf redeclared in this block
internal/services/category_tree.go:27:6: other declaration of parentOf

parentOf, derefID, publicParentOf and optionalID are all named after their resource now, so any number of tree resources coexist.

The generated type imported itself

A self-referential relation emitted an import for its own model, which in that model’s own file is a self-import:

// packages/shared/types/category.ts
import type { Category } from "./category";
export interface Category { ... }

TS2440, “import declaration conflicts with local declaration”, which fails tsc for the whole workspace rather than just that file. The import is skipped for a self-reference now, and parent_id is typed string | null to match the nullable column.

Relationship fields had no label

Carried over from v3.163.0 and worth repeating because the blast radius is wide: RelationshipSelectField never rendered one, so every generated form with a belongs_to had one control floating under the previous field’s label. Both relationship fields are labelled now.

The storefront guide now shows the code it uses

A reader pointed out that the guide called get() and rendered <ProductGridSkeleton /> without ever showing either. An audit of every code block in the guide found eight such references. All eight are written out now: get, formatMoney, ProductGridSkeleton, ProductCard, EmptyCart, StatusBadge, CancelledNotice, and the missing Order type import.

Every one of them was written into a real project and typechecked before it went into the guide, and the catalogue page was loaded in a browser to confirm the card renders and prices format.

v3.163.0August 19, 2026

The tree view looks like something now, shows its levels, and can add a child to any row.

Rows are cards

The drag handle and the row actions were opacity-0 until hover, so at rest a node had no affordance at all and the panel read as an unstyled list of names. Every row is a card now: a border, a background, a hover state, and a handle you can see before you reach for it.

Each row carries the record’s own image where the resource has one, falling back to an initials tile, so the leading block is a fixed width and labels line up down the whole tree. Under the name sits the slug, or a trimmed description. A node with children shows how many are beneath it, counting the whole subtree rather than one level.

Levels you can see

Indentation alone reads as “further right”. Three things make the hierarchy explicit: a guide line down each level, an elbow joining every card to its parent’s line, and an L1 / L2 chip on the row itself. On a wide screen a third-level row sits a long way from its parent, and counting pixels is not reading.

The panel header states the shape as well: how many top-level rows there are and how deep the tree goes, with Expand all and Collapse all beside it.

Add a child, honestly

There was deliberately no “add a child here” button, because the controller’s create() takes no starting values: the new row would have been born at the root with the parent silently dropped.

So the controller gained createWith(defaults). The form components already accepted a defaults prop for exactly this; nothing carried the values to them. Now a plus button on any row opens the create form with that row already chosen as the parent, and every other resource gains a way to open a form pre-scoped to a parent.

And a label that was missing everywhere

Building this surfaced an older bug with a much wider blast radius: RelationshipSelectField never rendered a label. Every generated form with a belongs_to had one control with nothing above it, sitting under the previous field’s label. Both the single and multi relationship fields are labelled now.

Found by loading the form in a browser, which is also where onAddChild is not defined turned up: the prop reached the row markup without reaching the row’s props, and Go compiling the template that contains it proves nothing about the TypeScript inside.

v3.162.0August 19, 2026

A tree could not be seeded or created on Postgres, and the tests said it could.

ERROR: insert or update on table "categories" violates foreign key
constraint "fk_categories_children" (SQLSTATE 23503)

A root has no parent, and --tree wrote that as an empty string. GORM creates a real foreign key constraint for the self association, and no constraint accepts "" as a reference: it is neither a key nor NULL. Every seeded category failed, and so did creating one by hand in the admin.

Products then failed too, for the same reason one step removed: with no categories to point at, the products seeder wrote category_id = "" and hit the identical error.

Why the tests passed

The generated tree tests run on SQLite, which does not enforce foreign keys unless asked. Postgres always does. Nine tests covering paths, moves, cycles and rebuilds all passed against a database that was quietly accepting a reference to a row that did not exist.

Those tests now open with:

db.Exec("PRAGMA foreign_keys = ON")

which caught two more writers of the same bad value the moment it was added: Reorder normalised NULL to "" on every root it touched, and RebuildPaths did the same. Both now normalise the other way.

What changed

A self-referential foreign key is a nullable *string, and NULL is the one value that means “no parent” everywhere: in the model, the tree service, the move endpoint, the importer and the public payload. An empty string arriving from a form select is converted at the edge by a generated optionalID helper, because an empty select is exactly how an admin creates a root.

The seeder changed in two ways. A self-reference is no longer given a parent at all, so seeded rows are roots you arrange by dragging, rather than a random hierarchy nobody asked for that can put a row above itself. And a required foreign key with no rows to point at now says so:

cannot seed product: no category rows exist yet. Seed Category first
(generate it with --faker, or add rows in the admin), then run grit seed again

which is the sentence somebody needed, in place of SQLSTATE 23503.

Verified end to end against a real Postgres database this time, not SQLite: 6 categories and 40 products seeded with no warnings, roots created from the admin form, a drag to root, a reorder, a rebuild, and zero rows left holding an empty parent.

Upgrading a project that already has a tree

Run grit update, regenerate the resource, then grit migrate to make the column nullable. If you generated with --public, delete internal/handlers/<resource>_public.go first and let it be rewritten: that file is never overwritten on purpose, and it compares the parent column as a string, which no longer compiles.

v3.161.0August 18, 2026

Moving a category could add one to the depth of every row in the table.

A subtree move rewrites descendants with a prefix match on the path:

WHERE path LIKE '<old path>%' AND id <> '<moved id>'

When the moved row has no path, that prefix is empty, and LIKE '%' matches every row in the table. The depth increment that follows then lands on all of them.

A row with no path is not exotic. It is exactly what --tree leaves behind when added to a table that already has rows, so this fired the first time somebody dragged one of those rows in the admin, which is the most likely first thing to do. Nothing looked wrong: the tree redrew correctly, and the damage was only visible by reading the rows.

A move now leaves the subtree rewrite alone when there is no old path, because a row without one has no descendants by path anyway. The moved row still gets a correct path, and Rebuild paths on the tree repairs anything already affected. Both the fix and the repair were verified against a database with the damage in it.

Found by reading the rows after driving the admin tree in a browser, and now pinned down by a generated test that moves a pathless node and asserts every other row is untouched. That suite ships into your project and is up to nine tests.

The storefront guide

The Category resource is generated once, with --tree --public in Step 1, rather than being generated plain and regenerated with --public four steps later.

Step 4e is new: multi-level categories end to end. What the path column is for, dragging in the admin, why seeded categories come out flat, and the piece that makes a tree worth having on a storefront, which is showing everything under Electronics rather than only what is filed directly in it.

v3.160.0August 17, 2026

A drag-and-drop tree in the admin, and a broken page that could not be fixed.

Fix this first

The API keys page has shipped since v3.156.0 with a literal newline inside a TypeScript string, so the admin app failed to compile in every scaffolded project. Run grit upgrade.

Worse than the typo was the reason it stayed: the page was in the scaffold’s file list and not in grit upgrade’s, so the fix had nowhere to go. Those two lists had drifted by 59 files, every one of them unfixable in an existing project. They are now one list, built once and shared, with the files a person is expected to edit excluded by name and the manifest guard still refusing to touch anything with local changes.

The tree view

A resource generated with --tree now gets a Table / Tree toggle on its list page. The tree opens by default, because somebody who asked for a hierarchy is looking for the hierarchy, and the table keeps every filter, tab and bulk action it had.

Drag a row onto another to nest it. Drag it between two rows to reorder. Drag it to the bar at the very top to promote it back to a root. Those three targets are the whole interaction, and a tree with fewer of them is a tree you cannot rearrange: without the sibling bars there is no way to reorder within a parent and no way to get a node back out to the top level.

Native HTML5 drag and drop, no library. dnd-kit would be nicer to write against and would put a dependency in every scaffolded admin for one screen, and a tree is the case the native API handles: one item, no sorting animation, no multi-select.

Dropping a node inside its own subtree is refused twice. The row shows a no-drop cursor and dims before you release, and the server refuses the move with 422 if a stale client tries anyway. A toast after a failed request is a worse answer than a cursor that says no.

There is deliberately no “add a child here” button. The obvious version calls the page’s create form, which takes no starting values, so the new record would be born at the root with the parent silently dropped.

Rows that predate the tree

Adding --tree to a table that already has rows leaves every one of them with a NULL parent_id, because that is what AutoMigrate fills a new column with. Any query spelling “is a root” as parent_id = '' matches none of them.

That bit twice. Roots returned nothing, which was at least visible. Reorder updated zero rows and answered 200, which is the worse kind: the tree redrew in the old order and nothing said why. Both treat NULL as no parent now, reorder normalises it on the way past, and the generated tests cover the migrated-rows case explicitly. There is a Rebuild paths button on the tree for the same situation.

Also: GripVertical is exported from the admin’s icon module. An icon in the map is not automatically a named export, and the drag handle is the first thing to need this one.

v3.159.0August 17, 2026

Category trees: --tree, and the reason a self-referential relation never worked before.

Electronics above Cameras above Lenses is the shape every shop needs, and Grit could not express it. A self-referential belongs_to did not compile:

invalid recursive type: Category refers to itself

Go rejects a struct that contains itself by value, so the association has to be a pointer. And the foreign key was marked binding:"required" in the model, the request struct, the Zod schema and the admin form, which meant that even once it compiled there was no way to create a root: every one of those four refused the empty parent the top of a tree has. All four are now optional for a self-reference and unchanged for an ordinary relation.

Materialized paths, and why not a recursive CTE

--tree adds parent_id, path, depth and position. The path is /id/id/id/, delimited on both sides so a prefix cannot half-match an id.

grit g resource Category --fields "name:string,slug:slug:name" --tree
name parent_id path depth
Electronics "" /1/ 0
Cameras 1 /1/2/ 1
Lenses 2 /1/2/3/ 2
# everything under Electronics, one indexed comparison
WHERE path LIKE '/1/%'

A recursive CTE reads better and is the wrong choice here: Grit supports Postgres, MySQL and SQLite, and CTE support, syntax and performance differ across all three. A generator emitting one query per dialect is a generator with three bugs. Nested sets give one range query and rewrite half the table on every insert, which is miserable for a tree somebody reorders in the admin. A materialized path is identical everywhere, and a move rewrites only the subtree that moved.

depth is stored rather than counted from the path, because counting separators in SQL is three different expressions across three dialects for something a move keeps correct with a single delta.

What gets generated

A tree service with the queries a hierarchy actually needs, each one a single round trip: Tree (the whole thing in one query, assembled in Go, no N+1), Roots, Children, Descendants, DescendantIDs, Breadcrumbs (ids read from the stored path, one IN query however deep), Move, Reorder and RebuildPaths. Plus five endpoints, and eight tests that ship into the project so they run against the dialect it actually uses.

Move is a transaction because three things have to happen together: the node takes its new parent, every descendant’s path is rewritten with REPLACE (one UPDATE, present on all three dialects, and it cannot race the way read-modify-write can), and every descendant’s depth shifts by the same delta.

It refuses a move that would put a node inside its own subtree, which is one string comparison because the parent’s path already contains every id above it. Without that check the subtree is detached from the tree and no query ever finds it again.

The generated tests earned their place before shipping: they caught RebuildPaths rewriting every row on every pass and leaving the table worse than it found it. That function is now deliberately not SQL, for the same dialect reasons as everything else here.

The storefront half

A public tree resource answers two more questions. Its detail response carries descendant_ids, and public foreign-key filters now accept a list, so “products in Electronics” means Electronics and everything under it in one request:

GET /public/categories/electronics -> { ..., "descendant_ids": [1,2,3] }
GET /public/products?category_id=1,2,3
GET /public/categories/tree -> the nested menu, one query

Splitting on commas is safe for ids and wrong for anything a person types, since “Smith, John” is one value, so InFilterable is opt-in per column and never inferred from the value. It is one more reason the filter lists are declared rather than guessed.

Adding --tree to a resource that already has rows leaves them with a NULL path, so the root queries treat NULL as no parent, and POST /<plural>/rebuild-tree reconstructs every path from parent_id alone.

v3.158.0August 17, 2026

A public endpoint you can actually filter, and a similar-items strip.

A category page needs three things a read-only list did not offer: products narrowed to one category, a price window, and a sort order. The first version of --public shipped with no filters at all, which was the safe default and not a usable one.

Filters, derived from the allowlist

A generated public handler now declares its filterable columns, and the rule for which ones is the whole design: the published ones. A column safe to show is safe to filter on. A column held back from the response stays unreachable from the query string, because otherwise ?cost_price=12 leaks by comparison exactly what the allowlist refused to leak directly.

# published, so filterable
GET /public/products?name=Kettle
GET /public/products?price_min=400&price_max=800
GET /public/products?category_id=<id>&sort_by=price&sort_order=asc
# held back, so ignored rather than applied
GET /public/products?stock=0 -> all 24 rows
GET /public/products?cost_price=0 -> all 24 rows
GET /public/products?archived_at=x -> all 24 rows

Foreign keys are the one addition: a category page cannot exist without ?category_id=, and filtering by an id is not publishing the relation. The id identifies a row the endpoint was already willing to return.

Text and richtext are left out, because equality on a description is never the question, and search already covers them.

Price windows

paginate.Config gains RangeFilterable, a separate whitelist from Filterable because the two answer different questions: equality on a price is almost never what a caller means, and a range on a status is meaningless. Numeric published columns get both. A bound that does not parse widens the window instead of failing the request, since ?price_min=cheap is a typo and an error page is a worse answer than results.

Similar items

--public on a resource with a belongs_to also mounts GET /public/products/:key/related: others sharing this one’s category, newest first, itself excluded, capped at 24 however large ?limit= asks. A resource with no parent gets no endpoint rather than one returning an arbitrary set.

Which relation defines similarity is the generator’s choice and not the caller’s. That keeps it one bounded query and stops the endpoint becoming a back door to filtering on something unpublished.

And the upgrade path, which is where this nearly went wrong

A handler declaring RangeFilterable does not compile against a paginate.go written before that field existed, so regenerating in an older project would have reported success and left a broken build. grit generate resource --public now brings paginate forward first, and only when the manifest proves nobody has edited it; a modified copy is left alone with a warning naming the one field to add.

Same for the route: a project that already had the two public routes gets just the related one added, rather than the generator seeing “already wired” and leaving a handler nothing ever calls.

v3.157.0August 17, 2026

Four bugs a browser found that a compiler could not.

Every endpoint in the new public API surface was verified with curl, and every one of these survived that. They came out of loading a storefront in a real browser instead.

The CSP blocked the API

A CSP source expression matches paths exactly unless it ends in a slash, so connect-src http://localhost:8080/api/v1 allows that one path and blocks every route under it. The Next.js and Vite configs put NEXT_PUBLIC_API_URL straight into the policy, so a value carrying a path silently broke every request in the app. Both configs now reduce it to an origin.

Silently is the operative word: there is no HTTP status, no server log, just a console violation and a fetch that never happened.

next/image threw on your own uploads

next/image refuses any remote host it was not told about, and it throws instead of falling back to a plain <img>, so a single product photo took the whole page down with “hostname is not configured”. Stored files live on the storage origin, never the app’s own, so this hit anybody who rendered an upload. The scaffolded config now declares that host, derived from the same NEXT_PUBLIC_STORAGE_URL the CSP uses, plus picsum.photos in development because that is where --faker points its placeholder images.

Regenerating a resource could stop the build

Adding a file field to an existing resource and regenerating declared the handler twice, and the API stopped compiling with no new variables on left side of :=. The injection guard compared the whole block it was about to write, and the new block carried Storage: svc.Storage, so it did not match the one already there.

productHandler := &handlers.ProductHandler{
DB: db,
}
productHandler := &handlers.ProductHandler{ // the generator wrote this second one
DB: db,
Storage: svc.Storage,
}

The guard is now the declaration rather than the body, and a handler already declared is left as it is, because those lines are somewhere a person may reasonably have added a field. With one exception: a resource that has just gained its first file field gets Storage wired into the existing block, since without it the create and update flows skip the S3 cleanup on replace and never mark uploads claimed.

A Postgres-only query on every health check

The health endpoint counted tables with information_schema.tables WHERE table_schema = current_schema(), which is Postgres. On SQLite that logged a red SQL error on every poll of the System Health page, and on MySQL it returned nothing, because current_schema() does not exist there either. Now three dialects get three questions, with the logger silenced on failure: a missing tooltip figure is not worth a stack of alarming log lines on a healthy server.

v3.156.0August 17, 2026

Per-key rate limits, and an admin that teaches the difference between the two kinds of key.

A limit per key

A key can now carry its own requests-per-minute figure. Sentinel already limits by IP, and these answer different questions: an IP limit protects the server from a flood, a key limit protects you from one client. A partner integration polling every second, or a storefront with a render loop, throttled without touching the limit that applies to everybody else.

curl -X POST .../api/api-keys -d '{"name":"storefront","kind":"publishable","rate_limit":3}'
# then, against a limit of 3
1 -> 200 X-RateLimit-Remaining: 2
2 -> 200 X-RateLimit-Remaining: 1
3 -> 200 X-RateLimit-Remaining: 0
4 -> 429 Retry-After: 60

A fixed window in Redis: one INCR against a key carrying the current minute, with a two minute expiry so the bucket cleans itself up. A sliding window would be fairer at the boundary and costs a sorted set per key; a fixed window is one round trip, which is the right trade for throttling a misbehaving client rather than metering billing.

Two deliberate choices worth naming. No Redis means no per-key limiting, rather than falling back to an in-process counter: an in-memory count is per instance, so the effective limit would silently multiply by however many API containers happen to be running. And a Redis error fails open, because refusing every request when a counter is unreachable turns a cache outage into an outage, and the IP limit still applies.

The admin page

Creating a key now starts with choosing its kind, as two cards that say what each one is for, because that choice decides everything else.

A publishable key is shown in full in the table, with a copy button. A secret key shows only its prefix. That difference is the whole design made visible: the publishable one is already in every copy of your app, so hiding it here would protect nothing and cost you the ability to read it when setting up a new environment. The secret one exists only as a hash.

For the same reason, creating a publishable key does not open the “copy this now or lose it” panel. Putting it behind that panel would teach exactly the wrong lesson about what it is.

Endpoints and origins get a textarea each, with the guidance next to the field rather than in documentation somebody has to find: a trailing * matches a prefix, and origins should be left empty for a mobile app, because native clients send no Origin header and an allowlist would reject every request they make.

Each row shows its kind, its limit, and how many endpoint and origin restrictions it carries, with the full lists on hover.

v3.155.0August 17, 2026

CORS from settings, and the preflight header that made every storefront request fail in a browser.

The second one first, because it is the bug. X-API-Key was missing from Access-Control-Allow-Headers. A storefront calls the public endpoints with that header, cross-origin, and a header absent from that list is stripped by the browser during preflight. So every public request failed in every browser and worked perfectly under curl, which is the worst shape a bug can have. Found by sending a real OPTIONS request rather than by reading the middleware.

CORS origins now come from a cors.origins setting, resolved per request:

# before whitelisting
curl -X OPTIONS .../api/v1/public/products -H "Origin: https://myshop.com"
(no Access-Control-Allow-Origin)
# whitelist it in the admin, no restart
curl -X PUT .../api/v1/settings -d '{"values":{"cors.origins":"https://myshop.com"}}'
# immediately
Access-Control-Allow-Origin: https://myshop.com

Per request rather than captured at boot, because the point of putting origins in settings is that somebody adds a domain at 9pm and it works. A setting that existed and did nothing until the next deploy would be worse than not offering one. The cost is a comparison against a single-digit list, on a store already cached in memory.

CORS_ORIGINS still applies when the setting is empty, which is the default, so nothing changes for an existing project until somebody types a domain into the admin.

Public responses are cached now

CacheResponse has been in internal/middleware/cache.go for a long time and was mounted on nothing at all. It is now on the public group, and only there.

Only there for a specific reason. The cache key is the URL and nothing else. On a public endpoint that is exactly right: every caller gets the same answer, so one copy serves all of them and a catalogue page stops hitting Postgres on every visit. On a protected endpoint the same key would serve one user's data to another.

The TTL is a cache.public_ttl_seconds setting, default 60, read once at boot. Unlike the origins, a cache lifetime is not something anybody changes at 9pm, and re-reading it on the hot path of a cached response would cost more than it saves. The docs say it needs a restart rather than implying otherwise.

Verified: two identical public requests return X-Cache: MISS then X-Cache: HIT, and a protected endpoint returns no X-Cache header at all.

v3.154.0August 17, 2026

Four bugs found by building the storefront guide instead of reading it.

I followed the ecommerce guide command by command in a fresh project. Everything below is something that walkthrough hit, and three of the four would have stopped a beginner cold.

A public handler that did not compile

--public, shipped yesterday, emitted models.FileRefs for a files field. The real type is files.FileRefs from internal/files. So any resource with an image on it produced a public handler that failed to build, which is most of the resources anyone would want public. The type map now returns what the model actually declares, and the import block is computed from the fields rather than guessed, so a resource of plain strings does not get an unused import instead.

Faker colliding on unique columns

sku:string:unique plus --faker --count 40 logged a constraint failure and seeded 39 rows. gofakeit.Word() draws from a finite word list, so forty rows on a unique column collide, and the seeder had no notion of unique at all. A unique string column now gets a readable prefix plus entropy, so a SKU seeds as SKU-APEJ0818. Forty of forty, forty distinct.

Seeded products with no category

The seeder does link a belongs_to properly: it plucks the parent ids once and picks one per row. But if no parent rows exist the foreign key is silently left empty, and generating Category without --faker means there are none. Forty products, zero categories, no error anywhere.

That one is a documentation bug rather than a code bug, and it is fixed in the guide: generate the parent with its own seed data first. Worth knowing as a rule, because it applies to every relation you seed.

The docs reference moved out of routes.go

141 route overrides and 28KB of descriptions sat in the middle of the file you open to find out how the application is wired: 38% of routes.go, none of it about routing. They now live in internal/routes/apidocs.go, and routes.go went from 1482 lines to 897.

The generator injects into the new file and falls back to routes.go for projects that predate the split, so an older project keeps documenting its endpoints rather than silently stopping.

Also

Every scaffolded home page linked to grit-vert.vercel.app/docs, a preview deployment rather than the docs site. Six links across two templates now point at gritframework.dev.

v3.153.0August 17, 2026

Publishable API keys, and --public on the generator.

Generated CRUD sits behind auth, which is right for an admin resource and wrong for anything a customer reads. A storefront has no logged-in user, so calling the generated list endpoint returns a 401. That is the first wall anyone building a public-facing app walks into, and until now Grit had no answer for it.

grit generate resource Product --fields "name:string,slug:slug:name,price:float,cost_price:float,stock:int" --public
✓ apps/api/internal/handlers/product_public.go (3 field(s) published)
Held back: cost_price, stock
Add any of those to the publicProduct struct in that file to publish them.
✓ GET /api/v1/public/products and /api/v1/public/products/:key (API key required)

Note what it held back without being asked. cost_price because a name containing cost, margin, profit, internal, supplier or wholesale is never published whatever its type. stock because a raw count is a business fact your competitors enjoy and a page almost always wants “in stock” instead. Relations are held back too: publishing one would publish a whole related record nobody vetted.

The response is an allowlist struct, never the model, which is the opposite default to the admin surface and the right one when the audience is the internet. A column you add next month is private until somebody adds it to that struct. The file is written once and never overwritten on a regenerate, because the allowlist in it is yours.

Two kinds of API key

A key your storefront holds is not a secret. It ships inside your JavaScript bundle or your mobile binary, where anyone can read it, and an APK is a zip file. Calling it a secret and hoping is how an admin credential ends up in a JavaScript file.

So a key now declares what it is. grit_pk_... is publishable: safe in a browser or a phone, and structurally incapable of reaching a route that was not marked public. Not because it lacks a permission, but because the middleware for protected routes refuses the kind outright, before permissions are consulted. No combination of scopes talks its way past that, and a publishable key never inherits its owner's permissions at all.

grit_sk_... is secret: server side only, reaches whatever its owner can. It is hashed and shown exactly once. A publishable key is stored in clear and readable from the admin forever, because it was never a secret and pretending otherwise costs the one thing that makes it pleasant, which is reading it again when you set up a new environment.

Keys also carry two new restrictions, which are different axes rather than alternatives. endpoints narrows a key to specific routes as method plus path with an optional trailing wildcard. origins restricts browser use to named sites. Worth having and worth not overestimating: an origin check stops another site's page using your key from a customer's browser, and stops nothing that is not a browser. Leave it empty for a mobile app, which sends no Origin header at all.

Two keys in every new project

The seeder now issues a publishable and a secret key, prints both, and writes the publishable one into apps/web/.env.local so a fresh storefront can call the API without anyone copying anything. Idempotent by name: seeding twice does not mint a second pair you cannot tell apart.

Verified against a fresh project. A publishable key on a public endpoint returns 200; the same key on a protected endpoint returns 403 with a message naming the fix; a secret key on that protected endpoint returns 200; and the public response contained exactly id, name, price and slug, with cost_price, stock, internal_note and active all absent.

Backward compatible: keys issued before kinds existed have no pk or sk segment and are still read as secret keys, exactly as they were.

v3.152.0August 16, 2026

A settings registry, so configuration is not a choice between a deploy and a code change.

Every application needs values that are configuration but not environment variables: company name, invoice prefix, default currency, whether to email on a new order. Those had three homes in Grit and all of them were bad. In .env, which needs a deploy to change and which no admin can touch. Hardcoded. Or a hand-rolled settings table with a hand-rolled admin page, written again in every project.

Declare it next to the code that reads it:

apps/api/internal/billing/settings.go
settings.Define(settings.Setting{
Key: "invoice.prefix",
Type: settings.TypeString,
Default: "INV-",
Label: "Invoice number prefix",
Help: "Appears before the sequential number on every invoice.",
Group: "Billing",
Validate: settings.MaxLen(8),
})

and read it, typed, anywhere:

prefix := settings.String(ctx, "invoice.prefix")
notify := settings.Bool(ctx, "notifications.email_enabled")

From the declaration you get the admin page, grouped and with the right control per type, validation at write time, a cache, and a resolution order that is stated rather than assumed: user override, then tenant override, then the stored global, then the environment, then the declared default. That order is what lets a per-user timezone and a per-tenant currency work without either knowing the other exists, and it is why this is quietly load-bearing for the billing and entitlements work later.

A setting declared global refuses a per-user override rather than storing a row nothing will ever read. Silently accepting it would be worse: the change appears to save and then does nothing, and there is no way to find out why.

A batch save validates everything before writing anything, because a half-applied save leaves the page showing a mix of stored and rejected values with no way to tell which is which. Changes go through the event bus, so they land in the activity feed: a settings change alters behaviour everywhere and otherwise leaves no trace.

Where the environment sits took a correction during testing. The store resolved stored-over-env while the handler refused to write whenever an env var existed, which made app.name permanently read-only in every scaffolded project, because the scaffold sets APP_NAME. Two precedences in one feature. The store's is the right one: the admin page is the point, and a value somebody sets there has to take effect. The environment supplies what the application boots with. Something that genuinely must not be changeable belongs in config, not here.

TypeSecret is a string the API never returns once set. An SMTP password belongs there: an admin can replace it and cannot read it, which is what people expect and rarely get.

Verified against a fresh project across 24 checks, run twice to prove the suite is not depending on a clean database.

v3.151.0August 16, 2026

Workflows: a status field can be a process, not just a column.

A select field accepts every one of its options on every record. draft can jump straight to shipped, a shipped order can go back to draft, and a support agent can mark an invoice collected. Any rule about which of those is allowed lived in the author's head, or in a check they had to remember to write in every place the field was touched.

A workflow: block on the field states the rules once:

order.yaml
- name: status
type: select
options: [draft, submitted, approved, shipped, cancelled]
workflow:
initial: draft
terminal: [shipped, cancelled]
transitions:
- action: submit
from: [draft]
to: submitted
- action: approve
from: [submitted]
to: approved
permission: orders.approve
- action: cancel
from: [draft, submitted, approved]
to: cancelled
confirm: true

The states come from the field's own options rather than being repeated under workflow:. Two lists of the same thing drift, and the drift is silent: a transition to a state the dropdown never offers.

From that, grit generate resource writes a definition in internal/workflow/, a guarded transition service, and POST /api/orders/:id/transitions/:action. An illegal move is a 422 that names the state the record is in and lists what is allowed from there, rather than a successful write leaving a record somewhere the business rules say cannot exist.

The guard is in the service, not the handler, because a handler is one caller. A job, a CLI command, an importer and an offline sync push all reach the service, and a rule enforced at one entrance is not enforced. The write is also conditioned on the current state, so two people pressing Approve at the same moment do not both succeed: the second affects no rows and gets the same 422.

Every transition emits its own event rather than a generic updated. A subscriber that cares about orders shipping should not have to diff two versions of a record to work out that is what happened, and the activity feed reads “Ship: approved to shipped” against the order's reference. This is the first thing built on the event bus from v3.150.0, and it is why that came first: without it a workflow engine would have grown its own hook mechanism and become a fifth disconnected system.

Validation runs when the definition is parsed, so a broken machine is a CLI error rather than a panic at boot. The check worth having is the one for a state nothing can leave: it is invisible until a record lands there in production and cannot be moved, and the message says both ways to fix it.

Verified against a live server: an order starts in its declared initial state, a legal move works, draft to shipped is refused with the allowed actions named, an unknown action is refused, a terminal state has no way out, and the full path submit to approve to ship works with each step in the activity feed under its own label.

v3.150.0August 16, 2026

Domain events: webhooks and realtime now actually fire.

Grit shipped four systems that want to know when something happens: the activity log, outbound webhooks, realtime websockets and background jobs. A generated handler told exactly one of them. I checked a scaffolded project and no handler anywhere called DispatchWebhook, and none broadcast a resource change. Both features were complete, documented, and fired by nothing.

Making them work meant hand-writing the call in every handler, for every operation, on every resource. Forgetting one produced no error: just a webhook subscription that never heard anything.

There is one bus now. A handler says what happened, once:

apps/api/internal/handlers/invoice.go
events.Emitted(c, "invoices", "Invoice", "created", item.ID, item.Number, "", nil, item)

and the audit log, realtime and webhooks are subscribers. The generator emits that line in place of the services.LogCreate it used to write, so every resource has created, updated, deleted and bulk events from the moment it is generated, with no per-resource wiring.

Two delivery modes, and which one a subscriber gets is a real decision rather than a setting. Audit is synchronous: the activity row exists before the caller is told the write succeeded, and it is the one subscriber that legitimately needs the request context, because the feed records IP and user agent. Everything with a network call is asynchronous: a webhook endpoint that takes four seconds must not make the API take four seconds.

The async copy of an event carries a nil request context, on purpose. A gin context is cancelled and recycled once the handler returns, so an async subscriber reading it would be looking at somebody else's request. Nil turns a subtle data race into an obvious nil pointer the first time anyone tries.

The queue is bounded at 1024 and drops when full rather than growing. An unbounded queue turns a slow subscriber into memory exhaustion, which fails later and worse. Drops are counted and reported on /api/health alongside the subscriber count and queue depth, because “did my webhook fire” deserves a better answer than reading logs. A subscriber that panics is logged and the others still run: a webhook formatter falling over is not a reason to fail a write that already happened.

Verified by running it. The activity feed is unchanged and the row exists with no sleep in the test, which is the assertion that proves audit is genuinely synchronous. And a ledgers.created event reached a connected websocket client, which had never happened in a generated Grit project.

This is the substrate the next few features sit on. Workflows emit transitions rather than inventing their own hooks, notifications subscribe rather than needing their own trigger, and automation rules become a subscriber with a condition attached.

v3.149.0August 16, 2026

Offline behaviour is declared, enforced and diagnosable.

Yesterday's release gave every client a sync engine. It mirrored every registered model, asked a human about every conflict, kept nothing off the wire and had no age limit. Those are reasonable defaults and nobody chose them, which is the difference between a feature and magic. A developer shipping a point-of-sale app had no way to state what they needed and no way to find out what they had.

A sync: block in the resource definition now states it: mode, conflict strategy, which fields cross the wire, which never leave the device, and how stale the mirror may get before the app should say so.

Three conflict strategies. manual parks the change with both versions attached and a human decides, which is what every project had and stays the default, because silently discarding somebody's work should be opt-in. server_wins discards the client's change and hands back the server row, for records a back office owns. client_wins overwrites, for records with a single author where the version check protects nothing.

Enforced on the server, not the client. A rule an old build can ignore is not a rule, so the decision is made where a request cannot argue with it, and GET /api/sync/policy publishes the declaration so clients render the right UI rather than keeping their own copy to drift. local_only is stripped on both sides, which is what makes it a promise rather than a convention.

max_offline_age is advisory by necessity, because a client that has not synced is by definition not talking to the server. What it buys is a client that can say so: stale is its own badge state, ranked above offline, because “you are offline” and “this data is too old to act on” are different messages and only one of them should stop somebody shipping against a three-day-old stock level.

grit sync doctor exists because every mistake here is silent. A field allowlist naming a column that does not exist errors nowhere: it excludes the real column, and every client mirrors rows with the value missing. A model with no Version field cannot detect a conflict at all, so it takes whichever write landed last and nobody is told. It also catches a policy that is declared but not enforced, which is the worst state of the three.

useSyncHealth covers the same ground inside the app. An outbox that stopped draining three days ago looks exactly like an outbox with nothing in it, and the only difference visible from inside is the pending count, the age of the oldest queued change, and the time since the last successful sync.

On encryption: SQLiteAdapter takes an already-open database, so an SQLCipher connection keyed from the OS keystore encrypts the mirror on mobile and desktop with no change on our side. The browser gets nothing, deliberately. There is no keystore, so any key the page holds sits in JavaScript beside the data it protects, and an encrypted-IndexedDB option would defend against a threat nobody has while implying it defends against the one people picture.

Verified by running it. Twenty-four checks against a live server: the policy is published, local_only never reaches the database, pull sends only the allowlisted columns plus the bookkeeping ones a client cannot work without, server_wins comes back as its own code with the server row attached, and a model with no declared policy still parks conflicts for a human. Then twenty-three more against the client: it reads the policy, falls back to defaults when the server is unreachable rather than refusing to open, strips local-only fields before sending, applies a server_wins override without prompting, skips online_only models entirely, and reports stale.

Two upgrade-path bugs fixed along the way. Everything policy-related lives in a new internal/sync/policy.go rather than as an edit to registry.go, so grit generate resource can add it to a project generated before policies existed. And the model discovery behind grit add offline matched only Register, so the one resource with a deliberately declared offline policy was the one left out of the mirror.

v3.148.0August 16, 2026

Offline sync is a property of a resource, not of the desktop app.

The API has served /api/sync/pull and /api/sync/push since v3.60, and every generated resource registers itself with the sync registry. The server side was already complete. What was missing was a client anywhere except apps/desktop, where the engine is written in Go and cannot be imported by a browser or a phone.

grit add offline installs packages/sync: the same mirror, the same outbox with the same squash rules, and the same version-checked conflict handling, in TypeScript, over a storage interface. Three adapters ship: IndexedDB for web and PWA, expo-sqlite for mobile, and an in-memory one for tests and server rendering. It wires itself into whichever of apps/web, apps/admin and apps/expo your project has, and mirrors every model the API registered, read out of routes.go rather than from a list that can go stale.

useOfflineResource("products") is the whole interface. It returns rows from the mirror and writes through the outbox, and the screen calling it does not branch on connectivity anywhere. useSyncStatus gives you the badge, useSyncConflicts gives you both sides of a conflict and the two ways to end it.

The parts worth knowing about are the ones where doing the obvious thing loses data. A conflicted change is parked rather than retried, because replaying it would overwrite exactly the state the user is being asked about. Concurrent syncs share one run, because two of them draining the same outbox send every change twice and the second copy conflicts with the first. Creating a row and then deleting it while offline cancels both ends instead of sending a delete for something the server has never seen. A pull follows full pages, because stopping after one would leave the mirror quietly behind after any bulk change.

Verified by running it: 45 checks against a mock server covering the squash rules, tombstones, cursor pagination, conflict parking, resolve, revert, retry after a transient error, and concurrent syncs. Typechecking proves a file is well-formed and says nothing about whether an offline app loses your work.

Also in this release, three documentation corrections. Email verification and API keys have shipped for a while and the docs never mentioned either, which is why an outside review filed both as missing features. They are now in Authentication, with the endpoints, the key format, and why the secret is hashed with SHA-256 rather than bcrypt. MySQL is named in the README stack table. And the mobile offline page no longer says that offline writes are a desktop-only feature.

v3.147.0August 16, 2026

grit upgrade stops overwriting the files you have edited.

It used to overwrite all of them, every time. The function that wrote them took a force parameter and never read it, so the flag was decoration and every upgrade was a forced one. If you had changed a framework component, the upgrade took it back and told you it had updated 87 files.

Grit now records what it writes. Every generated file gets an entry in .grit/manifest.json: which generator wrote it, at which version, and the hash of what it wrote. An upgrade compares that against what is on disk, so it can tell a file nobody has opened from one you have customised. Untouched files are replaced. Edited ones are named and left exactly as they are.

grit upgrade --diff prints a unified diff of your version against the new one, so you can take the parts you want by hand. grit upgrade --force does what upgrade always did.

The check sits at the single function every generated byte passes through, not at the callers. Upgrade regenerates the web app, the admin, the docs and the root config through four different paths that fan out to dozens of template functions, and one check at the one choke point is both smaller and harder to leave a hole in.

Two details that are not cosmetic. Hashes are taken with line endings normalised, because git on Windows checks files out with CRLF while the generator writes LF, and hashing raw bytes would report every file in a fresh clone as edited: an upgrade trusting that would refuse to update anything. And injection re-records rather than invalidates, so adding a route to routes.go does not make it read as hand-edited from then on.

A project created before this release has no manifest, so nothing can be said about what has been edited in it, and its first upgrade behaves exactly as it always did. That upgrade writes the manifest. Every one after it is protected. Commit .grit/manifest.json so the whole team gets it.

Also: grit generate resource records its files under the resource that owns them, which is what grit upgrade --resource will read next.

v3.146.0August 16, 2026

MySQL is a supported database.

Point DATABASE_URL at mysql://user:pass@tcp(host:3306)/db and the API connects. The scheme is stripped rather than parsed, because the driver wants its own DSN format and not a URL, and parseTime=true&loc=UTC is appended when absent: without it MySQL returns DATETIME columns as raw bytes and every time.Time field on every model fails to scan.

Three things had to change behind that. The two type:jsonb column tags are gone, since datatypes.JSON already picks jsonb on Postgres and json on MySQL by itself, and naming a Postgres type explicitly failed AutoMigrate on a database that has no such type.

The second was the dangerous one. Generated handlers skip the reload after a write when the generator can see the write is a single statement, on the strength of RETURNING filling the struct. MySQL has no RETURNING, and it does not say so: the write succeeds, the clause is dropped, and the record comes back with id, created_at and version all at zero. A create would have answered 201 with a half-empty body and no error anywhere. The optimisation is now decided at build time and applied at run time, through database.Write and database.SupportsReturning in the new internal/database/dialect.go. On Postgres and SQLite this costs one boolean.

The third: ?active=true arrives as a string. Postgres reads it as a boolean; MySQL stores the column as tinyint(1), coerces a non-numeric string to 0, and quietly matches nothing. Query filters now read the model schema and convert boolean columns only, because "true" is a legitimate thing for a varchar to contain.

grit generate resource writes internal/database/dialect.go if your project predates it, so a resource generated in an older project still builds.

Verified against MySQL 8.4: 33 tables migrated, both JSON columns landed as native json, create returns a complete record, and ?active=true returns rows where it previously returned none.

v3.145.0August 13, 2026

The detail page is customisable the same way the list page is.

Every customisation so far applied to the list view. The record page was a monolith with all its state inline, which is exactly the shape ResourcePage was in before its controller was extracted, so it got the same treatment. useResourceDetailController(resource, id) returns the record, the visible columns, the inline line-item fields, the related resources resolved out of the registry, and the edit, delete, print and PDF actions with their dialogs. The stock page is markup and nothing else now, which is the proof the hook is complete enough to build your own on.

Four slots. DetailPage replaces the whole thing, for a record that is not a field list: an order with a fulfilment timeline, a ticket with a thread. DetailHeader, DetailFields and DetailAside each take one part and leave the rest.

The three part slots receive the controller as a prop instead of calling the hook, and that was a bug before it was a design. Built as they first were, each slot made its own controller, so pressing Edit in a custom header opened a sheet the page around it never read and nothing happened. Caught by clicking the button. They share one controller now, which is what lets a part drive the page it sits in, and it matches how the list slots already worked.

form on the detail controller carries the record as well as the flag, mirroring the list controller. Without it every caller reaches for c.record and meets the difference between undefined, meaning still loading, and null, meaning creating, which the stock form distinguishes and a query result does not.

v3.144.0August 13, 2026

Filter presets as tabs, and query filters that actually filter.

A tab is a named set of query parameters. "Unpaid" is not a different page, it is this page with status=pending, and a tab says that more plainly than a dropdown somebody has to open to discover:

// apps/admin/resources/orders/orders.ts
table: {
  tabs: [
    { key: "all",     label: "All",     count: true },
    { key: "unpaid",  label: "Unpaid",  filters: { status: "pending" }, count: true },
    { key: "shipped", label: "Shipped", filters: { status: "shipped" }, count: true },
  ],
}

A real tablist, so arrow keys move between tabs and Tab leaves the group. Without roving focus a keyboard user walks through every filter on the way to the table, which with six tabs is six stops before reaching the thing being filtered. The table carries the matching tabpanel role and is labelled by the active tab, so a reader hearing a tablist also learns what it controls.

Counts are opt-in per tab, because each one is a request. The badge appears when its number arrives rather than showing a zero first: a tab that says 0 and then says 47 is worse than a tab that said nothing for a moment.

The filters they depend on were never wired up. Building this turned up that paginate.Bind never collected column filters from the query string. The code that applies them was there, with a comment promising ?status=active&building_id=..., and nothing ever populated it, so the admin's existing filter dropdowns sent parameters the API discarded. It went unnoticed because generated resources ship with an empty filters: [].

Query parameters that are not reserved pagination keys are collected now, and applied only where the handler whitelists them:

// apps/api/internal/handlers/shipment.go, generated
paginate.Config{
  Searchable: []string{"reference", "carrier"},
  Sortable:   map[string]bool{"id": true, "created_at": true, ...},
  Filterable: map[string]bool{"id": true, "reference": true, "status": true, ...},
}

The whitelist is not optional: the column name is interpolated into the WHERE clause, so an unfiltered version of this would let a caller write the query. Values were always parameterised. An unknown column is ignored rather than rejected, so a stray parameter is never an error. Verified against a running server: the three status tabs return 4, 1 and 5 of 10 rows, an unknown column changes nothing, and a quoted injection in the value matches zero rows because it is treated as a value.

grit upgrade does not touch API code, so an existing project needs grit generate resource to pick up the whitelist, and its tabs render unfiltered until it does.

v3.143.0August 13, 2026

One folder per resource.

Adding the .custom.tsx overlay doubled the number of files in resources/, and the flat layout stopped scaling: with twenty resources it is forty files in one directory, and the two halves of a single resource sort apart from each other whenever another name falls between them.

resources/
  index.ts
  products/
    products.ts          generated, rewritten freely
    products.custom.tsx  yours, written once
  users/
    users.ts
    users.custom.tsx

The overlay import inside the definition is unchanged. It was always ./products.custom and the two files are still siblings. What changed is the registry, which now imports ./products/products, and the route pages, which import @/resources/products/products.

Existing projects are moved for you. grit upgrade gives each resource its folder, carries the overlay across with it, and rewrites both the registry imports and the alias imports in the route pages. It runs before anything is written, because dropping a new users/users.ts into a project still holding a flat users.ts would leave two definitions and a registry pointing at the stale one.

Nothing is deleted, only moved, and the whole thing is safe to run repeatedly: the import patterns refuse a path that is already nested, so a second pass cannot produce products/products/products. There are tests for exactly that, and for the registry surviving untouched, since index.ts is not a resource and moving it would break every import at once.

generate, sync, grit g field and remove resource all read both layouts, so a project that has not upgraded yet keeps working. Writes always use folders.

Two fixes to yesterday's bulk actions. The bar is fixed to the bottom of the viewport now rather than sitting at the foot of the table. The original reasoning, that a floating bar covers the rows it acts on, only holds for a table that fits on screen: with twenty rows you tick something near the top and the bar appears below the fold, so as far as the operator can tell nothing happened. It is a centred pill rather than a full-width bar, and the page reserves space underneath while it is shown, so the last rows can still be scrolled clear of it.

And the bulk hook falls back to one request per row when POST /<resource>/bulk returns 404. That is the normal state of an upgraded project: grit upgrade replaces the admin but never regenerates API handlers, so the browser gets the new code while the server keeps the old routes, and without the fallback every existing install would 404 the moment somebody ticked a row. The fallback is genuinely worse, N requests and a partial result if one fails, so run grit generate resource for the real endpoint. Resources with no declared bulkActions now default to edit, export and delete rather than delete alone, since those three work against any API.

v3.142.0August 13, 2026

Bulk actions: edit, archive, restore, export and delete.

Tick some rows and a bar appears at the foot of the table. Until now the only thing you could do with a selection was delete it, and that was two buttons squeezed into the toolbar between the search box and the column picker, which put a Delete one gap away from a text field.

Archive is a real state, not a status field you have to invent. Every generated model gains archived_at, and it is deliberately not deleted_at: a soft-deleted row is gone as far as the app is concerned, while an archived one is still listable, still exportable and restorable in one click. The list endpoint hides archived rows unless ?archived=true asks for them, and the resource page grows Published and Archived tabs. Archive and Restore never appear together, because offering both is how an operator archives what they meant to bring back.

One request, one transaction. Bulk delete used to fire one DELETE per row from the browser: N transactions, N audit entries, and a half-applied result when the eleventh failed, with the operator told it failed while ten rows were already gone. There is a real endpoint now:

POST /api/v1/products/bulk
{ "action": "archive", "ids": ["...", "..."] }

{ "data": { "affected": 9, "requested": 12 },
  "message": "9 products archived" }

It reports what it actually did rather than what was asked. Archiving twelve rows of which three were already archived says nine, and the toast says so too. The patch action reuses the same whitelist PATCH does, so a client sending id or created_at has them dropped rather than honoured, and the id list is capped at 500 because an unbounded IN clause is a way to lock a table by accident.

Bulk edit is one field, on purpose. Editing every field at once means deciding what an empty input means, and there is no good answer: clearing destroys data nobody looked at, ignoring makes it impossible to clear anything. One field sidesteps it and is the actual job nine times in ten. Unique columns are left out of the list, because writing one SKU to forty rows is either a constraint violation or, worse, not one.

All of it is customisable. Pick the built-ins per resource with table.bulkActions, and add your own from the overlay file, where they can hold functions:

// resources/shipments.custom.tsx
bulkActions: [
  {
    key: "mark-delivered",
    label: "Mark delivered",
    icon: "CheckCircle",
    confirm: "Mark every selected shipment delivered?",
    visible: (rows) => rows.every((r) => r.status !== "delivered"),
    onSelect: async (ids, rows, { refresh, clearSelection, announce }) => {
      await markDelivered(ids)
      refresh()
      clearSelection()
      announce(rows.length + " marked delivered.")
    },
  },
]

The action gets the ids and the rows, so acting on what the operator ticked needs no second round trip for data already on screen. Replace the bar outright with the BulkBar component slot if the shape is wrong for you.

The bar sits in the flow at the foot of the table rather than floating over it: a floating bar covers the rows it acts on, and on a short table it covers the last two entirely. It is a labelled region, so it appears in a landmark list, and its arrival is announced, because ticking a checkbox does not move focus and a bar that silently appears is a bar a keyboard user never learns about. Delete is the only red control in it.

One pagination bug fixed on the way. Meta.Total, Page and Pages carried omitempty, so an empty result set came back as {"page":1,"page_size":20} with no total at all. Zero is an answer: every client reading meta.total got undefined, which renders as a blank stat card rather than a nought and turns arithmetic into NaN. It is the reason an empty resource showed a dash where a 0 belonged.

v3.141.0August 11, 2026

Seven fixes found by building an app with the customisation feature instead of reading it.

The three releases before this one shipped a way to replace a resource's table, form, empty state or whole page from a resources/<name>.custom.tsx file. Everything compiled and every test passed. Then we built a small admin with it: a product list with custom cells, a deal pipeline as a kanban board, an enquiry inbox with its own list and composer, and found this.

Tailwind never looked at your overlay. The admin's content globs covered app/, components/ and lib/, not resources/, which is the one directory the feature invites you to write markup in. Every class in an overlay was dropped from the stylesheet. The component was right, the DOM was right, and the screen showed a white status pill on a white background. Nothing but a browser could have caught it. Fixed in the scaffold and in grit upgrade.

You could not wrap the component you were replacing. The docs said a slot receives the stock component's own props, so (props) => <Card><DataTable {...props} /></Card> would work. It did not: DataTable and the form components took Record<string, unknown> while a typed overlay hands them Product, which has no index signature. DataTable, FormSheet, FormModal and FormModalSteps are generic over the row now, so wrapping works and so does passing controller.form.item to the stock form from inside a custom page.

grit upgrade was updating 31 files nothing imported. Its path list still used the PascalCase component names from before the kebab-case rename, so every upgrade wrote components/tables/DataTable.tsx next to the real data-table.tsx and left it there. The symptom was the opposite of an error: it reported dozens of files updated while the components your app actually renders were never touched, meaning no component fix shipped in an upgrade had arrived since the rename. Paths corrected, the duplicates are cleaned up on the next upgrade, and form-sheet, form-modal-steps, update-groups, resource-detail-page and use-resource-controller are now refreshed too.

grit generate resource Ticket deleted the support desk. It overwrote internal/models/ticket.go, taking TicketReply with it, reported success, and the build then failed with an undefined symbol in a different file. Thirty-odd built-in model names are reserved now, with an error that says which feature owns the name and suggests one that is free. --force is there if you mean it. A test scaffolds a project and compares the list against what is actually emitted, so a new built-in model cannot quietly go unprotected.

grit remove resource left the overlay behind. It imports a type the shared package no longer exports, so removing a resource stopped the admin from type-checking. An untouched stub is deleted; one you have written in is renamed to .custom.tsx.bak, which keeps it out of the TypeScript build without throwing your work away.

--faker seeded choice fields with dictionary words. A status:select:active|draft|archived column came back full of "moreover" and "ouch": values the form's own dropdown cannot offer, the API's validation would reject, and the generated TypeScript union says are impossible. Choice fields are now seeded from their own options.

The admin ships type-clean. The scaffold was writing an i18n layer: language-switcher.tsx, i18n/request.ts and four more files, without the next-intl dependency that makes them compile, so every new Next.js admin started life with three type errors. Worse, grit add i18n skips files that already exist, so the broken copies blocked the command that would have fixed them. They are gone from the scaffold and pruned on upgrade when next-intl is absent. A fresh admin now reports zero errors from tsc --noEmit.

v3.140.0August 11, 2026

Typed rows in resource customisations.

A cell renderer used to receive Record<string, unknown>, so every custom cell started with a cast and a renamed column failed at runtime in front of whoever opened the page. The customisation surface is now generic over the row, and the generated overlay wires it up for you:

import type { ResourceCustomisation } from "@/lib/resource";
import type { Product } from "@repo/shared/types";

const custom: ResourceCustomisation<Product> = {
  columns: {
    // row is a Product — price is a number, so toFixed exists
    price: { cell: (row) => <b>{"$" + row.price.toFixed(2)}</b> },
  },
};

export default custom;

Write row.prise instead and TypeScript stops you with Property 'prise' does not exist on type 'Product'. Did you mean 'price'? — which is the entire point of the change.

The row type comes from @repo/shared/types, the same interfaces grit sync already generates from your Go structs, so there is no second definition to keep in step. ColumnDefinition, ColumnClick, ResourceTableProps and ResourceComponents are all generic now; the registry stays untyped so it can still hold every resource in one array.

Existing projects are migrated for you. grit sync creates the overlay for any resource that predates it and threads the import into the definition, skipping anything already wired. Both operations are guarded, so running it twice does nothing the second time.

One bug fixed on the way: grit update refreshes resources/users.ts, which now imports its overlay — so an upgrade would have left the admin importing a file that did not exist. The upgrade creates it when missing and never overwrites one that is already there.

v3.139.0August 11, 2026

Custom tables, forms and pages, registered once and safe from the generator.

A resource has always been able to declare a custom cell renderer. Almost nobody could use it. resources/products.ts is a .ts file, so JSX will not compile in it, and grit generate rewrites that file whole — so anything you did put there was one command away from being deleted.

Resources are now two files. The generator owns one and never touches the other:

apps/admin/resources/
  products.ts          # generated — rewritten on every grit generate
  products.custom.tsx  # yours — created once, never touched again

The custom half holds components, and defineResource merges the two:

import type { ResourceCustomisation } from "@/lib/resource";

const custom: ResourceCustomisation = {
  columns: {
    status: { cell: (row) => <StatusPill value={String(row.status)} /> },
  },
  components: {
    Table: (props) => <TemplateTable rows={props.data} onSort={props.onSort} />,
  },
};

export default custom;

There are four slots — Table, Form, EmptyState and Page — and each receives exactly the props of the component it replaces. Because Table takes DataTable's own props, a replacement is a drop-in and you can also wrap the original rather than reimplement it: (props) => <Card><DataTable {...props} /></Card>. A Page slot replaces the whole list view and can call useResourceController for the data and behaviour.

Columns and fields are patched by key rather than replaced wholesale, which is what lets grit sync keep adding columns as the Go model grows without discarding your renderers.

Verified the way it needs to be: customise an overlay, regenerate the same resource with an extra field, and the config half picks up the new column while the custom half is left exactly as it was. Nothing changes for existing projects — a resource with no overlay behaves as it always did.

v3.138.0August 11, 2026

useResourceController() — the admin list page, minus the markup.

If you have bought an admin template and want to port its pages into Grit, the data was never the hard part. useResource, useCreateResource and friends have always been plain hooks that take an endpoint. The hard part was everything else the list page does: keeping search, sort, page and filters in the address bar so a refresh or a shared link rehydrates the same view; row selection; bulk delete behind a confirm; toasts; cache invalidation; and stat cards that follow the active date range instead of contradicting the table underneath them.

All of that lived inside ResourcePage, welded to Grit's DataTable. Swapping in your own table meant rebuilding it. Now it is a hook:

"use client";

import { useResourceController } from "@/hooks/use-resource-controller";
import { productsResource } from "@/resources/products";

export default function ProductsPage() {
  const c = useResourceController(productsResource);

  return (
    <TemplateShell title={c.pluralName} onAdd={c.create}>
      <TemplateTable
        rows={c.rows}
        columns={c.columns}
        loading={c.isLoading}
        sortKey={c.sortBy}
        sortDir={c.sortOrder}
        onSort={c.setSort}
        selected={c.selection}
        onSelect={c.setSelection}
        onRowClick={c.edit}
      />
      <TemplatePager
        page={c.page}
        pages={c.totalPages}
        total={c.total}
        onChange={c.setPage}
      />
    </TemplateShell>
  );
}

The controller hands back the data (rows, meta, isLoading), the query state and its setters — where setSort toggles direction and every setter that changes the query resets to page one — plus selection, visible columns, the create/edit/view/delete actions, dialog state for anyone rendering their own modals, and the same apiSearchParams the table queried with, so an export matches what is on screen.

Nothing changes for existing projects. ResourcePage was rewritten to consume the hook and render only markup, which is the point: if the stock page could not be rebuilt on the controller, the controller would be missing something. Every resource page keeps behaving exactly as it did.

Also fixed while in here: the Vite admin's next/navigation shim declared router.replace(to) with one parameter, so the { scroll: false } that Next.js callers pass was a type error in the TanStack admin and compiled fine in the Next.js one. The shim now accepts and ignores the options bag.

v3.137.0August 7, 2026

Grit UI blocks can now declare the shadcn primitives they use, and grit ui add tells you about them.

Every block in the registry so far has been self-contained markup: install it and it renders, with nothing else to add. That works for a hero section. It does not work for a login form, where the thing worth having is the field wiring — a label tied to its input, an error tied to it by aria-describedby, and aria-invalid that actually flips. Hand-rolling that per block is how you end up with forms that look right and report nothing.

So blocks can now declare registryDependencies, and the registry serves them. Installing one pulls its primitives in:

$ grit ui add application-ui-authentication-sign-in-card-with-oauth

  ✓ Sign in card with OAuth  components/grit-ui/authentication/sign-in-card-with-oauth.tsx

  Requires: @hookform/resolvers, react-hook-form, zod
  Install with: pnpm add @hookform/resolvers react-hook-form zod

  Uses shadcn primitives: button, form, input
  Add any you do not have: npx shadcn@latest add button form input

The primitives are named rather than installed, the same way npm packages already were: your package manager and workspace layout are your call. But naming them matters more here than it does for npm packages, because the failure mode is worse. A block that imports components/ui/button does not fail at install — it fails at your next build, in a file you did not write. That is the difference between a one-line fix and half an hour reading a module-not-found trace.

Marketing blocks are unchanged and stay dependency-free. A hero that drags four Radix packages into a project to render a heading and a link is a bad trade.

v3.134.0August 6, 2026

A generated write was sending seven statements to Postgres. It now sends one.

Turning on Postgres statement logging during a benchmark run showed what a single POST /products actually cost:

begin
INSERT INTO "products" (...)
commit
SELECT * FROM "products" WHERE id = $1
begin
INSERT INTO "user_activities" (...)
commit

Three separate problems, all fixed.

An audit row was written for requests with no authenticated user. The CRUD helpers record who changed what. With no actor there is no who, so the row answers nothing, and on a public endpoint every anonymous write became two inserts in two transactions, which is write amplification an attacker controls for free. LogCreate, LogUpdate and LogDelete now return early without an actor. Auth events are deliberately unchanged, because LogLoginFailed records an empty actor on purpose.

The handler re-read the row it had just written. That SELECT existed to pick up columns the database fills in. The generator now emits Clauses(clause.Returning{}), so the INSERT brings them back itself. Resources with relations keep the re-read, because RETURNING cannot populate a preloaded association.

GORM wrapped a single INSERT in a transaction. One statement is already atomic in Postgres, so BEGIN and COMMIT bought a guarantee that was already held and cost two round trips for it. Where the generator can see there are no children, no join rows and no sequence hook writing alongside, it now emits the write with SkipDefaultTransaction for that call only.

That last one is decided per resource, from the definition. The global DB_SKIP_DEFAULT_TRANSACTION stays off, because it cannot know whether your model has children and a half-written invoice is worse than a slow one.

v3.133.0August 4, 2026

GORM now caches prepared statements, and the implicit write transaction is finally something you can turn off.

Found by benchmarking Grit against Express: Express was winning on inserts, and the reason was not the framework. Every GORM write was BEGIN + INSERT + COMMIT — three round trips where one would do — with the statement re-planned by Postgres each time.

The Bun pair was re-run afterwards to measure it rather than assert it. Inserts went from 1,568 to 2,686 req/s on the same hardware, closing most of the gap to Bun without touching the default that keeps multi-row writes safe.

  • PrepareStmt is on by default. A query that runs a thousand times is planned once per connection instead of a thousand times. Disable with DB_PREPARED_STATEMENTS=false if you run pgbouncer in transaction mode, where server-side prepared statements do not survive.
  • DB_SKIP_DEFAULT_TRANSACTION=true drops GORM's implicit transaction around single writes — worth roughly a third of write throughput.

That second one is off by default, and that is deliberate. The resource generator emits models with relations, and saving a parent with children is several INSERTs. Without the wrapping transaction, a failure halfway leaves an invoice holding some of its line items and no error anyone notices until the numbers stop adding up. It would have made the benchmark look better; it is not worth that.

The full comparison — Grit against Bun, Encore.ts and Express, every framework on its own ORM, with the harness and raw results — is at /docs/benchmarks.

v3.132.0August 4, 2026

You can now actually run without Redis.

Setting REDIS_URL= in .env looked like it should disable Redis and silently did not. getEnv treats an empty value as unset and hands back the default, so the asynq worker and the cron scheduler started anyway, failed to dial, and retried in a tight loop — a process burning CPU on reconnects with nothing in the logs but a wall of dial errors. On a machine with no Redis, simply running the API cost real cycles.

The three cases are now distinguished properly:

  • REDIS_URL unset — the local default, which is what most dev setups want
  • REDIS_URL= — no Redis. Cache, background jobs, worker and cron all stay off, and the app says so once at boot rather than leaving you to wonder why your jobs never run.
  • REDIS_URL=redis://… — use it

Found while benchmarking, where the retry storm was polluting the measurements. Verified on a scaffolded project: with REDIS_URL= the log contains zero dial errors and the API serves normally.

v3.131.0August 4, 2026

A connection-pool default was costing 3.4x on read throughput. Found by benchmarking, fixed here.

The scaffold shipped SetMaxIdleConns(10) next to SetMaxOpenConns(100). Past ten concurrent requests, every connection handed back to a full idle pool is closed — and the next request makes Postgres fork a fresh backend. Under load that is a connection storm, and it surfaces as database CPU, which is the last place you would look for an application bug.

Measured with k6 at 50 concurrent users, 4 CPUs per container, on a single-row read:

  • idle=10 — ~810 req/s, Postgres pinned near 840% CPU while the API used 196%
  • idle=100 — ~2,720 req/s, both containers around 300%
  • Writes went from ~690 to ~1,310 req/s on the same change

Idle now defaults to Open, and both are tunable via DB_MAX_OPEN_CONNS and DB_MAX_IDLE_CONNS. Tunable rather than hard-coded because it is not a free win everywhere: on a query heavy enough to saturate the database — an unindexed COUNT over a large table on every request — a smaller pool acts as admission control and measured about 20% faster, since queueing in the app is cheaper than thrashing in Postgres. The default suits the common case; the knob is there for the other one.

v3.130.0August 4, 2026

Every endpoint at /docs now shows what it returns.

Most built-in operations rendered “No Body” — you could see the URL and nothing else. Measured against a live spec from a scaffolded project: 134 of 134 operations now carry a response schema, up from 29. Request bodies cover 117 of 134.

  • The other 17 are right as they stand. Fourteen are bodyless action POSTs (logout, revoke-all, retry, unlock, close, reopen…), POST /uploads is multipart rather than JSON, POST /webhooks/:provider takes whatever the third party sends, and the SAML callback is form-encoded.
  • Sixteen handlers that bound var req struct{…} inline now bind named exported types. gindocs reflects over a type, and routes.go is a different package — an anonymous or unexported struct gives it nothing to read. Sixteen already-named types were exported for the same reason.

Also fixed: a new project's own tests failed. newTestDB migrated only models.User, so registering could not write its session or activity row — and the duplicate-email case surfaced as a 500 instead of a 409. go test ./... is now green across all ten packages of a freshly scaffolded API.

v3.129.0August 4, 2026

Turn on two-factor from your phone or the desktop app, not just the admin panel.

v3.125.0 taught both clients to answer a 2FA challenge. Enrolling still meant opening the admin panel, which is an odd thing to require of someone holding the phone the authenticator lives on. Both clients now do the whole thing: setup, QR, verify, backup codes, regenerate and disable.

  • Expo: a new two-factor screen, reached from Settings → Security.
  • Desktop: a section on the profile page, between Password and Delete account.
  • No QR encoder ships in either bundle. The API returns the QR as a PNG data URI, which <img> and React Native <Image> both render directly.
  • Backup codes are shown once and the panel will not close until they are saved. On mobile that is Share rather than the clipboard — it is the affordance a phone actually has for getting text into a password manager, and it avoids adding expo-clipboard for one screen.

Also fixed: Expo apps crashed on the web target. expo-secure-store is the iOS keychain and the Android keystore, neither of which a browser has, so pnpm web died on the first render with getValueWithKeyAsync is not a function. A new lib/secure-store.ts wraps it: unchanged on native, localStorage on web. That is not equivalent storage, and the file says so — web is a preview and debugging target, and native builds keep real secure storage.

Verified by driving both running clients with live TOTP codes rather than by type-check alone.

v3.128.0August 4, 2026

grit swap input now restyles your forms, finishing what v3.127.0 started for buttons.

Forty form fields across thirteen files route their classes through inputClasses(). Swap in soft-filled and every field in a modal changes together, instead of one or two while the rest keep the old border.

  • Field surfaces unify. Some inputs were on bg-bg-elevated, some on bg-bg-secondary, some on bg-bg-tertiary — accidents, not decisions. The slot owns the surface now, so the whole form matches.
  • Checkboxes and radios deliberately stay out. The slot sets w-full, which is right for a text field and wrong for a 16px box. Fields carrying an explicit width stay out for the same reason — two width utilities would fight, and which one wins depends on Tailwind's internal ordering rather than the order you wrote them.
  • The table's page-size <select> stays out too. It is toolbar chrome, not a form field, and w-full would stretch it across the row.
  • The confirm-to-delete field now uses the slot's own invalid state rather than carrying focus:border-danger alongside the slot's focus:border-accent.

The import test from v3.127.0 now covers both slots, and a second budget test pins the count of hand-styled fields so it can only go down.

v3.127.0August 4, 2026

grit swap button now restyles the admin, not two stray components.

The button slot has shipped for a while, but almost nothing called it — admin pages hand-wrote bg-accent px-4 py-2 rounded-lg instead. So swapping in a variant changed two files and left every real page untouched. Forty-six call sites across the admin now route their classes through buttonClasses(), taking slot reach from 2 to 23 emitted files.

  • Only the class string changed. The element, its handlers, spinner logic and children are untouched — buttonClasses() is exported by the slot for exactly this, and it is what keeps links and labels that look like buttons on the same style after a swap.
  • Sizes are inferred from height, never width. A button that changes height shifts the row it sits in; a few pixels of horizontal padding go unnoticed.
  • Themed auth pages deliberately do not follow the slot. They style buttons with var(--auth-primary) so atlas, aurora and pulse can restyle them — routing those through the slot would fight the theme system.
  • Two new tests guard it. One catches a page that calls buttonClasses() without importing it, and the reverse — an import with no call, which is how a template file with several pages in it puts the import in the wrong one. The other pins the remaining inline count so it can only go down.

Verified visually rather than by type-check alone: under grit swap button glow-ring, the “New role” button on /system/roles goes from rounded-lg to a pill with no layout shift, on a page that had nothing to do with the slot before.

v3.126.0August 4, 2026

Your releases get the same supply-chain guarantees Grit's do.

Grit signs its own releases — SBOM, keyless cosign signature, provenance attestation — and gave the projects it scaffolds none of that. Generated projects now ship .github/workflows/release.yml, which does the same on any v* tag.

  • Cross-compiled API binaries (linux and darwin, amd64 and arm64), checksums, an SPDX SBOM, a keyless cosign signature over the checksums, and a build provenance attestation.
  • No secrets to configure. Keyless signing uses the workflow's GitHub OIDC identity, so there is no private key to store or leak, and the signature is logged publicly in Rekor.
  • A desktop job builds Wails installers on Windows and macOS, with Authenticode and notarization steps that activate when you add the certificates and otherwise emit a build warning rather than failing. The job only runs if the repo actually has a desktop app.

The generated YAML is parse-checked as part of verifying this — a workflow that only fails when you cut a release is worse than no workflow.

v3.125.0August 4, 2026

2FA no longer locks you out of the mobile and desktop apps.

Both clients read res.data.tokens.access_token straight after login. On an account with two-factor enabled that field does not exist — the API returns a pending token instead — so the app threw rather than asking for a code. Anyone who turned 2FA on in the admin could then sign in nowhere else.

  • Expo and the Wails desktop client both handle the challenge now: a code field, a backup-code toggle, and “trust this device for 30 days”.
  • login() returns the challenge rather than throwing, and a new verifyTOTP completes it — one function for both authenticator and backup codes, since only the endpoint differs.

Verified by signing in on the desktop app against a 2FA-enabled account with a live code: challenge shown, code accepted, session established. Enrolment is still admin-only on these two clients — they can complete a challenge, not set 2FA up.

v3.124.0August 3, 2026

The API reference stops burying your API.

  • Third-party mounts are out of the spec. Pulse, Sentinel and GORM Studio each mount their own dashboards inside your app, and 111 of their routes were being listed at /docs alongside the ~134 that are actually yours.
  • Documented operations went from 4 to 41, and the schema catalogue from 9 to 33. Sessions, API keys, uploads, backups, roles, notifications, users, the GDPR journal, the activity log and its integrity check now show a typed response instead of “No Body”.

93 operations are still undocumented — mostly mutations, which need a named request type before the reference can describe them (the handlers bind anonymous structs, and there is nothing for the generator to reflect over). That work continues in Phase 6.5.

v3.123.0August 3, 2026

API keys.

The JWT flow is built for a human at a browser — short-lived tokens, a refresh cookie, rotation. A cron job on someone else's server wants one long-lived credential in a header. Now it has one.

  • Create keys at /system/api-keys. The key is shown once; the server stores only a SHA-256 hash, so the panel refuses to close until you have copied or downloaded it.
  • Send it as X-API-Key: grit_… or Authorization: Bearer grit_…. The middleware populates exactly what the JWT middleware does, so every existing handler and RequireRole check works unchanged — including the admin-guarded routes.
  • The token is grit_<prefix>_<secret>. The prefix is indexed, so verification is one lookup rather than a scan, and the secret is compared in constant time.
  • Optional expiry, revocation, and last_used_at tracking. Revoked keys stay in the list — “which key did this?” is a question people ask about keys turned off months ago.

Seven generated tests cover hash-only storage, wrong secrets, revoked and expired keys, malformed tokens, and owner-scoped revocation. The flow was then driven over HTTP against a running API and through the admin in a browser.

v3.122.0August 3, 2026

Per-account lockout.

Sentinel rate-limits by IP, which does nothing against attempts spread across many addresses at one account — the shape of every credential-stuffing run. Ten wrong passwords now lock an account for fifteen minutes (LOGIN_MAX_ATTEMPTS, LOGIN_LOCKOUT_MINUTES; set the first to 0 to disable).

  • Only wrong passwords on real accounts count. An unknown email never locks anything — counting those would let anyone lock an address they can guess, turning a defence into a denial-of-service tool.
  • Locked accounts are refused before the password comparison, so the lockout cannot be probed by timing.
  • The counter increments with a single SQL expression, so parallel attempts cannot overwrite each other's count.
  • POST /users/:id/unlock (ADMIN) clears a lockout early, for the support call five minutes before a demo. The action is written to the activity log.
v3.121.0August 3, 2026

Email verification.

The User model has carried email_verified_at since the beginning, and only social sign-in ever set it — a field that looks like a feature and was not one. Now a password signup can prove its address.

  • A verification mail goes out on register, off the request path so signup never waits on SMTP. POST /auth/verify-email consumes the token; POST /auth/verify-email/send re-sends for the signed-in user (authenticated on purpose — an open “mail this address” endpoint is a spam cannon).
  • Tokens are single-use, 48-hour, and stored only as a SHA-256 hash. Issuing a new one burns the old. The address is recorded with the token, so a stale link cannot verify an address the user switched to afterwards.
  • Optional enforcement: set REQUIRE_EMAIL_VERIFICATION=true to refuse password sign-ins until confirmed. Off by default — turning it on for an existing project would lock out every user at once. Social and SSO logins are unaffected.
  • Admin UI: a /verify-email page for the link to land on, and a banner with a resend button for anyone who has not clicked it.

Seven generated tests cover the token rules, including the changed-address case. The whole flow was driven over HTTP, and the gate tested in both directions.

v3.120.0August 3, 2026

The tamper-evident audit log finally has a screen.

  • New page at /system/audit. The hash chain, the verify endpoint and the OCSF export have all shipped for a while, and nothing in the admin called any of them — so the one thing a compliance reviewer wants to see was invisible. The page lists every authenticated write with its method, status, duration and body digest, and a Verify chain button replays the whole chain. When a row has been edited it names the position, the id, and both hashes. Verified by editing a row directly in the database: the check caught that exact row.
  • Retention for the audit log. The model has always carried a comment saying to add this. A weekly audit:prune job trims entries past AUDIT_RETENTION_DAYS (default 365; set 0 to keep forever) and re-anchors the chain, so what remains still verifies — a plain DELETE would leave the log permanently reporting itself as broken.
  • The SSO connection test is reachable. The endpoint shipped with no button, so a mistyped issuer URL only surfaced when a customer tried to sign in.
  • The System Hub tile said SSO was OIDC. SAML 2.0 has been supported for a while.
v3.119.1August 3, 2026

Fixes the Vite admin build, broken by v3.119.0.

The two-factor card shipped to the Next.js admin only. The profile page is shared between both front-ends, so a project scaffolded with --vite imported a component that was never written and failed to build. The component is now generated for both.

Caught by scaffolding --triple --vite and building it — the step that was skipped before releasing 3.119.0.

v3.119.0August 3, 2026

Two-factor authentication is now something you can click.

The API has shipped TOTP for a while — setup, enable, disable, backup codes, trusted devices, the login challenge. None of it was reachable from the admin panel, so the feature existed only for people willing to write curl by hand. Worse, the login page did not understand the challenge: the first person to enable 2FA would have locked themselves out.

  • A Two-factor card on the profile page. Scan a QR, confirm with a live code, and get ten backup codes shown once — the panel will not close until you copy or download them, because the server keeps only hashes. Regenerate codes, review trusted devices, revoke one or all, and turn 2FA back off with your password.
  • The login page answers the challenge. A 6-digit field, a “trust this device for 30 days” option, and a fallback to a backup code.
  • The QR is rendered by the API and returned as a data URI on POST /auth/totp/setup, so no client ships a QR encoder or handles the raw secret to draw a setup screen.
  • Two new endpoints: GET /auth/totp/trusted-devices lists them (the status endpoint only ever returned a count, which you cannot act on) and DELETE /auth/totp/trusted-devices/:id revokes one.

Verified end to end against a generated project: enable, sign out, sign back in through the challenge with a real authenticator code, trust the device, and see it appear in the list.

v3.118.0August 3, 2026

Search was broken on SQLite. So were R2 image previews.

  • Every search box returned a 500 on SQLite. Search clauses were built with ILIKE, which is Postgres-only — on SQLite it is a syntax error, and SQLite is what the quick start and the generated Go tests use. One generated resource also searched id::text, another Postgres-only form. Both are now LOWER(col) LIKE LOWER(?), which behaves identically on both drivers.
  • Images uploaded to R2 never displayed. Object URLs were built from the configured S3 endpoint, and R2's endpoint only answers SigV4-signed requests — so every <img> got a 401 while uploads succeeded. It reads like a CORS problem and is not one. Storage now takes a browser-facing origin: R2_PUBLIC_URL (or S3_PUBLIC_URL, B2_PUBLIC_URL, MINIO_PUBLIC_URL, or a shared STORAGE_PUBLIC_URL). Set it to the bucket's public origin — an r2.dev subdomain, a custom domain, or a CDN — and stored URLs point there instead. Every scaffold now ships a test that pins this behaviour.

Existing projects: re-run the search fix by regenerating resources, or replace x ILIKE ? with LOWER(x) LIKE LOWER(?) in your services. For R2, add R2_PUBLIC_URL to .env.

v3.117.0August 3, 2026

Archive uploads work, and three display bugs are gone.

Found by building the demo forms for the homepage — every one of these survived because nothing had driven the feature end to end before.

  • A field declared file:zip could never be uploaded. The admin uploads via a presigned URL, and the presign endpoint validated only against a global allow-list that contained no archive types at all — while ignoring the field's own accepts. It was both too strict (rejecting a field's declared types) and too loose (a field declared file:pdf would presign a PNG). Presign now honours accepts, the allow-list covers zip/tar/gzip/rar/7z and legacy Office, and the completion step re-checks the type instead of recording whatever the client claims.
  • Error rate was shown 100× too high. Pulse reports error rates as a percentage already; the admin and desktop apps multiplied by 100 again, so 3 errors in 38 requests rendered as “789.47%”.
  • Acronyms in generated labels are no longer split. portfolio_url read as “Portfolio U R L”; it now reads “Portfolio URL”, and APIKey as “API Key”.
  • The auth response shape in the API reference was wrong — tokens are nested under data.tokens, notdata.
v3.116.0August 3, 2026

The API reference now documents request and response bodies.

Every operation at /docs used to render as “No Body”. Route introspection gave gin-docs paths and status codes, but it only attaches a schema where an override hands it a concrete type — and the scaffold registered none. The result was a reference with 250+ endpoints and not one example payload, plus copy-paste curl commands with nothing to post.

  • Generated resources document themselves. grit generate resource Product now also registers list, create, read, update and delete with typed request bodies and response schemas.grit remove resource takes them back out.
  • Named request types. Handlers bound to anonymous structs, which gave the reference nothing to reflect over. Create and update bodies are now CreateProductRequest / UpdateProductRequest — the same fields, with a name.
  • Auth endpoints documented by hand, including readable summaries. The inferred ones read as “Create a new login”.
  • The quick-access button moved to the bottom right in the admin and desktop apps. Bottom-left parked it on top of the sidebar's user footer at every sidebar width.

Existing projects keep working — the new documentation is added by the generator, so re-running grit generate resource on a fresh scaffold is the way to pick it up.

v3.115.1August 3, 2026

The Expo app's web target now runs.

A scaffolded Expo app declared a web target in app.json and shipped a pnpm web script, but not the two packages that target needs — so the script failed on a fresh project with “you don't have the required dependencies installed”.

  • Added react-native-web and @expo/metro-runtime at the versions Expo SDK 54 expects. pnpm web now bundles and serves.
  • Fixed a React duplication on web. The Metro resolver deduped react but not react-dom, on the reasoning that React Native has no use for it. True on native, false on web — where the hoisted Next.js copy (19.2.8) met Expo's React (19.1.0) and React refused to start. Both are now pinned to the app's own copies.
  • grit new --full help text corrected. It read as “triple plus docs”; it has always also included the Expo and Wails apps.

Existing projects: add the two packages with npx expo install react-native-web @expo/metro-runtime, or re-scaffold.

v3.115.0August 2, 2026

Swappable components. One command restyles the whole admin.

There is a difference between adding a button and swapping the button. Adding gives you a new file to import wherever you like. Swapping overwrites the one file every call site already imports — so grit swap button glow-ring restyles every button in your admin without you editing a single import.

  • Two slots to start: button and input, at components/ui/ in your admin. Browse the variants on Grit UI — swappable ones carry a Swappable badge and show both commands, because they install like any other block too.
  • The command refuses more than it accepts. A variant whose contract major differs from your slot is rejected rather than written. A slot file you have edited by hand is never overwritten without --force. The previous file is always backed up to .grit/swaps/, so grit swap button --revert is a real undo rather than a suggestion to check git.
  • And it type-checks afterwards, then rolls back on failure. This is the part that makes swapping safe on a real app. A variant that compiles in isolation can still be incompatible with your call sites — dropping a variant from a union, say. grit swap runs tsc after writing and, if it fails, restores the previous file byte-for-byte and records nothing. A swap that leaves your app not compiling is worse than one that refuses.
  • Admin only, on purpose. The marketing site and the admin have different primitives, and a slot that means two different things in two apps is not a slot.

Groundwork shipped with it: the admin now has real Button and Input primitives, adopted across the form fields and form actions. Previously there were 231 hand-rolled <button> elements that did not agree with each other on padding or font weight — which is why a colour bug could appear in several places independently.

v3.114.0July 31, 2026

A real date picker, and radio/checkbox groups that read as one choice.

  • Date and datetime fields get a proper picker. The native mm/dd/yyyy input is replaced with a calendar whose header carries a month dropdown and a year dropdown — so a date of birth in 1985 is two selections and a click, instead of holding an arrow key. The year list runs 100 years back to 10 forward by default; new minDate / maxDate field options narrow it and grey out days outside the range. Datetime fields keep a time row, and picking a day no longer resets the time to midnight. The panel is portalled, flips above the field when there is no room below, and closes on Escape without also closing the form modal around it.
  • Radio and checkbox groups render as one divided list.Options now sit in a single bordered container split by hairlines, each row carrying its control, a bold label and an optional description, with the selected row tinted and its text in the accent colour. Previously they were separate cards floating in a gap, which reads as several independent controls rather than one question with several answers.
  • Fixed: accent tints that silently rendered as nothing.The themes declare --accent as a hex, and Tailwind cannot inject an alpha channel into a bare var() — so bg-accent/10 and text-accent/80 compiled away entirely. The selected row had no fill at all. These now use color-mix, which follows whichever theme is active.
v3.113.0July 30, 2026

Create a related record without leaving the form, and save a multi-step form one step at a time.

  • Inline create from a relationship dropdown. Open the Category select on a Product form and the list now ends in a New Category row. It opens the Category resource’s own form in a nested dialog — stepper included, if Category declares steps — and the record you create becomes the selected value. Anything typed into the search box is carried into the new record, and the label appears immediately rather than flashing a raw UUID while the options refetch. The row only appears when the related model is a registered resource and you hold <slug>.create; set allowCreate: false on the field to hide it. Works the same way on many-to-many selects, where it appends to the selection.
  • Per-step Update on multi-step edit forms. Editing a record through a stepped form gives every step its own Update button. It is disabled until you change something on that step, saves only that step’s fields with PATCH, and goes back to disabled once it lands. Editing the address on step 3 no longer rewrites the twenty fields on steps 1 and 2 with whatever the form happened to be holding. A failed save leaves the step dirty so you can retry rather than showing a step that was never persisted. Applies to both modal-steps and page-steps; creating still submits once at the end, because a record that does not exist yet has nothing to PATCH against. Set perStepSave: false on the form to keep the old single-submit behaviour.
v3.112.0July 29, 2026

Grit UI — 100 components, and a registry that serves them.

  • ui.gritframework.dev — a browsable gallery with live previews of 100 React components across marketing (20), SaaS (30), ecommerce (20), layout (20) and auth (10). Every preview is a real render of the source you would install, not a screenshot that can quietly go stale.
  • grit ui list and grit ui add. Components are written into components/grit-ui/ in the right app for your architecture. An existing file is never overwritten without --force — once you have edited a component it is your code.
  • Works outside Grit entirely. Each component is a shadcn registry item, so npx shadcn@latest add https://ui.gritframework.dev/r/hero-split-01.json works in any React + Tailwind project. That one command writes the component, merges the design tokens into your CSS, and adds the colour scale to your Tailwind config.
  • The library needed real repair first. The components were recovered from git history, where they had been removed from the scaffold with a note that they would ship standalone — which never happened. As recovered, they were not shippable: 4 of 100 were advertised with no source at all, no registry item inlined its file content (so every install would have produced an empty file), 55 used state or event handlers without "use client" and would crash in any App Router project, 80 lacked a default export, 6 had required props with two genuine render crashes, and 4 used the JSX namespace React 19 removed. All fixed, and the four missing components were written rather than dropped.
  • Verified by installing, not by building. A throwaway consumer project confirms the written file is byte-identical to the source, and that both the CSS variables and the Tailwind scale merge.
  • Fixed: every failed command printed its error twice. Cobra printed a failing command's error and main printed it again. Long-standing, affected every command.

10 new tests. Matrix 73/0.

v3.111.0July 29, 2026

grit test — every suite, one report.

  • One command for a project with tests in three languages. Go in the API, Vitest in each frontend, Playwright at the root — and which of those exist depends on the architecture you scaffolded. grit test works that out and prints a single table with per-suite status and timing. Exits non-zero if anything failed, so it drops into CI unchanged.
  • Suites that cannot run are reported, not dropped. Every skip carries its reason — “no app under apps/ defines a test script”, “not requested — pass --e2e”. A runner that silently runs nothing looks identical to one that passed, which is the most expensive kind of green.
  • End-to-end is opt-in. Playwright needs the API and frontends already running; failing against a server that was never started tells you nothing about your code. --e2e turns it on.
  • Respects your setup rather than second-guessing it. When the root package.json defines a test script, that is used as-is — usually turbo fanning out across the workspace — instead of running every app separately and duplicating the work. The package manager follows whichever lockfile is present. Flags: --go, --node, --e2e, --race, --cover.
  • Rewritten philosophy page. The philosophy doc and the pitch now state what each decision costs, including when Go, React, or Grit itself is the wrong answer. Also corrected a claim there: batteries are switched off with MODULE_<NAME>=false in .env, not with grit new flags.

10 new tests. Verified on the api, single and triple architectures. Matrix 73/0.

v3.110.0July 29, 2026

grit mcp serve — your project, answerable by an AI agent.

  • A Model Context Protocol server over stdio. Register it with claude mcp add grit -- grit mcp serve --project . and an agent can ask Grit three things instead of inferring them from a grep: grit_project_info (architecture, module path, whether Go lives at the root or under apps/api), grit_list_routes (every route with its full path, handler and access level, filterable by method or substring), and grit_describe_models (fields, Go types, JSON names, GORM tags).
  • Read-only and static, on purpose. Every answer comes from parsing your source — no running server, no database, no credentials. So it works on a checkout that has never been started, has no secret to leak into an agent's context, cannot mutate your repo, and cannot be talked into running a migration by instructions hidden in a README. An agent that wants to change the project still calls the CLI, where the change lands in your diff.
  • Fixed: grit routes was printing every path one segment short. Routes mount under r.Group("/api/" + APIVersion), and the parser only understood string literals — so the prefix evaluated to nothing and /api/v1/users was reported as /users. Wrong in the worst way, because it looks right. The parser now resolves string constants, including inside const (…) blocks, and renders anything it still cannot resolve as {Name} rather than dropping it silently. This had to land first: an MCP tool confidently handing an agent the wrong URL is worse than no tool at all.
  • Also fixed in the same parser: nested groups now inherit from the receiver they were actually created on. The previous code searched the line for any known variable name while iterating a map, so a line mentioning two known groups could pick the wrong parent on some runs and not others.
  • Not shipped yet, deliberately: openapi and recent_errors. Both need a running server and a database connection, which is a credential and connection story the read-only tools don't require — a different surface, worth doing on its own.

19 new tests. Matrix 73/0.

v3.109.0July 29, 2026

Generated Go is now gofmt'd.

  • Every Go file Grit writes goes through go/format first. Grit builds Go by concatenating strings, which is impossible to keep aligned by hand — struct tags drifted out of column and import groups came out in whatever order the generator happened to append them. A fresh project is now gofmt -l clean, and the templates only have to be correct rather than pretty.
  • Formatting can never break scaffolding. If generated source doesn't parse, the original text is written unchanged instead of raising an error. A syntax error should surface at go build on your project, where the compiler points at the offending line — not as an opaque scaffolding failure with nothing on disk to inspect.
  • Fixed a real bug this surfaced: the second resource you generate no longer breaks GORM Studio. Formatting removes the optional trailing comma from a single-line composite literal, so the studio model list became {&models.User{} /* grit:studio */} — and the injector, which assumed that comma was there, produced &models.User{} &models.Post{}. That parses as a bitwise AND and failed with a mismatched-types error nowhere near the cause. Inline injection now supplies the separator instead of assuming it, which also makes it robust to however you have hand-edited the line.

Matrix 73/0.

v3.108.0July 29, 2026

Primary keys are now UUIDv7.

  • A new internal/ids package, and every model uses it. ids.New() replaces uuid.New().String() in all 36 places Grit mints an identifier — scaffolded models, generated resources, the desktop app and its offline sync engine, and the saved-views and multitenant plugins. A v7 UUID is still a standard 128-bit UUID that any client can generate offline with no coordination, but it carries a millisecond timestamp in its high bits, so ids sort chronologically.
  • Why it matters: index locality. Random v4 keys scatter inserts across the whole B-tree, so every write dirties a different page and the index fragments as the table grows. Time-ordered keys append to the right-hand edge instead. You also get a free ORDER BY id that means “oldest first” without a second index on created_at.
  • The trade-off, stated plainly: v7 ids leak creation time. The timestamp is readable by anyone holding the id. If you expose raw primary keys in public URLs, you are also publishing when each record was created — and, across two ids, how fast you are growing. That is fine for most applications and wrong for some. If it is wrong for yours, internal/ids is one small file with one function; change New() and every model follows.
  • Existing rows keep working. Both versions are UUIDs in the same varchar(36) column, so there is no migration — old rows stay v4, new rows are v7, and nothing needs to be rewritten. Only ids created from this version on will sort chronologically.
  • Four tests ship in every project, and they test the property, not the spelling. That ids are lexically time-ordered, are valid version 7, stay unique across 10,000 generations inside a single millisecond, and are never empty. That last one is why New() falls back to v4 rather than returning an error: an unordered id is a performance regression, an empty primary key is data corruption.
  • Also fixed: Organization.Active could never be stored as false. The sixth instance of the gorm:"default:true" trap — GORM omits zero-valued fields from the INSERT when the column has a default, so an organization created suspended came back live.

Matrix 73/0.

v3.107.0July 28, 2026

A linter that starts green, and a connection pooler.

  • .golangci.yml in every project — and it passes on day one. The enabled set was chosen by measurement rather than taste: each of the eleven linters was run against a freshly generated API and only the ones reporting zero findings were turned on. That is the difference between a linter you keep and one you disable within the hour. It covers what actually kills a Go service — leaked response bodies, unclosed sql.Rows, unchecked Rows.Err(), requests built with no context, nil returned after an error check — plus govet with its two noisiest style analyzers switched off.
  • The stricter linters are documented, not hidden. The five that weren't green (errcheck, staticcheck, errorlint, gosec, unused) are listed in the config with their real finding counts and what causes them, so you can adopt them one at a time instead of meeting 133 findings on a new project.
  • PgBouncer in the production compose. Postgres forks a backend process per connection, so connection count — not query load — is usually what falls over first, and the API plus asynq workers are already multiple processes against one database. Transaction pooling multiplexes 500 client slots onto 20 server connections. Opt in with DB_HOST=pgbouncer DB_PORT=6432; the config notes exactly which Postgres features don't survive transaction pooling.
  • A request-lifecycle doc. The exact middleware order, why CORS must run before auth, why Recovery sits after Logger, why CSRF skips bearer clients, and why c.Abort() is mandatory in denying middleware.

Matrix 73/0.

v3.106.1July 28, 2026

Three correctness fixes, one of which broke every SQLite project.

  • Postgres-only SQL in two background queries. The user-cleanup worker and the 24-hour activity panel used NOW() - INTERVAL '30 days', which is Postgres syntax and errors on SQLite — a first-class target and the quick-start default. On those projects the cleanup job failed on every run and soft-deleted users were never purged. Both now compute the cutoff in Go and bind it as a parameter.
  • A GORM default that inverted five security switches. A bool column declared gorm:"default:true" can never be stored as false through a create: GORM omits zero-valued fields when the column has a default, so the database default wins. That silently flipped User.Active, FormShare.Enabled and BackupSchedule.Enabled — creating a deactivated user gave you an active one, and a share link meant to start disabled went live. Removed the defaults; every create path already set these explicitly. internal/models/bool_flags_test.go now ships in every project to stop it recurring.
  • No more native browser dialogs. Six destructive actions used window.confirm — unbrandable, unstyleable, and on the desktop app rendered as OS chrome. All now use the themed ConfirmModal (admin) or the promise-based useConfirm() the desktop scaffold already shipped but two of its own pages ignored.
  • A ten-point “Nielsen Pass” added to GRIT_STYLE_GUIDE.md as a pre-ship gate for admin pages. Every item on it is a bug that actually shipped and had to be fixed by hand afterwards.

Matrix 73/0.

v3.106.0July 28, 2026

Enterprise SSO — sign in with your customer's identity provider.

  • OpenID Connect, one connection per customer. Add a connection in System → Single sign-on with an issuer URL, client ID and secret, and the domains it covers. Anything with a discovery document works — Okta, Entra ID, Auth0, Keycloak, Google Workspace, Ping, OneLogin. No new dependency: it's built on the OIDC provider goth already ships.
  • Routed by email domain. The login page now offers Sign in with SSO: the user types their work address and the server decides where it belongs, so you never publish a list of your customers on a public page. An address with no connection falls through to the password form.
  • Users provisioned on first login, roles from IdP groups. Map IdP groups to roles ({"it-admins":"ADMIN"}) and they're re-applied on every login — so removing someone from a group in the IdP revokes their role here too, which is the only reason to map groups at all. Just-in-time provisioning can be turned off for customers who pre-create users.
  • Identities are linked by subject, not email. A new user_identities table matches on the IdP's immutable sub first, so someone who changes their email at the identity provider keeps their account and their data instead of silently getting a second one. Email is the fallback, which is also how an existing password user gets linked the first time their company turns SSO on.
  • Client secrets are encrypted at rest (the same AES-256-GCM field encryption used elsewhere) and are write-only — the API never returns them, so editing a connection shows a blank field meaning “keep what's stored”.
  • Connections go live without a restart. Providers are built at boot and rebuilt when a connection is saved. The registry is owned and RWMutex-guarded rather than using goth's package-level provider map, whose unsynchronised writes would be a fatal concurrent map read/write the moment an admin saved a connection while somebody was signing in. A connection whose discovery fails is logged and skipped so one broken IdP can't stop everyone else.

SAML 2.0 as well. Pick the protocol per connection. A SAML connection takes the IdP's metadata (URL or pasted XML) instead of client credentials, and publishes an SP metadata endpoint plus an ACS endpoint for the customer's IdP admin. The service-provider keypair is generated on first use and its private key encrypted at rest; authentication requests are signed (RSA-SHA256) so providers that require signed requests work without extra setup. IdP-initiated sign-in is on by default, since starting from the provider's app tile is how most enterprise users actually log in.

Both protocols converge on one identity shape before any account is touched, so SAML inherits the provisioning, identity-linking and role-mapping behaviour OIDC already has tests for rather than growing a second, subtly different copy.

Matrix 73/0.

v3.105.0July 28, 2026

API versioning — the whole surface now lives under /api/v1.

  • Every route is versioned. Auth, resources, admin, public forms, blogs — all of it hangs off a single v1 group in routes.go, driven by an APIVersion constant. Once anything outside your repo calls your API — a mobile build you can't force-update, a partner integration — you can't change a response shape without breaking it. The prefix is where the new shape goes: add a v2 group beside v1, leave v1 answering the old way, and retire it when your logs say nobody's left.
  • Nothing breaks on upgrade. Unversioned /api/… requests are transparently re-dispatched to /api/v1/… and answered normally, with Deprecation: true and a Link: </api/v1>; rel="successor-version" header so the old path shows up in callers' logs. It runs as the 404 fallback, so the only requests that pay for it are ones that were going to 404 anyway.
  • All five clients pinned in one line each. Admin, web (Next and Vite), the single-app SPA, desktop, and Expo each export an API_VERSION and apply it centrally — endpoints stay written as /api/users, so moving to v2 is a one-line change per app rather than a find-and-replace across ~200 call sites, and an app can never end up half-migrated.
  • /api/ws stays unversioned — a WebSocket upgrade can't safely pass through the re-dispatch, and a transport endpoint isn't part of the REST surface being versioned.

Matrix 73/0.

v3.104.0July 28, 2026

Real PDFs, named toasts, and a way back out of the System Hub.

  • Server-rendered PDFs. Every generated resource now exposes GET /api/<resource>/:id/pdf, and the detail page has a PDF button beside Print. The document is laid out from the record — a title block, a two-up field grid, line items as a table, totals and notes — with a repeating header and footer carrying “Page N of M” on every page. Because it's rendered in Go rather than by the browser, the same bytes can be emailed or archived. The generic renderer lives in internal/pdf/record.go; the handler it drives is plain generated Go you can restyle.
  • Toasts name the resource. “Invoice created successfully” instead of a bare “Created successfully” — across create, update, save, delete, and bulk delete (which also reports the count: “3 Invoices deleted successfully”).
  • Back to System Hub. Sub-pages under /system/* and /settings/* were dead ends once the sidebar collapsed into a single hub link. They now derive a back link automatically — including pages added by plugins. Override with backHref, or pass backHref={null} to suppress it.
  • Access Reviews: a real form. “New review” opened a raw window.prompt; it now opens a proper sheet with a name field and an optional note (which the API already stored but nothing could set).
  • GDPR is connected to Users. The page took a pasted UUID — it now has a searchable user picker, and the Users table gained an Erase (GDPR) row action that deep-links with the subject pre-selected. The journal still records erasures only; an ordinary delete is a reversible soft delete, and the page now says so instead of leaving you wondering why nothing appeared.
  • Custom row actions. The Erase (GDPR) entry is built on a new table.rowActions extension point — give it a label plus an href(row) or onClick(row), optionally variant: "danger" and a visible(row) predicate.
  • Better browser print, too. Proper @page margins, ink-friendly colors, repeated table headers across pages, and no rows split down the middle.

Matrix 73/0.

v3.103.0July 28, 2026

A developer-defined Generate button for form fields.

  • generate on a text / number field. Give a field a generate: (values) => string | number function and Grit renders a small Generate button in its label row. Clicking it runs your function with the current form values and fills the input with the result — the function can be async (call an endpoint, derive from another field, mint a code), and the button shows a spinner until it resolves. The field stays visible and editable.
  • The visible counterpart to auto. Use number:string:auto when a value should be assigned silently on the server and hidden from the form; reach for generate when the user should see the field and trigger generation themselves. You wire generate by hand in the resource definition — the generator never emits it. Works in modal, full-page, and multi-step forms; both admins.

Matrix 73/0.

v3.102.0July 28, 2026

A calmer sidebar — one System Hub with tabs.

  • The rail is just resources + System Hub now. The old Internal and System nav groups (Activity, Support, Notifications, Health, Performance, Security, Roles, Access Reviews, GDPR, …) no longer crowd the sidebar. Every operational surface moved into the System Hub at /system.
  • The hub is now tabbed. Surfaces are grouped under Operations, Security & Access, Data & Files, Communication, and Settings — pick a tab, then a tile. Access Reviews, GDPR, and Dashboard settings are first-class tiles here.
  • Plugins get an Extensions tab. Links that plugins inject (Webhooks, Impersonate, …) surface under a dedicated Extensions tab that only appears when something is installed — no plugin changes required.

Matrix 73/0.

v3.101.0July 28, 2026

Clickable table columns.

  • onClick on any column. Two behaviors are built in — onClick: "link" opens the row's detail page (with a hover open arrow), and onClick: "copy" copies the cell value to the clipboard with a check-mark flash — or pass onClick: (value, row) => … to do anything (open a modal, fire a mutation, deep-link elsewhere). The click is isolated: it never triggers the row's other actions and composes with format, badge, and custom cell.
  • Click-to-open out of the box. Generated resources now set onClick: "link" on their first plain column, so the primary identifier — invoice number, name, title — opens the detail page on click without any wiring. Relationship columns are left alone (linking a related entity's name to this resource's page would mislead).

Matrix 73/0.

v3.100.0July 28, 2026

Auto-numbered fields in one modifier: number:string:auto.

  • The auto field modifier. Declare a field as number:string:auto:INV and Grit does everything an auto-generated identifier needs: it stands up the atomic internal/sequence counter package (and registers its table with AutoMigrate), generates the model's BeforeCreate hook to fill the field as INV-202607-0001, marks the column optional, and hides it from the create/edit form — while keeping it on the table and detail page. The prefix is optional (number:string:auto derives one from the model name); each auto field gets its own counter keyed <model>_<field>.
  • No more “why is the number field empty and required?” auto is a shortcut over grit generate sequence; the hook calls the generic sequence.Next directly (never the services wrapper — that would be a models→services import cycle), so generated projects compile clean. Reach for grit generate sequence directly when you want a yearly/never reset, a custom width, or to call the counter from your own handler.
  • Under the hood. The sequence generator no longer depends on the process working directory — the resource generator wires the counter using the project root it already knows, so auto works the same however you invoke it.

Matrix 73/0.

v3.99.0July 28, 2026

Searchable select fields, and a fixed Access Reviews icon.

  • Select is now a searchable combobox. Every generated select field — including command-generated ones like a status dropdown — opens a panel with a type-to-filter search box and keyboard navigation (↑/↓, Enter, Esc), instead of a plain native <select>. Fields that pull choices from an endpoint (optionsUrl) get the same treatment. Proven in a browser: a five-option priority select filtered to one as you typed.
  • Access Reviews sidebar icon. Its UserCheck icon was missing from the sidebar's internal icon map, so the entry rendered blank. Added it; the icon now shows.

Matrix 73/0.

v3.98.0July 28, 2026

A radio field type, and “New child” buttons on detail pages.

  • radio field type. A single choice rendered as a radio-button group — same Go string, Zod z.enum, and TS union as select, but the options are laid out as buttons instead of a dropdown. Use it for a few visible choices (a plan tier, a priority): plan:radio:free=Free|pro=Pro. Labels stay optional — bare values are capitalized (past_due → Past Due).
  • Create a child from its parent. A resource's detail page already lists the records that belongs_to it; now each of those tables has a New <child> button that opens the child's create form with the parent already filled in. On a customer you get New Invoice scoped to that customer; on a category, New Product in that category. Backed by a new defaults prop on the form components for create-time pre-fill.

Proven in a browser: on a customer's page the New Invoice button opened a Create Invoice drawer with the customer pre-selected and the status shown as radio buttons; saving wrote an invoice carrying the right customer_id. Matrix 73/0.

v3.97.0July 27, 2026

Invoices & line items — a guide, plus print. A new Invoices & Line Items guide documents the parent-with-children pattern end to end — and it's generic: read “Invoice” as orders/order-items, purchase-orders/lines, or whatever you're modeling.

  • One command vs. separate. The guide breaks down --items (child resource + inline line-items table, saved atomically with the parent) and shows the equivalent two grit g resource calls with an explicit belongs_to if you'd rather build the pieces yourself.
  • Auto-numbering. Documents grit generate sequence — atomic, gap-free numbers like INV-202607-0001 backed by a DB counter — and how to call NextInvoiceNumber from BeforeCreate so every record is numbered without collisions.
  • Print (new). Every generated resource detail page now has a Print button. A print stylesheet isolates the record: the detail content is wrapped in #print-area and everything else — sidebar, navbar, Edit/Delete controls, related tables — is hidden, so the printout is just the record and its line items.

Proven in a browser against a live invoice with two line items: the Print button renders, #print-area wraps the details + items, the chrome carries no-print, and the @media print rules ship in the admin CSS. The print feature works on every resource with no per-resource code. Matrix 73/0.

v3.96.0July 27, 2026

grit g field — add a column to an existing resource. Forgot a field? Add it in place without regenerating:

grit g field Invoice status:select:draft=Draft|sent=Sent|paid=Paid
grit g field Invoice notes:text

It injects the column into the Go model, the create/update Zod schemas, the TypeScript type, and the admin form + table — in place, at structural anchors, so it works on resources generated before the command existed and never disturbs your hand edits. The database column is added by GORM on the next grit migrate (the model is the source of truth), so there's no migration file to manage. Re-running is idempotent.

Supports scalar, select, and toggle types; relationship, file, slug, and array fields still want a regenerate (they change imports and joins), and the command says so. Proven end to end: added a select and a text field to a generated Invoice, confirmed all five injections, rebuilt the API, ran grit migrate and watched it ALTER the live table (“added 1 column(s): priority”), and rebuilt the admin clean. 4 unit tests. Matrix 73/0.

v3.95.0July 27, 2026

Option-backed field types: select, check, and toggle. Define dropdowns, checkbox groups, and switches — with their choices — right in the --fields string, and the whole stack is generated to match.

grit g resource Invoice --fields   "number:string,status:select:draft=Draft|sent=Sent|paid=Paid,   channels:check:email=Email|sms=SMS|push=Push,active:toggle"
  • select:v=Label|… — a single choice. Go string, Zod z.enum([…]), a TS string-literal union, and a dropdown in the admin form.
  • check:v=Label|… — many choices. Go datatypes.JSONSlice[string], z.array(z.enum([…])), and a checkbox group.
  • toggle — an on/off boolean rendered as a switch. A bare option value like in_progress is auto-labeled “In Progress”.

Proven end to end: 7 unit tests over the parser and every type mapping, the admin builds, the create form renders the dropdown / checkbox group / switch with the right labels, and a create round-trips with the right stored types — status a string, channels a JSON array, active a boolean. Matrix 73/0. (An add-column command, grit g field, follows next.)

v3.94.0July 26, 2026

Full in the interactive picker, and two themes reimagined.

  • Full architecture is now the first option in grit new's interactive selector — Web + Admin + API + Docs + Expo + Desktop in one pick, the same as the --full flag.
  • Aurora → Apple. Reworked into a monochrome, iCloud-inspired theme: near-black text and CTAs on white and Apple's warm greys, blue reserved for links only. The sign-in button is the black pill you know.
  • Pulse → Cloudflare. A premium blue theme: Cloudflare-blue CTAs on a cool grey-blue canvas, white elevated cards, a deep-blue hero panel, and Cloudflare orange as the single warm accent. The serif display face is gone — clean Onest sans throughout.

Both themes were reworked across every surface — the Next and TanStack admin, the web app, the auth pages, and the shared token bag — and verified in a browser: the Aurora login renders the Apple card-and-black-button look, the Pulse login the blue-hero split, and the Pulse dashboard the premium blue cards on the cool canvas. Matrix 73/0.

v3.93.0July 26, 2026

grit generate perf — a k6 load test for your API. grit generate perf writes perf/load.js and a runbook. The script follows Grit's conventions: it registers and logs in a user in k6's setup() to mint a bearer token, then every virtual user hits the health check, an authenticated profile read, and — with --resource Blog — that resource's list endpoint.

Thresholds (p95 < 500ms, error rate < 1%) fail the run on a regression, so it doubles as a CI gate, not just an ad-hoc benchmark. Flags: --resource, --vus, --duration, --target; override the base URL at run time with BASE_URL. Proven by generating the script in a scaffolded project and running k6 against a live server — hundreds of iterations across 15 VUs with no failures. 6 generator unit tests. Matrix 73/0.

v3.92.0July 26, 2026

Field-level encryption — transparent AES-256-GCM on any column. Declare a model field as crypto.EncryptedString and it is encrypted at rest: GORM stores ciphertext, your code reads plaintext, and JSON responses stay plaintext. The column is opaque to anyone with the database but not the key.

  • Versioned scheme (enc:v1: = AES-256-GCM, fresh nonce per write) so it can rotate later. Key comes from FIELD_ENCRYPTION_KEY (base64, 32 bytes); with no key set the type passes values through as plaintext, so a project can adopt encryption later without a migration.
  • Non-deterministic by design, so encrypted columns can't be queried by equality — for data you store and display (notes, tokens, contact details), not keys or lookup columns. A malformed key fails startup rather than silently running without the encryption you configured.
  • A footgun the type closes for you: GORM map-based Updates bypass a column's encoder unless the value is itself an EncryptedString — a bare string would store plaintext. The scaffolded handlers wrap the value, and it was proven at runtime that a bio set through the API lands in the database as ciphertext while the API still returns plaintext.

7 unit tests ship in every project (round-trip, random nonce, wrong-key failure, disabled passthrough, key validation, JSON transparency, ciphertext on write). The User bio field ships as the worked example. Matrix 73/0.

v3.91.0July 26, 2026

GDPR data toolkit — right-to-access and right-to-erasure. The two data-subject rights every privacy regime turns on, scaffolded into every project.

  • Export (Art. 15). GET /api/users/:id/gdpr-export returns a full JSON copy of a person's data — profile, uploads, sessions, activity — with the password hash and OAuth ids scrubbed. A user can export their own; an admin, anyone's.
  • Erasure (Art. 17). /system/gdpr hard-deletes the records that exist only to serve a user and anonymizes the account in place, keeping the id so references resolve to a tombstone. It uses an unscoped delete on purpose — a normal GORM delete would only soft-delete rows carrying gorm.DeletedAt, leaving the PII physically in the table.
  • Tamper-evident deletion journal. Every erasure appends one hash-chained row — who erased whom, when, how many records fell, never the erased person's data. The admin page shows a live “chain verified” badge that turns red the moment any entry is altered.

The audit log is left intact by design: its rows hold a bare UUID, so scrubbing the user anonymizes them too, and editing them would break the audit hash chain. Proven end to end — unit tests, a runtime walkthrough, and a browser erase that physically removed a user's uploads and left a verified journal entry. 6 unit tests ship in every project; admin-only, with self-erasure refused. Matrix 73/0.

v3.90.0July 25, 2026

Access reviews — the recertification workflow auditors ask for. SOC 2 CC6.2/CC6.3 and ISO 27001 A.9.2.5 all require periodic, documented proof that someone with authority reviewed who has access to what. The admin panel now has it at /system/access-reviews.

  • A campaign snapshots every current role assignment into a list of items to certify. The snapshot copies each user's email and role name, so the record stays legible even after the user or role is later deleted.
  • A reviewer approves (keep) or revokes (remove) each grant. Revoking deletes the role assignment immediately and writes an access_review.revoke event to the audit log — which flows out through the OCSF/SIEM export from v3.89.0.
  • Three invariants auditors care about are enforced in the service, not just the UI: a revoke is terminal (the grant is gone), a completed review is immutable evidence, and you cannot complete a review with grants still undecided.

Proven end to end in a browser: opened a campaign, clicked Revoke on a real grant, and confirmed the role assignment was gone from the database and the revocation had landed in the audit trail; the Complete button stayed disabled until every item was decided, then signed the review off with a timestamp and reviewer. 7 unit tests ship in every project. Admin-only — non-admins get 403. Matrix 73/0.

v3.89.0July 25, 2026

Ship your audit trail to any SIEM — OCSF export. Grit already records a semantic activity log (who did what: auth.login, user.delete, session.revoke_all, with actor, severity, resource and IP). It now speaks the vendor-neutral Open Cybersecurity Schema Framework that Splunk, Elastic, Microsoft Sentinel, Chronicle and Amazon Security Lake all ingest.

  • GET /api/audit/ocsf (admin only) streams the log as newline-delimited OCSF JSON. Each event is mapped to its class — a failed sign-in becomes Authentication (3002) with status_id 2, account changes become Account Change (3001), everything else API Activity (6003).
  • Cursor pagination, not offset. The response headers carry the exact position to resume from, so a collector polling every minute never skips or repeats a row — proven with disjoint pages in testing.
  • Pull, not push. No credentials for Grit to store, no queue to babysit; the collector owns its cursor — the model every one of those SIEMs already ships an HTTP connector for.
  • An unknown action still exports (API Activity, Unknown activity), so a new event type is never silently dropped. Grit's native action name is preserved under unmapped.grit_action for pivoting back.

Verified against a running server: real register / failed-login / login events came back OCSF-conformant (required fields present, type_uid = class_uid*100 + activity_id, epoch-millis time), the cursor produced non-overlapping pages, and the endpoint returned 401 unauthenticated and 403 for a non-admin. 6 unit tests ship in every project.

v3.88.0July 24, 2026

Supply chain: signed releases, an SBOM, build provenance — and 12 CVEs removed from every generated project.

Adding govulncheck to CI immediately paid for itself. It found a reachable vulnerability in crypto/tls (GO-2026-5856) — and because the fix landed in Go 1.25.12, the 1.24 toolchain we built with had no patched release at all. Every published Grit binary, and every generated project's production Docker image (golang:1.24-alpine), shipped that vulnerable standard library. Both now build on Go 1.26.

Scanning a freshly scaffolded project then turned up 11 more reachable vulnerabilities across 6 modules — s3 was 46 minor versions behind, and x/image alone accounted for five. Dependencies were resolved against a real project, built and tested, and the proven set lifted into the template. A fresh scaffold now reports 0 reachable vulnerabilities, down from 11. Transitive modules that MVS would otherwise settle on a vulnerable version of are pinned to explicit security floors, each annotated with the advisory it closes.

  • Signed releases. A SHA256SUMS file signed with cosign keyless — no signing key exists to be stolen, and the identity is recorded in Rekor, so a signature cannot be produced outside a real run of the release workflow.
  • SBOM (SPDX JSON) attached to every release, and SLSA build provenance — verify with gh attestation verify that a binary came from this repo's workflow rather than someone's laptop.
  • Reproducible builds via -trimpath.
  • SECURITY.md — private disclosure, response targets, scope. A vulnerability in generated code counts as a vulnerability in Grit, because every user gets that code.
  • Continuous scanning. govulncheck gates CI, and the nightly canary now scans the dependency surface of a freshly generated project — the code users actually deploy — so the next CVE to land in a transitive dependency is caught overnight.
  • OpenSSF Scorecard runs weekly and publishes publicly, so a prospective adopter can check the score without asking.

Also dropped feature/s3/manager, which the scaffold declared but never imported and which AWS has since deprecated.

v3.87.0July 24, 2026

Password reset actually resets the password. It didn't before. ForgotPassword generated a token and logged it without storing it; ResetPassword hashed the new password, discarded it with _ = hashedPassword, wrote nothing, and returned “Password reset successfully”. Anyone who used forgot-password believed they had locked an attacker out and had changed nothing.

  • Tokens are stored as SHA-256 only, are single-use (enforced by one conditional UPDATE, so two concurrent requests can't both consume one), and expire after an hour.
  • Requesting a new link retires the previous one — otherwise every request would widen the window of usable tokens.
  • Completing a reset revokes every session. The reason you reset a password is to evict whoever you think is in your account.
  • forgot-password returns an identical response whether the address exists or not, so it can't be used to enumerate your users. Delivery failures are logged, never surfaced.
  • In development the link is logged so you can finish the flow without an email provider. In production that's suppressed — a live reset token in a log file is a credential — and replaced by a loud warning that RESEND_API_KEY is missing.

The landing page ships too. There was no /reset-password route, so even a working token would have hit a 404. Both admin frontends now have one: it reads the token from the query string, confirms the new password, handles the used / expired / malformed-link cases, and returns you to sign in.

Also fixed: grit add web-auth produced a project that could not build. The generated web login page called useSearchParams() with no Suspense boundary, so next build failed outright on /login. It went unnoticed because web auth is opt-in and nothing in the release matrix ever ran the command — there is now a kit that does.

Verified by walking the whole flow in a browser — request a link, follow it from the log, set a new password, sign in with it — plus 17 end-to-end HTTP assertions and 8 unit tests that ship in every scaffolded project. The assertion that matters: the old password now returns 401.

v3.86.0July 24, 2026

Sessions you can actually revoke. A JWT is self-contained — once signed it stays valid until it expires, and nothing the server does can take it back. Every refresh token is now backed by a sessions row, which makes “sign out this laptop”, “sign out everywhere”, and “kill every device when the password changes” possible for the first time.

  • Active Sessions screen on the admin profile page — every signed-in device with its browser, OS, IP and last activity, the current one badged, per-device sign-out, and “sign out of all other devices”.
  • Rotation with replay detection. Every refresh swaps the token. Presenting an already-rotated one is the signature of theft, so the session is revoked rather than refreshed — surfacing the compromise instead of letting both parties quietly share the account.
  • Idle and absolute timeouts (7 and 30 days by default, both overridable). Most apps ship one; auditors ask for both.
  • Changing a password signs out every other device and re-issues the caller a fresh session, so they stay signed in.
  • The raw token is never stored — only its SHA-256 — so a dump of the table cannot be replayed as a login. New endpoints: GET /api/auth/sessions, DELETE /api/auth/sessions/:id, POST /api/auth/sessions/revoke-all.

Security fix — every JWT now carries a unique jti. Found while testing this: two tokens minted for the same user in the same second were byte-identical (same claims, same second-resolution exp, same key), so two devices logging in together shared one refresh token — indistinguishable and impossible to revoke separately. Tokens are now unique per issuance.

Proven against a running app, not just compiled: three devices signed in, one revoked, its refresh returning 401 SESSION_REVOKED while the others kept working — 22 end-to-end assertions covering revoke-by-id, cross-user isolation, replay detection, password change, revoke-all and logout. Scaffolded projects ship 9 session tests of their own.

v3.85.0July 24, 2026

Outbound webhooks — grit plugin add webhooks. Core already verifies incoming webhooks (Stripe/GitHub signatures); this sends outgoing ones, signed to the Standard Webhooks spec so any consumer can verify them with an off-the-shelf library.

  • One command wires the grit-webhooks module, migrates the tables, mounts the subscription + delivery-log endpoints, and adds a System → Webhooks admin page.
  • Signatures cover {id}.{timestamp}.{body} (so a captured delivery can't be replayed), per-subscription whsec_ secrets, exponential backoff with jitter, a dead-letter after the retry budget, and one-click resend.
  • Fire an event from any handler: handlers.DispatchWebhook("invoice.paid", data).

This is the first plugin to wrap an external Go module — proving the “package + plugin” shape: the runtime logic lives in a versioned module you go get -u, the plugin generates only the thin wiring. Verified end to end in a live app: a delivery arrived with a signature that validated independently in Python, and appeared in the delivery log.

v3.84.1July 24, 2026

Fixes a broken grit new --full. The scaffolded docs app stopped building with TypeError: e.createContext is not a function. Nothing in Grit changed — the docs template pinned fumadocs ^14, whose last release was January 2025, and its floating transitive dependencies drifted out from under it. A Radix UI patch published mid-run was the trigger; behind it, fumadocs-ui@14 also wanted lucide-react ^0.473 while the scaffold pinned ^0.303, so CircleX didn't exist.

The docs app is now on fumadocs 16 + fumadocs-mdx 15 + Next 16 + Tailwind v4, matching the web and admin apps (which were already on Next 16 — docs was the straggler). MDX generation moved from a postinstall hook into the build/dev scripts, because in a pnpm workspace the hook runs before the local binary is linked. lucide-react is aligned on ^0.468 everywhere, which also fixes the missing-icon errors (CircleX, CloudUpload) you'd hit adding icons to the admin.

Verified by scaffolding a fresh --full project and building all three frontends, then the full kit matrix.

v3.84.0July 24, 2026

Plugins can now depend on real Go modules. The plugin system documented that a plugin's GoDeps were “added to go.mod” — but nothing did that. The field was copied into the lockfile and otherwise ignored. No built-in plugin declared one, so it went unnoticed; any plugin wrapping an external module would have generated code importing something absent from go.mod and failed to build.

grit plugin add now runs go get for each declared dependency before writing any file — go get loads the module graph, and doing it after emitting code that imports the not-yet-required module is exactly what makes it fail. It also means a network error aborts the install before anything on disk changed. The lockfile records what was actually fetched rather than what was merely declared.

This unlocks the “package + plugin” shape: the heavy runtime logic lives in a versioned Go module you upgrade with go get -u, while the plugin generates only the thin wiring you own and can edit.

Alongside it, the grit-plugins packages were repaired and are installable for the first time: their module paths pointed at a GitHub org that doesn't exist, so go get failed for all ten. They also stored user_id as uint while a Grit User.ID is a UUID string — meaning every authenticated request returned “Invalid user ID in context”. Both are fixed, tagged v0.2.0. Note that grit-websockets duplicates the built-in MODULE_REALTIME — check core before adding a dependency.

v3.83.0July 22, 2026

A big admin round: inline line-items, detail pages, and form polish. Most of this came from building a real freight app on Grit and hitting the rough edges.

  • Inline line-items (parent + child in one form). A new grit generate resource Invoice --items "InvoiceItem:description:string,qty:int,unit_rate:float" scaffolds the parent with an editable line-items table inside its form — add rows, a live per-row and grand total — and the child saved atomically with the parent in one GORM transaction (has-many). The child is generated as a full resource (so it's filterable by the parent FK) but hidden from the sidebar. This is the Invoice/InvoiceItem shape that a Category/Product split can't express.
  • View opens a detail page, not a modal. Every view now navigates to /resources/<slug>/<id> — a real page that presents the record, edits it in place, and loads every related table (its line-items, plus any resource that belongs_to it), so an Invoice shows its items without a hand-written page.
  • Card-style radio & checkbox. The radio and checkbox field types now render as selectable cards (label, description, right-aligned hint), not bare inputs.
  • Sheet forms. The create/edit drawer opens at 50% width with a maximize toggle to 80%, square edges, and an optional form.sheetWidth: "wide".
  • Comma-formatted numbers everywhere. Every number input, including the new line-item cells, thousands-separates as you type (1000 → 1,000), honoring the field's int/uint/float domain.
  • Tighter type scale. The admin base font drops to 15px for a denser, dashboard-like feel.

Adding a field to an existing model? Edit the Go model, run grit sync (regenerates the shared types + Zod and adds the field to the admin table + form, non-destructively), then grit migrate for the column. See Code Generation.

v3.82.0July 21, 2026

The admin sidebar and dashboard now honour the permissions you grant. A role granted only two resources — say Categories and Products — now sees exactly those two. Before this release the navigation and dashboard were gated only by a coarse admin/editor check, so a limited role could still see Users, Blogs, Dashboard settings, Support and the activity log in the sidebar even though every underlying API route already rejected them.

  • Sidebar resources are filtered by the viewer's <resource>.view grant. Each generated resource already registers that permission, so the nav matches what the role can actually open.
  • Internal nav — the activity log is gated on audit.view, support triage is admin-only, and Dashboard settings moved to admin-only. Notifications stay visible to everyone (they're your own).
  • The dashboard body — stat tiles, Quick access and the By-resource widgets — is gated the same way, so it shows the same surface as the sidebar instead of leaking every resource.

Super-admins (the * grant) short-circuit every check and still see everything. Verified end to end: a fresh triple app, a custom support role granted only Categories and Products, logged in through the browser — sidebar and dashboard showed only those two resources, their own notifications and the dashboard itself; the admin still saw the full app.

v3.81.0July 21, 2026

Three new first-party plugins, each built and verified end to end in a running app, and each reversible with grit plugin remove down to a byte-for-byte revert.

  • grit plugin add impersonate — an admin signs in as another user to reproduce a bug or check their access, then returns in one click. The session swap is server-side through HttpOnly cookies (the admin never handles a token), and every start and stop is written to the activity log.
  • grit plugin add command-palette — a ⌘K / Ctrl-K palette to jump to any resource or system page, built from the resource registry. Frontend-only: it touches no Go at all.
  • grit plugin add saved-views — save a table's filters, sort, search and date range as a named view, per user, per resource. Built on the URL state the tables already use, so nothing in the table itself changes.

Enabling the frontend plugins meant adding a few reusable injection markers to the admin (a layout banner slot, a system-nav slot, and a resource-table toolbar slot) that community plugins can target too. New posts on The Daily Grit cover the plugin model and how to build your own.

v3.80.0July 21, 2026

Backups were unrestorable. A backup you can't restore is a hope, not a backup — so this one got tested end to end, and it was broken.

grit restore runs migrations first (which seed the default ADMIN, EDITOR and USER roles) and then replays the dump — which carries its own copy of those same roles. The dump's inserts collided with the freshly seeded rows on the unique role-name index, and the entire restore aborted with duplicate key value violates unique constraint "idx_roles_name". Every backup was affected.

Restore now clears the backed-up tables (TRUNCATE … RESTART IDENTITY CASCADE) inside the restore transaction before replaying the dump, so the seeded rows can't collide and the restored database matches the backup exactly. Verified by restoring a real archive into a fresh Postgres, checking every row count, and logging in with the restored credentials. A regression test guards that restore truncates before it replays.

Also: two new posts on The Daily Grit — roles, permissions & automatic backups by default, and a guide to Grit plugins (what they are, the default ones, and how to build your own).

v3.79.0July 21, 2026

A batch of fixes from hands-on testing of the admin, all verified in a running app.

  • Image uploads were blocked by the app's own CSP. Presigned uploads PUT straight from the browser to object storage, but the Content-Security-Policy only allowed the API origin — so every upload (and every stored image) was blocked. The storage origin is now in connect-src and img-src, defaulting to local MinIO; set NEXT_PUBLIC_STORAGE_URL / VITE_STORAGE_URL to your S3/R2 public origin in production.
  • Custom roles didn't appear when creating a user. The role dropdown was a hardcoded ADMIN/EDITOR/USER list, so a role you defined in Roles & permissions could never be assigned. It now loads every role from the API.
  • Blogs was missing from the permission catalog. The built-in Blog resource had no catalog entry, so no role could be granted blog access. Added under Content → Publishing.
  • A role assigned to users could be deleted, silently stripping their permissions. Deletion is now blocked while any user holds the role (via either the role string or a role assignment); reassign them first. Unassigned roles delete cleanly and their name is immediately reusable.
  • Sidebar & icon fixes: the “Roles & permissions” link had no icon; the icon map was missing ~29 names the resource generator could produce, so many generated resources fell back to the same generic document icon. Both fixed.
  • System Hub now links to Roles & permissions and Data & Backup, which were previously unreachable from it.
  • Permission modules in the role editor now collapse by default (web, mobile and desktop) — expand one at a time instead of a wall of every feature.
  • grit migrate no longer prints three scary record not found lines while seeding the default roles — that was the seeder's normal “does this role exist yet?” check, now quiet.
v3.78.0July 20, 2026

Fixes the mobile screens shipped in v3.77.0. The Expo api.get() resolves to the parsed response body, not an axios-style { data: body } wrapper. The new roles screens unwrapped one level too many, so every query resolved to undefined and React Query raised “Query data cannot be undefined” — the permission editor could not load.

The same mistake predated these screens: the home stat card read res.data?.meta?.total where the body already is res, so the user count read 0 even for an administrator who was allowed to see it.

The new roles screens also omitted showBack on their ScreenHeader, so they opened with no back button and stranded you on the page. Every other non-tab screen already passed it.

Verified on an emulator: the roles list renders built-in and custom roles with correct grant counts, and the editor seeds its checkboxes from the server-expanded permission set.

v3.77.0July 20, 2026

Social login buttons no longer appear before a provider exists. SOCIAL_AUTH_ENABLED defaulted to true while GOOGLE_CLIENT_ID and GITHUB_CLIENT_ID ship empty, so every fresh project rendered a “Continue with Google” button that dropped the user on a page reading no provider for google exists. It now defaults to false; fill in a provider's credentials and flip it on.

The mobile app ignored the flag entirely. Web and admin both gate their social block, but the Expo login screen rendered it unconditionally — there was no SOCIAL_AUTH_ENABLED check anywhere in the Expo app. It now reads EXPO_PUBLIC_SOCIAL_AUTH_ENABLED, matching the other two clients.

Roles & permissions now exist on mobile and desktop. Both apps knew only the coarse user.role string — neither called /api/auth/permissions, and both offered a hardcoded USER / EDITOR / ADMIN picker, so a role you defined in the admin could never be assigned from a phone or the desktop client. Each now ships a usePermissions() hook and a full permission editor against the same endpoints and the same wildcard semantics as the web admin, and creating a user binds them to the role record via PUT /api/users/:id/roles rather than only setting the legacy string.

Creating a user from mobile always failed: the screen posted to /api/admin/users, which is not a registered route — a guaranteed 404. And the mobile home screen fetched the ADMIN-only /api/users for every signed-in user, rendering the resulting 403 as 0 Total Users; two of its four stat cards were hardcoded zeros and a third duplicated the first. It now asks only when the user holds users.view, and shows only the count the API actually reports.

Found by running the Expo app on an Android emulator: Metro bundle, launch, sign in, dashboard.

v3.76.0July 20, 2026

The Vite admin was unreachable. Both route guards checked localStorage.getItem('access_token'), but auth tokens live in HttpOnly cookies and are never written to localStorage. The check was always null, so every signed-in user was bounced straight back to /login — you could authenticate successfully and still never reach the dashboard. The guards now ask the API (/api/auth/me), which is what the documentation already described.

The Vite CSP ignored VITE_API_URL. vite.config.ts read it from process.env, but Vite does not load .env files into process.env for the config file itself — that needs loadEnv. The origin silently fell back to localhost:8080, so anyone who moved the API had every request blocked by their own Content-Security-Policy. The dev-only public-IP hint is allowed there too, matching the Next.js apps.

Both were found by driving the Vite admin in a browser: login, dashboard, and the roles screen now match the Next.js admin exactly.

v3.75.0July 20, 2026

Four bugs found by actually running a scaffolded app — booting the API, driving the admin in a browser — rather than only compiling it. None of them could fail a build.

The Roles & permissions page crashed on every new project. authz.Expand returned a nil slice for a role with no grants, which Go marshals as null. The roles UI maps over expanded and reads .length, so the default USER role — which grants nothing — took down the whole screen. Expand now returns [], and the UI tolerates a null from any source.

Version never incremented, breaking offline sync. Every model's BeforeUpdate hook did x.Version++, which mutates the Go struct but never reaches the UPDATE statement — generated services update with a map, and the SQL is built from that map. The column stayed at 1 forever, so an offline client could never detect that a record had moved on. All seven hooks, including the one the resource generator emits, now use tx.Statement.SetColumn. A regression test guards it.

Three 404s on the login page. brand.config.ts shipped default hero image paths that the scaffold never included, so the Pulse auth carousel requested three images that did not exist. The list now defaults to empty and the auth screens fall back to a themed gradient.

Admin Quick Links ignored the configured API URL, hardcoding localhost:8080 in all four dashboard styles. And the public-IP hint the API client fetches is now dev-only and allowed by the CSP — it was logging a Content-Security-Policy violation on every page load, and reaching out to a third party from production builds.

v3.74.0July 19, 2026

Fix: single-binary apps could not install on pnpm 11. pnpm install in frontend/ exited non-zero with ERR_PNPM_IGNORED_BUILDS on esbuild. pnpm 11 made an ignored build script a hard error, renamed onlyBuiltDependencies to allowBuilds, and stopped reading the pnpm field in package.json altogether. Scaffolded projects now declare allowBuilds in pnpm-workspace.yaml, keeping the pnpm 10 spelling alongside it so they install on either version.

And the bug that failure was hiding: with install fixed, pnpm build failed too. The single-mode use-blogs hook imported @repo/shared/types, but a single-binary app has no pnpm workspace and therefore no packages/shared. The shared schemas and types are now mirrored into frontend/src/shared/ with a tsconfig alias, so import paths read the same in every architecture. The mirrored theme module reads import.meta.env.VITE_THEME instead of process.env.NEXT_PUBLIC_THEME — the latter is undefined in a browser, so --theme was silently ignored at runtime.

Fix: the Expo app did not typecheck. A progress bar built its width by string concatenation, which React Native types as DimensionValue rather than string, and the local User interface was missing avatar.

All twelve kits now scaffold, install, compile, and typecheck clean.

v3.73.0July 19, 2026

Fix: the Vite web app could not build. Found by a systematic pass over every kit — --double --vite and --triple --vite produced an apps/web that failed to compile.

The Vite admin was fixed for this in v3.62.0, but the Vite web app never got the same treatment: its components still imported next/link and next/navigation, it shipped no compat shim, it was missing the @repo/shared dependency and vite-env.d.ts, and its build script ran tsc -b before the plugin had generated the route tree.

And a latent runtime bug in both Vite apps: transformed components read process.env.NEXT_PUBLIC_*, which Vite does not polyfill — those components would have thrown process is not defined in the browser. The build never caught it, because vite build uses esbuild and does no type checking. The compat transform now rewrites them to import.meta.env.VITE_*.

Both Vite apps now build and typecheck cleanly.

v3.72.0July 19, 2026

Plugins — and multi-tenancy as the first one. Grit can now be extended, and #71's last open point is answered.

grit plugin list
grit plugin add multitenant
grit plugin remove multitenant

A plugin generates code into your project rather than being a runtime dependency — Grit is a generator, so there is no framework object to hook into. You own the code and can edit or delete it.

Removal is exact. Installation records every file and every injected snippet in .grit/plugins.lock.json, and removal replays it backwards. A plugin author writes no uninstall code at all — a separate hand-maintained removal list is precisely how this kind of tooling drifts and starts leaving projects that don't compile. Code you edited by hand is reported, never overwritten.

The multitenant plugin adds organizations, per-organization roles (reusing the roles system, not a parallel one), and automatic query scoping via a GORM callback. Mark a model with tenant.Owned and every query is scoped for you; opt out deliberately with tenant.Unscoped(db).

Scoping fails closed: a query with no active organization errors rather than quietly returning every tenant's rows. Hand-written scoping fails the other way — one forgotten WHERE is a silent cross-tenant leak that no test catches, because the query returns more rows rather than failing. No subdomains; the active org comes from a header and membership is always verified server-side.

See Plugins and Multi-tenancy.

v3.71.0July 19, 2026

Turn modules off you don't use. Answers the third point in #71 — every project shipping AI, cron, jobs, backups and webhooks whether it wants them or not.

Eleven MODULE_* flags in .env: AI, JOBS, CRON, BACKUP, WEBHOOKS, REALTIME, FILES, MAIL, AUDIT, FLAGS, TWOFACTOR. A disabled module mounts no routes, registers no workers, and disappears from the admin sidebar and System hub.

All default to true, so upgrading changes nothing. The code stays in your repo — it's your codebase, so delete it if you want it gone entirely.

See Turning modules off. Note the flags are read at startup, so changing one needs a restart.

v3.70.0July 19, 2026

Permissions are complete. This finishes the arc started in v3.66.0 — catalog, API, admin UI, frontend gating and docs.

The role dropdown now actually takes effect. Grant resolution prefers the user_roles table, so changing a user's role in the admin used to update the string and change nothing about what they could do — a silent no-op. Editing a user now syncs their assignment, and a regression test proves a demotion removes the permission.

Frontend gating. A new usePermissions() hook exposes can("products.delete") and can("products.*") for hiding buttons and nav items. It is a Set lookup, not a second wildcard matcher — the API returns permissions already expanded, so the client can't drift from the server. Sidebar items can now declare a requires permission; the Roles screen itself is gated on roles.view.

Docs: a Roles & Permissions guide covering key format, guarding routes, the admin UI, upgrading an existing app, and why hiding UI is not access control.

v3.69.0July 19, 2026

The Roles & permissions screen. Permissions are now manageable from the admin panel — at /system/roles, in the sidebar under System. This completes the feature started in v3.66.0; until now roles could only be managed over HTTP.

Create and edit roles with a permission tree: tri-state checkboxes at module, group and feature level, a CRUD matrix per feature (actions a feature doesn't support render as a dash rather than a checkbox that does nothing), a live “N / total granted” counter, a filter, and copy-permissions-from another role.

Selections are seeded from the server's expanded grant list and collapsed back to wildcards on save — tick every action on a resource and it stores products.*, so the role keeps inheriting actions added later. Built-in roles show their name locked and no delete button, matching the server, which refuses both regardless of what the UI allows.

The same component serves the Next.js and Vite/TanStack admins. That's deliberate: a permission editor that disagreed between the two would be a security bug, not a cosmetic one.

v3.68.0July 18, 2026

Roles & permissions API. Scaffolded projects now expose the endpoints the admin UI (shipping next) is built on:

  • GET /api/permissions — the catalog tree
  • GET|POST /api/roles, GET|PUT|DELETE /api/roles/:id
  • PUT /api/users/:id/roles — assign roles to a user
  • GET /api/auth/permissions — the caller's own permissions

Grants are stored unexpanded so wildcards keep inheriting, but served expanded so the frontend never reimplements wildcard matching — a duplicated matcher is how the system this was modelled on ended up with Go and TypeScript rules that disagreed.

Built-in roles are protected server-side: renaming or deleting ADMIN is refused by the API, not merely greyed out in the UI. Their permissions stay editable. Unknown permission keys are rejected on write, so a typo can't be stored and then silently never match. Assigning roles keeps the legacy users.role string in step, so routes still guarded by role name don't start returning spurious 403s.

v3.67.0July 18, 2026

Generated resources register their own permissions. grit generate resource Product now adds products.create, products.view, products.edit and products.delete to the authz catalog, so a new resource is grantable straight away instead of needing a hand-edit. grit remove resource takes them back out.

Roles holding a wildcard pick the new keys up automatically — a role granted products.* (or *) covers actions added later, because grants are stored unexpanded.

Machine-written entries live in generatedModules() between grit:perms:auto-* markers; hand-written permissions belong in coreModules(), where removal will never touch them.

v3.66.0July 18, 2026

Permissions land — roles are now bags of permissions. Raised in #71: guarding endpoints by role name doesn't scale, because adding a role means editing every route. Routes can now check a permissioninstead.

A permission key is <resource>.<action> — products.create, users.delete. Roles hold grants, and grants may use wildcards (products.*, *). Wildcards are stored as authored, so a role granted products.*automatically picks up actions added to the catalog later.

Nothing breaks. RequireRole keeps its signature and now accepts either style, passing if any argument matches: RequireRole("ADMIN", "perm:users.delete"). Every existing RequireRole("ADMIN") call site works untouched, so permissions can be adopted route by route. Apps upgrading from role-only auth keep working before anyone is assigned a role, because grant resolution falls back to the legacy users.role string.

New in a scaffolded API: internal/authz/permissions.go (catalog + matcher), internal/authz/grants.go (the single GrantsFor seam, cached with immediate invalidation on revoke), and models.Role + a many-to-many user_roles join. Default roles seed automatically on migrate, and re-seeding never overwrites an operator's edits.

Note ADMIN gets * while EDITOR and USER get scoped grants matching what the routes already enforced — giving every role every permission would have handed ordinary users the admin panel, since the guard is any-match.

Still to come: permission codegen from grit generate resource, the roles admin UI, and the multi-tenant plugin.

v3.65.0July 18, 2026

Fix: grit remove resource now actually removes a resource. It deleted the model but left the handler, service and seeder behind, so the project stopped compiling with undefined: models.<Name>. This affected both generated resources and the demo Blog that ships with every project — so “don't want the blog? remove it” didn't work.

Removal now covers every artefact and injection: the import handler, the scaffold's differently-named files (blog_handler.go, blog_service.go, blogs_seeder.go), the seeder registration, the sync registry, all three switch-dispatch files (form-share, resource-stats, chart), both handler-init shapes, public route groups, TanStack/Vite admin routes, and nested [id]/[slug] page directories. Imports orphaned by the removal are pruned, so you don't trade undefined: models.X for imported and not used.

The web home page's “Recent Posts” section is now wrapped in grit:home:blog-* markers so removing Blog cuts it out cleanly — previously the page kept importing the deleted hook and the web app failed to build.

Verified end to end: grit new --triple → grit remove resource Blog leaves zero references, and both the Go API and the Next.js web app build. Same for a generated resource. Regression tests added for the case-arm removal, the import pruning (which previously matched a code comment) and the marked-region cut.

v3.64.0July 17, 2026

Fix: a --vite app can now be containerised. The Docker generator handed every frontend the Next.js Dockerfile regardless of the chosen frontend, so a Vite (TanStack) app's image build failed outright — the runner stage copied .next/standalone and ran node server.js, but a Vite build emits a static dist/ and has no server. grit new --vite produced an app that could not be built into an image at all.

Vite apps now get a Dockerfile that builds the static bundle and serves it with nginx, plus an nginx.conf with a SPA history fallback (deep links like /system/health return index.html instead of 404) and the same security headers as everything else. The production script-src is stricter than dev's — a Vite production build has no inline scripts — while style-src/font-src allow Google Fonts so the theme fonts still load.

The prod compose file now passes VITE_API_URL and VITE_THEME as build args for Vite apps instead of NEXT_PUBLIC_API_URL. This matters: Vite inlines env at build time, so setting it on the running container does nothing — a Vite app given the Next.js var silently built against localhost:8080 and every API call failed in production.

Also: the Vite web app now sends security headers from its dev/preview servers (previously only the admin did), via one shared source so web and admin can't drift. Verified by building and running the image — the SPA serves, all headers are present, the deep-link fallback works, and the API URL is baked into the bundle.

v3.63.0July 17, 2026

Security headers on every scaffolded frontend. The Go API has always sent security headers via middleware.SecurityHeaders, but the Next.js apps sent none — Next.js has no defaults, you have to opt in. So a scaffolded app's public face scored an F on securityheaders.com with all six headers missing: Strict-Transport-Security, Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy.

grit new now ships all six (plus Cross-Origin-Opener-Policy) on the web, admin and docs apps, with poweredByHeader: false so the framework and version aren't advertised. The policy mirrors the Go API's, and the Vite/TanStack admin sends the same set from its dev and preview servers. One shared source in the scaffold, so the two halves of an app can't drift apart.

Two CSP details are deliberate and documented in the generated config: script-src allows 'unsafe-inline' because Next.js inlines its bootstrap and streams the RSC payload through inline <script> tags — 'self' alone white-screens the app; and connect-src includes the API origin, because in double/triple mode the browser calls the Go API cross-origin and a missing entry breaks every fetch with a silent CSP violation.

Existing projects: grit update, then copy the securityHeaders block and the headers() + poweredByHeader fields from a freshly generated next.config.ts.

Also on the docs site: hero headings drop the old purple gradient for solid foreground (15.5:1 contrast in dark, 17.1:1 in light — AAA in both, where the brand blue only reaches 3.8:1 in light mode), and the header nav is trimmed from nine links to six, with the rest moved into the footer.

v3.62.0July 17, 2026

The TanStack (Vite) admin is now the real admin, not a shell of stubs. Every route in the --vite admin was a hand-written placeholder: the dashboard showed four -- cards, the system pages rendered “System page content will be loaded here”, the profile and auth pages were bare, most sidebar links 404'd, and the 404 itself was TanStack's bare text. Meanwhile the real pages existed — they were only wired into the Next.js admin.

Routing is now the only thing that differs between the two admins. Each route is a thin wrapper that renders the same page component the Next.js admin uses, transformed by the next-compat layer. 26 real pages and 27 routes are generated, including every sidebar destination (/system, /system/activity, /system/health, /system/notifications, /system/performance, /system/support, /settings/dashboard) plus backups, observability, form-shares and the detail routes. Unmatched URLs render the branded 404.

Theme parity. The Vite admin hard-coded class="dark" on <html> and set no data-theme, pinning it to the dark override forever — so an atlas project (a light theme) rendered dark and looked nothing like the Next.js admin. It now sets data-theme from the scaffold theme (overridable at runtime via VITE_THEME, mirroring NEXT_PUBLIC_THEME) and loads that theme's fonts, which previously fell back to system-ui.

Also fixed: 21 components the pages depend on were never generated for the Vite admin — including AuthShell (the themed login chrome), UserMenu (which owns sign-out, so logout did nothing), PageHeader, the dashboard widgets, and their deps (@react-pdf/renderer, the full TipTap set).

Existing --vite projects: grit update and regenerate apps/admin.

v3.61.0July 16, 2026

Fix: generating a resource no longer blanks the TanStack (Vite) admin. After grit generate resource, the admin rendered a blank page and threw Cannot read properties of undefined (reading 'charAt') from defineResource. The resource definition is imported by the registry at startup, so the failure took down every route — including Login.

Cause: the TanStack generator had its own copy of the resource-definition template, and it had drifted from the shared lib/resource.ts contract — emitting a flat { plural, apiEndpoint, columns, fields } shape where defineResource expects slug, endpoint, icon, table and form. Both admins consume the identical defineResource, so they now share a single content builder and cannot diverge again; only the destination path differs. The previously-missing stacked-cell component (imported when the name/email column-pack heuristic fires) is now generated for the Vite admin too.

Existing projects: grit update, then re-run grit generate resource for any resource generated on v3.60.0 or earlier under --vite (or fix apps/admin/src/resources/<name>.ts by hand to the shape above).

v3.60.0July 16, 2026

Fix: the TanStack (Vite) admin now builds and runs. Projects generated with --vite shipped an admin that failed to start — the reused dashboard components still imported Next.js APIs, several components and dependencies were missing, and the build config had a chicken-and-egg with the route tree. grit start admin now boots cleanly and pnpm build produces a bundle.

What changed under the hood: a next-compat shim maps next/link, next/image, next/navigation and next/dynamic onto TanStack Router + the DOM; the api-client exposes the api alias and reads import.meta.env instead of process.env; a useAuth() hook is provided; and the previously-missing form-sheet, update-groups, import-modal and export-menu components plus their dependencies (xlsx, react-dropzone, sonner, TipTap, @repo/shared) are now generated and declared.

The Vite admin now uses Tailwind CSS v4 via @tailwindcss/vite — no postcss.config or tailwind.config file, with the design tokens moved into @theme so [data-theme] switching still repaints at runtime. (The Next.js web/admin apps remain on Tailwind v3.)

Bumped Sentinel to v2.2.1 in scaffolded APIs, picking up the GORM has-many / batch-create panic fix.

v3.59.0July 14, 2026

Fix: the Expo app no longer crashes with “Invalid hook call”. In a full monorepo, the web and admin apps pin a newer React than Expo does. With a hoisted node_modules, React Native ended up resolving the Next.js React while the app used its own copy — two React instances in one Metro bundle, which throws Invalid hook call / Cannot read property 'useContext' of null.

The generated apps/expo/metro.config.js now de-duplicates React: every react import in the Metro bundle resolves to the Expo app's own copy, so there is exactly one React instance. (Only react is deduped — React Native ships its own renderer and doesn't usereact-dom.)

Existing projects: grit update, copy the new apps/expo/metro.config.js, then restart Metro with the cache cleared — npx expo start -c.

v3.58.0July 14, 2026

A real Data & Backup page, and a configurable backup schedule. Both the desktop app and the admin panel now have a prominent Data & Backup entry in the sidebar. The page shows your latest snapshot, the recent history, one-click Generate now and Download, and — new — a schedule you control: daily, weekly, monthly, or yearly at a time of day you pick (default weekly). Instead of a fixed cron, a lightweight checker runs every 30 minutes and consults the schedule, so the period changes at runtime with no restart. New endpoints: GET/PUT /api/backup-settings.

The quick-access menu is now configurable from Settings. Pick the floating button's position — bottom-left, bottom-center, or bottom-right — and choose up to 10 tiles: reorder by removing built-ins and adding your own custom links. On the desktop it lives in Settings → Quick access menu; the admin keeps its inline configurator. Changes reflect live in the floating menu.

Existing projects: grit update, run grit migrate to add the backup_schedules table, then re-generate or copy the desktop/admin files to pick up the new pages.

v3.57.0July 14, 2026

Offline sync is sharper — and it no longer gets stuck. A desktop app that created a record offline (with an image) could show “pending changes” forever after reconnecting: the row synced, but the follow-up image-URL update failed server-side. The root cause was the sync push handler calling .Updates(rawMap), which hands nested JSON fields (a FileRef image, a FileRefs slice, a belongs-to relation) straight to the driver — Postgres can't encode a Go map into a json column. The update now decodes into the typed model first (like create does), so driver.Valuer fields round-trip correctly and the outbox clears.

The Sync page in the desktop app got a real upgrade: a live syncing spinner, a Settings tab with an auto-sync toggle (on by default; turn it off to confirm changes by hand), and a richer Pending changes tab — colored create/update/delete badges, the record's real name, an expandable details view of exactly what will push, and per-row Confirm / Revert plus Confirm all / Discard all.

Under the hood the sync engine gained SetAutoSync, PushOne (confirm a single change), and RevertChange/RevertAll (discard a queued change and pull server truth back). When auto-sync is off the background loop still pulls fresh data — it just never pushes without your say-so.

Existing projects: grit update, then re-generate or copy the desktop sync files to pick up the fix.

v3.56.0July 13, 2026

Grit is the only CLI you need — in every architecture. grit migrate, grit seed, and grit start server now work in single and api-only projects too, not just the monorepo modes.

Project detection now keys on grit.json (present in every mode) instead of requiring turbo.json + apps/api, and the API is located at the project root for flat layouts. No more cd apps/api && go run cmd/server/main.go.

The grit new success message now prints the real grit-first golden path — docker compose up -d → pnpm install → grit migrate → grit seed → grit start — and the docs & courses were swept to match: no raw go run / pnpm dev / cd apps/* for running your app. A new Coming from Laravel / Django / Next guide maps your muscle memory to Grit.

Existing projects: grit update to pick up the CLI changes.

v3.55.0July 12, 2026

Seeders now understand relationships — and the Seeders docs page is rewritten to match.

A generated seeder used to skip belongs_to fields, so a faker Product had no Category. Now the seeder loads the parent ids once (Pluck) and links every row to a real existing parent — a random one for --faker, the first one for the static example (via new pickID / firstID helpers). Seed parents before children (the runner calls seeders in generation order) and the graph wires itself up. Verified: 12 faker products all linked to real categories.

Existing projects: grit update, then re-generate any seeders you want relationship-aware.

v3.54.0July 12, 2026

Database seeders per resource — with an example record, faker, and a one-flag hook on generate.

  • Per-resource seeder files. The single seed file is now split into internal/database/<name>_seeder.go— including the built-ins (users_seeder.go, blogs_seeder.go) — each easy to find and edit. A thin Seed() runner calls them all.
  • New command. grit generate seeder <Resource> [more...] creates a seeder for an existing resource, pre-filled with one example record (values inferred from each field's type), and registers it in the runner.
  • Generate hook. grit generate resource X --fields ... --seed emits the seeder alongside the resource.
  • Faker. Add --faker (and --count N, default 10) to generate a loop that fills rows with gofakeit — name/email/price/etc. picked from the field name and type, and image fields get a real sample image URL. gofakeit ships in the API so it works offline.
  • Run. grit seed runs every seeder (also runs on migrate).

Verified end to end: a scaffolded API generates static + faker seeders that compile, and grit seed populates the database (users, blogs, an example customer, 5 faker products with images, a category). Existing projects: grit update.

v3.53.0July 12, 2026

Fixed a React version mismatch that white-screened the desktop app. React 19 hard-errors when react and react-dom aren't the exact same version ("Incompatible React versions"), and the app renders a blank black screen.

The previous pin lived in a pnpm override that pnpm 10 applied to react but not react-dom, so they drifted onto different 19.x lines (react 19.1.0 vs react-dom 19.2.7). Both are now pinned to one exact version directly in every frontend's package.json — a far more reliable guarantee than an override. grit update also surgically re-pins them in existing apps without touching your other dependencies.

Verified: a fresh --full app installs with react and react-dom both at 19.2.7, the desktop app renders (no version error), and the desktop/admin builds pass.

Existing app white-screening? grit update, then rm -rf node_modules pnpm-lock.yaml && pnpm install, and restart the desktop.

v3.52.0July 12, 2026

The admin panel builds for production again, and installs are quieter. Three infrastructure fixes so next build (and grit build) succeed on a fresh app.

  • Tiptap / Turbopack resolution. A new root .npmrc sets node-linker=hoisted. Next.js Turbopack couldn't resolve packages that live only in a nested pnpm dependency — most visibly @tiptap/starter-kit's transitive extension packages (the rich-text editor), which failed the build with "Can't resolve ‘@tiptap/extension-horizontal-rule’". A flat layout puts every dependency where Turbopack can find it.
  • Test configs excluded from the build type-check. The admin/web tsconfig now excludes vitest.config.ts, Playwright config and test files, so a Vitest-vs-app Vite version mismatch can't fail the production type-check.
  • pnpm 10 overrides. The React version pin moved from the (now-ignored) package.json pnpm.overrides field to pnpm-workspace.yaml, so it actually applies and the deprecation warning is gone.

Verified end to end on a fresh --full scaffold: the admin next build compiles and generates all pages, and the desktop Vite build passes.

Updating an existing app? grit update writes the new .npmrc, workspace overrides and tsconfig — then run rm -rf node_modules && pnpm install once so the hoisted layout takes effect.

v3.51.0July 12, 2026

The desktop app now accepts file uploads while offline. Previously the dropzone disabled itself with a "reconnect to upload" hint; now you can add images and files any time.

Offline, a picked file is kept inline as a data URL — so it saves with the record and its thumbnail shows immediately, tagged Pending. When the connection returns, a background reconciler (usePendingUploads) scans the local mirror for those pending files, uploads each to /uploads, and swaps in the real file reference — then the normal sync pushes the corrected record. No lost uploads, no blocked workflow.

Existing projects: grit update and re-generate. Verified on a fresh scaffold: the dropzone is interactive with zero console errors and builds clean; the offline round-trip runs in the live desktop app.

v3.50.0July 12, 2026

Desktop form polish: real confirm dialogs, searchable relationship pickers, comma-formatted numbers, relationship columns show names, and offline rows fill in their slug/created date.

  • Confirm dialogs — deletes used the native window.confirm, which in Wails shows an ugly "wails.localhost says" box. They now use a styled, promise-based confirm modal (matching the admin), wired into resource deletes, bulk delete, Users, and account deletion.
  • Searchable selects — relationship (belongs-to) pickers, like a product's Category, are now a typeahead combobox instead of a native dropdown.
  • Number formatting — int/float inputs group with thousands separators as you type (1000000 → 1,000,000), while still storing a clean number.
  • Relationship columns — a table's belongs-to column now shows the related record's name instead of the raw id (resolved client-side from the offline mirror).
  • Offline rows — a record created offline now gets a client-side created_at and a slug immediately (from its name/title) instead of blank cells; the server's authoritative values sync in afterward. Offline creates also log to the activity feed once they sync (they push through the same endpoint the online path does).

Existing projects: grit update and re-generate. Verified on a fresh scaffold: a price field renders 1,000,000, the category picker is a searchable combobox, zero console errors.

v3.49.0July 12, 2026

Desktop sidebar cleanup + a bottom user menu, and a console-warning fix in the admin.

The desktop System section was overloaded; it now shows just System Health, Security and System Hub — Performance, File Storage, Background Jobs, Cron and Dashboard settings are one click away from the Hub. The Blogs resource was removed from the desktop. And the sidebar now has a proper bottom-left user menu (avatar + name/email → Profile, Settings, Log out), matching the admin.

Admin: the table's image/video cells rendered <img src=""> for records with no image, which makes the browser re-request the page and logs a warning. Those cells now show a dash for empty values.

Existing projects: grit update. Verified: the desktop nav is trimmed, the user menu opens with Profile/Settings/Log out, zero console errors.

v3.48.0July 11, 2026

Three more system pages land on the desktop: File Storage, Background Jobs and Cron Schedules. These were on the admin System hub but missing from the desktop; now both sidebars match.

File Storage — total files, total size and image count, plus a thumbnail grid of recent uploads (opens the file). Background Jobs — the async queue's Active / Pending / Completed / Failed / Retry counts (with a clear "needs Redis" state when the queue is offline). Cron Schedules — the recurring tasks registered with the scheduler and their cron expressions.

All three are wired into the System sidebar section and the System Hub tile grid, and stay offline-graceful like the rest. Existing projects: grit update. Verified on a fresh scaffold: all three render and appear in the nav with zero console errors.

v3.47.0July 11, 2026

Offline edits now show up in the activity feed. Creating, updating or deleting a record on the desktop app goes through the offline sync engine (/sync/push), which applied the change but never wrote a semantic activity row — so the audit feed only ever showed sign-ins.

The sync push handler now emits the same Created / Updated / Deleted {Entity} activity that the online REST handlers do, attributed to the signed-in user, with a human label pulled from the record (name/title/slug). So a category you create offline reads "Created Category — Phones" in Activity once it syncs, exactly like one created online. (The online admin path already logged these.)

Existing projects: grit update. The semantic activity pipeline is confirmed working end to end (auth events already write activity rows); the sync handler now calls the identical logging functions on every applied change.

v3.46.0July 11, 2026

The desktop Profile page is now the full account manager, matching the admin. It was a single read-only card; it now has five sections.

Profile picture (upload a new avatar — it uploads via the API and saves to your profile), Personal information (first/last name, email), Professional information (job title + bio), Password (new + confirm with match/length validation), and a Delete account danger zone with a confirm step that logs you out. Each block saves independently via PUT /profile.

Existing projects: grit update. Verified on a fresh scaffold: all five sections render with eight inputs and two save buttons, zero console errors.

v3.45.0July 11, 2026

Desktop tables get row selection, an export menu, bulk import and image/slug columns — plus a full Users manager.

The desktop DataTable now matches the admin's feature set: a checkbox column with a bulk-action bar (select rows → delete many at once), an Exportdropdown (CSV or JSON), and bulk Importfrom a CSV. Generated resource tables also stopped hiding slug and image/file columns — an uploaded image now shows as a thumbnail (with a "+N" count for multi-file fields) and the slug is visible.

Users moved into the Manage section and is now a full CRUD screen: create, edit and delete accounts (name, email, password, role, active) through a slide-over form, plus bulk-delete.

Existing projects: grit update and re-generate. Verified on a fresh scaffold: a Category table shows Name / Slug / Cover (thumbnail) / Created with a checkbox column, Import button and CSV/JSON export menu; the Users page creates accounts via its drawer form — zero console errors.

v3.44.0July 11, 2026

Every page header now carries the standard action cluster on the desktop, matching the admin. The top-right of each page's PageHeader now shows a consistent row: refresh · theme switcher · [page action] · notifications · user menu.

Refresh re-fetches the page's data, the switcher toggles light/dark, a page's primary CTA (e.g. "New ticket") slots into the middle, the bell opens Notifications, and the avatar opens a menu (Profile, Settings, Log out). The desktop's separate top bar is now just the ⌘K search — the action buttons that used to be duplicated there live in the header, so there's one consistent place for them.

Existing projects: grit update. Verified: the dashboard header shows refresh/theme/notifications/user, and a page with a CTA (Support) shows all five including "New ticket", with zero console errors.

v3.43.0July 11, 2026

The quick-access button is now a Windows-Start style grid launcher. Instead of a small dropdown list, the floating button (now a grid icon, docked bottom-left by default) opens a wide, centered menu of icon cards— each with an icon, title and description.

The grid includes navigation shortcuts (Dashboard, Sync, System Hub), a New {Resource} card for every generated resource (using that resource's own icon), and system shortcuts. You can still configure the corner, toggle which cards appear, and add custom links — all stored per-device.

Both the admin panel and the desktop app get the identical launcher. Existing projects: grit update. Verified: the desktop button docks bottom-left with the grid icon and opens a 1024px grid of icon cards with zero console errors; the admin build typechecks clean.

v3.42.0July 11, 2026

Desktop fonts now render (offline), and Performance throughput reads correctly. Two fixes from desktop polish feedback.

Fonts. The desktop app pulled its fonts from the Google Fonts CDN — which the Wails webview can't reach offline — and the body was hardcoded to a font that was never loaded, so everything fell back to the system font. Fonts are now self-hosted via @fontsource (bundled by Vite: Inter, Geist, Onest, DM Serif Display, JetBrains Mono) and the body follows the active theme's font variable. Verified: Inter loads from the bundle with no network and applies to the UI.

Throughput. The admin Performance page rounded throughput to a whole number, so a real-but-low rate like 0.14 req/s displayed as 0/s. It now keeps two decimals below 10 req/s (and the desktop page rounds its raw value the same way instead of showing a 17-digit float).

Existing projects: grit update.

v3.41.0July 11, 2026

The desktop offline form now handles image & file fields. This closes the one gap called out when the desktop resource forms first reached parity: file and files fields were skipped. They now render a proper dropzone.

Drag-and-drop (or click) upload with image thumbnails and file chips, single or multiple files, and an accept filter derived from the field's type (e.g. cover:file:image only takes images). Uploads go through the API's /uploads endpoint and store the returned FileRef in the record — which the offline sync engine mirrors like any other field.

Because a binary can't be pushed through the JSON sync outbox, the dropzone is offline-aware: when the app can't reach the server it disables itself with a "reconnect to upload" hint instead of silently failing. No new dependencies — it's a hidden input plus drag handlers, themed off your active tokens.

Existing projects pick this up with grit update and a re-generate. Verified on a freshly scaffolded Photo resource: a single-image Cover field and a multi-image Gallery field render in the create/edit drawer with zero console errors.

v3.40.0July 11, 2026

A configurable floating quick-access button — on both the admin panel and the desktop app. A round "+" button floats over every page; click it for a quick menu with a New {Resource} action for every resource you've generated, plus system shortcuts (New ticket, and New blog post on desktop).

It's configurable in place: click the gear in the menu to pick the button's corner (any of the four), toggle which default actions show, and add your own custom links. Config is stored per-device in localStorage (key grit-quick-access), so it's instant and works fully offline on the desktop.

The two apps share an identical design and config shape; each just wires navigation and its resource list to its own router (Next.js on admin, TanStack on desktop). Existing projects pick it up with grit update. Verified: the desktop button, its menu (New-per-resource + shortcuts) and the config panel all render and work with zero console errors; the admin build typechecks clean.

This completes the desktop↔admin parity series (v3.36–v3.40): full-height login & collapsible sidebar, the Sync center, the dashboard, resource tables & drawers, all system pages, and now the quick-access button.

v3.39.0July 11, 2026

Desktop parity, round four: the full admin sidebar and every system page. The desktop client now has the same Internal and System sections the admin panel ships — so the sidebars finally match top to bottom.

Ten new pages: Users (accounts & roles via the shared DataTable), Blogs (list + a full post editor), Activity (audit log with summary cards and All/Flagged/Critical tabs), Support (tickets + conversation threads with replies and close/reopen), Notifications (with mark-as-read), Dashboard settings (widget toggles), System Health (Postgres/Redis/API/Jobs/Email cards), Performance (latency/traffic/errors/ saturation + slowest routes), Security (bans, rate-limit hits, recent threats + the escalating-ban policy), and a System Hub landing grid.

Every page is offline-graceful: each query falls back to an empty/zero shape when the app is offline, exactly like the admin's own try/catch behaviour, so nothing errors out — System Hub and Dashboard settings even render entirely from local state. All pages are themed off your active theme tokens.

Existing projects pick this up with grit update. Verified end to end on a freshly scaffolded project: the full sidebar renders in the right order and all ten pages load with zero console errors.

v3.38.0July 11, 2026

Desktop parity, round three: generated resource pages now match the admin panel. A generated resource on the desktop used to be a bare search box + a three-column table + a plain full-page form. It's now the same rich experience the admin ships.

Every generated list page renders a full DataTable: four stat cards (Total, This week, This month, Updated recently), a search box, a date-range filter, a column-visibility toggle, CSV export, sortable headers, row actions, and pagination. Create and edit now happen in a right slide-over drawer (the same "sheet" form the admin uses) instead of separate full pages, with a proper Cancel/Save footer.

It's all offline-first: the table reads the rows the sync engine already mirrored locally, and every stat, search, filter, sort and page is computed in-memory — no network, works fully offline. Charts and tables are themed off your active theme tokens.

Note: file/image dropzone fields still aren't rendered in the offline form (uploads need the API); that's the next follow-up. Existing projects pick this up with grit update and a re-generate. Verified end to end on a freshly scaffolded project: stat cards, the full toolbar, sortable headers, pagination, and the create/edit drawer with all typed fields render with zero console errors.

v3.37.0July 11, 2026

Desktop parity, round two: the dashboard now matches the admin panel. The desktop app's home screen was a pair of placeholder cards; it's now the same "captivating" dashboard the admin ships.

A time-of-day greeting ("Good morning, Ada"), four live stat tiles (Users, Events in the last 24h, Notifications, and a desktop-specific Sync-status tile), a 7-day activity area chart, a severity-mix donut, a recent-activity feed, and quick-access tiles for every generated resource. Charts are rendered with the same recharts the admin uses, themed off your active theme tokens.

It stays offline-first: every stat query falls back to zero or empty when the app is offline, so the dashboard never blanks out, and the resource tiles come from the local nav config so they render instantly with no network. The Sync-status tile reads straight from the offline engine (online/offline + pending-change count) and links to the Sync center.

Existing projects pick this up with grit update. Verified end to end on a freshly scaffolded project: greeting, all four tiles, both charts, the activity feed, and quick-access all render with zero console errors.

v3.36.0July 11, 2026

Desktop parity, round one: full-height login, a collapsible sidebar, and a real Sync page. The desktop client is being brought to visual and behavioral parity with the admin panel. This release lands the app chrome and the offline control center.

Login now fills the window. The split auth shell (hero panel + form) stretches to the full height under the titlebar, matching the admin login exactly — the form wrapper wasn't resolving min-h-full inside a flex-1 parent, so the shell collapsed to content height. Verified: the hero panel measures the full window height minus the native titlebar.

The sidebar collapses. Just like the admin, the desktop sidebar now toggles between a 240px labeled rail and a 64px icon-only rail (with tooltips), and the choice persists across launches. Nav is driven from a single nav-config source of truth.

New: a Sync page. Because the desktop app is offline-first, there's now a dedicated /app/sync screen — Overview, Modules, and Pending-changes tabs showing live sync status (online/offline), last-sync time, per-module pending counts, a stable device ID, a Work-offline toggle, and a Sync-now action. The sync engine now issues and persists a device ID and reports the set of synced tables.

Existing projects pick this up with grit update. More parity work (dashboard, resource tables & forms, system pages, quick-access button) is on the way.

v3.35.4July 11, 2026

Go hot-reload now works out of the box — no more "install air" tip. grit start and grit start server used to fall back to a plain go run (no reload) and print "Tip: install air…" unless you'd globally installed it yourself. A Grit app is supposed to hot-reload without any setup.

Grit now runs air via go run github.com/air-verse/air@v1.65.3 when it isn't already on your PATH — so it's effectively bundled: no go install, compiled once then served from the build cache. Every Grit API already ships a .air.toml, so .go edits rebuild and restart automatically. A globally-installed air is still preferred when present.

This needs no project changes and works on existing projects too — just grit update. Verified end to end: with no global air, grit start server launches air, serves the API, and a .go file change triggers a rebuild.

v3.35.3July 10, 2026

Fixed: desktop login succeeded but never redirected. The API returns { data: { user, tokens: { access_token, refresh_token } } }, but the desktop's useLogin read access_token off the top level. It stored undefined as the token — so the very next thing that happened was /app's beforeLoad finding no token and redirecting straight back to /auth/login. The login itself had worked; the token was simply thrown away.

The same shape mismatch was in useRegister and in the api-client's 401 refresh interceptor (which read data.access_token instead of data.data.tokens.access_token), so a token refresh would have logged the user out. All three are fixed, and AuthResponse now models the real payload.

Verified against a running API: the old expression evaluates to undefined on the real login and refresh responses, the new one yields a valid JWT.

v3.35.2July 10, 2026

Desktop CORS, properly fixed. v3.35.1 tried to allowlist the Wails webview by enumerating origins, and got them wrong — the real dev origin is http://wails.localhost:34115 (host and port), not http://wails.localhost or http://localhost:34115. Worse, that port comes from wails.json, so any enumeration is one config change away from silently breaking again.

The CORS middleware now matches the Wails webview by host instead: any http(s)://wails.localhost on any port, plus wails://wails for macOS/Linux builds. Nothing needs to go in CORS_ORIGINS, which is back to just the web app and admin.

This is safe by construction — wails.localhost is a virtual host the webview resolves internally, so no page on the public internet can be served from it. Verified with preflight and actual requests: the six legitimate origins are allowed, while evil.example.com, null, and three spoof attempts (wails.localhost.evil.com, a wails.localhost query string, and a wails.localhost@evil.com userinfo trick) are all blocked.

Existing projects can unblock immediately without touching code by adding the dev origin to CORS_ORIGINS in the root .env and restarting the API: http://wails.localhost:34115. Re-scaffolding the API picks up the robust host match.

v3.35.1July 10, 2026

Fixed: desktop login failed with "Network Error" — CORS blocked the Wails webview. The desktop app calls the API at http://localhost:8080/api, but CORS_ORIGINS only allowed the web app (3000) and admin (3001). The webview's origin was never in the allowlist, so the login request was rejected before it left the browser and axios surfaced nothing but a bare Network Error.

The scaffolded .env and the Go default now include every Wails origin: localhost:5174 and localhost:34115 (wails dev), wails.localhost (Windows build) and wails://wails (macOS + Linux build). A web page can't forge these origins, so this adds no attack surface — verified that evil.example.com and null are still rejected.

Existing projects: append the desktop origins to CORS_ORIGINS in your root .env and restart the API:

CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:5174,http://localhost:34115,http://wails.localhost,wails://wails
v3.35.0July 10, 2026

The desktop app now shares the admin's themes. grit new --theme=atlas|aurora|pulse already styled the admin panel and web app; the Wails desktop client ignored it entirely and shipped its own hardcoded dark palette. It now reads the same packages/shared/themes.ts token bag, so both apps look like one product.

Every surface, not just auth. The desktop's Tailwind colours were already wired to CSS variables, so driving those variables from the shared tokens means the dashboard, settings, sidebar, topbar and every generated resource screen adopt the active theme with no per-page changes. Fonts and border radius come from the theme too (atlas → Inter, aurora → Geist, pulse → Onest + DM Serif Display), and the right Google Fonts stylesheet is emitted at scaffold time.

Themed auth shells. Login and register now render the same three shells the admin uses, picked from the theme's authLayout: atlas → split-static (hero panel left, form right), aurora → centered card on a pastel wallpaper, pulse → editorial split-carousel.

Dark mode stays. The desktop keeps its light/dark toggle — it defaults to light to match the admin, and dark mode adopts the theme's brand colours over neutral dark surfaces rather than inventing a second palette per theme.

Verified by building and rendering the desktop bundle headlessly for all three themes: each mounts with zero JS errors and shows its own layout, palette and font (atlas #4f46e5 hero, aurora #7c3aed centered card, pulse #fbbf24 accent + DM Serif).

v3.34.4July 10, 2026

Fixed: blank white/black screen from mismatched React versions. The desktop window opened but rendered nothing, and the culprit was a dependency-resolution bug affecting the whole monorepo — not just desktop.

apps/expo pins react to exactly 19.1.0 (React Native requires an exact match), so pnpm deduped every ^19.0.0 in the workspace down to 19.1.0 — but nothing constrained react-dom, which floated up to 19.2.7. React 19.2's react-dom checks that the two versions match and throws "Incompatible React versions" (React error #527) at mount, so the app renders nothing at all, with no visible error. apps/web and apps/admin resolved to the same broken pair.

The root package.json now pins both workspace-wide:

"pnpm": { "overrides": { "react": "19.1.0", "react-dom": "19.1.0" } }

Verified by rendering the built desktop app headlessly: before the fix <div id="app"> was empty with React error #527; after, the login screen renders with zero JS errors.

Existing projects: add that pnpm.overrides block to your root package.json and re-run pnpm i.

v3.34.3July 10, 2026

Fixed: the monorepo desktop app couldn't build at all. grit start desktop in a --full / --desktop project failed during wails build. Four separate defects stacked up, and the frontend had never compiled since the sync engine landed:

  • The route tree was never generated. Routes lived in routes/_app/ and routes/_auth/, but a leading underscore makes a TanStack pathless layout — so _app/index.tsx resolved to / and collided with routes/index.tsx. The generator errored ("Conflicting configuration paths") and never wrote routeTree.gen.ts, which cascaded into a createFileRoute error on every route. Renamed to real segments (routes/app/, routes/auth/), matching the /app/... and /auth/login links the app already used.
  • The build script ran in the wrong order. tsc -b && vite build typechecked before Vite's router plugin generated routeTree.gen.ts, so a fresh clone always failed. Now vite build && tsc -b (tsc still gates the build).
  • Half the Wails bindings weren't typed. The window.go.main.App declaration in vite-env.d.ts listed 13 methods and omitted every sync binding (LocalCreate, Sync, PendingCount, ResolveConflict…) plus the offline-mode ones — even though sync-client.ts called them. All are declared now.
  • Wails couldn't generate bindings for SyncResult. Its time.Time fields made the generator print Not found: time.Time and drop the models. They're RFC3339 strings now — identical JSON, since time.Time already marshalled that way.

Verified end-to-end: a fresh --full project with generated resources now runs pnpm build clean — routeTree.gen.ts is produced with the right /app/products/$id/edit routes, and tsc -b --force reports zero errors.

Existing --full projects: rename apps/desktop/frontend/src/routes/_app → app and _auth → auth (and _app.tsx/_auth.tsx likewise), then update the createFileRoute("/_app/…") ids to "/app/…". Or just re-scaffold the desktop app.

v3.34.2July 10, 2026

Sentinel v2.2.0 — and it immediately caught a dead-config bug in Grit's own WAF settings. Sentinel v2.2.0 adds ValidateConfig, which Mount now runs at startup so config that silently does nothing shows up in the boot log instead of as a 403 weeks later. Running it against Grit's scaffolded config surfaced two real problems, both now fixed.

1. WAF exclusions never matched. The WAF matches ExcludeRoutes against the real request path (c.Request.URL.Path), not gin's route template — so entries like /api/blogs/:id only ever matched the literal string :id, never /api/blogs/123. Five of seven excluded routes were dead. In production (ModeBlock) that meant editing a blog/post/article with richtext was WAF-inspected and its <p>/<img> tags flagged as XSS — a 403 on every rich-text save, and on both public form-share endpoints. Now uses subtree wildcards (/api/blogs/*), verified against real URLs.

2. Security data was being thrown away on every deploy. Grit passed its *gorm.DB but never set Storage, so Sentinel silently fell back to a local sentinel.db SQLite file — ephemeral inside a container, so each redeploy dropped the threat log and blocked-IP list. Storage is now set explicitly, pointing at the app's Postgres (falling back to SQLite when the app itself runs on SQLite).

Also picks up Sentinel v2.1.2 (globstar route patterns after segment wildcards). Grit's config now validates with zero errors and zero warnings.

v3.34.1July 10, 2026

Critical: Sentinel upgraded to v2.1.1 — the WAF was 403'ing real users in production. Grit scaffolds Sentinel with WAF.Mode = ModeBlock outside dev, and the pinned version carried two false-positive bugs that rejected ordinary traffic:

  • Every Chrome 140 user got a 403. The SSRF rule matched the unanchored string 0.0.0.0, which occurs inside the User-Agent Chrome/140.0.0.0 (and 130, 120, 110). Fixed upstream in Sentinel v2.1.0.
  • Roughly one session in ten was 403'd at random. SQLi_Basic matched a bare -- anywhere, and SQLi patterns were scanned against headers. JWTs are base64url (which includes -), so a cookie holding two tokens contains -- about 9% of the time — and it re-rolled on every token refresh, so it looked like flaky networking, not a firewall. Fixed upstream in Sentinel v2.1.1.

New projects now pin github.com/MUKE-coder/sentinel/v2 v2.1.1 (Sentinel finally ships a proper /v2 module path, so we track real tags instead of a pseudo-version). Real SSRF, SQLi and XSS payloads are still detected — only the false positives are gone.

Existing projects must migrate by hand — grit upgrade doesn't rewrite your apps/api/go.mod. In apps/api, change the import in internal/routes/routes.go from "github.com/MUKE-coder/sentinel" to sentinel "github.com/MUKE-coder/sentinel/v2", then run go get github.com/MUKE-coder/sentinel/v2@v2.1.1 && go mod tidy. If you worked around this by setting ModeLog, it is now safe to go back to ModeBlock.

v3.34.0July 9, 2026

grit start now runs every app — including the desktop. From the project root, grit start boots the Go API, the Next.js apps, and (when apps/desktop exists) the Wails desktop window too, all in parallel — Ctrl+C stops them together. And you can start any single app from the root, just like grit start server: grit start web, grit start admin, grit start expo, and grit start desktop. No more cd-ing into each app to run its dev server.

v3.33.0July 9, 2026

Generated offline-first desktop screens. In a monorepo with a desktop client (--full or --desktop), grit generate resource now scaffolds full CRUD screens for the Wails desktop app — a list view, create/edit forms (with typed inputs and belongs_to pickers), a React Query hook, and a sidebar entry. Every screen reads and writes through the offline-first sync engine (local SQLite mirror + outbox), so it works with no connection and reconciles automatically when you're back online — the same command that already fans out to web, admin, and mobile now covers desktop too. This is what makes a full offline/online desktop app (a POS, an inventory tool, a field-ops app) mostly generated code.

v3.32.0July 9, 2026

Offline-first desktop, and a deep security & correctness pass. This release makes the monorepo desktop client a true online/offline hybrid, and fixes a batch of issues found in a full audit of the generated code.

New — offline-hybrid desktop. The apps/desktop client (from --full or --desktop) now works online by default, continuously mirroring server data into a local SQLite copy in the background. A Work offline toggle in the dashboard's Settings lets you keep working against that local copy with no connection; every edit queues, and the moment you switch back online it auto-reconciles — pushes your changes (with the existing per-field conflict merge) and pulls anything new. Deletes now propagate to offline clients via tombstones. grit generate resource registers each new model for offline sync automatically.

Security. Closed a SQL-injection vector in the shared paginator's date_field parameter (reachable on every generated list endpoint) and whitelisted the generated service's ORDER BY. Uploads now sniff real content type instead of trusting the client header, cap the request body, and reject HTML/SVG payloads. The seeder refuses the default admin123 password in production, and token refresh re-checks that the account still exists and is active.

Correctness. Fixed generated desktop CRUD (models now assign their UUID and use string IDs end-to-end — the old code could store only one record and silently no-op updates and deletes); the desktop embedded API moved off port 34115 so it no longer collides with wails dev; date fields, a belongs_to CSV-import build breaker, and a mobile Bearer undefined token-refresh bug are all fixed. Backups now stream (no more loading the whole database into memory), include many-to-many join tables, and no longer corrupt values containing --. CSV import streams and batches instead of buffering the whole file, and stalled import jobs are reaped.

v3.31.83July 8, 2026

NSIS made discoverable for desktop installers. Building a Windows installer with grit package needs NSIS, and it was easy to miss. The desktop app's README now lists NSIS as a prerequisite (with winget install NSIS.NSIS and friends), and whenmakensis is missing grit package now prints the exact install commands and a PATH hint instead of a bare link — or points you at --no-installer. No behaviour change, just fewer dead ends.

v3.31.82July 8, 2026

New: grit package — build a distributable desktop installer. Run it inside a grit new-desktop app and it produces the artifact you hand to a user: on Windows an NSIS installer (the single *-installer.exe in build/bin/), on macOS/Linux the platform binary/app bundle. It wraps wails build, checks the toolchain (wails, plus makensis for the installer) up front with a clear error, and prints where the artifact landed. --no-installer builds the raw binary only; --platform cross-compiles. For a full versioned release, scripts/release-desktop.sh <version> still ships.

v3.31.81July 8, 2026

Desktop relationship fields are now a real dropdown. A belongs_to field in a generated desktop form used to render as a plain text box where you had to paste the related row's id. It now loads the related records via their list binding and renders a proper <select> of names — pick a Category from the list instead of typing a UUID.

v3.31.80July 8, 2026

Fix: desktop list crashed on file fields. A generated desktop list rendered a file field's FileRef object directly into a table cell, which React refuses ("Objects are not valid as a React child"). File columns now render a thumbnail (and files columns a small stack), so an inventory list with a product photo displays instead of white-screening. Completes the desktop upload support from v3.31.79.

v3.31.79July 8, 2026

Desktop apps are now hybrid — and support file uploads. A grit new-desktop app still runs on Wails + SQLite/Postgres, but it now also embeds a real Gin REST API in the same binary. The router is mounted twice: as the Wails asset-server handler (so the webview calls /api/… and loads <img src="/uploads/x.jpg"> same-origin, no port, no CORS) and on 127.0.0.1:34115 for curl / other clients.

File uploads work end-to-end. grit generate resource ... photo:file:image now produces a working image field: a native file picker that uploads to POST /api/uploads, files stored under the OS app-data dir (writable even when the app is installed in Program Files), a preview in the form, and files:image for multi-image galleries. New internal/files, internal/storage and internal/api packages back it.

Two codegen bugs fixed along the way: a file: field used to emit *files.FileRef with no import (and no files package at all), breaking the build; and a slug field called slugify() from a package that didn't define it. Both now compile.

v3.31.78July 8, 2026

Grit UI is no longer baked into generated apps. It lives on as a standalone library, so a new project starts lean. Scaffolded apps no longer include the UIComponent model, the registry handler (/r.json, /r/:name, /ui-components, admin CRUD), the 91-component seeder, packages/grit-ui/, or the web /components browser.

A fresh --triple project now registers 19 models instead of 20, and seeding no longer plants 100 component rows — your first backup drops from 109 rows to 9. Existing projects are untouched; delete those files yourself if you want the same trim.

v3.31.77July 8, 2026

Automatic weekly database backups. Every Grit API now takes a full-database backup every Sunday at 02:00 UTC and uploads it to your object storage (R2 / S3 / MinIO). The four most recent are kept; older ones are purged from storage but their rows survive as an audit trail.

Each archive is a ZIP: one CSV per table (opens in any spreadsheet), a dump.sql of INSERTs in parent→child order wrapped in BEGIN/COMMIT, and a metadata.json manifest of row counts. It's pure Go — no pg_dump binary — so it works on Postgres and SQLite alike. The table list is derived from models.Models(), so every grit generate resource is included automatically and a table name can never be injected.

Four surfaces: a Backups page in the admin panel (list, back up now, download), REST endpoints (GET /backups, POST /backups/generate, GET /backups/:id/download — which mints a 15-minute pre-signed URL so the browser pulls straight from storage), a mobile Backups screen, and the CLI:

grit backup                 # dump + upload to object storage
grit backup -o backup.zip   # write a local archive (no storage needed)
grit restore backup.zip     # migrate, then replay in ONE transaction

Restore is a first-class command, not a doc page — a backup you have never restored is a rumour. Manual backups are rate-limited to one per 24h; the weekly cron bypasses it and uses asynq.Unique so a rolling deploy can't enqueue it twice. Without object storage configured (typical in dev) the weekly job skips silently.

v3.31.76July 6, 2026

Numeric inputs format as you type — and never rescale. Number fields in generated mobile forms now show thousands separators while typing (1000 renders 1,000) and submit the plain number. What you type is what's stored: enter 100 and the record holds 100 — no cents conversion, no divide-by-100. New lib/format.ts exposes formatNumberInput() / parseNumberInput();float fields keep up to two decimals.

v3.31.75July 6, 2026

Multi-image fields on mobile. A files field (name:files:image) now renders a proper multi-picker: select several photos from the gallery at once, see them as a grid of removable thumbnails, each uploaded in the background, and the payload carries an array of file references. Single file fields stay single-select. The picker sheet already supported multi-select; generated forms now use it for array fields.

v3.31.74July 6, 2026

Searchable select for relationships on mobile. A belongs_to field in a generated form used to render every related record as a horizontal row of pills, which falls apart once there are more than a handful. It now uses a new RelationSelect component: a tidy select that opens a bottom sheet with a pinned search box and a scrollable, filtered list — pick one and it fills in. Wired into every generated resource form; regenerating a resource with a relationship picks it up.

v3.31.73July 6, 2026

MinIO is now reachable from mobile devices. The dev docker-compose.yml published MinIO on 127.0.0.1:9002 (localhost only), so a phone or emulator couldn't load uploaded images even though the API (bound to all interfaces) worked fine — list and detail thumbnails stayed blank. It now binds 9002:9000 on all interfaces, so devices on your LAN can fetch stored images. Pairs with resolveImageUrl() (v3.31.72), which rewrites the localhost host to your dev IP. Existing projects: change the minio ports to "9002:9000" / "9003:9001" and docker compose up -d minio.

v3.31.72July 6, 2026

Image previews everywhere on mobile. Generated resources now show pictures throughout: an instant local preview in the create/edit form the moment you pick a photo (with an upload spinner overlay), a thumbnail column in the list table, and the hero image on the detail screen.

New lib/images.ts → resolveImageUrl() fixes the classic dev gotcha: MinIO hands back http://localhost:9002/... URLs that a device or emulator can't reach (localhost = the device itself). It rewrites the host to the same dev host the app already uses for the API, so stored images actually load — while real S3/R2 public URLs pass through untouched. Every generated list, detail and form image runs through it; regenerating a resource adds it.

v3.31.71 · critical fixJuly 6, 2026

Fix: request bodies larger than 4 KB were silently truncated. Pulse's error-tracking middleware captures a request-body snippet for error context, but it restored only that snippet to the request — discarding everything past MaxBodySize (default 4096 bytes). Every request that sends a Content-Length (mobile apps, native/CLI clients, curl) reached handlers with a body cut to 4 KB, so file uploads and any large JSON POST failed with confusing "no file" / parse errors. Browsers were unaffected because fetchsends multipart chunked (no Content-Length), which skipped the capture — which is why the web dropzone always worked while mobile never did.

The scaffold now mounts Pulse with WithRequestBodyCaptureDisabled(), so the full body always reaches your handlers. This affects every generated API; regenerate or add that option to your Pulse mount. Mobile image uploads (avatar, resource forms, imports) now work end-to-end.

v3.31.70July 6, 2026

A proper image picker for mobile forms. Tapping an image field used to jump straight into Android's system crop screen — whose only button was CROP, with no clear "use this photo" and no permission prompt of our own.

Now it opens a clean, themed picker sheet: choose Library or Camera (with a friendly permission prompt and an "Open Settings" fallback if access is off), then preview the selection and decide — Use photo, Crop (the native editor, only when you ask for it), or choose a different one. The dropzone shows a spinner while the upload runs. The generated resource form uses it for every image field; regenerating any resource upgrades an existing app.

v3.31.69July 6, 2026

Fix: image uploads from the Expo app. Uploads were failing with 400 "No file provided" because expo-file-system's uploadAsync sends an empty body under the New Architecture on SDK 54 (the request landed in a few ms with no file). The mobile upload helper now uses fetch + FormData with a React Native file descriptor and — crucially — never sets Content-Type by hand, so fetch keeps the multipart boundary intact. Fixes avatar, blog and every generated resource-form image field.

The /uploads handler is also more robust: it falls back to the first file part under any field name and logs the request's content-type + fields when a file is genuinely missing, so client-side multipart problems are diagnosable from the server terminal. Re-run pnpm i in apps/expo after updating (the helper no longer needs expo-file-system for uploads).

v3.31.68July 6, 2026

Background CSV import — imports now run server-side and survive leaving the screen. The import endpoint no longer blocks: it reads the upload, creates a job, processes rows in a goroutine and returns 202 with a job id.

Backend (every architecture)

A shared ImportJob table tracks every resource's imports. POST /<plural>/import kicks the work off and returns immediately; a new shared GET /imports/:id reports live processed / total plus the final created / skipped / failed counts and per-row errors, so a large file never times the request out.

Mobile

Imports run in a module-level store, so they keep uploading and polling even after you close the import sheet. A persistent progress banner shows every in-flight import across navigation, then the result — tap "Continue in background" and carry on using the app. Regenerating any resource upgrades an existing app to the new flow.

v3.31.67July 3, 2026

CSV import — bulk-create records from a spreadsheet. grit generate resource now generates a bulk import endpoint (all architectures) plus a full mobile import flow.

Backend (every architecture)

Each resource gets POST /<plural>/import (upload a CSV → typed bulk-create) and GET /<plural>/import/template (a ready-to-fill header CSV). Everything is optional except model-required fields: file columns are skipped; a belongs_to is given by name (a category column, not category_id) and the related record is looked up and created if missing; rows that hit a unique constraint are skipped (safe to re-import) and other failures are reported per-row.

Mobile

The resource list gains an import action that opens a sheet: download the template, pick a CSV, preview the parsed rows, import with a progress bar, then a summary of created / skipped / failed with per-row errors. Adds expo-document-picker — run pnpm i. Background (async) import is the final step.

v3.31.66July 3, 2026

Mobile: relationship filters. Resource lists with a belongs_to field gain a funnel action that opens a filter sheet.

The sheet shows a picker per relationship (loaded from the related resource); pick a value to scope the table (?<fk>=<id>, which the API already supports), with an All chip and a Clear all. The funnel shows a dot while filters are active, and export respects them. Resources without a relationship simply don't show the funnel. Re-run grit generate resource to pick it up. CSV import is the last piece.

v3.31.65July 3, 2026

Mobile: CSV export. Every generated resource list gains a download action that exports the data as CSV and opens the native share sheet.

Tapping export downloads /<plural>/export (honouring the current search) to a file and hands it to the OS share sheet — mail it, save it, open it in Sheets. New lib/export.ts helper built on expo-file-system + expo-sharing (run pnpm i). Filters and CSV import land next.

v3.31.64July 3, 2026

Mobile: scrollable data table. Generated resource lists now render as a horizontally-scrollable table — a column per field with tap-to-sort headers — instead of cards.

The title field leads (bold), followed by a column for every scalar and belongs_to field; dates, numbers and booleans format per cell. Tap a sortable header to sort (wired to the API's sort_by / sort_order, which the list hook now accepts), tap a row to open the detail. Search, infinite-scroll pagination and the quick-create sheet are unchanged. Re-run grit generate resource to switch a list to the table.

v3.31.63July 3, 2026

Mobile: quick-create bottom sheet. Adding a record no longer always means a full-screen navigation — the resource list's + now opens a slide-up sheet with the form, while detailed edits stay a full page.

New shared FormSheet component — a themed, keyboard-aware bottom sheet built on React Native's Modal (no extra dependencies). The generated list renders the same <Name>Form inside it for a fast add; the detail screen's Edit still opens the full page for longer records. One form, two containers. Re-run grit generate resource to pick it up.

v3.31.62July 3, 2026

Mobile: full CRUD on generated resources. Generated resources now support edit, update and delete, not just create + read.

grit generate resource now emits a shared <Name>Form component (in components/resource-forms/) that both the create and edit screens render — the create page and a new app/<plural>/edit/[id].tsx screen pre-fill from the record and drive useCreate / useUpdate. The detail screen gains Edit and Delete (with a confirm) actions. The shared form is container-agnostic, ready to drop into a bottom sheet next. Because generation is now idempotent, re-run grit generate resource <Name> --fields … to add CRUD to an existing resource.

v3.31.61July 3, 2026

Mobile: built-in Blog & User resources. The scaffolded Expo app now surfaces the framework's built-in Blog feature and lets you add users — no admin panel required.

The More tab's Resources section now leads with Users and Blogs (alongside your generated resources). Blogs get a paginated, searchable list plus a create screen (title, excerpt, content, cover image upload, publish toggle) backed by /admin/blogs — the posts grit seed already creates now have a home. The Users list gained a + to create a user (name, email, password, role, active) via /admin/users.

v3.31.60July 3, 2026

generate resource is now idempotent. Re-running grit generate resource <Name> for a resource that already exists used to append duplicate injections — duplicate switch cases, routes and exports — which broke the API build. Now every injection is skipped when it's already present.

The two low-level inject helpers gained a whitespace-insensitive "already there?" guard, so a second run finds each of its injections in place and does nothing (files are also just overwritten). Safe to re-run to pick up regenerated files, or after editing a resource's fields.

v3.31.59July 3, 2026

Mobile: create forms + a More tab hub. Generated mobile resources can now add data, not just browse it — so a --mobile project no longer needs an admin panel to get started.

Create screen per resource

grit generate resource now also scaffolds app/<plural>/new.tsx — a form wired to the generated useCreate<X> mutation, with an input per field: text / number / textarea / toggle for scalars, an image picker (upload → FileRef) for file fields, and a chip picker for belongs_to relationships. The list screen gains a + button to reach it.

"More" tab

The mobile Explore tab is now More (with an ellipsis icon) and acts as the app hub: a Resources section that grit generate resource injects each new resource into, plus the Users / Storage / Analytics / Notifications tools. Restart nothing — reload the Expo app.

v3.31.58July 3, 2026

Fix: mobile file uploads ("No file provided"). Avatar / image uploads from the Expo app failed with a 400 even though a file was selected.

Two causes, both fixed. On the client, React Native's fetch + FormData (RN 0.81 / Expo SDK 54) can drop the file part entirely; the upload helper now uses expo-file-system's native uploadAsync, which streams the file reliably. On the server, the audit-log middleware read the entire request body to digest it — wasteful for a binary upload and enough to leave ParseMultipartForm with nothing to parse; it now skips multipart/form-data bodies. Upload errors also surface the server's actual reason now instead of a generic failure. Adds expo-file-system — run pnpm i, restart the API, and npx expo start -c.

v3.31.57July 3, 2026

Fix: mobile mutations blocked by CSRF (403). Native clients could read data but every write — file uploads, generated-resource create/update/delete, profile updates — failed with a 403 CSRF_INVALID.

React Native's fetch (and Android's OkHttp) transparently store and resend the grit_access cookie the API sets at login. TheAutoCSRF guard saw that stray cookie and treated a bearer-authenticated request as cookie-authenticated, demanding a CSRF token the app never sends. The guard now skips CSRF whenever an Authorization: Bearer header is present — an explicitly-authenticated request can't be forged cross-site, so it's CSRF-immune regardless of a tag-along cookie. On an existing project, restart the API after pulling the fix.

v3.31.56July 3, 2026

Mobile code generation. grit generate resource now scaffolds the mobile app too, not just the backend, web hooks, and admin.

Generated Expo screens & hooks

When a project has an apps/expo app, generating a resource also writes a typed React Query hook (infinite-scroll list, single item, and create/update/delete mutations), a paginated list screen, and a detail screen — all field-aware (images become thumbnails, a belongs_to renders its related record, dates/bools/files format sensibly). A shared safe-area ScreenHeader with a back button ships with the scaffold.

Relationship filtering

Every belongs_to resource is now filterable by its foreign key — GET /products?category_id=… returns just that parent's children, and the generated hook takes the same filter. This is what powers a real category → products browse flow on mobile.

Mobile app polish

The scaffolded Expo app gained a full light/dark theme (default light, with a working Settings toggle), the Grit logo on auth, a floating glass tab bar lifted above the Android system nav, safe-area page headers, wired-up Explore destinations (Users with pagination, Notifications, Storage, Analytics, Content, Integrations), and profile avatar upload + change password. Also fixed: the physical-device API URL (derived from Expo's host), a splash-screen hang, the auth token shape, and post-login navigation. Adds expo-image-picker, expo-linear-gradient, expo-blur, and react-native-css-interop — run pnpm i then npx expo start -c.

v3.31.55July 3, 2026

Redesigned mobile auth & navigation. The scaffolded--mobile app now ships a premium, production-grade UI out of the box instead of the plain starter screens.

Polished login & register

Both auth screens are rebuilt as a single elevated card on a faint architectural grid: a gradient brand header, icon-prefixed inputs, a show/hide password toggle, inline validation, a gradient primary CTA, and Google sign-in. Every action fires haptic feedback and the card animates in with a spring FadeInUp.

Floating glass tab bar

The bottom navigation is now a floating, rounded bar with a native frosted-blur background on iOS (solid elevated surface on Android) and a selection haptic on every tab switch. A new PressableScale primitive gives buttons the tactile spring-press micro-interaction. New dependencies:expo-linear-gradient and expo-blur — runpnpm i then npx expo start -c.

v3.31.54July 3, 2026

Mobile styling fix (NativeWind). A scaffolded--mobile app rendered completely unstyled — raw text on a black screen — and hung on startup.

The Expo app was missing its Babel config

NativeWind requires a babel.config.js with thejsxImportSource: "nativewind" option and thenativewind/babel preset — without it every className is silently ignored. The scaffold never generated that file. It now ships one, plus the react-native-worklets dependency and its Babel plugin (required by Reanimated 4, whose absence caused the startup hang), andweb.bundler: "metro" in app.json. On an existing project, add apps/expo/babel.config.js, installreact-native-worklets, then restart Metro with a clear cache:npx expo start -c.

v3.31.53July 3, 2026

Mobile (Expo) scaffold fixes. A fresh --mobileapp now starts cleanly with correct dependency versions and real app icons.

App icons & splash now ship with the scaffold

app.json referenced ./assets/icon.png and./assets/splash.png, but those files were never generated — so Metro failed with “Unable to resolve asset”. The Grit logo is now embedded in the CLI and written to icon.png,splash.png, adaptive-icon.png, andfavicon.png on scaffold (with matching Android adaptive-icon and web favicon entries in app.json). The same brand logo is also dropped into the web, admin, and single-app public/ folders.

Expo dependency versions aligned to SDK 54

The Expo app pinned expo 54 but shipped SDK-53 versions of a few packages, triggering compatibility warnings. Bumpedexpo-image (~3.0.11), expo-haptics (~15.0.8),expo-web-browser (~15.0.11), react-native (0.81.5), and typescript (~5.9.2). On an existing project you can also runnpx expo install --fix.

v3.31.52July 1, 2026

Self-update fix. grit update could report success but leave no grit on your PATH.

go install now targets grit's real location

When grit was installed via the install script (into ~/.grit/bin), the update's go install step wrote the new binary to the Go default GOBIN (~/go/bin) instead — while the running binary had already been renamed aside. The result: “Updated to vX” followed by grit: No such file or directory. The updater now sets GOBIN to the directory grit actually lives in, so the refreshed binary lands exactly where your PATH expects it. If you hit this on an older version, recover with curl -fsSL https://gritframework.dev/install.sh | sh (or the PowerShell one-liner), then update as normal.

v3.31.51July 1, 2026

Windows dev fix. grit start no longer fails to boot the frontend on machines where Turborepo's native binary can't load.

Dev no longer depends on the turbo binary

On some Windows setups turbo dev exits with 0xC0000135 (STATUS_DLL_NOT_FOUND) because turbo ships a platform-specific native binary that needs the Visual C++ runtime — which blocked grit start on a fresh machine. The scaffolded root dev script now uses pnpm's own parallel runner (pnpm --parallel --filter "./apps/*" --if-present run dev), which needs no native binary, so the web and admin dev servers always come up. Turbo is kept for build / lint / test where its caching helps.

v3.31.50June 26, 2026

Form-sharing polish. Four fixes from one fresh-project test session, all on the /system/form-shares surface.

1. Resource is now a dropdown, not a text input

Typing Catgeory instead of Category in the New Share modal silently created a broken share -- the dispatcher fell through to default, the public form showed an empty state, and the operator didn't find out until a customer hit it. The modal now lists every registered resource in a <select>, sourced from a new GET /api/admin/form-shares/resources endpoint.

2. Form preview with per-field hide toggles

Once a resource is picked, the modal renders a preview of the public form: every field with its type, required/optional badge, and a Hide checkbox for each optional one. Required fields can't be hidden (the submit would 422). The selected hidden keys persist with the share and the public-form endpoint filters them out server-side -- so anonymous visitors never see a column the operator marked private.

3. Custom title + description on the public form

The public form's heading used to be <resource> submission + a hardcoded “Fill out the form below to submit a new <resource>.” Both can now be customised per share in the New Share modal. Title falls back through three sources -- custom_title → label → resource name -- so old shares keep working with the same heading they had before.

4. The public form actually renders the right fields now

This was the big one. v3.31.43 fixed services/form_share_dispatch.go to return the resource's real field schema (via reflection) -- but the matching change to webPublicFormPage() in the framework scaffold never landed. Every project scaffolded with v3.31.43 through v3.31.49 was shipping a hardcoded name / email / phone / message contact form, regardless of what the resource actually looked like.

The scaffold's public form page is now the fields-aware version: reads the new fields[] , custom_title, custom_description from the API and renders one input per field with the right HTML shape (text / email / tel / textarea / number / checkbox / date / datetime / file).

For projects already scaffolded with the stale page, the upgrade is a one-file copy: apps/web/app/forms/[token]/page.tsx from a fresh scaffold replaces the broken one.

Plus: Edit modal now scaffolds in fresh projects

v3.31.43 added an Edit button to the form-shares table -- but only as a hand-applied patch to the ecom test project, never in the scaffold. v3.31.50 ships it properly: a Pencil button on each row opens an Edit modal with the same preview + hide toggles + title / description / password controls.

Data model

models.FormShare gains three columns:

  • custom_title / custom_description -- short strings
  • hidden_fields -- JSON array of field keys to omit

GORM AutoMigrate adds them on next boot. The new // grit:form-share:registered marker in RegisteredResources() gets injected by the generator on each grit generate resource; pre-v3.31.50 projects warn instead of failing.

v3.31.49June 25, 2026

Three small ergonomic wins from real operator feedback. All three landed together in v3.31.49.

1. Activity log shows the operator's real IP, not "::1"

Local-dev activity rows showed ::1 in the IP column because gin's ClientIP() correctly reports the IPv6 loopback for same-machine traffic. Operators expect to see their actual public IP.

The admin / web axios clients now fetch the operator's public IP once per session (cached in sessionStorage; sourced from api.ipify.org) and attach it as X-Public-IP-Hint on every API call. The new services.ResolveClientIP helper honours the hint only when the TCP peer is loopback, so production traffic from real proxies (which sets X-Forwarded-For for gin to consume) keeps using the trusted path and can't be spoofed by a client header.

When the lookup fails (offline / ad-blocker), the feed falls back to the prior behaviour and renders localhost (::1) with the raw value tucked next to it so the origin stays inspectable.

2. Web navbar gets an Admin CTA back

v3.31.42 replaced the navbar's Admin link with the v3.31.42 UserMenu (Login / Sign up + avatar dropdown). Operators landing on the marketing site lost the one-click bounce to the admin app and had to type the URL by hand.

v3.31.49 puts an Admin button back in the navbar, both in the base scaffold (no-auth, post v3.31.48) and in the auth-aware variant. Points at NEXT_PUBLIC_ADMIN_URL (defaults to http://localhost:3001 for dev; set to your prod admin origin before shipping).

3. Landing page surfaces all the dev URLs

The grit new welcome banner prints every URL the scaffold ships with: API, API Docs, GORM Studio, Sentinel, Pulse, Admin, MinIO, Mailhog. Once the terminal scrolls past, operators have to dig back through history to find the right one.

A new <DevLinks /> component renders all of them as a clickable grid at the bottom of the web landing page, grouped by function (App / API / Data / Ops) and colour- coded. The whole section is wrapped in a NODE_ENV !== "production" check at module level so production marketing pages never leak the internal port map -- the section disappears from the prod bundle entirely, not just hidden behind a class.

Files changed

  • Backend: new services/clientip.go (ResolveClientIP); inline mirror in middleware/activity.go (HTTP audit logger); CORS Access-Control-Allow-Headers extended to allow the hint.
  • Admin: lib/api-client.ts fetches + caches the public IP, attaches the hint; activity page's prettyIP helper renders "localhost" for loopback so fall-back rows still read cleanly.
  • Web: components/navbar.tsx + auth variant gain the Admin button; components/dev-links.tsx new file; app/page.tsx renders <DevLinks /> at the bottom.
  • Env: NEXT_PUBLIC_ADMIN_URL documented in .env with the default + a prod-deployment note.
v3.31.48June 25, 2026

Two bug fixes from real user feedback on v3.31.47. Both happened on fresh grit new scaffolds: a broken Go file from a marker collision, and web auth shipping by default when it should be opt-in.

1. injectBefore now matches marker as a standalone line

The generator's injectBefore did a raw strings.Index on the marker. Markers like // grit:form-share:fields sometimes appear inside the docstring of the function they precede (“...at the marker comment...”). The substring match landed there first and the case got injected into the comment, not the function body -- producing a syntax error.

The matcher now walks line-by-line and requires the trimmed line content to equal the marker exactly. Docstrings that mention the marker by name are safe again, and the form-share scaffolded comments stay readable.

2. Web auth is now opt-in via grit add web-auth

The base web scaffold has been quietly shipping the full auth surface since v3.28.1: login / register / forgot-password / callback pages, the five themed AuthShells, the useAuth hook, AuthProvider, UserMenu, the web-session marker, and Login / Sign up buttons in the navbar. That was always intended to be opt-in via grit add web-auth -- the base scaffold should be a clean marketing site with no auth UI.

v3.31.48 moves all of those files into grit add web-auth:

  • Base scaffold: navbar shows Home / Blog / Docs / GitHub only, no Login / Sign up. AppChrome keeps /forms/<token> as the only chromeless prefix (for public form-share).
  • grit add web-auth now writes everything: hooks/use-auth.ts, lib/auth-provider.tsx, lib/web-session.ts, the four (auth) pages, the five themed shells, UserMenu, middleware.ts, ProtectedWebRoute.tsx -- and REPLACES the navbar + AppChrome with their auth-aware variants (which add the (auth) chromeless prefixes and the UserMenu in the navbar). Replacement requires --force for safety.

Migrating existing projects

Projects scaffolded with v3.31.x before this release already have the auth files. They keep working unchanged -- no removal happens automatically. Future grit new calls produce the clean base scaffold; if you need auth on a fresh project, run grit add web-auth right after grit new.

Both bugs were reported same day

The user spun up a fresh ecom-app, hit the form-share dispatch syntax error ongrit start, then noticed the web shipping auth they didn't want. Both shipped fixed in v3.31.48 within a few hours.

v3.31.47June 25, 2026

The Preset Chart builder. Operators can now build custom charts straight from Dashboard Settings -- pick a resource, pick a preset, pick a visualization. The charts render in the Charts section alongside the system Activity + Severity widgets. No SQL involved.

Four presets

The presets cover the bulk of admin-dashboard needs without introducing a query plane:

  • Count over time -- daily count of new records (no field needed)
  • Group by field -- top-N counts grouped by a categorical column (e.g. orders by status, products by category)
  • Sum over time -- daily sum of a numeric column (e.g. revenue per day)
  • Avg over time -- daily average of a numeric column (e.g. average order value)

Five visualizations

Each chart renders as bar, line, area, pie, or donut -- using Recharts. The builder dims out incompatible combinations (pie for a time-series, line for group_by) so users see why a choice doesn't make sense rather than picking a broken combo and getting a flat chart.

How it works under the hood

Same dispatch pattern as the v3.31.44 resource stats. A new service file chart_dispatch.go ships with a switch over resource name + a reflective helper that runs the right SQL for each preset:

  • count_over_time: pulls timestamps + buckets in-memory (portable across SQLite + Postgres)
  • group_by: SQL GROUP BY field ORDER BY COUNT(*) DESC LIMIT N
  • sum_over_time / avg_over_time: pulls (created_at, field) pairs + aggregates in-memory

Field whitelisting is the security boundary: the helper reflects on the model to build two sets (string/bool columns valid for group_by, numeric columns valid for sum/avg) and rejects any field not in the right set. The same dispatch marker used by v3.31.44 (// grit:resource-stats:dispatch) is reused, so one generator injection covers both the sparkline + the chart presets for a new resource.

New endpoint

GET /api/admin/dashboard/chart/:resource?preset=group_by&field=status&limit=10 returns { data: { preset, rows: [{x, y}], meta } }. The frontend ChartCard renders the right Recharts component based on the saved viz; the {x, y} shape works for all four presets without a discriminator.

Data model

models.DashboardLayout gains one new JSON column:

  • custom_charts -- array of user-defined chart configs. The PUT handler validates each entry on write (drops malformed rows individually rather than rejecting the whole save).

GORM AutoMigrate adds the column on next boot. No manual migration; existing saved layouts continue to work (empty array = no custom charts).

Frontend pieces

Three new files in the admin scaffold:

  • components/dashboard/CustomChartCard.tsx -- renders one chart with Recharts. Loading + error states inline so a broken chart never blanks the section.
  • components/dashboard/ChartBuilderForm.tsx -- the inline builder. Resource picker, preset picker (with tiles), field picker (filtered by preset), viz picker (with grey-out for incompatible). Used in the new Custom charts section on Dashboard Settings.
  • Settings page Custom charts panel -- lists saved charts with Edit + Delete; click Add chart to open the inline builder.

Research note

The design is the “Preset Charts” pattern from the research pass (Metabase, Grafana, Looker Studio, Superset, Power BI). It's the table-first pattern with curated presets rather than the dimension/metric drag-drop pattern -- fits Grit's convention-over-configuration audience and slots straight into the v3.31.45 settings page. The dimension/metric builder (Design B from the research) stays available as a future v3.32 upgrade if users start asking for one more axis of freedom.

v3.31.46June 25, 2026

Polished By-Resource Latest tables + per-resource layout toggle. The v3.31.44 Latest list rendered a single “Name: X · Status: Y” line per row, which turned any FileRef column into a JSON blob visible to the user. v3.31.46 swaps that for the same column-driven table layout the resource list page uses, with proper FileRef thumbnails, badges, date formatting, and currency rendering.

The Latest table now uses renderCell

Every cell in the dashboard's Latest table now goes through the same renderCell dispatch the resource list pages already use. That means columns with format: "image" render thumbnails, columns with format: "badge" render the configured pill, dates and currency get their normal formatting. The column picker still uses the v3.31.44 heuristics (prefer name/title/email/status/price) but now always reserves a slot for any image / FileRef column so visual rows always have a thumbnail when the model defines one.

Per-resource layout toggle: Split vs Tabs

The v3.31.44 layout was hardcoded: Split (Total card ~33% on the left, Latest table ~67% on the right). That ratio breaks down for resources with many columns or long string values -- the Latest table never has room to breathe. v3.31.46 adds a per-resource layout mode:

  • Split (default) -- the v3.31.44 side-by-side layout.
  • Tabs -- both widgets render full-width inside a tabbed container. Two tabs (Total <Resource> / Latest <Resource>) with the Latest tab opened by default since that's the widget that benefits most from the extra width.

Picked per resource in Dashboard Settings under the new Resource layout panel (below the By Resource checkboxes). Only resources with at least one widget enabled show up in the picker -- the choice is moot otherwise.

Data model

models.DashboardLayout gains one new JSON column:

  • resource_layouts -- a string-keyed object ({ "products": "tabs", "orders": "split" }). Only non-default (tabs) entries are persisted; missing slugs fall back to split at render time. The PUT handler validates incoming values and silently drops anything that isn't split or tabs.

GORM AutoMigrate adds the column on next boot. No manual migration; existing saved layouts continue to work (an empty map means “every resource uses split” -- the v3.31.44 behaviour).

Coming in v3.31.47

Next release will tackle the “build a custom chart” ask -- give operators a way to pick a resource + group-by field + aggregation + visualization (bar/line/pie/donut) without writing SQL. The design landed on the “Preset Charts” pattern (count over time, group by field, sum/avg over time, top-N) -- those four cover the bulk of admin-dashboard needs without introducing a query plane, and slot straight into the same Dashboard Settings page as the v3.31.45 toggles.

v3.31.45June 25, 2026

Per-resource dashboard customisation + section reordering. The v3.31.44 “By Resource” band was uncustomisable — it always rendered the Total + Latest pair for every resource. Dashboard Settings now exposes both halves per resource, and the four top-level dashboard sections (Cards, Charts, Tables, By Resource) can be reordered.

Per-resource toggles in Dashboard Settings

A new By Resource section appears at the bottom of /settings/dashboard, grouped by resource. Each resource exposes two checkboxes:

  • Total <Resource> — the stat card with the 30-day sparkline.
  • Latest <Resource> — the newest-N records table.

Toggling either one off hides just that widget; the row stretches the visible half to fill the available width. Resources with both halves unchecked don't render at all. The resource-level dashboard: { enabled: false } opt-out still exists for resources that should never appear on the dashboard, even as catalog entries.

Section reorder

A new Section order panel sits at the top of Dashboard Settings, showing the four sections as a numbered list with up/down chevrons. The saved order persists on the existing DashboardLayout row (new section_order column). The dashboard page renders the sections in that order using CSS order on a flex container — no JSX restructure was needed.

Data model — two new JSON columns

models.DashboardLayout gains two fields, both JSON arrays:

  • resources — enabled keys for the By Resource band, formatted as "<slug>:total" / "<slug>:latest". Same presence-vs-absence semantics as the existing cards / charts / tables arrays: an empty list on a saved row means “hide everything”; a missing row means “show defaults.”
  • section_order — section keys in render order. Default empty (= built-in order). Unknown keys are silently dropped at render time; missing default keys get appended to the end so a saved layout from before a new section was added still renders the new section.

Backward compatibility

Pre-v3.31.45 projects don't have the new columns. GORM AutoMigrate adds them on next boot. Existing saved layouts continue to work — both new arrays default to empty, which means “use built-in defaults” (all resource widgets shown, default section order). Frontend SavedLayout gains the two fields as required (TypeScript-side); the wire shape allows them to be omitted on the PUT body, treated as empty.

v3.31.44June 25, 2026

Per-resource dashboard widgets, scoped by DateFilter. Every newly generated resource now ships with two preset widgets on the main dashboard: a Total stat card with a 30-day sparkline on the left and a Latest 5 records preview on the right. Both honor the existing dashboard DateFilter so the count obeys whichever range the operator has selected.

How it works

Three pieces ship together:

  • Service: services.ComputeResourceStats in apps/api/internal/services/resource_stats_dispatch.go — a generator-driven switch over resource name, each case calls a single reflective helper that counts rows in the active range, builds a 30-day sparkline, and lists the newest N (JSON round-tripped so json:"-" columns like PasswordHash never leak).
  • Endpoint: GET /api/admin/dashboard/resource-stats/:resource — accepts the same created_since / created_from / created_to params the resource list pages already use, so the wire shape matches.
  • Widgets: ResourceStatCard, ResourceLatestTable, and a thin ResourceWidgetsRow wrapper. The dashboard page maps over registered resources and renders one row per resource below the existing Quick Access section.

Sparkline window is always 30 days

The sparkline ignores the active date filter on purpose — under the “Today” preset it would collapse to a single bar, which carries no information. The total + latest list still obey the filter; only the trend chart is fixed.

Opt-out per resource

Resources can hide their widgets by setting dashboard: { enabled: false } in the resource definition. The flag is opt-out by design: a new resource is more often than not worth showing on the dashboard.

Backward compatibility

The generator injects a switch case into resource_stats_dispatch.go on each grit generate run, at the marker // grit:resource-stats:dispatch. Projects scaffolded before v3.31.44 don't have the file or the marker — the generator detects this and prints a one-line warning instead of failing. Patch existing projects by copying the scaffold file from the framework repo, then re-running grit generate to populate the cases (or hand-edit them).

v3.31.43June 25, 2026

Form-share polish: matching public form + editable shares. Two small but high-impact fixes on top of the v3.31.41 form-share generator. Both ship through the framework scaffold and the generator, so new resources pick them up automatically and existing projects get the imports added lazily on the next grit generate.

1. Public form renders the resource's actual fields

Before this release the public share page at /forms/[token] rendered a hardcoded Name + Email + Phone + Message contact form for every resource. Creating a share for a Category with name + image fields still showed the contact form — and the Name field happened to line up purely by coincidence. Submitting any other field shape was effectively impossible.

The dispatcher now exports services.PublicFields(resourceName), a per-resource switch that reflects the model struct and returns a typed PublicFieldInfo[] with one entry per user-facing column (framework + auto fields are skipped). The HTTP type for each field is inferred from the Go type: FileRef → file, time.Time → datetime, bool → checkbox, int/float → number, and string with name heuristics for email / phone / textarea fields.

GET /api/public/forms/:token now returns a fields[] array alongside resource_name and has_password. The web page consumes it and renders one input per field, with proper shapes for checkbox, number, textarea, and date/datetime. File fields render an inline “File uploads aren't supported on public-share forms” explainer instead of an unusable input — file uploads require the auth-gated /api/uploads endpoint and aren't supported on anonymous shares yet.

2. Admin can edit existing shares

The admin form-shares page already had Audit / Copy / Open / Delete buttons but no way to change a share's label or password protection after creation. Want to add a password to an existing link? Delete + recreate, and re-distribute the new token to every recipient.

A new Edit button opens a modal with three controls:

  • Label — free text, optional.
  • Password mode — three pills: Keep current, Set password, Remove password. “Remove” is disabled when the share has no password.
  • New password — shown only when mode is “Set password”.

The backend handler at PATCH /api/admin/form-shares/:id already supported the full payload (it accepts password: "-" as the sentinel for “remove”); this release just adds the missing UI to call it.

Backward compatibility

Projects scaffolded before v3.31.43 don't have the // grit:form-share:fields marker or the reflect + strings imports the new code depends on. The generator now adds the imports lazily on the first generated resource and prints a one-line warning when the marker is missing, pointing operators at a manual patch. Existing shares keep working — they just continue to show the hardcoded form until the project is re-scaffolded or the dispatcher is patched.

v3.31.42June 25, 2026

Three web-auth fixes shipped together. All surfaces — admin scaffold, web scaffold, grit add web-auth, and the generator — picked up the changes so existing and new projects both benefit.

1. Auth pages render full-bleed (no navbar / footer)

Until now the web app's (auth)/login, (auth)/register, (auth)/forgot-password, (auth)/callback, and forms/[token] pages all rendered inside the root layout, which pinned the <Navbar /> + <Footer /> to the top and bottom. The auth pages already supply their own AuthShell chrome (same template the admin uses) so the result was visually doubled.

New components/AppChrome.tsx is a tiny client wrapper that conditionally renders Navbar + Footer based on the pathname. The root layout drops to a thin server component again. Auth + public form-share pages render full-bleed; everything else is unchanged.

2. grit_web_session marker stops admin sessions from unlocking web pages

grit_access is set by the API on the API origin (localhost:8080). That same cookie is also used by apps/admin — so an operator who signed in via the admin app could open apps/web/account in the same browser and walk straight past the web middleware. The API call from useMe() succeeded (the cookie is valid), and ProtectedWebRoute happily rendered the page.

New grit_web_session marker cookie is set by the web app's own login/register flow on the WEB origin (localhost:3000) via client JS — non-HttpOnly, intentionally — and cleared by logout. Middleware reads grit_web_session instead of grit_access. Admin-only sessions never stamp the marker, so the web gates bounce them. The real session security is unchanged: useMe() still validates the API JWT; the marker is just a fast edge check.

Mechanically: lib/web-session.ts with setWebSessionMarker / clearWebSessionMarker / hasWebSessionMarker; called from useLogin + useRegister (onSuccess), useLogout (onSettled), and the direct-submit (auth)/login + (auth)/register pages.

3. Navbar UserMenu replaces the Admin button

The web navbar used to ship with a single Admin link that punted everyone to the admin app's login. Replaced with components/UserMenu.tsx:

  • Signed out — Log in + Sign up buttons that link to the web app's own auth flow.
  • Signed in — avatar dropdown with name + email at top, Account link, Sign out button.
  • Loading — a placeholder of the same width as the signed-out CTA pair so the navbar doesn't shift on useMe() resolve.

Bonus: web-hook generator now imports FileRef

Same TS2304 fix shipped in v3.31.37 for writeTSTypes, applied to writeReactQueryHooks. Generated apps/web/hooks/use-X.ts files with :file: / :files: fields now emit import type { FileRef } from "@repo/shared/schemas" at the top.

Migration

Run grit upgrade or hand-patch:

  • Drop apps/web/components/AppChrome.tsx + UserMenu.tsx + lib/web-session.ts in.
  • Update app/layout.tsx to render <AppChrome> instead of <Navbar /> ... <Footer />.
  • Update middleware.ts to read grit_web_session.
  • Add the marker calls to hooks/use-auth.ts + (auth)/login/page.tsx + (auth)/register/page.tsx.
  • Replace the Admin button in components/navbar.tsx with <UserMenu />.
v3.31.41June 25, 2026

System Hub tile for Public form sharing + two course lessons rewritten to match what actually happens. No code-behavior changes — this release closes the gap between the documentation and the running system.

System Hub tile

The /system/form-shares page has existed since v3.31.20 but wasn't reachable from the /system tile grid — operators had to know the URL by heart. The course lesson pointed users at "System → Public form sharing" which only existed as a sidebar shortcut, not as a Hub tile. Now both surfaces carry it. The tile shows up in all four System Hub variants (default admin_v331_files.go + minimal/modern/glass via admin_v3315_pages.go).

public-form-sharing lesson

Fixed the navigation reference (System Hub tile vs direct route) and added a new section on file upload limitations. The default /forms/[token] template renders text inputs only — :file: / :files: / :image: columns are silently skipped because /api/uploads is auth-gated. The lesson now walks through three production-shaped workarounds (presigned URLs, external links, magic-link auth).

grit-expose lesson

Added a deep "Behind the scenes: how the auth bypass actually works" section that walks the four security layers operators should understand before shipping a --public-share form to production:

  • The routes live in a separate Gin group (publicForms := r.Group("/api/public/forms")) with no middleware.Auth.
  • Sentinel rate-limits each token aggressively by IP.
  • The dispatcher service is the real security boundary — only resources with an explicit case in the switch can be created; unknown keys in the request body are silently dropped at the typed-struct decode.
  • The optional bcrypt password on the FormShare row is the fourth layer.

Plus the same file-upload caveat with a copy of the three workaround paths.

Migration

Run grit upgrade to pull the System Hub tile into an existing project. The tile is purely additive — no behaviour change for anything that was already working.

v3.31.40June 25, 2026

Per-user dashboard customisation + dashboard date filter. A new Settings → Dashboard page lets each operator pick which stat cards, charts, and tables show up on their dashboard, grouped by module. The dashboard also gets a date-window filter that scopes every stat and chart to the selected range.

Backend

  • New model DashboardLayout (one row per user, unique on user_id) with three JSONSlice[string] columns for cards / charts / tables plus a date_preset text column for the persisted window.
  • New handler exposing GET /api/dashboard-layout (returns the current user's saved layout or a zero-valued struct if none) and PUT /api/dashboard-layout (whole-row replace).
  • Empty layout (id === "") = "show all widgets". Saved layout with cards: [] = "hide every stat card". The frontend distinguishes the two by checking layout.id.

Widget catalog

New lib/dashboard-catalog.ts aggregates the catalog of pickable widgets from two sources:

  • System widgets (Users, Events 24h, Notifications, Resources count, Activity 7-day chart, Severity mix, Recent activity, Quick access) — these are the legacy hard-coded dashboard tiles, now opt-out-able per user.
  • Per-resource widgets — every entry in a ResourceDefinition.dashboard.widgets array contributes one catalog entry, grouped under the resource's module name.

Widget keys are stable strings (system:users, products:total-products, etc.) so the saved layout doesn't break when widget order changes in the definition.

Settings page

At /settings/dashboard: three sections (Cards / Charts / Tables), each with checkbox lists grouped by module. Per-section Select all / Deselect all; per-module All / None for fine-grained configuration. Sidebar nav gets a new "Dashboard settings" entry under System (no admin gate; every user can customise their own view).

Dashboard date filter

The existing DateFilter component from v3.31.34 is now on the dashboard too. URL-persisted via ?date=preset / ?date_from / ?date_to; initial value falls back to the saved date_preset so a refresh keeps the window. Every system widget query keys on the active dateParams so changing the filter retriggers a refetch with ?created_since=7d / ?created_from=...&created_to=... appended.

Migration

New scaffolded projects ship everything wired up. To add to an existing project, run grit upgrade (which writes the new files), then hand-add the model to models/user.go's Models() list and the routes to routes.go — or rerun the upgrade with --force if you haven't customised those files. The existing hand-coded dashboard page keeps working with no changes; the new filtering activates only after you replaceapp/(dashboard)/dashboard/page.tsx with the v3.31.40 template (it reads useDashboardLayout() and resolveEnabledKeys()).

Heads-up for early adopters: the v3.31.40 framework scaffold ships the building blocks (model + handler + catalog + hook + Settings page + sidebar entry) but does not auto-rewrite the dashboard page — that lands in a follow-up release once the multi-style dashboard variants (default / modern / minimal / glass) are all refactored to use the new layout reader. Until then, the example dashboard refactor in the docs walks you through the changes by hand.

v3.31.39June 24, 2026

CUD activity logging on every generated resource. Until this release the Activity feed only carried sign-ins and sign-outs — every Create / Update / Delete on a generated resource went unrecorded. Now each one writes a row with a human-readable summary using a fixed format convention.

Format convention

{verb} {entityType} {identifier}[: {detail}]

identifier is the human-readable label (name, title, slug, sku); it must never be blank — the helper falls back to (unnamed) if the caller hands it an empty string. detailis optional extra context (price for Create, diff for Update) and only renders when non-empty.

Example rows in the feed:

  • Created Product Desktop: KES 340,000
  • Updated Product Desktop: changed name, price
  • Updated Category Phones: image changed
  • Deleted Blog "Welcome to the new site"

Three new helpers in services/activity.go

  • LogCreate(db, c, entityType, identifier, resourceID, detail)
  • LogUpdate(db, c, entityType, identifier, resourceID, detail)
  • LogDelete(db, c, entityType, identifier, resourceID)

Plus DiffSummary(updates) for rendering a GORM Updates() map as a sorted, deterministic diff string (1 field → field changed; 2–3 fields → changed a, b, c; 4+ → N fields changed (a, b, c, ...)). Errors are logged, never returned — losing an audit row should not fail a real request.

Generator emits the calls automatically

Every grit generate resource from v3.31.39 onward inserts:

  • services.LogCreate(...) after a successful Create
  • services.LogUpdate(...) after Update and Patch (the grouped Save handler from v3.31.18 is logged the same way)
  • services.LogDelete(...) after Delete

Identifier expression is picked at generation time from the model's fields, in priority order: Name, Title, Slug, SKU, Subject, Label, Email. Falls back to item.ID so the log line is never blank.

Migration

Re-run grit generate resource X for each existing resource (the file rewrite picks up the new log calls), or hand-patch each handler:

  • Add "<module>/internal/services" to the imports.
  • Drop services.LogCreate/Update/Delete(...) calls right after each success path, before thec.JSON(...).
  • Use services.DiffSummary(updates) for the diff string in Update / Patch.

Existing auth helpers (LogLogin, LogRegister, LogLogout) are unchanged — they keep their semanticauth.X action names.

v3.31.38June 24, 2026

Auto comma-formatting on number inputs. Typing 3000 in a price field now reads 3,000 on screen the moment the fourth digit lands. Helps catch zero-count mistakes (the "is that 30k or 300k?" problem) without changing the wire shape — the form still submits a plain JS number, and the API still receives an int or float as before.

How it works

NumberField switched from type="number" to type="text" with inputMode="decimal" (or "numeric" for int/uint columns). The visible value is the comma-formatted string; the field also keeps a parsed JS number in form state. Mobile keyboards still pop up correctly thanks to inputMode; the comma can render literally because text inputs don't strip non-digits.

Cursor position is preserved across the reformat by counting non-comma characters before the caret and restoring after the same count in the new value, so editing in the middle of a number doesn't fling the caret to the end.

numberKind hint

New FieldDefinition knob:

numberKind?: "int" | "uint" | "float"

Tells the input which characters to accept:

  • int — negatives yes, decimals no
  • uint — neither negatives nor decimals
  • float — both (legacy permissive default when unset)

The generator now emits the right numberKind for every number field based on the Go column type. grit sync also adds it when injecting newly-added Go fields into existing admin resource files. Hand-written resources that don't set numberKind stay permissive — no breaking change to your existing forms.

Edge cases handled

  • Paste "$1,234.56" — strips the dollar, keeps the value
  • Mid-typing "3000." — preserves the trailing dot so the user can finish the decimal
  • Backspace through a comma — the comma re-inserts after the digit is removed; caret tracks digit count, not column position
  • Leading zeros — "0123" collapses to "123"; "0" stays "0" so "0.5" is reachable
  • External value sync — opening Edit on an existing record formats the loaded number; subsequent typing skips the sync to avoid stomping mid-edit state

Migration

Replace apps/admin/components/forms/fields/number-field.tsx with the rewritten file and add the optional numberKind knob to apps/admin/lib/resource.ts on FieldDefinition. Existing resource files keep working — numberKind defaults to float when unset, which gives the legacy permissive behaviour. Run grit sync on any project to backfillnumberKind for new fields the generator finds; existing fields aren't touched.

v3.31.37June 24, 2026

Bug fix: opening a Create form on a resource with a :files: column no longer crashes into the global error boundary. Same release also tidies up three companion TS errors that were red-squiggling in IDEs even though SWC stripped them at runtime.

What was broken

buildDefaults in form-builder.tsx seeded every non-toggle field to "" (empty string). For files / images / videos types, react-hook-form's initial state was therefore a string. The matching field component (FilesField, ImagesField, VideosField) immediately called .map() on that "array" — strings have no .map → TypeError → the parent FormSheet blew up into Next.js' error boundary. The user saw "Something went wrong".

The fix

buildDefaults now branches by field type: arrays default to [], nullable objects (file / image / video) default to null, toggles stay false, everything else stays "".

const ARRAY_FIELD_TYPES = new Set([
  "files", "images", "videos", "multi-relationship-select",
]);
const NULLABLE_OBJECT_FIELD_TYPES = new Set([
  "file", "image", "video",
]);
// ...
} else if (ARRAY_FIELD_TYPES.has(field.type)) {
  defaults[field.key] = [];
} else if (NULLABLE_OBJECT_FIELD_TYPES.has(field.type)) {
  defaults[field.key] = null;
}

All three field components also got a defensive Array.isArray guard so a stale form or a deserialised-wrong API response can't crash the dropzone — the field just renders empty and the user can still upload.

TypeScript clean-up

  • ColumnFormat union now includes "file" and "files" — both renderers were already implemented but the type union was stale, so resource definitions emitted by the v3.31.30+ generator flagged a TS2322 on every file column.
  • Generated packages/shared/types/<model>.ts files now import FileRef from schemas/file-ref when any field is :file: or :files: — previously the type was referenced without an import, fine at runtime but red squiggles in the IDE.
  • ImportModal's narrowing check against resource.table.import dropped the redundant !== false comparison (TS2367) — a plain truthy check correctly handles all three values of the union.

Migration

Run grit upgrade, or hand-patch:

  • apps/admin/components/forms/form-builder.tsx — update buildDefaults + the file / files renderer fallbacks.
  • apps/admin/components/forms/fields/{files,images,videos}-field.tsx — replace the (value ?? []).map() with the Array.isArray guard.
  • apps/admin/lib/resource.ts — add "file" and "files" to ColumnFormat.
  • apps/admin/components/tables/import-modal.tsx — simplify the importCfg check.
  • For models with file columns, add import type { FileRef } from "../schemas/file-ref"; at the top of packages/shared/types/<model>.ts.
v3.31.36June 24, 2026

Bug fix: FileRef inserts now succeed on Postgres. Single-file (:file:) and multi-file (:files:) columns failed to insert on Postgres with ERROR: invalid input syntax for type json (SQLSTATE 22P02). SQLite and MySQL projects weren't affected. This release fixes the framework scaffold; existing projects get a one-file patch.

What was broken

FileRef.Value() and FileRefs.Value() returned the []byte from json.Marshal() directly:

func (f FileRef) Value() (driver.Value, error) {
  return json.Marshal(f)   // returns []byte
}

Go's database/sql accepts []byte as a valid driver.Value — and lib/pq (the standard Postgres driver) encodes []byte as bytea, Postgres' binary type. Postgres then tries to insert the bytea blob into a json column, fails to parse the framing, and rejects with SQLSTATE 22P02.

The fix

Both Value() implementations now convert the JSON bytes to a Go string before returning:

func (f FileRef) Value() (driver.Value, error) {
  b, err := json.Marshal(f)
  if err != nil {
    return nil, err
  }
  return string(b), nil   // text, not bytea
}

lib/pq sends string values as plain text, which Postgres parses as JSON cleanly. SQLite and MySQL are tolerant of both shapes; only Postgres was strict.

Regression guards

Two new tests in the scaffolded file_ref_test.go assert that Value() returns a string type — so a future contributor can't silently revert to []byte without CI catching it. The tests fail with a clear message pointing at the Postgres bytea-vs-json issue.

Migration

Replace apps/api/internal/files/file_ref.go with the regenerated copy:

grit upgrade --files

Or hand-patch both Value() methods to wrap the json.Marshal result in string(b) before returning. Postgres-on-prod users running existing projects should ship this immediately. SQLite-dev or MySQL projects have no urgency.

v3.31.35June 24, 2026

Excel import + export, fully client-side via SheetJS. Continues the data ops arc. Every resource list page now ships with a three-format download menu (CSV / Excel / JSON) and a drag-and-drop Excel import that previews, validates, and submits rows without a single new API route.

Why client-side

The original v3.31.35 plan put export and import on the server: excelize for writing, asynq + Resend for the >5000-row async cutoff, a new /import endpoint with template + validation. Doing it in the browser via SheetJS (xlsx ^0.18.5) collapses all of that — no new routes, no async wiring, no "your file is ready" email loop, and tenant row data never leaves the user's session just to build a file.

Trade-off: very large datasets (~50k+ rows) are gated by the browser's memory ceiling, not server RAM. The export menu still streams every page from the API before building the file, so the output represents the whole filtered dataset — not just what's on screen.

New lib/excel-utils.ts

  • exportToFile(rows, columns, name, format) — writes CSV / XLSX / JSON, auto-sizing columns up to 60 chars.
  • fetchAllPages(endpoint, params, onProgress) — loops the resource API at page_size=200 until every row is in hand.
  • downloadImportTemplate(resource, allowedFields?) — blank workbook keyed by form field keys with a placeholder example row.
  • parseImportFile(file, resource, allowedFields?) — coerces each cell to the right JS type via the field definition, returns per-row errors.
  • submitImport(endpoint, rows, onProgress) — POSTs each valid row at concurrency 4 with live progress.

ExportMenu

Split button in the toolbar: clicking the main half exports in the default format (Excel when enabled, else CSV); the chevron opens a menu with the other formats. Uses the active search, sort, filters, and date range so an export honours the view the user is looking at.

ImportModal

Three stages — file pick → validation preview → submit with progress bar. The preview surfaces per-row errors with field+reason, flags unknown header columns, and disables Import when nothing is valid. On submit, React Query invalidates the resource list so the table reflects the new rows.

Header matching is loose: spaces, underscores, hyphens, and case are normalised before lookup, so the same template works whether a user's spreadsheet has first_name, First Name, or firstname.

Per-resource opt-out

table: {
  // Hide a format from the export menu (default: all on).
  export: { csv: true, excel: true, json: false },
  // Or disable export entirely.
  // export: false,

  // Restrict importable fields to a subset.
  import: { fields: ['title', 'price', 'stock'] },
  // Or disable import entirely.
  // import: false,
}

Migration

Three new files in your scaffolded admin app: apps/admin/lib/excel-utils.ts, apps/admin/components/tables/export-menu.tsx, apps/admin/components/tables/import-modal.tsx. Three refreshed files: apps/admin/lib/resource.ts, apps/admin/components/tables/table-toolbar.tsx, apps/admin/components/resource/resource-page.tsx. Run grit upgrade to pull them in. The xlsx dependency was already declared in package.json from v3.31.34, so no install step is needed.

Coming next

v3.31.36: PDF export via @react-pdf/renderer.

v3.31.34June 24, 2026

Date filter end-to-end + stats now actually reflect the filtered window. Begins the data ops arc and fixes a latent bug where "This Week" / "This Month" stat cards were showing the total count instead of the windowed count.

The latent stats bug

Auto-default stat cards have been emitting endpoints like /api/products?page_size=1&created_since=7d for a while, but the API ignored created_since — so the "This Week" card returned the same total as the "Total" card. v3.31.34 makes the backend honour the param.

Server-side (paginate package)

Bind(c) now parses four query params:

  • ?created_from=2026-01-01 — inclusive lower bound
  • ?created_to=2026-12-31 — inclusive upper (snapped to 23:59:59.999)
  • ?created_since=7d — relative shortcut (h / d / w / m units)
  • ?date_field=published_at — override the default created_at target column

Explicit created_from / created_to win over created_since so a stat-card link doesn't clobber a user's picked range. Applied as a single WHERE clause in List[T]; both offset and cursor pagination paths inherit.

Resource def

table: {
  dateFilter: { enabled: true, field: 'created_at', label: 'Created' }
}

Enabled by default. Set enabled: false to hide. Override field for resources where the meaningful date isn't created_at (e.g. a Booking resource filtering by scheduled_for).

DateFilter component

New <DateFilter> in components/tables/date-filter.tsx:

  • Four presets — Today, Last 7 days, Last 30 days, This month
  • Custom range with two date inputs + Apply button
  • Active state shows the current selection as a toolbar pill; X clears
  • Close-on-outside-click popover
  • URL-persisted via ?date=preset + ?date_from / ?date_to so refresh + shared links rehydrate

Stats reflect the filter

When the user picks a date range, ResourceListView appends the resolved query params to every stat card's endpoint. The card labels stay fixed ("Total", "This Week", etc.) but their numbers now match the table below. No more "Total: 10,000; list shows 142" mismatch.

Migration

Four files refreshed: apps/api/internal/paginate/paginate.go, apps/admin/components/tables/table-toolbar.tsx, apps/admin/components/resource/resource-page.tsx, apps/admin/hooks/use-resource.ts, plus the new apps/admin/components/tables/date-filter.tsx.

Coming next

v3.31.35: Excel import + async cutoff for export (>5000 rows = asynq job + Resend email) + per-resource opt-out. v3.31.36: PDF export via @react-pdf/renderer.

v3.31.33June 24, 2026

File lifecycle — immediate S3 delete on replacement + daily orphan cleanup cron. Closes the loop on the file-fields work from v3.31.30-32: bucket no longer accumulates dead objects when files get swapped or forms get abandoned.

internal/files lifecycle helpers

  • DiffSingle(old, new) — returns the key removed when a single-file column is replaced or cleared.
  • DiffMulti(old, new) — returns keys present in old but missing from new (gallery pruning).
  • CleanupRemoved(ctx, st, old, new) — reflection-based: walks both struct values, finds *FileRef + FileRefs fields, computes the diff, deletes the removed S3 objects. One line in the handler regardless of how many file columns the resource has.
  • ClaimRefs(ctx, db, record) — walks the same FileRef columns and stamps claimed_at = now() on the underlying Upload rows so the orphan cleanup cron knows the upload is in use.
  • RunOrphanCleanup(ctx, db, st, minAge) — finds Upload rows with claimed_at IS NULL older than minAge (24h), deletes them from S3 and the DB. Best-effort S3 delete: if it fails we still drop the DB row so the same orphan isn't retried forever.

Upload.ClaimedAt column

New nullable timestamp on the Upload model. Auto-migration adds it; existing rows start as NULL and get claimed the next time their parent record is updated. The 24h grace period before orphan cleanup means a fresh deploy won't purge historical uploads — the cron only catches uploads truly created in the past 24h that never got claimed.

Daily cron job

New uploads:cleanup_orphans asynq task runs at 03:15 daily (low-traffic window). Registered in internal/cron/cron.go and handled by handleUploadsOrphanCleanup in internal/jobs/jobs.go.

Generated handler injection

Resources with :file: / :files: fields now get:

  • Storage *storage.Storage field on the Handler struct, wired in routes via Storage: svc.Storage.
  • Create handler: files.ClaimRefs call after successful save.
  • Update handler: snapshots the old record, diff-deletes removed S3 objects, then claims the new refs.

Resources without file fields stay exactly as before — no Storage field, no extra imports, no dead code. The injection is conditional on the generator detecting at least one file/files field.

Migration

Existing projects: the Upload model needs the ClaimedAt column. GORM auto-migration in cmd/server/main.go handles it on next boot. Re-run grit generate resource <Name> on any resource with file fields to pick up the cleanup-aware handler template.

Coming next

v3.31.34 begins the data ops arc — server-side Excel export via excelize, bulk Excel import with template generation + row-by-row validation, React-PDF rendering, per-page date filter, and per-resource opt-out for export / import.

v3.31.32June 24, 2026

Storage admin page surfaces FileRef totals. The original Files page was a flat uploads grid — useful for browsing but offered no sense of how much storage you were actually using, or what was eating it.

New API endpoint

GET /api/uploads/stats returns:

  • total_count — how many uploads
  • total_size — sum of bytes
  • by_kind — count + size grouped by MIME bucket (image / video / audio / pdf / document / spreadsheet / other). Single SQL GROUP BY with portable CASE expression — works on Postgres and SQLite without engine-specific JSON functions.

Storage stats panel

The Files admin page now shows three big numbers up top (Total files / Total storage / Avg file size), then a per-kind breakdown with proportional progress bars sorted by largest consumer. Image-heavy projects can see at a glance whether to migrate to a CDN; CSV-heavy projects can spot a runaway export pipeline.

Dropzone variant standardisation

Default + Compact variants now route their uploading state through the unified <UploadProgress> component so the per-field progress prop (bar / circular / pulse) actually takes effect on both. Minimal, Avatar, and Inline variants are space-constrained by design and keep their bespoke single-spinner treatment.

Migration

Three files refreshed: apps/api/internal/handlers/upload.go (Stats handler), apps/api/internal/routes.go (new route), apps/admin/app/(dashboard)/system/files/page.tsx (stats panel) and apps/admin/hooks/use-system.ts (useUploadStats hook). Re-run grit generate resource for any resource to pull the updates.

Coming next

v3.31.33 ships the file lifecycle work: immediate S3 delete when a record swaps its file, plus a daily orphan-cleanup cron that purges Upload rows whose key is referenced nowhere. v3.31.34 begins the data ops arc — date filter, Excel import/export, PDF render via @react-pdf/renderer.

v3.31.31June 24, 2026

File fields polish — progress variants, type-aware previews, reorder.

Bug fix from v3.31.30

The Dropzone was still reading data.original_name and data.mime_type from the upload response, but v3.31.30 changed POST /api/uploads to return a FileRef shape with data.name and data.mime. Files uploaded after v3.31.30 appeared with the generic File client-side fallback name instead of the real filename from the server. Now reads both shapes (FileRef first, legacy fallback) so cross-version compatibility holds.

UploadedFile also carries the explicit key field now, so the FileField bridge round-trips the S3 key losslessly instead of recomputing it from the URL pathname.

Three progress variants

  • bar (default) — linear progress bar with spinner + percentage label.
  • circular — donut with the % inside. SVG, no extra dependency.
  • pulse — three pulsing dots + %. Minimal chrome for compact contexts.

Pick a variant per field:

{ key: "avatar", type: "file", accepts: ["image"], progress: "circular" }

The Default dropzone variant routes its uploading state through the new <UploadProgress> component. The other four dropzone variants (compact / minimal / avatar / inline) keep their bespoke inline progress UI for now — v3.31.32 standardises them.

Type-aware FilePreview

Single image preview stays as a thumbnail. Video gets a play badge over a dark thumb. Audio shows a music icon. PDF / Word / Excel / CSV render format-specific glyphs with colour-coded tints (PDF red, Word blue, Excel green). Everything else falls back to the generic File icon.

Reorder by up/down arrows

Multi-file (:files:) preview rows now show small up/down arrow buttons when reorderable is true (default). Adjacent swap; first row's up button is disabled, last row's down button is disabled. No new dependencies — drag-reorder via dnd-kit is a future polish.

Resource def knobs

Three new optional props on file/files FieldDefinition:

  • dropzone: "default" | "compact" | "minimal" | "avatar" | "inline"
  • progress: "bar" | "circular" | "pulse"
  • reorderable: boolean (default true; multi-file only)

These are pure overrides — the CLI doesn't emit them automatically; hand-edit the resource def to customise.

Migration

Existing scaffolded projects need three files refreshed: components/ui/dropzone.tsx, components/forms/fields/file-field.tsx, and components/forms/fields/files-field.tsx. Plus add FileSpreadsheet and Music to the export block in lib/icons.ts. Re-run grit generate resource for any resource to pull the updates — the files live once, not per resource.

v3.31.30June 24, 2026

File fields — first-class file + files types in grit generate resource. Replaces the awkward old pattern of treating uploads as string URLs.

New CLI syntax

Single file: grit generate resource Product --fields "image:file:image" scaffolds a single-image field that accepts jpg / png / gif / webp / avif / svg.

Multiple files: gallery:files:image for a multi-image gallery.

Bracketed accept-list for mixed types: attachment:file:[pdf,doc,image,video,zip]. Bare commas don't work because the top-level field separator is also ,; the parser is bracket-aware so the inner list stays glued together.

Accept aliases: image, video, audio, pdf, doc, excel, csv, zip, archive, all.

What gets generated

  • Go model: field typed as *files.FileRef (single) or files.FileRefs (multi), stored as JSON via GORM Value/Scan adapters in the new internal/files package.
  • Zod schema: imports FileRefSchema from the shared package — a single source of truth for the JSON shape.
  • Admin resource def: auto-emits accepts and maxSizeMB so the form's upload endpoint enforces the per-field validation.
  • FormBuilder: dispatches file / files types to the FileRef-aware FileField / FilesField components.
  • DataTable: file columns render as thumbnails for images, MIME-typed icons for everything else. Multi-file columns stack the first three thumbnails with a +N overflow chip.

API changes

POST /api/uploads now accepts ?accepts=<aliases>&max_size=<bytes> query params so the server validates against the per-field accept set (not just a global allowlist). Response shape changed to return a FileRef directly under data — drop-in for form state.

Defaults

  • Single file max: 5MB (300MB for video).
  • Multi-file count: 5.
  • Dropzone variant: the existing default boxed-dashed style. v3.31.31 adds 4 more variants (minimal, card, avatar, inline) + 3 progress variants + dnd-kit reorder.

Migration

Existing scaffolded projects need three things to pick up file fields: apps/api/internal/files/ (new package), the updated handlers/upload.go, and the refactored components/forms/fields/file-field.tsx + files-field.tsx + the new lib/file-accepts.ts. Re-run grit generate resource for any resource to get the updated templates — the new code lives once per project, not per resource.

v3.31.29June 24, 2026

Stats cards now refetch after create / update / delete. Total / This Week / This Month no longer go stale until manual reload.

The bug

Resource mutations all called invalidateQueries({ queryKey: [endpoint] }) on success, expecting React Query's prefix-matching to invalidate every query under that resource. But the stat-card query in PageHeader was keyed with ["stat", endpoint, field] — starting with the literal string "stat", not the endpoint. The invalidation never matched it, and a staleTime: 30_000 meant the value didn't even auto-refetch for 30 seconds.

Stats also use endpoints with query-string suffixes (e.g. /api/products?page_size=1&created_since=7d), so even if the key had started with the endpoint string, it wouldn't have matched the bare /api/products the mutation invalidates.

The fix

Stat queryKey now starts with the base endpoint (no query string): [endpoint.split("?")[0], "stat", endpoint, field]. Mutation invalidation prefix-matches it, and the staleTime is gone so the cards refetch immediately on success.

Migration

Existing projects: copy the new StatCardItem hook body from components/layout/page-header.tsx. One-function change.

v3.31.28June 24, 2026

Colored toasters. Success toasts are now green, errors red, warnings amber, info blue — instead of the previous neutral grey-on-grey that made every toast look identical.

What changed

The scaffolded <Toaster> in components/shared/providers.tsx now passes richColors, and app/globals.css bridges Grit's theme tokens (--success, --danger, --warning, --info) into sonner's palette slots via color-mix(). Each theme (atlas / aurora / pulse / midnight) already redefines those four tokens, so toasters automatically pick up the active brand colors — no per-theme overrides needed.

Migration

Existing projects: copy the new Toaster mount from providers.tsx and the [data-sonner-toaster] CSS block from globals.css (right after the scrollbar rules). All toast call sites in the scaffold already use toast.success() / toast.error() etc., so they pick up the new colors with zero code changes.

v3.31.27June 24, 2026

Fix React Rules of Hooks violation when formView: 'page' resources switch between list and form views. Reported by a learner who scaffolded a Category resource with formView: 'page' and clicked "New Category".

The bug

ResourcePage declared a few hooks at the top (useRouter, useSearchParams), then performed early returns for the form-page case (action=create or action=edit), then declared ~20 more hooks below (useState, useResource, useMemo, useCallback x many). When the URL changed and the component switched between list mode and form mode, the hook count changed between renders — React 19 throws "Rendered fewer hooks than expected."

The fix

Split ResourcePage into a thin router shell + a separate ResourceListView component. The router only calls useSearchParams and the routing helpers, then either renders one of the form variants or delegates to ResourceListView. The list view owns all 20+ list-mode hooks. Each function now has a stable hook count across renders, and the form path never mounts the list-mode hooks (so it doesn't spawn an unnecessary useResource fetch either).

Migration

Existing projects using formView: 'page' or 'page-steps' need to update apps/admin/components/resource/resource-page.tsx. Re-run grit generate resource <Name> on any resource (the file lives once, not per-resource) or copy the new structure from the scaffold output.

v3.31.14June 21, 2026

CLI prompt cleanup, Sentinel/Pulse links go to the API, and the Security + Performance dashboards finally show real data.

CLI: one form instead of three selects

grit new's architecture / frontend / theme prompts were running as three back-to-back huh.NewSelect calls. On Git Bash (MINGW64) the lack of full ANSI cursor-up support meant each re-render stacked into scrollback, producing the "same prompt printed twice" effect. Combined into a single huh.NewForm with conditional WithHideFunc groups — one tidy block of output, atomic submit.

Sentinel / Pulse links point at the API origin

/system/security "Open Sentinel" used a Next.js <Link href="/sentinel/ui">, which resolves relative to the admin host (:3001). Both Sentinel and Pulse are mounted on the Go API (:8080), so the links 404'd. Replaced with a plain <a> using NEXT_PUBLIC_API_URL.

Security + Performance dashboards return real data

Both pages were calling endpoints that either didn't exist (/api/admin/performance/summary) or returned a wrapped {data: {...}} envelope with raw Sentinel/Pulse internals under unfamiliar keys ({summary, score, threats, ...}). The React queries unwrapped axios' .data and looked for data.banned_ips_now / data.latency.p50 which didn't exist in the response — every KPI rendered as 0 or em-dash.

  • handlers/observability.go rewritten: hits Pulse's /overview, /runtime/current, /database/n1/ranked, and /errors endpoints, then reshapes the responses into a flat {latency, traffic, errors, saturation, slowest_routes, n1_detections, recent_errors} envelope. No more {data: ...} wrapper.
  • handlers/security.go rewritten: hits Sentinel's /ip/blocked, /analytics/summary?window=24h, and /threats (the prior /dashboard/summary endpoint doesn't exist in this Sentinel version); returns the flat {banned_ips_now, auto_bans_24h, active_bans, recent_threats, ...} shape the page expects.
  • Performance page corrected to hit /api/admin/observability/summary instead of the nonexistent /performance/summary.
v3.31.26June 21, 2026

Fix two bugs in the FormShare dispatcher template reported by a learner who created a fresh project and ran grit generate resource Category + Product. The API failed to build with syntax error: non-declaration statement outside function body.

Bug 1 — marker collision with doc comment

The scaffolded services/form_share_dispatch.go doc comment literally contained the string // grit:form-share:dispatch marker. When the generator ran injectBefore for a new resource, it found that occurrence first (above the function, outside any function body) and inserted every case there. The function's switch stayed empty, and the cases sat in package scope where they produced a syntax error.

Fix: rephrased the doc comment to describe the marker without containing the marker string.

Bug 2 — function param named "body", inject uses "fields"

The dispatcher's third parameter was body map[string]interface{}, but every injected case uses json.Marshal(fields). Even if Bug 1 hadn't hit first, the cases would have failed to compile with undefined: fields.

Fix: renamed the parameter to fields so it matches what the inject template produces.

Migration

Existing projects that ran grit generate resource X on or after v3.31.20 may have a broken form_share_dispatch.go. To fix:

  1. Open apps/api/internal/services/form_share_dispatch.go.
  2. Move any case "X": blocks that landed above the function back inside the switch below.
  3. Rename the function parameter from body to fields if needed.
  4. Rephrase the doc comment so it doesn't contain the literal marker string.
v3.31.25June 21, 2026

Audit trail for public form submissions. The last deferred item from PLAN_FORMS_AND_SHARING.md. Operators can now see every submission that came in through each share — with timestamp, IP, and User-Agent.

What landed

  • New FormSubmission model — one row per successful public submission. Captures share_id, resource_name, record_id, IP, User-Agent, timestamp. Soft-deletable for retention.
  • PublicSubmit writes the audit row after a successful dispatch. Best-effort: failure to write the audit row does NOT roll back the user's submission. They still get their record; the admin just misses one line in the trail.
  • New admin endpoint: GET /api/admin/form-submissions?share_id=&resource_name= — paginated audit log, filterable by share or resource.
  • Admin UI: the /system/form-shares page gains an Audit button per share. Click → modal listing the 100 most recent submissions for that share with timestamp, record ID, IP, and a truncated UA tooltip.

Why a separate table, not a column

An earlier draft considered adding source_share_id as a column on every scaffolded model. That approach is invasive — every existing project would need a migration to add the column to Contact / Application / Lead / etc. The audit-table approach is purely additive: new project or existing, grit migrate creates the new form_submissions table and existing models stay untouched.

Bonus: the audit table captures richer data than a column could (IP + User-Agent), which is useful for spam triage and compliance.

Phase recap, complete

Every numbered item on PLAN_FORMS_AND_SHARING.md has shipped:

  • v3.31.16 — sync auto-add admin fields
  • v3.31.17 — formView sheet / modal / page
  • v3.31.18 — form groups + per-group PATCH
  • v3.31.19 — column-pack auto-detection
  • v3.31.20 — public form sharing
  • v3.31.21 — grit expose form / table
  • v3.31.22 — grit add web-auth
  • v3.31.23 — course lessons + tests
  • v3.31.24 — --public-share + --token flags
  • v3.31.25 — audit trail (this release)
v3.31.24June 21, 2026

grit expose form gains --public-share + --token flags — the deferred public-form variant from the v3.31.21 changelog now ships. Scaffold a public-facing form at any URL of your choosing that posts to a FormShare endpoint instead of the authenticated hook.

Usage

# Hard-code the token into the page
grit expose form Contact \
  --to apps/web/app/contact-us/page.tsx \
  --public-share \
  --token 9CkLh7gJZQrPeNwMo3F8x_iVjA8U2nXt

# Or omit --token and let the page read NEXT_PUBLIC_FORM_TOKEN at runtime
grit expose form Contact \
  --to apps/web/app/contact-us/page.tsx \
  --public-share

What the generated page does

  • Posts to /api/public/forms/<token>/submit — no auth required, no useCreate hook imported.
  • Probes /api/public/forms/<token> on mount to confirm the share is enabled and to learn whether to render a password gate.
  • Shows an amber "Form unavailable" card when the token is missing, disabled, or invalid — instead of a blank form.
  • Token resolution: literal from --token when set; otherwise process.env.NEXT_PUBLIC_FORM_TOKEN at module load. Pick whichever fits your env model.

When to use this

Use --public-share when you want a branded public form at your own URL (/contact-us, /apply, /leads) instead of the default /forms/[token] page. The dispatcher, rate limits, and password gate behave identically; only the URL and styling are yours to control.

Lesson update

The grit-expose lesson now has a "--public-share: a public form on YOUR url" section with both embed-token and env-token examples.

v3.31.23June 21, 2026

Docs + tests follow-up to the PLAN_FORMS_AND_SHARING.md arc. Phases 2-4 shipped without dedicated course lessons; this release closes that gap.

Three new course lessons

Chapter 4 ("Code Generation & Type Sync") picks up a new module — Going public — with three lessons covering the post-resource lifecycle:

  • grit expose form / table — when to use each, anatomy of the commands, field filtering, combining with form sharing.
  • Public form sharing — the dispatch pattern, password gating, sharing the link, disabling and regenerating, what it can't do (yet).
  • Protecting web pages — middleware vs ProtectedWebRoute, when each one fits, how they layer.

Unit tests for the expose package

internal/expose now has 10 unit tests covering the security-critical field filter (autoFields drops framework columns, pointer + value associations, slice associations; keeps all 7 primitive types), label generation (acronym handling), and the pluralisation helpers (pluralPascal, pluralKebab). Plus path validation for resolveTarget. All passing.

Course chapter 4 now has 13 lessons

Up from 10 in the previous release. The chapter covers the full resource lifecycle from initial generation through customisation, sharing, exposure, and protection — end to end.

v3.31.22June 21, 2026

grit add web-auth — Phase 4 of PLAN_FORMS_AND_SHARING.md, the final phase. With this release, every phase of the forms / sharing initiative has shipped (v3.31.16 → v3.31.22).

The web app already shipped with login / register / forgot-password pages and a useMe() hook. What was missing: a way to mark which customer-facing pages require sign-in. grit add web-auth closes that gap with two complementary patterns.

Files scaffolded

  • apps/web/middleware.ts — SSR cookie redirect. Runs on every Next.js request, checks for the grit_access HttpOnly cookie, redirects to /login?next=… when missing on a protected path. Also bounces already-signed-in visitors off the login/register pages so they don't see a form they don't need. Edit the PROTECTED_PATHS and AUTH_PATHS arrays to customise.
  • apps/web/components/ProtectedWebRoute.tsx — client-side wrapper. Wraps a page with <ProtectedWebRoute>{children}</ProtectedWebRoute> to enforce auth in cases where middleware can't help — e.g. role-gated content (the cookie doesn't carry the role; useMe() returns the full user). Supports an optional roles prop.

The two patterns

  • Middleware (SSR) — fast, no network round-trip per request, no flash of unauthorized content. Use for "is the visitor signed in?" pages: account dashboards, checkout, member-only content.
  • ProtectedWebRoute (client) — makes a real /api/auth/me probe. Catches expired-but-present cookies and supports role checks. Use it when middleware isn't enough.

Behavior

Both files are idempotent — re-running grit add web-auth without --force skips existing files. The scaffold prints a clear notice so operators know what was created and what was preserved.

Phase recap (v3.31.16 → v3.31.22)

  • v3.31.16 — sync auto-adds new model fields to admin resource files
  • v3.31.17 — formView sheet / modal / page
  • v3.31.18 — form groups + per-group PATCH save
  • v3.31.19 — column-pack auto-detection (name + email)
  • v3.31.20 — public form sharing (token + bcrypt password)
  • v3.31.21 — grit expose form / grit expose table
  • v3.31.22 — grit add web-auth (this release)
v3.31.21June 21, 2026

grit expose form / grit expose table — Phase 3 of PLAN_FORMS_AND_SHARING.md.

Two new CLI commands that scaffold a Next.js page in apps/web/ for an existing resource. The page consumes the auto-generated React Query hook directly, so you get list/create flows on a customer-facing site without re-implementing anything.

Commands

grit expose form Contact --to apps/web/app/contact-us/page.tsx
grit expose table Contact --to apps/web/app/contacts/page.tsx
  • Each command parses apps/api/internal/models/<snake>.go to determine the resource's primitive fields. Relationship pointers (Group *Group or Group Group) and slices (Tags []Tag) are filtered out — only fields that can render as one <input> or one table cell make it through.
  • Both commands refuse to overwrite an existing file unless you pass --force — protects hand-customised pages from accidental loss.
  • Generated pages are plain Tailwind (no admin chrome), suitable for embedding on a marketing site or a customer dashboard.

Supporting fixes

  • Web hook imports: the generator now branches its apiClient import path by app — @/lib/api-client for admin, @/lib/api for web. Resolves a pre-existing "Cannot find module" error in web-side resource hooks.
  • Scaffolded apps/web/lib/api.ts now re-exports apiClient = api so generated hooks resolve symmetrically across both apps.
  • Web package.json gains @hookform/resolvers as a dep (was only in admin before).
  • ParseGoStructs exported from the internal/generate package for reuse by the new internal/expose package.

Known limitations

  • Generated forms don't use the shared Zod schema for validation — the schema's camelCase field names don't match the API's snake_case JSON keys. Forms submit snake-case keys directly; server-side validation is the source of truth. Add client-side validation by hand if you need it.
  • Forms have one field per primitive column. Custom widgets (rich text, image uploaders, relationship dropdowns) need manual additions after generation.
  • --public-share / --public flags (post via the public form-share endpoint instead of via the auth'd hook) are still on the roadmap.
v3.31.20June 21, 2026

Phase 2 of PLAN_FORMS_AND_SHARING.md — public form sharing. Generate a token-protected link for any of your resources and anyone with the link can submit the form, no admin login required. Optional bcrypt password on the share for an extra gate.

What landed (end-to-end)

  • FormShare model (token, optional bcrypt PasswordHash, enabled, submission count, label). Auto-migrated on grit migrate.
  • Admin handler + routes: GET/POST/PATCH/DELETE /api/admin/form-shares.
  • Public handler + routes (no auth, no CSRF): GET /api/public/forms/:token + POST /api/public/forms/:token/submit. Both paths are listed in Sentinel's ExcludeRoutes so the WAF doesn't block public JSON bodies.
  • Marker-driven resource dispatch: every grit generate resource appends a case to services/form_share_dispatch.go that JSON-decodes the public payload into the resource's model + calls db.Create(). Whitelisted by name — unknown resources can't be submitted publicly.
  • Admin page at /system/form-shares: list shares, create new (with password), toggle enabled, copy public URL, delete.
  • Public web page at apps/web/app/forms/[token]/page.tsx — a minimal name/email/phone/message form that posts to the public submit endpoint. Tailored forms for other resource shapes come via grit expose form in Phase 3.

Threat model

  • Resource whitelisting: the dispatch service's switch statement is the gate. A share token can't conjure a record for a resource that hasn't been explicitly added.
  • Field whitelisting: each resource case JSON-decodes onto its typed model. Unknown JSON keys are silently ignored; private fields (id, created_at, …) are untouched.
  • Rate limiting: Sentinel still rate-limits the public path by IP — the WAF body inspection is the only thing skipped.
  • Password (optional): bcrypt cost 10. Submitted as _password alongside the fields; rejected with 401 if mismatched.

Deferred to v3.31.21

  • Audit trail: a source_share_id column on each submitted record so admins can filter "show me public submissions" per resource.
  • Per-resource public form pages: Phase 3's grit expose form <Resource> will scaffold a tailored public page with the exact field shape, replacing the generic name+email+phone+message default.
v3.31.19June 21, 2026

Column-pack auto-detection. Phase 1.4 of PLAN_FORMS_AND_SHARING.md.

Generate a resource with both name and email (or both first_name and last_name) and the table now ships with those fields packed into a single stacked column — name on top, email muted below. No hand-written cell: callback needed.

How it works

  • New helper: apps/admin/components/tables/stacked-cell.tsx. Exports a StackedCell({ top, bottom }) function returning two-line JSX. Called as a function (not JSX syntax) so resource files stay .ts.
  • Generator now runs a pack-detector over the resource's field list. When a pattern matches, the absorbed fields are silently skipped and the packed line is emitted in their primary's slot.
  • Import of StackedCell is conditional — resources without a pack stay clean.

Patterns recognised today

  • name + email → "Contact" column
  • first_name + last_name → "Name" column

Both are easy to extend in internal/generate/column_packs.go. Money + currency badge, status + relative date, and a few others are roadmap.

Existing resources

Pre-v3.31.19 resources don't auto-pack. Either add the pack by hand (the customising-tables lesson has the recipe), or wait for grit pack table <Resource> in a future release.

v3.31.18June 21, 2026

Form groups + per-group PATCH save. Phase 1.3 (partial) of PLAN_FORMS_AND_SHARING.md.

Long Update views with 10+ fields used to save the whole record on every click — risky when two operators edit different sections at once, slow because the payload is large, and tedious because every field had to be re-validated. Define form.groups and each group renders as its own Card on the Update page, with its own Save button that PATCHes only that group's fields.

What landed

  • New Patch handler on every generated resource. Whitelists writable columns so the partial endpoint can't be tricked into setting id / created_at /deleted_at / version from the client.
  • PATCH /api/<plural>/:id route registered alongside PUT for every resource (both standard and role-restricted route groups).
  • usePatchResource(endpoint) hook in the admin's use-resource module. Same shape as useUpdateResource but calls PATCH and toasts "Saved" on success.
  • GroupDefinition type on FormDefinition.groups. Each group is { title, description?, fields: string[], scope?: "create" | "update" | "both" }.
  • <UpdateGroups> component renders each scope: "update" or "both" group as a separate Card on the Update page (when formView: "page" + form.groups are defined).
  • ResourcePage dispatcher: when editing + groups present, route to UpdateGroups; otherwise fall back to the single-form FormPage.

The "create-and-update" pattern

Use scope: "create" on the minimal required fields and scope: "update" on the rest. Operators get a frictionless Create form; detailed editing happens on the Update page as cards with partial saves.

Deferred to v3.31.19

Group rendering on the Create flow as a multi-step wizard. The existing steps field still works for that. v3.31.19 unifies them so groups drive both contexts.

v3.31.17June 21, 2026

Form render modes — sheet / modal / page. Phase 1.2 of PLAN_FORMS_AND_SHARING.md.

The formView field on defineResource now accepts "sheet" as an explicit value, and "modal" renders as a proper centered dialog instead of a sheet. Defaults are unchanged — resources without an explicit formView still get the long-form-friendly drawer.

The three rendering choices

  • "sheet" (default) — right drawer on desktop, bottom sheet on mobile. Best for long forms and multi-line fields.
  • "modal" — centered dialog over a backdrop. Best for short focused forms (1–6 fields).
  • "page" — dedicated route via ?action=create|edit. Best for very long forms or anything that needs shareable URLs.

What shipped

  • New FormSheet component (apps/admin/components/forms/form-sheet.tsx) — the long-form-friendly drawer, formerly the implementation of FormModal.
  • FormModal rewritten as a proper centered dialog (max-w-md, backdrop blur, padding).
  • ResourcePage dispatcher picks the right component based on resource.formView.
  • ResourceDefinition type union expanded to include "sheet".

Migrating

If you previously set formView: "modal" explicitly and want the old sheet behavior, change it to "sheet". Resources that left formView undefined stay on the sheet — no migration needed.

v3.31.16June 21, 2026

grit sync now auto-adds new model fields to admin resource files. Phase 1.1 of the PLAN_FORMS_AND_SHARING.md roadmap.

Add a column to a Go model, run grit migrate + grit sync, and the field now appears in apps/admin/resources/<plural>.ts as both a column and a form input — with a sensible default type inferred from the Go type. Customised entries (labels, helper text, badges, custom cell renderers) are never touched.

How it works

The generator now emits marker comments around the auto-managed columns + form fields:

columns: [
  // grit:cols:auto-start
  { key: "name", ... },
  // grit:cols:auto-end
],
form: {
  fields: [
    // grit:fields:auto-start
    { key: "name", ... },
    // grit:fields:auto-end
  ],
},

Sync diffs Go model fields against the file. For each field with a key: not found anywhere in the file, it inserts a default entry above the auto-end marker. Sync is insert-only — it never modifies or removes existing entries.

Backward compatibility

Resources scaffolded before v3.31.16 don't carry the marker comments. Sync prints a per-resource warning and skips them. To enable auto-add on an existing resource, hand-edit the file to wrap its columns array and form fields array with the four marker lines once. After that, future syncs pick the file up.

What's next (Phase 1 continued)

v3.31.17+ ships the rest of Phase 1 per PLAN_FORMS_AND_SHARING.md:

  • Form render mode (sheet | modal | page)
  • Form groups (steps in create, cards in update) + PATCH endpoint for per-group saves
  • Column-pack default heuristic + grit pack table <Resource>
v3.31.15June 21, 2026

Auth UX overhaul + admin polish from a real app-building session. Seven concrete fixes driven by feedback while building a contact-app on the prior release.

Framework fixes

  • Protected admin routes no longer flash a blank white page when the session expires or the API restarts. The admin layout now redirects to /login on both network errors AND null user (401), and shows a spinner while the redirect fires. (internal/scaffold/admin_layout_files.go)
  • Login page bounces to /dashboard when the session cookie is still valid — no more seeing the login form while you're already signed in.
  • New SessionWatchdog component surfaces a modal at 14:30 of idle time with a 30s countdown — "Stay signed in" refreshes via /api/auth/refresh, "Sign out" or timeout calls useLogout(). Configurable via NEXT_PUBLIC_SESSION_IDLE_MS and NEXT_PUBLIC_SESSION_COUNTDOWN_MS.
  • Sentinel WAF no longer blocks richtext admin POSTs. The 64 KB body cap is now 1 MB (richtext + embedded inline images need it), and admin write endpoints with HTML payloads (/api/blogs, /api/posts, /api/articles, /api/uploads) are listed under ExcludeRoutes so the WAF's XSS detection stops flagging every <p> tag.
  • Generated resource tables drop the ID column by default. UUIDs are noisy and rarely scanned by eye — operators who need it can add it back manually.
  • ColumnDefinition gains an optional cell?: (row) => ReactNode renderer — pack multiple fields into one column (name + email stacked, price + currency badge, status pill + relative date) without dropping out to a hand-written page.tsx. Takes precedence over format and badge when set.
  • grit sync prints a heads-up that it does NOT update the admin resource definition, pointing operators at apps/admin/resources/<plural>.ts when a new model field doesn't show up in the admin form.

Chapter 4 — 4 new lessons

Chapter 4 now has 11 lessons (up from 7) — fully covering the post-generation flow:

  • grit remove resource — the rollback half of the lifecycle.
  • Customising admin forms — all 17 field types, helper text, multi-step flows.
  • Customising admin tables — formats, badges, filters, and the new cell() render function with three column-packing recipes.
  • Using the generated API from the web app — list, search, detail, create form with the auto-generated React Query hook and shared Zod schemas.
v3.31.13June 21, 2026

Root .env is now the single source of truth for THEME + SOCIAL_AUTH_ENABLED. Setting SOCIAL_AUTH_ENABLED=false in the monorepo's root .env didn't hide the Google / GitHub buttons even after a server restart — Next.js only auto-loads .env from its own package directory (apps/admin/, apps/web/), so process.env.SOCIAL_AUTH_ENABLED inside next.config.ts was undefined and the || "true" fallback always won.

Both scaffolded next.config.ts files now read the root .env directly via a tiny inline parser before the env block is evaluated. Shell env still wins (only unset keys are filled in), so CI / Docker overrides are unaffected. After upgrading, restart pnpm dev (Next.js reads env at boot, not on file-watch).

v3.31.12June 21, 2026

System Health / Security / Performance pages build clean. The scaffolded /system/health, /system/security, and /system/performance pages import five lucide icons (CheckCircle, Server, HardDrive, Clock, Gauge) that weren't re-exported from apps/admin/lib/icons.ts. A fresh pnpm --filter ./apps/admin build failed with Export <Name> doesn't exist in target module. Added the five names to both the lucide-react import block and the named re-export block. All 24 admin routes now prerender on a fresh scaffold.

v3.31.11June 21, 2026

air entrypoint points at the built binary, not the source dir. v3.31.10 fixed the .exe extension but the scaffolded .air.toml still set entrypoint = "./cmd/server" — and air tries to exec the entrypoint as the binary, so Windows hit CMD will not recognize non .exe file. Per air's docs, entrypoint names the built binary (the same role build.bin plays). Fixed to entrypoint = "./tmp/server.exe". Verified with a live grit start server in a fresh scaffold plus a /api/health curl returning the full database/redis/jobs/email shape.

v3.31.10June 21, 2026

Scaffolded .air.toml uses an .exe binary on Windows. v3.31.9 shipped grit start with air-backed hot reload, but the generated .air.toml used bin = "./tmp/server". Windows refuses to CreateProcess an extension-less file, so starting the dev loop popped a "Select an app to open 'server'" dialog instead of running the API. Switched to cmd = "go build -o ./tmp/server.exe ./cmd/server" in the scaffolded template so Windows can execute the output directly.

v3.27.0June 20, 2026

Admin auth sweep + fresh-scaffold type-clean. Closes the last gap left by v3.26.0's HttpOnly cookie story: the admin app now uses cookies end-to-end too, js-cookie is gone from both frontends, OAuth no longer leaks tokens via URL params, and both apps/web + apps/admin return zero type errors on a fresh scaffold for the first time.

Admin uses HttpOnly cookies

  • apps/admin/lib/api-client.ts drops js-cookie, adds withCredentials: true, and echoes the grit_csrf cookie into X-CSRF-Token on every mutation.
  • apps/admin/hooks/use-auth.ts imports User, LoginRequest, RegisterRequest, AuthResponse, ApiResponse from @repo/shared/types instead of declaring them inline. useMe returns null on 401 instead of throwing. useLogout doesn't clear tokens locally — the API does it via Set-Cookie.
  • The admin root redirect page (app/page.tsx) drops the Cookies.get('access_token') check (which couldn't see HttpOnly cookies anyway) in favour of a useMe() probe.
  • The 401-refresh interceptor now POSTs /api/auth/refresh with an empty body — the API reads grit_refresh from the cookie and issues a new grit_access via Set-Cookie.
  • profile delete drops Cookies.remove calls — the Go DeleteProfile handler now calls ClearAuthCookies as part of its response.
  • js-cookie and @types/js-cookie dropped from apps/admin/package.json.

OAuth without URL leakage

The Go OAuth callback handler now calls SetAuthCookies BEFORE the 307 redirect to /auth/callback. The cookies travel on the redirect response itself, so the callback page no longer needs to read access_token and refresh_token from the URL. Tokens never appear in browser history, server access logs, or Referer headers. Closes the gap left when v3.26.5 fixed email/password.

UUID vs number ID drift cleaned up

  • useBulkDeleteResource signature ids: number[] → ids: string[] (Grit's models all use UUID primary keys).
  • form-modal.tsx + form-page.tsx + their -steps variants stop casting item.id / editId to Number — they pass the IDs through as strings, matching useUpdateResource / useResourceItem.
  • relationship-select-field.tsx + multi-relationship-select-field.tsx use String(item.id) instead of asserting as number.
  • hooks/use-system.ts dropped its inline Upload interface and imports from @repo/shared/types; UploadListResponse is now an alias for PaginatedResponse<Upload>.
  • Admin icon map: Cpu, Zap, Globe added; Shield was imported but not re-exported — fixed. System observability / security pages corrected from @/lib/api to @/lib/api-client.

Net effect: a fresh grit new → pnpm install → pnpm exec tsc --noEmit on apps/web AND apps/admin returns zero type errors for the first time. The Go API builds and template tests pass unchanged.

v3.26.5June 20, 2026

Web hooks finally consume packages/shared + use the v3.26.0 HttpOnly cookie auth. Closes a contradiction a learner spotted: the docs teach "shared types live in packages/shared" but the scaffolded use-blogs and use-auth hooks duplicated User and Blog inline.

Type imports

  • use-blogs.ts now imports Blog + PaginatedResponse from @repo/shared/types.
  • use-auth.ts imports User, LoginRequest, RegisterRequest, AuthResponse, ApiResponse from the same barrel.
  • lib/auth-provider.tsx imports User from @repo/shared/types instead of a 10-line local copy.
  • Web app now has @repo/shared: workspace:* in its package.json (was missing). next.config.ts gets transpilePackages: ['@repo/shared'] so SWC picks up the TS source.

Auth flow uses HttpOnly cookies end-to-end

  • Axios client gets withCredentials: true so the browser actually attaches the grit_access / grit_refresh cookies the API issues.
  • A request interceptor echoes the grit_csrf cookie into X-CSRF-Token on every state-changing request — required by the AutoCSRF middleware that v3.26.0 wired in.
  • use-auth.ts dropped js-cookie, storeTokens / clearTokens / getAccessToken, and every Authorization: Bearer header attachment. useMe returns null on 401 instead of throwing.
  • Login + register pages no longer call Cookies.set('access_token'). The API sets HttpOnly cookies via Set-Cookie; the browser stores them; JS never touches tokens.

Known gaps deferred to a follow-up release

  • OAuth callback page still reads tokens from URL params and sets them via Cookies.set. Proper fix requires the Go-side OAuth handler to set cookies before redirecting.
  • Admin app still uses js-cookie + Bearer header auth throughout. Bigger refactor (TOTP, OAuth begin/callback, profile page, multi-step token refresh) that warrants its own release.
v3.26.4June 15, 2026

grit start now actually starts both, like the help said it would.

The bug

In a web project, grit start (no subcommand) was falling through to cmd.Help() — printing the available subcommands and exiting. The command's ownLong description said it would start both the API and the client, but only grit start server and grit start client actually did anything.

What changed

  • grit start in a web project now spawns go run cmd/server/main.go (in apps/api/) and pnpm dev (at the project root) in parallel.
  • Output from both processes is streamed to the same terminal with a coloured [api] / [web] prefix per line so a developer can tell whose log is whose without splitting panes.
  • Ctrl+C (and SIGTERM) is forwarded to both children. If either child exits on its own, the other is shut down too — no zombie processes left behind.
  • Desktop projects are unchanged — grit start still calls wails dev. The subcommands grit start server and grit start client still work if you only want one side.
v3.26.3June 15, 2026

Redis + MinIO host ports moved to dodge native-install collisions. Same pattern as v3.26.2 did for Postgres, applied to the two other ports learners actually clash on.

What changed

  • Redis: dev host port 6379 → 6380. Native installs (Memurai on Windows, brew install redis, apt install redis-server, WSL Redis) all bind 6379.
  • MinIO S3 API: dev host port 9000 → 9002. Portainer's admin UI defaults to 9000; SonarQube and a handful of monitoring stacks grab it too.
  • MinIO console: dev host port 9001 → 9003. Less common collision but kept in sync with the API port shift.
  • Mailhog kept on 1025 / 8025. Almost zero dev machines have anything on those — shifting them just adds learner confusion without preventing real failures.

Inside the Docker network

Containers still listen on the canonical ports — Redis on 6379, MinIO on 9000 + 9001. The host-port shifts only affect how you reach them from your laptop. Prod compose is unchanged because inter-container traffic uses the docker network hostnames (redis, minio) and container ports.

Where else this surfaces

  • .env: REDIS_URL=redis://localhost:6380 and MINIO_ENDPOINT=http://localhost:9002.
  • The CLI "next steps" banner after grit new now prints the new host ports.
  • Scaffolded README's services table and the docs lessons (Docker primer, dev-servers, batteries/redis-cache, batteries/s3-storage) updated to match.
v3.26.2June 15, 2026

Postgres host port 5432 → 5434 to dodge Windows WinNAT reservations. Closes the "An attempt was made to access a socket in a way forbidden by its access permissions" bind error that hit Windows users on a fresh docker compose up -d.

What was wrong

Even with no process visibly holding port 5432, Docker Desktop on Windows would refuse to bind it. Cause: the WinNAT service / Hyper-V Virtual Switch silently reserves TCP port ranges at boot, and 5432 sits inside one of the common reservations on Docker Desktop + WSL2 default installs.

What changed

  • Dev docker-compose.yml now publishes Postgres on host port 5434 (not 5432). Container port stays 5432 inside the Docker network.
  • .env sets POSTGRES_PORT=5434 so the Go API connects to the same host port.
  • docker-compose.prod.yml pins POSTGRES_PORT=5432 in the api service environment because inter-container traffic uses the container port, not the dev host port.
  • Docker primer lesson gains error 2a, covering the Windows-specific bind error with the netsh int ipv4 show excludedportrange diagnostic and three fix paths.

Net effect: a fresh grit new → docker compose up -d → grit migrate now succeeds on Windows machines whose Hyper-V reservation overlaps 5432 — without any user-side intervention.

v3.26.1June 15, 2026

Single source of truth for Postgres credentials — fresh scaffolds Just Work. Closes the "SQLSTATE 28P01: password authentication failed" trap that bit every learner whose .env and docker-compose.yml drifted apart.

The bug

v3.25.x and v3.26.0 scaffolds wrote three disagreeing copies of the DB credentials: docker-compose.yml hardcoded grit:grit, .env's DATABASE_URL used grit:grit, and .env's POSTGRES_PASSWORD said change-me-in-production. The moment a learner edited one, the others were out of sync and grit migrate failed.

The fix

  • One canonical POSTGRES_* block in .env — POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB / POSTGRES_HOST / POSTGRES_PORT.
  • POSTGRES_PASSWORD is now generated at scaffold time as a 48-hex-char random string (alongside JWT_SECRET, PULSE_PASSWORD, etc.) — even APP_ENV=production is safe on first boot.
  • docker-compose.yml reads from .env via $${VAR:-grit} substitution. No hardcoded credentials anywhere.
  • The Go API builds DATABASE_URL from the same POSTGRES_* parts at startup. Set DATABASE_URL only if you want to point at external Postgres (Neon, Supabase, RDS) or SQLite — it's the explicit escape hatch.
  • Prod compose now sets POSTGRES_HOST=postgres in the api environment so the Go binary finds the postgres container on the docker network. No embedded DATABASE_URL in the compose YAML anymore.

A fresh grit new → docker compose up -d → grit migrate now succeeds without any editing.

v3.26.0June 14, 2026

Concepts ch.2 expansion, Docker hardening, and the long-overdue grit.json fix. A teaching + security release driven by real student feedback.

Concepts course chapter 2

  • "Tour of your project" lesson rewritten end-to-end. The original 30-second map was missing most of what the scaffold actually produces. The new lesson walks every folder + file matched against a real fresh scaffold: every package under apps/api/internal (25+ packages in a reference table), the full apps/web + apps/admin layouts, packages/shared + packages/grit-ui, tests/k6 (6 scripts), e2e/ (Playwright), .claude/, .github/, and every root config file.
  • New "A Docker primer" lesson inserted between project-tour and dev-servers. Most learners stall at docker compose up -d because nobody taught Docker first. The new lesson covers what Docker is, image/container/volume, install per OS, how Grit uses Docker, the 12 commands you'll actually type, the 6 errors learners hit + their fixes, plus a full "Run Grit without Docker" path (Neon + Upstash + Cloudflare R2 + Resend).

Docker scaffold hardening

  • docker-compose.yml binds every port to 127.0.0.1, not the Docker default 0.0.0.0. Coffee-shop wifi can no longer reach Postgres with grit:grit credentials.
  • docker-compose.prod.yml documented as reverse-proxy-first. A top-of-file comment block spells out the security posture: nothing uses ports:, only expose:. Postgres + Redis have NO host binding at all in prod. Traffic must arrive via Traefik / Caddy / nginx / Dokploy on the same Docker network.

Scaffold fixes

  • grit.json now writes the real CLI version. Previously hardcoded to 3.3.0 (a leftover placeholder from when the grit.json schema was at 3.3). Fresh projects now show the actual scaffolding CLI version (3.26.0 today). Closes the "is my project version really 3.3?" confusion.

Docs site

  • Single-source-of-truth version constant in config/site.ts. The header badge, install lesson example output, verify-install lesson, animated terminal, and changelog all read from one place — no more drift between the CLI version and what the website shows.
v3.25.2May 31, 2026

Smarter grit update + docs sweep. Two follow-ups to the v3.25 install/update flow.

Update command

  • Short-circuits when already on latest. The version check that previously only ran on the GitHub-binary path is now lifted to the top of grit update — both the Go-install and GitHub-binary strategies skip their work when there's nothing to do. One HTTP round-trip, then exit. (Was: always rename + go install + cleanup, even when already current.)
  • Unix path no longer deletes before installing. POSIX keeps the running process's inode alive when the file at the same path is overwritten, so go install can write straight on top. We previously did os.Remove first, which left the user stranded if go install failed.
  • Windows rename now rolls back on failure. The .exe-locked-while-running dance (rename current to .old → write new → delete .old) now restores the original binary if go install fails before writing the new one, so a flaky network or proxy issue can't leave the user with no usable grit.

Docs sweep

  • Replaced every go install github.com/MUKE-coder/grit/... install reference across docs pages, tutorials, courses, and the structured-data FAQ schema with the v3.25 one-line install script (with go install kept as a secondary option for power users with Go installed).
  • Hero terminal animation now opens with curl -fsSL https://gritframework.dev/install.sh | sh instead of go install.
v3.25.1May 31, 2026

Scaffold now generates real secrets — and SQLite uncommenting is clean. Two papercuts that turned into roadblocks the moment anyone flipped APP_ENV=production on a fresh project.

Fixes

  • Random secrets at scaffold time. grit new now generates cryptographically random values (crypto/rand → hex) for JWT_SECRET, SENTINEL_PASSWORD, SENTINEL_SECRET_KEY, and PULSE_PASSWORD when writing .env. Previously these shipped as your-super-secret-... and admin/sentinel / admin/pulse, which Sentinel v2 and Pulse v1 explicitly refuse to start with in release mode. A fresh scaffold now boots cleanly in production mode with both dashboards mounted — no manual openssl rand step required.
  • Clean SQLite uncomment line. The commented-out SQLite DSN previously had multiple leading spaces (#   DATABASE_URL=sqlite:...), so removing the #  left a line with leading whitespace. godotenv tolerated it, but it was ugly and confusing. Now single-#-prefixed for a clean uncomment.
  • k6 tutorial: turn Sentinel + Pulse off for the bench. Both sit in the request middleware chain. Leaving them on while load-testing means measuring them, not Gin. Step 3 now sets SENTINEL_ENABLED=false + PULSE_ENABLED=false alongside the SQLite switch.
v3.25.0May 31, 2026

One command to update: grit update. The CLI now checks GitHub for the latest version, compares against the running binary, and — depending on whether Go is on your PATH — either runs go install ...@latest or downloads the matching binary from the GitHub release and swaps it in place. Atomic swap is handled by inconshreveable/go-update, so it works correctly even on Windows where you can't overwrite a running binary.

What changed

  • Smart grit update — first checks GitHub releases. If you're already on latest, exits in a single round-trip with Already on the latest version. No more wasted go install runs.
  • No-Go-toolchain mode. If the go binary isn't on PATH, grit update falls back to the GitHub-binary path automatically. Means grit can keep itself current even for users who installed from the prebuilt archive and never touched Go.
  • --from-release flag forces the GitHub-binary path even when Go is installed. Useful if you're behind a corporate proxy that blocks the module proxy but allows github.com.
  • Alias grit self-update for discoverability — same command, clearer intent than update.

How to get it

This is the bootstrap release — you need to install v3.25.0 manually one time, then future versions are a single command:

# from any directory, with Go on PATH:
go install github.com/MUKE-coder/grit/v3/cmd/grit@v3.25.0

# from then on:
grit update
v3.24.0May 31, 2026

SQLite, AI prompts everywhere, and a Learnings journal. Three shipping threads: (1) the scaffolded API now speaks SQLite, not just Postgres — flip DATABASE_URL=sqlite:./app.db in .env and you skip Docker entirely; (2) every tech-kit page now ships a copyable starter prompt for claude.ai plus a new four-step AI Integration wizard that generates a tailored planning prompt; (3) a new Learnings section opens an engineering journal — first entry walks a stateless service + k6 load test from grit new --api all the way to a committed p50/p95/p99 latency chart.

Scaffold & framework

  • SQLite support. internal/database/database.Connect now branches on DSN prefix: sqlite://path, sqlite:path, sqlite::memory:, or Postgres for anything else. Uses github.com/glebarez/sqlite (pure Go, no CGO) so it works on Windows without a C toolchain. Existing Postgres setups are unchanged.
  • .env documentation. Both .env and .env.example now show all three DSN shapes inline.
  • Demo DEMO_MODE bypass. Sentinel v2 + Pulse v1 refuse to start in release mode with default credentials. The public Grit demo now opts in via DEMO_MODE=true in demo/internal/routes/routes.go — production deploys still get the gate; the publicly pokeable demo skips it.

Docs site

  • Per-kit starter prompts. All 7 tech-kit pages (single, single-vite, double, triple, api, mobile, desktop) now have a "Plan this kit with an AI" section. One copy button gives you a prompt to paste into claude.ai with your idea — Claude returns project-description.md, project-phases.md, design-style-guide.md, and prompt.md, the four planning files you feed to Claude Code.
  • New AI Integration page. A four-step wizard (Platform → Tech Kit → Use case → Your prompt) that customizes the prompt per project shape. Modelled on the DGateway integration helper.
  • New Learnings section. /docs/learnings is an engineering journal. The first entry — a stateless service + k6 load test walkthrough — covers everything end to end: scaffold, install k6, smoke test, average-load profile, JSON output, three charting options, percentile interpretation table, and committing the milestone.
  • Navbar trim. Dropped Stack Selector and Tutorials from the top nav (still reachable from the sidebar and search). Replaced with the AI Integration link as a top-level highlighted item.
v3.23.0May 29, 2026

Security & deploy hardening release. Three threads in one ship: (1) the deploy-day fixes uncovered by a real production build on --single --vite; (2) the React/Vite UI primitives every Grit project ends up writing by hand; (3) a full pass against the OWASP Top 10:2025 with code-level defences and a documented testing methodology. Two new docs pages — /docs/security and /docs/testing — are the audit checklist clients will walk with you.

Deploy reliability

  • cron.Start(cfg, cache) ships. The single-app main.go was importing a function that didn't exist; project wouldn't compile out of the box. The new helper wraps the existing Scheduler and returns (*Scheduler, error) so callers can stop it on shutdown.
  • asynq worker now actually starts. The single-app main.go was queueing jobs without ever starting jobs.StartWorker, so the token-cleanup task and every email/SMS/cron job sat in Redis forever. Worker startup + graceful shutdown wired in.
  • SPA fallback no longer loops behind reverse proxies. c.FileFromFS("index.html", ...) triggered http.FileServer's canonical-URL 301 rule and ping-ponged forever behind Traefik / Cloudflare (ERR_TOO_MANY_REDIRECTS). The scaffold now pre-reads index.html once and serves via c.Data().
  • UUID primary keys no longer 401 every request. GORM's db.First(&user, id) shorthand assumes an integer PK; with the scaffold's UUID-string PK Postgres rejected it with "trailing junk after numeric literal". All eight call sites (auth middleware + UserHandler + TOTP) switched to db.Where("id = ?", id).First(...).
  • Single-app layout cleaned up. main.go moved to the project root so //go:embed all:frontend/dist resolves on a fresh clone; a placeholder frontend/dist/index.html ships so go build works before pnpm build; apps/api/Dockerfile + the multi-app docker-compose.prod.yml are no longer generated in --single mode (Dokploy was auto-detecting them and failing). A new root Dockerfile pins pnpm to 9.15.0, chowns before USER (fixes Sentinel/Pulse "out of memory (14)" SQLite errors).
  • Auto-migrate + first-boot seed. Single-app main.go now runs models.Migrate(db) on startup (gate via AUTO_MIGRATE=false) and seeds when the users table is empty (gate via AUTO_SEED; off by default in production). Fresh-deploy → working-login is a single command.
  • Vite scaffold fixes — postcss.config.cjs (was .js, broke ESM package.json); api.ts uses import.meta.env.VITE_API_URL instead of process.env.NEXT_PUBLIC_API_URL; vite-env.d.ts declares the type so tsc --noEmit stops erroring; navbar/footer use TanStack Router's Link + useRouterState instead of next/link / usePathname; @tanstack/router-cli in devDeps + a postinstall hook so routeTree.gen.ts exists on a fresh clone.

Vite UI primitives (every project ends up writing these)

  • lib/auth.ts — handles the actual {data:{user, tokens:{access_token, refresh_token, expires_at}}} envelope, persists tokens, exports login / register / me / refresh / logout / clearAuth. Handles the TOTP-challenge response shape too.
  • lib/api.ts — auto-attaches Authorization: Bearer, transparently retries once on 401 via /api/auth/refresh. Single-flight refresh so a burst of 401s doesn't fan out into N refresh calls.
  • ConfirmDialog + useConfirm — Promise-based confirm: const ok = await confirm({ title, message, tone: 'danger' }). Esc cancels, Enter confirms.
  • MoneyInput — Intl.NumberFormat thousands separators, prefix slot, value: number | null.
  • Combobox — keyboard-friendly (Arrow Up/Down/Enter/Esc), filter on label + sublabel.
  • SessionExpiryMonitor — decodes JWT exp, shows a Stay / Logout modal 30s before expiry. Stay calls refresh().
  • StatusBadge<TStatus> — typed status → tone (success / warning / danger / info / neutral) with a default tone map for paid / pending / overdue / etc.
  • StatsRow — list-page stat cards (label, value, sub, icon, tone).

OWASP Top 10:2025 hardening

Every fresh grit new project now ships defences for every category by default. The new Security Guide walks each one category-by-category.

  • A01 IDOR — internal/authz · authz.MustOwn(c, db, dest, id) returns 404 (not 403) on every failure so existence isn't leaked through error-message differences. authz.RequireRoles("admin") middleware for admin routes.
  • A01 SSRF — internal/safefetch · drop-in safefetch.Get(ctx, url) validates scheme + host pre-flight AND re-checks the resolved IP at TCP-connect time via net.Dialer.Control — closes the DNS-rebind TOCTOU. Blocks loopback, RFC1918, link-local, CGNAT (100.64/10), AWS IMDS (169.254.169.254 + fd00:ec2::/32), metadata.google.internal.
  • A02 — SecurityHeaders middleware extended · strict Content-Security-Policy (default-src 'self' + script allowlist + frame-ancestors 'none' + object-src 'none'), plus Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy. Skipped on /docs, /studio, /sentinel, /pulse which serve vendored UIs.
  • A03 Supply chain · .github/dependabot.yml (Go modules + npm + GitHub Actions, weekly) and .github/workflows/security.yml running govulncheck + pnpm audit (high+) + CodeQL Go/JS on every PR and weekly.
  • A01-adjacent CSRF — middleware.CSRF · double-submit-cookie defence for cookie-auth routes (OAuth flow). SameSite=Strict on the token cookie.
  • A09 — middleware.LogSecurityEvent · typed event constants for login success/failure, logout, password change, TOTP enable/disable, role change, account lock, authZ denial. Rides the existing tamper-evident ActivityLog hash chain.

Performance & security testing methodology

  • k6 suite in tests/k6/ — all six test types from the testing course (smoke / average-load / stress / spike / soak / breakpoint) share one user journey in lib/common.js. SLO-aligned thresholds; smoke + average-load suitable as a CI regression gate.
  • New /docs/testing page — k6 install + reading results, the 5-phase pentest methodology, the attack catalogue with curl one-liners against a Grit app (IDOR / SQLi / XSS / SSRF / brute-force / misconfig), CVSS scoring + audit-report structure.
  • Sentinel and Pulse issues filed for the cross-project improvements this release uncovered: Sentinel #2 CSP report endpoint, #3 SSRF guard, #4 user-scoped rate limit + CAPTCHA, #5 CVSS finding model + alerts. Pulse #1 p50/p95/p99 + SLO alerts, #2 N+1 detector, #3 USE method dashboard, #4 k6 timeline + flame graphs.
v3.22.0May 2, 2026

Performance hardening release. A senior-level audit of every scaffold template found 27 issues — the 10 critical and high-impact ones are fixed. Apps built with Grit should now show materially lower CPU burn under sustained load.

Critical fixes

  • ActivityLogger middleware — was spawning a fresh goroutine per request, each blocking on a row-level FOR UPDATE lock for the audit hash chain. At 10k req/s the old design created 10k goroutines all serializing on the same lock. Replaced with a bounded channel (4096) + single writer goroutine. The single-writer design eliminates the lock entirely (chain ordering is sequential by construction); the bounded channel caps memory + goroutine count under traffic spikes. Drops on overflow rather than OOM, with a new AuditDroppedCount() helper for monitoring saturation.
  • audit.VerifyChain — was loading the entire activity_log table into memory before scanning. At 1M rows that's 250MB+ heap, instant OOM at 100M. Now walks in chunks of 1000 rows with a cursor on (created_at, id), honours context cancellation, and the integrity endpoint passes a 60-second deadline so a runaway scan can't hold the connection forever.

High-priority fixes

  • flags.Engine.evaluate — copies the flag struct under RLock then releases before doing all decision logic (date checks, allowlist scans, bucketing, JSON parsing). Cuts lock-hold time from milliseconds to nanoseconds on the flag-check hot path.
  • Cache middleware — SHA-256 cache keys swapped for FNV-1a. ~50× faster on the hot path of every cacheable request, no correctness loss (cache keys don't need cryptographic strength). responseCapture switches []byte append to bytes.Buffer — 3 allocations instead of one per Write chunk.
  • Generated service queries — Update dropped the redundant third First() after Updates() (Updates mutates the loaded struct in place); Delete dropped the preflight First() (GORM's Delete is atomic + RowsAffected reveals existence). 2 queries saved per generated CRUD op.
  • Generated Export handler — was loading every matching row with Find(&items); now uses FindInBatches in chunks of 1000. CSV exports stream directly to the response writer (true streaming, constant memory). XLSX still buffers because excelize has no streaming API, but the scan is chunked so we don't hold the entire result set in one slice. New export.CSVRows() helper for header-less subsequent batches.

Medium fixes

  • Webhook Replay — retry_count increment is now atomic via gorm.Expr("retry_count + ?", 1). Two concurrent replays of the same event no longer race to write the same +1 result.
  • Flags bucketFor for anonymous users — crypto/rand.Read instead of time.Now().UnixNano() % 100. The old approach was biased toward recent buckets under high QPS.

Skill file: Performance & Production Hygiene section

The .claude/skills/grit/SKILL.md that grit new generates now includes a dedicated Performance & Production Hygiene section. AI assistants helping users build apps will see explicit hot-path rules, DB query rules, background job rules, logging rules, and memory rules — including which framework primitives are already audited (so they know not to reintroduce the patterns this release just fixed). Examples:

  • Never spawn unbounded goroutines per-request — use a buffered channel + fixed worker pool (the ActivityLogger pattern).
  • Never hold a mutex across slow operations — read shared state, copy what you need, release, then do the work (the flags.evaluate pattern).
  • Never load a whole table into memory — use paginate.List, FindInBatches, or cursor walks (the VerifyChain pattern).
  • Never use time.Now().UnixNano() % N for randomness — biased by call frequency; use crypto/rand.
v3.21.0May 2, 2026

Feature flags + A/B testing baked into the framework (#46). No LaunchDarkly bolt-on, no PostHog SaaS dependency — the engine, the model, the admin endpoints, and the realtime push all ship in every scaffolded API.

Usage

if flags.IsEnabled(c, "new_dashboard") {
    // … render the new dashboard
}

switch flags.Variant(c, "checkout_redesign") {
case "control":   /* old flow */
case "variant_a": /* new flow */
case "variant_b": /* alternate new flow */
}

Mechanics

  • FeatureFlag.Rules JSON holds rollout_percentage, allowlist_user_ids, blocklist_user_ids, enabled_from, enabled_until, variants.
  • All flags load into an in-memory cache at boot. A background goroutine refreshes every 30s; admin writes trigger an immediate refresh. Flag checks never hit the DB.
  • Sticky bucketing: SHA-256(user_id || ":" || flag_name) % 100. A user always lands in the same bucket for a given flag — no flicker between sessions.
  • Allowlist always passes (bypasses the percentage roll). Blocklist always denies. Both run before the percentage check.
  • A/B mode kicks in when Rules.Variants is non-empty. Variant() returns the bucket-mapped variant string. Sticky per (user, flag).

Realtime updates

When a flag is created / updated / deleted, the engine refreshes its cache and broadcasts a "flag.updated" realtime event over the v3.12 WebSocket hub. Frontend subscribers can invalidate their cache and refetch — flag changes propagate in <1s across all connected clients.

Admin endpoints

  • GET /api/admin/flags — paginated list (searchable on name + description, sortable on name / created_at / enabled).
  • POST /api/admin/flags — create. Name is unique + immutable.
  • PUT /api/admin/flags/:id — update description / enabled / rules. Bumps Version (the v3.14 optimistic-lock column).
  • DELETE /api/admin/flags/:id — remove + invalidate cache.
  • GET /api/admin/flags/:id/exposures — variant counts for the rollout-health view: [{ "variant": "enabled", "count": 4231 }, ...].

Fail-closed semantics

  • Unknown flags return false. A typo in a flag name never accidentally enables a feature.
  • Misconfigured Rules JSON also returns false — the engine never panics on bad data.
  • Anonymous users (empty user_id) get a random bucket per request + are not exposure-tracked. For sticky anonymous flags, pass a stable identifier (session ID, device ID).

Pairs with the v3.16 activity log + v3.19 hash chain — every flag change is auditable, signed, and tamper-evident. SOC2-ish flag governance for free.

v3.20.0May 2, 2026

Webhook receiver framework (#57). Wiring up Stripe / GitHub / WhatsApp / any HMAC-signed inbound webhook is now <10 lines of app code. Signature verification, idempotency, failed-handler replay — all framework concerns now.

The shape

// In your app boot (e.g. internal/webhooks/handlers.go)
func init() {
    webhooks.Register("stripe", webhooks.Provider{
        SecretEnv: "STRIPE_WEBHOOK_SECRET",
        Verify:    webhooks.StripeVerifier,
        Extract:   webhooks.StripeExtractor,
    })

    webhooks.On("stripe", "invoice.paid", func(ctx context.Context, e *models.WebhookEvent) error {
        // … process the event
        return nil
    })
}

The framework already mounted POST /webhooks/:provider in routes — the path param picks the registered provider. No per-provider routing code in your app.

Pipeline

  1. Route hits → look up provider (404 if unknown).
  2. Read raw body + headers.
  3. provider.Verify(secret, body, headers) — 401 on signature mismatch.
  4. provider.Extract(body, headers) returns (eventType, externalID).
  5. Insert into webhook_events — UNIQUE on (provider, external_id) means duplicate deliveries become status=skipped no-ops.
  6. webhooks.Dispatch(ctx, event) runs the registered handler for (provider, eventType), falling back to a catch-all "" handler if no specific match.
  7. Handler success → status=processed; handler error → status=failed + handler_error recorded. Provider always gets 200 once we persisted the event, so retries don't hammer.

Shipped verifiers

  • HMACVerifier(header) — generic hex HMAC-SHA256 in a named header. Most simple partners use this.
  • StripeVerifier — Stripe's t=...,v1=... scheme with 5-minute replay tolerance.
  • GitHubVerifier — GitHub's X-Hub-Signature-256: sha256=... header.
  • Roll your own VerifyFunc for anything else — it's just func(secret string, body []byte, headers map[string]string) error.

Shipped extractors

  • JSONFieldExtractor("type", "id") — pulls top-level fields from the JSON body. The most common shape (Stripe-style envelopes).
  • GitHubExtractor — reads X-GitHub-Event + X-GitHub-Delivery headers.

Admin endpoints

  • GET /api/admin/webhooks?provider=stripe&status=failed — paginated list with the standard envelope. Filters: provider, status.
  • POST /api/admin/webhooks/:id/replay — re-runs the handler for an existing event. Increments retry_count and records the new outcome. Use this after a deploy fixes a handler bug.

Pairs naturally with the v3.10 idempotency middleware — both are "safe replay" primitives, just on different sides of the network. Outbound retries reuse Idempotency-Key; inbound duplicates dedupe on (provider, external_id).

v3.19.0May 2, 2026

Tamper-evident audit log via append-only hash chain (#48). Builds on the v3.16 ActivityLog — every row now carries PrevHash + Hash columns where Hash = SHA-256(PrevHash || canonical(row)). Mutating any row breaks the chain on the next verification pass.

The chain

  • Genesis row has PrevHash = ""; every subsequent row references the previous row's Hash.
  • Hash input is the stable canonical form of the audit-relevant fields (user_id, method, path, status, payload digest, IP, UA, duration, created_at unix-nano). ID + PrevHash + Hash themselves are not in the canonical form — they're either random (ID) or derived (Hash, PrevHash).
  • Insert uses FOR UPDATE lock on the latest row inside the same transaction, so concurrent writes serialize cleanly without forking the chain.

The package

New internal/audit ships these:

  • audit.Canonical(entry) — stable JSON bytes for hashing.
  • audit.ComputeHash(prevHash, canonical) — runs SHA-256 over prevHash || canonical; returns hex.
  • audit.AppendChained(db, entry) — atomic insert with chain lock. The middleware uses this; you can call it from anywhere.
  • audit.VerifyChain(db) — walks every row in (created_at, id) order and recomputes hashes. Returns ChainStatus with the first mismatch (broken_at_id + expected vs got + message).

The endpoint

GET /api/admin/activity/integrity

→ { "valid": true, "total_entries": 12345 }

→ { "valid": false, "broken_at": 47, "broken_at_id": "uuid",
    "expected": "abc123...", "got": "def456...",
    "message": "hash mismatch — row was modified, deleted, or inserted out of order" }

Wire this to a nightly cron + alerting webhook for free SOC2-ish audit monitoring. Run it on-demand from a settings page when staff need the current chain state.

What this defends against

  • Direct SQL UPDATE / DELETE on activity_logs — the most common attack vector (DBA covering tracks).
  • Out-of-band insertion of forged history.

What it does NOT defend against

  • Compromise of the running server itself — an attacker with code execution can rewrite the entire chain.
  • External anchoring (publishing the daily root hash to a public ledger like a tweet, a transaction, or a Sigstore log) is the follow-up — flagged in #48 as bonus material, not shipped here.

Verification cost: O(n) — about 2–3 seconds per million rows on a warm cache. The middleware insert is still fire-and-forget so audit DB latency never blocks the response path; chain failures log instead of cascading.

v3.18.0May 2, 2026

PDF generation module (#13). Every scaffolded API ships internal/pdf/ with Grit-styled section helpers + a worked RenderInvoice template. Pure Go, no Chromium / wkhtmltopdf native dependencies.

The Doc primitives

pdf.New() returns a *Doc preconfigured with Helvetica + 20mm margins + A4 portrait + Grit blue accent. Embeds the underlying *fpdf.Fpdf so the full library is available when helpers don't fit.

  • Header(title, subtitle) — accent-colored 22pt title + muted-gray subtitle line.
  • KV(label, value) + TwoColumnKV(...) — small-caps label + body value pairs.
  • Table(headers, rows, widths, aligns) — light gray header row, plain data rows, configurable widths + alignment per column.
  • Totals([]TotalLine) — right-aligned totals stack; the bold line gets accent coloring + slightly larger size for the grand total.
  • Notes(text) — labeled multiline section, skipped when empty.
  • Footer(text) — centered italic 25mm above the page bottom.
  • d.Bytes() finalizes and returns the PDF byte slice ready to stream to c.Data(200, "application/pdf", b).

RenderInvoice — worked example

pdf.RenderInvoice(pdf.Invoice{
    Number:    "INV-202605-0001",
    IssueDate: time.Now(),
    DueDate:   time.Now().Add(14 * 24 * time.Hour),
    BillTo:    pdf.Party{Name: "Abu Seal", Contact: "abu@example.com"},
    Items: []pdf.LineItem{
        {Description: "Office rent — June", Quantity: 1, UnitPrice: 1500000, Total: 1500000},
        {Description: "Service charge",      Quantity: 1, UnitPrice:  120000, Total:  120000},
    },
    Subtotal: 1620000, Total: 1620000,
    Currency: "UGX",
    Notes:    "Pay by mobile money: +256...",
})

Returns ([]byte, error) — wire it to a handler:

func (h *InvoiceHandler) PDF(c *gin.Context) {
    inv, _ := h.Service.GetByID(c.Param("id"))
    bytes, err := pdf.RenderInvoice(toInvoice(inv))
    if err != nil { respond.Internal(c, err); return }
    c.Header("Content-Disposition", `attachment; filename="` + inv.Number + `.pdf"`)
    c.Data(200, "application/pdf", bytes)
}

Copy invoice.go as a starting point for receipts, leases, statements, quotes — the same primitives compose all of them. Add github.com/go-pdf/fpdf v0.9.0 dependency lands automatically in scaffolded go.mod.

v3.17.0May 2, 2026

Quality-of-life bundle. Four GitHub issues closed: #12, #31, #35, #43.

grit init — #35

New CLI command writes CLAUDE.md + AGENTS.md to the current directory. Both files carry the framework's hard rules (Forms / Frontend stdlib / Data / Backend / Resources / Sync / Auth) so contributors and AI assistants get the conventions right on first PR. Skips existing files unless --force is passed; re-run with --force after a major framework upgrade to refresh.

Verbose AutoMigrate — #31

Migrate() now snapshots ColumnTypes before and after each AutoMigrate call and logs a diff:

================================================================
DATABASE MIGRATION — 8 model(s) registered
================================================================
  + created models.Building
  ~ models.User — added 2 column(s): is_vip, vip_notes
----------------------------------------------------------------
Migration done — 1 created, 1 altered (+2 column), 6 unchanged.
================================================================

Silent migrations are gone. Also fixes a pre-existing bug where Migrate skipped already-existing tables — so columns added to a model never actually landed in the DB. Now they do.

Cursor-based pagination — #43

  • paginate.List gains opt-in cursor mode via Config.CursorMode: true. Response carries Meta.NextCursor + Meta.HasMore instead of Page/Pages.
  • Detects HasMore by fetching PageSize + 1 rows — no separate count query needed.
  • Cursor is opaque base64 of (sort_value, id) so pages stay stable when rows insert mid-pagination. Works with any sort field; extracts the value via reflection on the last row.
  • Total count opt-in via Config.IncludeTotal — costs an extra COUNT(*), leave off unless your UI shows a "X of Y" indicator.
  • Offset mode stays the default for back-compat; new resources can flip the flag.

Generator quality — #12

The remaining tag-default heuristics from issue #12:

  • URL fields (suffix _url + named url / image / avatar / thumbnail / logo / cover / icon / banner / photo) get size:500 instead of size:255. UTM-tagged links and signed S3 URLs blow past 255 in the wild.
  • Long-text fields named description / notes / content / body / summary / bio / details / comment / comments / message get type:text.
  • Money fields on float type (suffix _amount / _price / _total / _cost / _fee / _balance / _rent / _salary / _wage / _value / _revenue / _deposit + named amount / price / total / cost / fee / balance / subtotal) get type:decimal(12,2) for fixed-precision storage. No more 1.99 + 0.01 = 1.9999999.
v3.16.0May 2, 2026

Three coherent admin-operations features at once: CSV/Excel export per resource (#15), activity audit log middleware (#32), and the apiErrorMessage frontend helper (#27).

CSV / Excel export per resource — #15

  • New internal/export package: CSV(w, items, opts) and XLSX(w, items, opts) with a typed Column{Header, Field, Format} config. Field uses dot-notation for associations ("Tenant.Name").
  • Format strings: "currency:UGX", "date:2006-01-02", "datetime", "bool". Empty string falls back to fmt.Sprintf("%v").
  • Resource generator now emits an Export(c *gin.Context) handler method on every new resource, with columns derived from the field list. Routes inject GET /api/<plural>/export automatically.
  • Honours the same search param as List, so users can export a filtered subset.
  • Adds github.com/xuri/excelize/v2 v2.8.1 to scaffolded go.mod.

Activity audit log — #32

  • New models.ActivityLog with user_id + method + path + status + payload digest (sha256, not raw body) + IP + user-agent + duration. UUID PK; created_at indexed for time-range queries.
  • New middleware.ActivityLogger(db) mounted on every protected mutation route. Skips safe methods + non-2xx responses + unauthenticated requests.
  • Insert is fire-and-forget (goroutine). Audit DB latency never blocks the response path; if the DB is down the entry drops rather than failing the request.
  • New endpoint GET /api/admin/activity (admin-only) with paginate.List filtering by user_id, method, and path prefix. Drop in any audit-log UI.

apiErrorMessage helper — #27

  • Three helpers in packages/shared/types/api.ts: apiErrorMessage(err, fallback?), apiErrorCode(err), apiErrorFields(err).
  • Walks the standard envelope chain (response.data.error.message) plus axios err.message plus a fallback so toast.error(apiErrorMessage(err)) is always meaningful.
  • apiErrorCode returns the envelope's code string ("VALIDATION_ERROR", "VERSION_CONFLICT", etc.) for branching logic.
  • apiErrorFields surfaces per-field validation details so forms can highlight specific inputs.
  • New internal/respond package on the server side too: respond.NotFound / Validation / Forbidden / Conflict / Internal for handlers, replacing ad-hoc inline c.JSON(500, gin.H{...}).
v3.15.0May 2, 2026

Frontend stdlib + form primitives. Closes seven GitHub issues at once (#19, #20, #21, #22, #23, #33, #34). Every primitive lifted from real Grit-built business apps.

Format helpers (lib/format.ts) — #33

  • formatCurrency(amount, currency?) — locale-aware, no-decimal mode for UGX / JPY / KRW / RWF / TZS / VND.
  • formatDate(value, fmt?) — token formatter (yyyy / MMMM / MMM / MM / dd / HH / mm / ss). Default "MMM d, yyyy".
  • formatDateTime(value) — "May 2, 2026 · 2:30 PM".
  • humanize("checked_in") → "Checked in".
  • initials("Abu Seal") → "AS".
  • setFormatConfig({ locale, currency }) at boot to override.

<CurrencyField> — #19

Live comma formatting as the user types ("3000" → "3,000"), paste-friendly ("$1,234.56" works), emits raw number to onChange. Optional prefix slot for currency code. Auto-toggles between formatted display (blur) and raw digits (focus) so editing isn't a fight.

<SearchableSelect> — #20

Combobox with typeahead, ↑/↓/Enter/Esc keyboard nav, portaled dropdown (escapes overflow:hidden ancestors), optional clear button. Replaces native <select> for FK fields and any enum with > 5 values.

<DateField> + <DateRangeFilter> — #21

  • <DateField> wraps native <input type="date"> with the standard label/hint/ error chrome — picked the native one for a11y, RTL, and i18n.
  • <DateRangeFilter> — preset chip bar (Today / Last 7 / Last 30 / This month / Last month / Last 90 / This year / All) + custom-range fallback.
  • presetRange("last90") helper exposed for non-UI uses.

<Drawer> — #22

Right-edge slide-in panel. Closes on Esc + backdrop + X button. Configurable widths (sm/md/lg/xl). Optional sticky footer slot for the typical Cancel/Save row. Pair with <FormGrid> + <FormActions> from v3.11 for the standard create/edit experience.

<StatusBadge> — #34

Status string → coloured pill. Default map covers paid / active / completed / pending / overdue / cancelled / draft / archived / checked_in / in_progress and friends. Override or extend per app:

setStatusVariants({
  shipped: "info",
  on_hold: "warning",
});

<AppShell> + grouped sidebar — #23

  • New lib/nav-config.ts — single source of truth for sidebar sections. Adding a new section is a one-line config edit.
  • components/layout/sidebar.tsx rewritten as a config-driven grouped sidebar (section title + items with icons + optional badges).
  • New components/layout/app-shell.tsx — bundles TitleBar + Sidebar + Topbar + scrollable content + Cmd/Ctrl-K command palette in one component. Wrap your dashboard Outlet with this.
v3.14.0May 2, 2026

Offline-first foundation. Git-style sync model — work locally, click Sync explicitly, resolve conflicts per-field, push one-by-one. Every scaffolded API now has Version-tracked rows + the POST /api/sync/push and GET /api/sync/pull endpoints; every desktop scaffold ships a local SQLite mirror, an outbox with squash semantics, and a title-bar Sync button + conflict-resolution dialog.

Server: versioning + sync endpoints

  • Version int column added to User, Upload, Blog. A BeforeUpdate GORM hook auto-increments on every server-side write. The resource generator emits both on every new model.
  • POST /api/sync/push accepts a batch of changes; each entry includes the version the client believes the server has. On mismatch the response contains VERSION_CONFLICT + the current server state, so the client can drive a merge UI.
  • GET /api/sync/pull?model=X&since=cursor returns every row in the table updated after the cursor, paginated, with a new cursor in the response.
  • New internal/sync/registry.go maps logical table names (e.g. "buildings") to reflect.Type so the handler decodes dynamic payloads. New resources auto-register via // grit:sync marker.

Desktop: sync engine

  • New apps/desktop/sync/ Go package. Opens a local SQLite file under the OS user-config dir on app boot.
  • Three tables: sync_records (local mirror — reads come from here), sync_outbox (pending changes; UNIQUE on (model, entity_id) for squash), sync_cursors (incremental pull positions).
  • Squash semantics: edit a record three times offline → one outbox entry with the final state. delete-after-create cancels both locally without ever hitting the network.
  • Engine.Sync() runs Pull then Push. Push posts the whole outbox in one HTTP call; the response drives per-entry result handling — successes clear from the outbox, conflicts get the server state stashed for the merge UI.

Wails bindings

The frontend talks to the engine through these Wails-bound methods on App:

  • LocalCreate / LocalUpdate / LocalDelete — write-through to local SQLite + outbox.
  • LocalGet / LocalList — read from the local mirror.
  • Sync(tables) — pull listed tables then push the outbox. Returns counts.
  • PendingCount, GetPendingChanges — drive the title-bar badge and the review panel.
  • ResolveConflict(table, entityID, mergedData, serverVersion) — accepts the user's merge for a conflicted entry.

UI

  • Title-bar Sync button with a pending-count badge. Green refresh icon when clean; amber alert + count when there are pending changes.
  • PendingChangesPanel — right-edge drawer listing every outbox entry, split into "Needs review" (conflicts) and "Ready to push". Sync now button at the bottom.
  • ConflictDialog — field-level merge UI. Three columns (Field / Local / Server v_N), per-field click to choose. Apply builds the merged record and calls ResolveConflict.

React hooks

  • usePendingCount() — polls every 2s for the badge.
  • usePendingChanges() — full outbox + refresh function.
  • useSyncMutation(tables) — kicks off a Sync, exposes running/result/error state.
  • useResolveConflict() — applies one merge and refreshes.

Wire format

POST /api/sync/push
{ "changes": [
    { "op": "create", "model": "buildings", "id": "uuid", "version": 0, "data": {...} },
    { "op": "update", "model": "tenants",   "id": "uuid", "version": 5, "data": {...} },
    { "op": "delete", "model": "leases",    "id": "uuid", "version": 3 }
] }

→ { "results": [
    { "ok": true, "new_version": 1 },
    { "ok": false, "code": "VERSION_CONFLICT", "server_version": 7, "server_data": {...} },
    { "ok": true }
] }

Deferred to v3.14.1: React Query offline-aware data hooks (useOfflineList, useOfflineGet, useOfflineMutation) and the resource generator emitting offline-aware frontend hooks. The engine and primitives ship now; ergonomics layer next.

v3.13.0May 2, 2026

New grit generate sequence command produces atomic, gap-free sequential numbers like INV-202605-0001. Pattern lifted from a real Grit-built rental management app — invoice / receipt / order numbering is now a one-liner.

What it generates

  • First invocation only: internal/sequence/sequence.go — a generic counter package with Counter (the GORM-backed row), Config (name + prefix + reset + width), and an atomic Next(db, cfg, t) helper.
  • Every invocation: internal/services/<name>_sequence.go — a typed convenience wrapper. Handlers call e.g. services.NextInvoiceNumber(h.DB, time.Now()) without knowing the prefix or reset cadence.
  • Auto-injects &sequence.Counter{} into the Models() migration slice (idempotent).

Mechanics

  • Counter rows keyed by (name, bucket) where bucket is "YYYYMM" for monthly resets, "YYYY" for yearly, or empty for never. So a monthly counter automatically restarts at 1 on the first call of each new month.
  • Atomic via row-level SELECT FOR UPDATE on Postgres (concurrent callers serialize on the counter row). SQLite serializes writes globally so it's also safe.

Usage

grit generate sequence Invoice
grit generate sequence Order --prefix ORD --reset yearly --width 6
grit generate sequence Receipt --reset never

Flags:

  • --prefix — alphabetic prefix (default: first 3 chars of the name, uppercased)
  • --reset — when the counter resets: monthly (default), yearly, or never
  • --width — zero-padded width of the numeric portion (default 4)

The grit generate report generator (Recharts tabs page + Go ReportService) is deferred to a future release — it needs more design work for the React chart layer than fits a same-day release.

v3.12.0May 2, 2026

Realtime WebSocket hub baked into every API + a desktop client + hooks for subscribing. And a sweep of every remaining numeric ID — UUIDs are now the canonical ID type everywhere in the framework.

Realtime hub (API)

  • New package: internal/realtime/hub.go. One Hub per process; each user can have multiple connections (desktop + mobile + web).
  • Hub.SendToUser(userID, evt), SendToUsers(ids, evt), and Broadcast(evt) let any handler or service push events.
  • Slow-client safe: per-connection 32-message send buffer; when full, that one client's message is dropped — never blocks the entire hub. Slow clients resync on their next REST refetch.
  • New handler: internal/handlers/realtime.go upgrades the request to a WebSocket and registers the client with the hub.
  • Mounted at GET /api/ws?token=<jwt> — query-string auth because browsers can't set custom headers on the WS handshake.
  • Wire format: { type: "<topic>", payload: {...} }. Suggested topics: chat.message.new, notification.new, system.connected, or your own resource.<name>.<verb> namespace.
  • Dependency added: github.com/gorilla/websocket v1.5.3.

Realtime client (desktop)

  • New file: frontend/src/lib/realtime.ts. Singleton client with auto-reconnect via exponential backoff (1s, 2s, 4s, 8s, capped at 15s).
  • Global realtimeBus EventTarget — any component can subscribe.
  • Start from AuthProvider after tokens land, stop on logout.
  • New hook: useRealtimeEvent<T>(type, callback) subscribes to a typed topic and unsubscribes on unmount. Plus useRealtimeAny() for catch-all handlers (debug, toast bar).

ID consistency sweep — UUIDs everywhere

v3.9.1 standardized the User model on string UUID PKs but a long tail of numeric IDs remained in the framework. v3.12.0 cleans them all up.

  • Go scaffold: the prebuilt Blog model in api_blog_files.go swaps from gorm.Model (auto-incr uint) to a string UUID PK with a BeforeCreate hook. Service signatures (GetByID, Update, Delete) and handler param parsing all switch from uint to string.
  • Standalone desktop scaffold (grit new-desktop): User, Blog, and Contact models all switch to string UUID PKs with BeforeCreate hooks. All Wails-bound App methods and underlying service signatures use id string. Frontend mutation typings follow.
  • Shared TS types: User, Upload, and Blog interfaces all use id: string. URL builders in API_ROUTES take id: string. The BlogSchema Zod schema uses z.string().
  • Admin TS: DataTable selection state, generic useResourceItem / useUpdateResource / useDeleteResource mutation typings, RelationshipSelectField single value, MultiRelationshipSelectField array values, and handleDelete callbacks all switch from number to string.

Net effect: UUID is the canonical ID type across the entire framework. Any resource generator output, any scaffolded type, any Go signature — all string UUIDs. No more id: number hiding in some corner.

v3.11.0May 2, 2026

Three new desktop primitive files ship with every --desktop scaffold, lifted from a real Grit-built rental management app. They cover the master-detail layout, form chrome, and filter chips that every CRUD page reinvents — saving ~200 LOC per resource.

components/two-pane.tsx — master-detail layout

  • TwoPane — outer flex container with overflow handling.
  • ListPane — fixed-width (352px) left pane with title + count + new button + searchbar + optional filters slot + scrollable body + optional footer. Toolbar slot for refresh buttons or other actions.
  • ListRow — icon/avatar + title + subtitle + right-side meta. Selected state shows a 2px accent bar on the left edge.
  • DetailPane — right pane with optional header + scrollable content. empty=true renders an EmptyState with the configured title/hint instead.
  • EmptyState, DetailSection (small caps section header), and DetailField (labelled value rows for read views).

components/form.tsx — form chrome

  • TextField, TextAreaField, SelectField — forwarded refs, consistent label/hint/error layout, focus ring, disabled styling. Plug straight into react-hook-form.
  • FormGrid — 1, 2, or 3 columns on >=sm; stacks on small screens.
  • FormSection — small caps title + optional description over a stack of fields.
  • FormActions — Cancel + Submit pair with isPending support (button disables, label flips to "Saving...").

components/filter-chip.tsx — filter chips

  • FilterChip — toggleable pill, active state shows accent background; optional onClear renders an X to clear a single filter; optional count renders a small count badge.
  • FilterBar — horizontal scrollable wrapper. Drop intoListPane's filters slot.

Tailwind tokens

  • Added listpane spacing token (22rem / 352px) to the desktop Tailwind config so w-listpane works.
v3.10.0May 2, 2026

Foundation release for upcoming offline-first work. Every scaffolded API now ships with idempotent-retry semantics; every scaffolded client auto-attaches an Idempotency-Key on mutations; and the desktop scaffold gains a connection-status indicator backed by an API heartbeat.

Idempotency middleware (API)

  • New file: internal/middleware/idempotency.go — wired into routes.Setup as a global middleware.
  • Activates only when the request carries an Idempotency-Key header and the method is POST/PUT/PATCH/DELETE.
  • First 2xx response is cached in Redis for 24 hours, keyed by (method, path, key). Subsequent requests with the same key replay the cached response instead of re-executing the handler.
  • Errors (4xx/5xx) are intentionally not cached — clients can retry transient failures with the same key.
  • Sets Idempotent-Replayed: true response header on cache hits so clients can distinguish replays from fresh executions.

Client-side header injection

  • Desktop, Expo, web, and admin clients all auto-attach a UUIDv4 Idempotency-Key on unsafe methods via the request interceptor.
  • The 401-refresh path now reuses the same key when re-issuing a request after a token refresh — so a token expiring mid-write can never double-create.

Online-status hook (desktop)

  • New hook: useOnlineStatus() at frontend/src/hooks/use-online-status.ts.
  • Combines navigator.onLine (cheap pre-check) with a 15-second heartbeat to /api/health (the truth signal). Returns { isOnline, lastCheckedAt }.
  • Heartbeat times out after 5s so a sleeping laptop surfaces as offline instantly on wake.
  • The title-bar gains a ConnectionIndicator — small green/amber dot reflecting API reachability. Hover for last-checked timestamp.

This is the foundation for the offline-first scaffold landing in a later v3.x release — write-queues, optimistic updates, and last-write-wins conflict resolution all need stable idempotency keys to be safe.

v3.9.2April 25, 2026

Every grit generate resource run now emits a List handler that is ~15 lines instead of ~55. The page / sort / search boilerplate moved into a shared internal/paginate package that ships with every scaffolded API — one source of truth for clamping, whitelisting, and search. Addresses issue #14.

New paginate package

  • paginate.List[T](query, paginate.Bind(c), paginate.Config{...}) — typed, generic helper that runs search, sort, filter, and pagination against any *gorm.DB query.
  • paginate.Bind(c) reads page, page_size, search, sort_by, sort_order from the Gin query, clamps page to ≥ 1 and page_size to [1, 100].
  • paginate.Config whitelists sortable columns and declares the searchable column set — requests for columns outside the whitelist fall back to created_at desc.
  • paginate.Result[T] returns the canonical { data, meta: { total, page, page_size, pages } } envelope — matches the existing API response format exactly.

Generator update

  • The emitted List handler now delegates to paginate.List. Every generated resource gets the same clamping, whitelisting, and UUID-safe search behavior — no per-resource drift.
  • Searchable column selection uses IsSearchable() (text / string / slug / richtext only), so FK UUID columns are no longer accidentally included in ILIKE search — a leftover rough edge from issue #12.
v3.9.1April 24, 2026

Patch release fixing compilation and consistency bugs in v3.9.0. Every freshly scaffolded project (including --mobile --desktop) and every grit generate resource run now produces Go code that builds cleanly on the first try. Thanks to issue #9, #10, #11, and #12.

Scaffold fixes

  • Missing imports: added "log" to config.go, "gorm.io/gorm/logger" to user.go, "net/http" to middleware/logger.go.
  • Stray package prefix: removed handlers. qualifier on IsTrustedDevice (same-package call).
  • User ID type consistency: normalized UserID and UploadID to string UUIDs across 2FA models, auth service, TOTP handler (c.GetString("user_id") replaces c.GetUint), jobs package, and upload handler.

Desktop scaffold fixes

  • keychain.go moved from internal/ to the top level (the subdirectory file was declaring package main, which Go rejects).
  • go.mod module path fixed from <project>/apps/api/apps/desktop to <project>/apps/desktop.

Resource generator fixes

  • Service signatures take id string instead of id uint -- matches the UUID string PK the models have always emitted.
  • Handler FK fields, handler M2M arrays, TS interface FK fields, and TanStack hook ID types all switched to string (were uint / number).
  • Initialism-aware toPascalCase / toSnakeCase: owner_id → OwnerID (was OwnerId), image_url → ImageURL (was ImageUrl), api_key → APIKey. Round-trips correctly (snake → pascal → snake).
  • Zod schemas now emit snake_case field names matching the Go handler's JSON tags (previously emitted camelCase, causing validation andShouldBindJSON mismatches).
  • Zod FK and M2M validators use z.string().uuid() instead of z.number().int().
  • FK columns generate with gorm:"size:36;index" (matches UUID PK width).
v3.9.0April 15, 2026

New: --desktop flag

  • Desktop + mobile + API in one monorepo — grit new myapp --mobile --desktop scaffolds a complete multi-client SaaS: Go API shared by an Expo mobile app AND a Wails desktop app. All three share the same packages/shared types and schemas.
  • Wails as a thin client — The new desktop app is a frameless Wails window that calls the shared API over HTTP. No embedded Go business logic, no local SQLite. Wails bindings are used only for native OS features: window controls, file dialogs, and OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) for JWT storage.
  • Distinct from grit new-desktop — The standalone offline-first desktop scaffold (grit new-desktop) is unchanged. --desktop is a new, separate capability for always-online multi-client apps.

Premium Desktop UX

  • Platform-aware window chrome — macOS traffic lights on the left, Windows/Linux controls on the right. Detected at runtime viaGetPlatform() Wails binding.
  • Command palette (⌘K) — every scaffolded desktop app ships with a Raycast/Linear-style command palette. Searchable navigation + actions with keyboard-first UX.
  • Fixed 240px sidebar — not collapsible. Desktop windows are wide enough; collapse toggles are a web pattern.
  • Global keyboard shortcuts — useShortcuts() hook with defaults: ⌘K palette, ⌘, settings, ⌘L logout, Esc to close.
  • More negative space — content padding is 32px(vs web's 24px) for long focus sessions. Subtler shadows (OS chrome already provides elevation).

Style Guide

  • New §14.5 Desktop App Patterns section inGRIT_STYLE_GUIDE.md covering window chrome, sidebar (not collapsible), topbar, command palette, keyboard shortcuts, OS keychain integration, typography (tighter than web), and do's & don'ts (no breadcrumbs, no header banners, no web-style autoplay).

Usage

grit new myapp --mobile --desktop --next
# apps/api + apps/web + apps/expo + apps/desktop

grit new myapp --desktop --triple
# apps/api + apps/web + apps/admin + apps/desktop

grit new myapp --api --desktop
# apps/api + apps/desktop (minimal)
v3.8.0April 11, 2026

Design System

  • GRIT_STYLE_GUIDE.md — First official style guide for all Grit-scaffolded projects. Premium Minimal aesthetic (Linear / Vercel school), Grit purple #6C5CE7 primary, Onest font. Covers typography, color palette, spacing, shadows, every component spec (buttons, inputs, cards, tables, modals), auth page rules, CLI scaffolding design, admin panel patterns, email templates.

Admin Layout

  • Topbar refactor — Moved sidebar collapse toggle to top-left of the topbar (next to mobile menu button). Moved theme toggle, notifications bell, and enhanced user menu to the top-right cluster alongside search. The sidebar now contains only navigation. Matches modern dashboard patterns (Linear, Vercel, Raycast).
  • Enhanced user menu — Dropdown now shows User Activity, Settings, Billing, and Log out sections with a user name/email header.

PageHeader Component

  • Consistent page headers — New <PageHeader /> component at components/layout/page-header.tsx with title, description, breadcrumbs, actions slot, and a 4-card stats grid. Every generated resource page auto-includes it.
  • Auto-generated stats cards — Resource pages now ship with 4 default stat cards (Total, This Week, This Month, Updated Recently) fetched from the API. Override via defineResource({ stats: { cards: [...] } }) or disable with stats: false.

Auth Pages

  • New centered auth variant — grit new myapp --style centered scaffolds Linear-school single-card auth pages (login, sign-up, forgot-password). ~420px card on a subtle radial gradient background. The original split-screen design remains the default (unchanged).
v3.7.0April 3, 2026

Security

  • Security headers middleware — New SecurityHeaders() middleware adds X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy, and HSTS (when HTTPS detected) on every response.
  • Max body size middleware — 10MB default limit returns 413 on exceed.
  • JWT secret validation — Warns if JWT_SECRET is shorter than 32 characters.
  • Sentinel WAF — Now runs in ModeBlock in production (was always ModeLog). Development keeps ModeLog.

Performance

  • GORM AutoMigrate silence — Migration now uses a logger.Silent session to suppress schema inspection SQL noise. Fixes issue #8.

Web App Auth

  • Auth pages for the web app — The web app (apps/web) now ships with its own auth pages: login, register, forgot-password, OAuth callback. Previously only the admin panel had auth. This is critical for e-commerce and SaaS where end users log in on the web app, not the admin.
  • useAuth() hook — React Query + js-cookie token management with AuthProvider context wrapping the web app.

Mobile (Expo)

  • Major Expo scaffold upgrade — 4 tabs (Home, Explore, Profile, Settings) instead of 2. All forms use react-hook-form + zod. Home screen with stat cards and pull-to-refresh. Explore screen with search and category discovery. Settings with SectionList. Profile with display/edit mode.
  • OAuth in mobile — Google OAuth via expo-web-browser with deep-link callback handling.
  • New Expo dependencies — react-hook-form, @hookform/resolvers, zod, expo-image, expo-haptics, expo-web-browser. Splash screen config in app.json.
v3.6.0March 27, 2026

Features

  • Scaffold into current directory — grit new . and grit new ./ now scaffold into the current directory instead of creating a subfolder. Infers the project name from the folder name. Also auto-detects when the current directory name matches the project name.
  • --force flag — Allows scaffolding into non-empty directories. Useful when a repo was cloned first (with README, .git, LICENSE) before scaffolding: grit new . --triple --vite --force.
  • --here flag — Explicit alternative to grit new . for in-place scaffolding.
  • 30 standalone courses — Added 20 new courses to the learning platform (42 total across 3 tracks + 20 standalone). Topics include testing, GORM mastery, WebSockets, Stripe payments, blog/CMS, CI/CD, middleware, and the 100-component UI registry.

Bug Fixes

  • Flags now skip interactive prompt — Running grit new myapp --triple --vite no longer shows the architecture/frontend selection prompt. Flags act as true shortcuts for non-interactive setup.
  • Module path upgrade to /v3 — Fixed go install ...@latest downloading v2.9.0 instead of v3.x. All import paths updated from grit/v2 to grit/v3.
v3.5.0March 26, 2026

Documentation

  • Full docs redesign — Rebuilt the documentation site with a Tailwind CSS-inspired aesthetic. New dark theme (#0b1120), sky-blue accents, cleaner header with backdrop blur, redesigned code blocks with file tabs and line highlighting, and new StepWithCode component for two-column step-by-step guides (text left, code right).
  • Installation page redesigned — Step-numbered sections (01-04) with the new two-column layout, system requirements table, architecture shortcuts, and services grid.
  • Architecture Modes page — Visual cards for all 5 architectures (single, double, triple, API only, mobile) with directory structure trees, features list, ideal use cases, and frontend framework comparison.
  • TanStack Router guide — Complete guide for the TanStack Router frontend option: project structure, routing patterns, comparison table with Next.js, route examples, and admin panel auth guards.
  • New CLI Commands page — Documents grit routes,grit down/up (maintenance mode), and grit deploy. Includes complete command reference table for all 21 CLI commands.
  • Deploy Command guide — Step-by-step deployment pipeline with systemd service unit and Caddyfile examples, flags table.

Improvements

  • Updated skill file with all v3.x architecture modes, frontend options, and new CLI commands.
  • Updated sidebar with new pages: Architecture Modes, New CLI Commands, TanStack Router, Deploy Command.
  • Frontend sidebar section renamed from “Frontend (Next.js)” to “Frontend” to reflect multi-framework support.
v3.4.0March 26, 2026

Features

  • Multi-architecture code generator — grit generate resourcenow works for all 5 architecture modes and both frontend frameworks. Generates Go model, service, and handler at the correct path (internal/ for single app,apps/api/internal/ for monorepo). Generates React Query hooks and admin resource pages for both Next.js and TanStack Router.
  • grit.json project manifest — Every scaffolded project now includes a grit.json file at the root with architecture andfrontend fields. The generator reads this to determine correct file paths and template variants, eliminating fragile filesystem heuristics.
  • TanStack Router resource generation — When generating resources in a TanStack Router project, creates route files atsrc/routes/_dashboard/resources/ using createFileRoute instead of Next.js app/(dashboard)/resources/ page convention.
v3.3.0March 26, 2026

Features (Goravel-Inspired)

  • grit routes — List all registered API routes in a formatted table. Parses routes.go and shows method, path, handler, and middleware group (public/protected/admin). Works for both monorepo and single app projects.
  • grit down / grit up — Maintenance mode.grit down creates a .maintenance file that triggers the new maintenance middleware, returning 503 for all requests. grit up removes it and resumes normal operation.
  • grit deploy — One-command production deployment. Cross-compiles for Linux, builds frontend, uploads binary via SCP, configures a systemd service, and optionally sets up Caddy reverse proxy with auto-TLS. Supports--host, --domain, --key flags orDEPLOY_HOST/DEPLOY_DOMAIN/DEPLOY_KEY_FILE env vars.
  • Maintenance middleware — All scaffolded projects now include aMaintenance() Gin middleware that checks for a .maintenancefile on every request. Runs as the first global middleware.
v3.2.0March 26, 2026

Features

  • Single app architecture — grit new my-app --single creates a single Go binary that serves both the API and an embedded React SPA. Uses go:embedto bake the built frontend into the binary at compile time. One file to deploy. Dev mode runs Go on :8080 and Vite on :5173 with API proxy.
  • Parameterized API paths — All Go API file generators now useopts.APIRoot() and opts.Module() helpers, enabling the same template functions to generate files for both monorepo (apps/api/) and single app (project root) architectures.

Single App Structure

  • cmd/server/main.go — Entry point with go:embed frontend/dist/* and SPA fallback routing
  • internal/ — Full Go backend (same as monorepo API)
  • frontend/ — React + Vite + TanStack Router SPA
  • Makefile — make dev (parallel servers), make build (single binary)
v3.1.0March 26, 2026

Features

  • TanStack Router frontend scaffold — When selecting TanStack Router (Vite) as your frontend, both the web app and admin panel are now fully scaffolded with Vite + TanStack Router + React Query + Tailwind CSS. Includes file-based routing via @tanstack/router-vite-plugin, API proxy in dev mode, and all the same features as the Next.js scaffold.
  • TanStack Router admin panel — Complete admin panel with TanStack Router: auth pages (login, sign-up, forgot password), dashboard layout with sidebar, resource management (users, blogs) via ResourcePage component, system pages (jobs, files, cron, mail, security), profile page. All existing React components (DataTable, FormBuilder, widgets) are reused with automatic"use client" directive stripping.
v3.0.0March 26, 2026

Features

  • Interactive project creation — grit new my-app now launches an interactive prompt to select your architecture and frontend framework. Power users can skip with flags: --single --vite, --triple --next,--api, etc.
  • 5 architecture modes — Choose the project structure that fits your team:Single (Go API + embedded React SPA, one binary),Double (Web + API Turborepo),Triple (Web + Admin + API Turborepo),API Only (Go backend, no frontend),Mobile (API + Expo React Native).
  • Frontend framework choice — Pick between Next.js (SSR, App Router) and TanStack Router (Vite, fast builds, small bundle, SPA). Available for all architecture modes that include a frontend.

Breaking Changes

  • Options struct refactored — The internal Options struct now uses Architecture and Frontend enum fields instead of boolean flags. Legacy flags (--api, --mobile, --full) still work via the Normalize() migration layer.
v2.9.0March 26, 2026

Features

  • Two-Factor Authentication (TOTP) — Every grit new project now includes a complete 2FA system with authenticator app support (Google Authenticator, Authy, 1Password, etc.). Zero-dependency RFC 6238 implementation with HMAC-SHA1. Includes setup flow with QR code URI generation, 6-digit code verification with ±1 window clock skew tolerance, and seamless integration with the existing JWT login flow.
  • Backup Codes — 10 one-time-use recovery codes generated when enabling 2FA. Each code is individually bcrypt-hashed for storage. Codes can be regenerated at any time (invalidates previous set). Use during login as an alternative to the authenticator app.
  • Trusted Devices — “Remember this device” option during TOTP verification. Sets an HttpOnly cookie with a SHA-256 hashed token stored in the database. Trusted devices last 30 days with sliding expiry (refreshed on each use). Users can revoke all trusted devices from their account.

New Endpoints

  • POST /api/auth/totp/setup — Generate TOTP secret + QR URI (authenticated)
  • POST /api/auth/totp/enable — Verify initial code and activate 2FA
  • POST /api/auth/totp/verify — Verify TOTP code during login (public, uses pending token)
  • POST /api/auth/totp/backup-codes/verify — Use backup code during login
  • POST /api/auth/totp/disable — Disable 2FA (requires password)
  • GET /api/auth/totp/status — Check 2FA status, remaining backup codes, trusted device count
  • POST /api/auth/totp/backup-codes — Regenerate backup codes
  • DELETE /api/auth/totp/trusted-devices — Revoke all trusted devices
v2.8.0March 16, 2026

Features

  • Vercel AI Gateway integration — Replaced the multi-provider AI service (Claude, OpenAI, Gemini with separate API implementations) with Vercel AI Gateway. One API key now gives access to hundreds of models from all major providers through a single OpenAI-compatible endpoint. Models use the provider/model format (e.g. anthropic/claude-sonnet-4-6, openai/gpt-5.4, google/gemini-2.5-pro). Includes automatic retries, fallbacks, spend monitoring, and zero markup on tokens.

Breaking Changes

  • AI environment variables — AI_PROVIDER, AI_API_KEY, and AI_MODEL have been replaced with AI_GATEWAY_API_KEY, AI_GATEWAY_MODEL, and AI_GATEWAY_URL. Update your .env file accordingly. Get your API key from vercel.com/ai-gateway.
v2.7.0March 10, 2026

Features

  • 10 Official Plugins — New grit-plugins ecosystem with drop-in Go packages for common functionality: WebSockets (grit-websockets), Stripe payments (grit-stripe), OAuth social login (grit-oauth), notifications (grit-notifications), full-text search (grit-search), video processing (grit-video), WebRTC conferencing (grit-conference), outgoing webhooks (grit-webhooks), i18n translations (grit-i18n), and PDF/Excel/CSV export (grit-export). Each plugin includes a Claude Code skill file for AI-assisted integration.
  • Claude Code Skills format — Updated the scaffolded AI skill file from a monolithic GRIT_SKILL.md to the official Claude Code skills directory structure (.claude/skills/grit/SKILL.md + reference.md) with YAML frontmatter. AI assistants can now discover and use Grit conventions automatically.
  • Grit UI component registry (100 components) — Expanded from 91 to 100 pre-built components across 5 categories: marketing (21), auth (10), SaaS (30), ecommerce (20), and layout (20).

Documentation

  • New Plugins page — overview of all 10 plugins with installation, environment setup, quick start code, features, and use cases for each.
v2.6.0March 6, 2026

Fixes

  • GORM Studio (Desktop) — Replaced the broken custom HTML studio with the real gorm-studio package. Desktop studio now runs on port 8080 at /studio using Gin + gorm-studio, matching the web scaffold. Auto-opens browser on launch.
v2.5.0March 6, 2026

Features

  • GRIT_SKILL.md — Desktop scaffolds now include a GRIT_SKILL.md file in the project root. This is a comprehensive AI reference (12 sections) covering architecture, CLI commands, resource generation, field types, code markers, golden rules, and common LLM mistakes — so AI assistants can work with the project correctly out of the box.
  • Comprehensive README — The scaffolded README.md now includes a full project walkthrough, “Adding a New Module” guide, supported field types table, customization section (window size, title bar, database, app name), code markers reference, and a ready-to-use AI prompt for building a Task Manager app.

Fixes

  • Dashboard stats cache — Dashboard statistics now update immediately after creating a blog or contact. Changed query keys from ["blogs-stats"] to ["blogs", "stats"] so TanStack Query's prefix matching invalidates dashboard queries when resources are created or deleted.
v2.4.0March 5, 2026

Features

  • Window controls on auth pages — Login and register pages now include minimize, maximize, and close buttons with a draggable title area, so users can move and manage the window before signing in.
  • Show/hide password toggle — All password fields on login and register pages now have an eye icon toggle to reveal or hide the password text.

Fixes

  • Desktop build script — Removed tsc from the frontend build script. TanStack Router's Vite plugin generates routeTree.gen.ts during the Vite build, so running tsc before Vite caused Cannot find module './routeTree.gen' errors.
  • Title bar import path — Fixed the Wails binding import in title-bar.tsx from a 2-level to 3-level relative path.
  • Auth hook file extension — Renamed use-auth.ts to use-auth.tsx so TypeScript handles the JSX correctly.
  • Create resource cache refresh — Blog and contact create pages now invalidate the React Query cache before navigating back, so new records appear in the table immediately.
v2.2.0March 4, 2026

Fixes

  • Desktop auth hook file extension — Renamed the scaffolded use-auth.ts to use-auth.tsx so TypeScript correctly handles the JSX in <AuthContext.Provider>. Previously, grit new-desktop projects would fail to compile with TS1005: '>' expected errors.

Documentation

  • Added Desktop Handbook PDF download links to all 8 desktop documentation pages.
v2.1.0March 4, 2026

Features

  • TanStack Router for desktop — Migrated the desktop frontend from React Router to TanStack Router with file-based routing. Routes are auto-discovered by the Vite plugin — no centralized route registry. Uses createHashHistory() for Wails compatibility and Route.useParams() for type-safe params. Resource generation now creates 5 files (list, new, edit routes + model + service) and performs 10 injections (down from 12).
  • Mobile navigation — Added a hamburger menu to the docs site header, visible below the lg breakpoint. Opens a Sheet sidebar with all navigation links. Auto-closes on link click.
  • CGO-free SQLite — Replaced gorm.io/driver/sqlite (requires CGO) with github.com/glebarez/sqlite (pure Go) in all scaffold templates. Desktop apps now build and run without CGO or a C compiler.
  • 20 Desktop Project Ideas — New project ideas page with 20 ready-to-build desktop app ideas across business, education, healthcare, logistics, and more. Each includes resources, field definitions, and grit generate commands.

Documentation

  • Added TanStack Router explanations to all desktop doc pages: overview, getting started, first app, resource generation, and POS app.
  • Updated LLM Reference, GRIT_SKILL.md, and database docs to reflect TanStack Router and CGO-free SQLite changes.
v2.0.0March 4, 2026

Features

  • Native desktop apps (Wails) — New grit new-desktop command scaffolds a complete desktop application with Go backend, React frontend (Vite + TanStack Router + TanStack Query), SQLite database, JWT authentication, blog and contact CRUD, PDF/Excel export, custom title bar, dark theme, and GORM Studio. Compiles to a single native executable for Windows, macOS, and Linux. See Desktop docs.
  • Desktop resource generation — grit generate resource now works inside desktop projects. Generates Go model, service, and TanStack Router route files (list, new, edit), then injects code into 10 locations (db.go, main.go, app.go, types.go, sidebar.tsx, studio/main.go) using grit: markers. See Desktop Resource Generation.
  • Project type auto-detection — All CLI commands now auto-detect whether you are inside a web (Turborepo) or desktop (Wails) project. No flags needed.
  • grit start for desktop — Running grit start inside a desktop project launches wails dev with hot-reload for both Go and React.
  • grit compile — New command that runs wails build to produce a distributable native binary.
  • grit studio — New command that launches GORM Studio. For desktop projects it starts a standalone server on port 4000. For web projects it opens the browser to the embedded Studio route.
  • grit remove resource for desktop — Removes a previously generated desktop resource, deleting files and reversing all 10 marker injections.
  • Grit UI component registry (91 components) — Every scaffolded web project now includes a shadcn-compatible component registry with 91 pre-built components across 5 categories: marketing (14), auth (10), SaaS (30), ecommerce (20), and layout (18). Install via npx shadcn@latest add from /r endpoints.

Documentation

  • New Desktop (Wails) section — 8 pages covering overview, getting started, first app tutorial, POS app tutorial, resource generation, building/distribution, project ideas, and LLM reference.
  • Updated LLM Reference with complete desktop section: project structure, CLI commands, markers, and architecture comparison.
v1.4.0March 2, 2026

Features

  • Gzip response compression — All API responses are now compressed automatically via a custom Gzip() middleware using the Go standard library compress/gzip at BestSpeed. JSON payloads shrink by 60–80%, reducing bandwidth on paginated list endpoints with zero external dependencies.
  • Request ID tracing — A RequestID() middleware injects a unique X-Request-ID header on every request (echoes the upstream header or generates a nanosecond-based ID). The ID is stored in Gin context and included in every structured log line for end-to-end request tracing.
  • Database connection pool tuning — The scaffold now sets four GORM pool parameters: MaxIdleConns(10), MaxOpenConns(100), ConnMaxLifetime(30m), and ConnMaxIdleTime(10m). This prevents stale connections after network interruptions and avoids connection exhaustion under load.
  • Cache-Control headers on public blog endpoints — The ListPublished handler now returns Cache-Control: public, max-age=300 (5 minutes) and GetBySlug returns Cache-Control: public, max-age=3600 (1 hour). CDNs and edge caches can now serve public blog content without hitting the Go API.

Documentation

  • New Performance page — comprehensive guide to all backend (Go/API) and frontend (Next.js) performance optimisations that ship with every Grit project out of the box. Covers Gzip, Request ID, connection pool, Cache-Control, presigned uploads, background jobs, Redis caching, Server Components, ISR, React Query, next/image, Turborepo, and code splitting.
  • New Complete LLM Reference page — a dedicated machine-readable guide that teaches AI assistants everything about Grit: project structure, all CLI commands, every field type, code patterns, API response format, code markers, naming conventions, all batteries, performance features, and the golden rules that must never be broken.
v1.3.0February 26, 2026

Features

  • Presigned URL uploads — File uploads now bypass the API server entirely. The browser gets a presigned PUT URL, uploads directly to S3/R2/MinIO, then records the upload in the database. This fixes file uploads breaking behind reverse proxies (Dokploy/Traefik/Nginx) due to request body size limits and timeouts. Includes progress tracking via XHR.
  • Error pages for scaffolded apps — New grit new projects now include error.tsx, not-found.tsx, and global-error.tsx for both admin and web apps. Errors are displayed with styled UI instead of the default Next.js error page.
  • Production-ready Docker config — docker-compose.prod.yml now uses expose instead of ports, env_file for secrets, MinIO service, named bridge network, build args for NEXT_PUBLIC_API_URL, and Go 1.24.
  • Sentinel ExcludePaths — Pulse, GORM Studio, Sentinel, and API docs paths are now excluded from rate limiting by default, fixing Pulse health checks triggering rate limits.

Documentation

  • New Create without Docker guide — set up a Grit project using Neon, Upstash, Cloudflare R2, and Resend instead of Docker.

Infrastructure

  • Scaffold Dockerfile updated from Go 1.23 to Go 1.24
  • Next.js Dockerfile now accepts NEXT_PUBLIC_API_URL as a build argument
  • .env template includes Docker Compose production variables (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, API_URL)
v1.1.0February 25, 2026

Features

  • Default font changed to Onest — New projects scaffolded with grit new now use the Onest Google Font for all UI text instead of DM Sans. JetBrains Mono remains the code font. The font is loaded via next/font/google with weights 400, 500, 600, and 700.
  • Hire Us page — New /hire page for professional Grit development services. Includes service offerings, tech stack overview, and contact CTA.
  • Monetization banners — Docs sidebar now shows promotional cards for GritCMS, developer hiring services, and donations — visible on every documentation page.
  • Grit Fullstack Course page — New /course page with a 10-module curriculum covering Go, React, Next.js, and the full Grit stack.

Improvements

  • Top navigation now includes GritCMS, Hire Us, and a Sponsor heart icon for quick access to all revenue channels.
  • richtext added to the FieldType union for better type safety in the code generator.

Bug Fixes

  • OAuth callback fix — Fixed TokenPair struct field access in the social login callback handler (was using map indexing instead of struct fields).
  • Course waitlist fix — Fixed Google Sheets submission to use form-encoded data instead of JSON.

Documentation

  • New CLI Cheatsheet page — complete reference for all Grit CLI commands with flags, field types, generated files, common workflows, and full command tree.
  • New Social Login (OAuth2) setup guide for Google and GitHub authentication.
  • Updated Docker Cheat Sheet with force remove commands for containers and volumes.
  • Updated AI skill guide with social login (OAuth2) section.
v1.0.0February 24, 2026

Features

  • Social Login (Google + GitHub) — Every grit new project now includes OAuth2 social authentication via Gothic. Users can sign in with Google or GitHub on all auth pages (login, register, admin). Accounts are linked by email — existing users who sign in with a social provider are automatically connected. Configurable via GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID environment variables.
  • GORM Studio v1.0.1 — Updated to the first stable tagged release of GORM Studio.

Improvements

  • User model now includes Provider, GoogleID, and GithubID fields for social account linking. Password field is now nullable to support OAuth-only accounts.
  • Admin users table shows Provider column with badges (Email, Google, GitHub) and new filter option.
  • Social login buttons (Google + GitHub) appear on all 4 admin style variants (default, modern, minimal, glass).
v0.19.0February 24, 2026

Fixes

  • gin-docs AuthConfig — Updated scaffold template to use the new gindocs.AuthConfig struct instead of the deprecated gindocs.AuthBearer constant, fixing compilation errors in newly scaffolded projects.

Documentation

  • New Your First App tutorial — step-by-step Contact Manager guide covering project setup, resource generation, and CRUD
  • New Dokploy Deployment guide with Dockerfile examples
  • Improved terminal blocks across all tutorials with copy buttons and horizontal scroll
  • Updated API Documentation page to reflect the new AuthConfig struct format
v0.18.0February 22, 2026

Features

  • Pulse (Observability) — Every grit new project now includes Pulse, a self-hosted observability SDK. Provides request tracing, database monitoring, runtime metrics, error tracking, health checks, alerting, Prometheus export, and an embedded React dashboard at /pulse. Enabled by default, configurable via PULSE_ENABLED. See Pulse docs.

Documentation

  • New Pulse (Observability) page covering configuration, endpoints, health checks, alerting, Prometheus metrics, and data storage
v0.17.0February 22, 2026

Features

  • API Documentation (gin-docs) — Replaced hand-written Scalar/OpenAPI spec with gin-docs, a zero-annotation API documentation generator. Routes and GORM models are introspected automatically to produce an OpenAPI 3.1 spec with interactive Scalar or Swagger UI, plus Postman and Insomnia export.
  • Dark/Light mode for Go Playground — The playground now follows the site-wide theme toggle, switching between VS Code dark and light CodeMirror themes.
  • Umami Analytics — Optional visitor analytics via self-hosted Umami, configured with NEXT_PUBLIC_UMAMI_WEBSITE_ID environment variable.

Documentation

  • New API Documentation page covering gin-docs configuration, GORM model schemas, route customization, UI switching, and spec export
  • Full SEO + AEO implementation: sitemap, robots.txt, JSON-LD structured data, per-page metadata

Infrastructure

  • Added Dockerfile for docs site deployment (Next.js standalone output)
  • Google Search Console verification
v0.16.0February 21, 2026

Features

  • Go Playground — Interactive code editor at /playground with Go syntax highlighting, code execution via the official Go Playground API, example snippets, share links, and keyboard shortcuts (Ctrl+Enter to run).
  • GORM Studio updated — Updated to latest version with raw SQL editor, schema export (SQL/JSON/YAML/DBML/ERD), data import/export (JSON/CSV/SQL/XLSX), and Go model generation from database schema.

Documentation

  • Go for Grit Developers — comprehensive rewrite with 22 sections covering methods, Gin routing, middleware, CORS, handler/service architecture, GORM CRUD, migrations, seeding, JWT auth flow, and RBAC
  • Fixed right-side table of contents for the Go prerequisites page
  • New Middleware and CORS sections added to Go guide
v0.15.0February 20, 2026

Features

  • Security (Sentinel) — Every grit new project now ships with a production-grade security suite powered by Sentinel. Includes WAF, rate limiting, brute-force protection, anomaly detection, IP geolocation, security headers, and a real-time threat dashboard at /sentinel/ui. See Security docs.
  • Admin security page — New System → Security page in the admin panel embeds the Sentinel dashboard for monitoring threats without leaving the admin UI.

Documentation

  • New: Security (Sentinel) documentation page
  • Migrated getting-started pages (Installation, Quick Start, Troubleshooting) to use CodeBlock component
  • Added prerequisite learning pages for Go, Next.js, and Docker
v0.14.0February 18, 2026

Features

  • Multi-step forms — New formView: "modal-steps" and "page-steps" variants with horizontal/vertical step indicators, per-step validation, progress bar, and clickable step navigation. See Multi-Step Forms.
  • Standalone component usage — FormBuilder, FormStepper, and DataTable can now be used on any page in both web and admin apps without the resource system. See Standalone Usage.
  • Richtext field type — New richtext field with Tiptap WYSIWYG editor (bold, italic, headings, lists, code blocks, links, undo/redo).
  • string_array field type — Store arrays of strings using datatypes.JSONSlice[string]. Works with PostgreSQL and SQLite. Maps to string[] in TypeScript and z.array(z.string()) in Zod.
  • Built-in blog example — grit new now scaffolds a complete blog with model, service, handler, seed data, public web pages, and admin resource definition.
  • Sidebar user avatar — Admin sidebar shows the current user's avatar with a dropdown menu for profile and logout.
  • Profile avatar upload — Profile page now supports avatar image upload.
  • react-hook-form in web app — Web app scaffold now includes react-hook-form as a dependency, enabling standalone FormBuilder usage out of the box.

Bug Fixes

  • Scalar API docs crash — Fixed c.String treating HTML as a format string. Now uses c.Data to avoid panics when Scalar HTML contains % characters in CSS/JS.
  • Blog route conflict — Admin blog CRUD routes moved from /api/blogs to /api/admin/blogs to avoid conflict with public blog routes.
  • Select dropdown styling — Fixed relationship select dropdown rendering behind modals using portal-based positioning.

Documentation

  • New: Build a Product Catalog tutorial — resource generation, multi-step forms, standalone DataTable & FormBuilder
  • New: Multi-Step Forms guide
  • New: Standalone Usage guide
  • New: Changelog page
  • Updated CLI Commands, Code Generation, Quick Start, Resources, Shared Package, Web App, Seeders, and Forms pages
v0.12.0February 2026

Features

  • Relationship support — New belongs_to and many_to_many field types for the code generator. Automatically creates foreign keys, junction tables, and relationship-aware form fields.
  • Relationship select fields — New relationship-select and multi-relationship-select form field components with search, portal-based dropdowns, and tag-based multi-select.
  • Beginner tutorial — "Learn Grit Step by Step" tutorial walking through building a full-stack app from scratch.
v0.11.0February 2026

Features

  • Full-page form view — New formView: "page" option renders forms as dedicated pages instead of modals.
  • slug field type — Auto-generates URL-friendly slugs with unique suffixes. Excluded from create/update forms and Zod schemas.
  • DataTable column customization — Hide/show columns, column visibility toggle in table toolbar.
  • grit start commands — grit start client and grit start server for running frontend and API separately.
v0.10.0January 2026

Features

  • Style variants — --style flag for grit new with 4 admin panel styles: default, modern, minimal, and glass.
  • Air hot reloading — Go API development with automatic rebuild on file changes using Air.
  • grit remove resource — Remove a generated resource and clean up all injected code (model, handler, routes, schemas, types, hooks, admin pages).
  • AI workflow docs — Guides for using Grit with Claude and Antigravity AI assistants.