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.
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: { // addlabel: "Photo",after: "sku",cell: (variant) => <Thumb src={variant.images?.[0]?.url} />,},sku: { label: "Barcode" }, // rename a built-inoverride: { 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.
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.
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.
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.
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:stringresource "OrderItem" not found (generate it first): no model foundfor "OrderItem": generate the resource first (looked inapps/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.
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.
Installing a Grit UI block into a scaffolded app started an interactive setup instead of installing anything.
cd apps/webnpx 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.
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 propertiesfrom 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.
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 blockinternal/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.tsimport 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.
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.
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 keyconstraint "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.
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.
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.
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" --treename parent_id path depthElectronics "" /1/ 0Cameras 1 /1/2/ 1Lenses 2 /1/2/3/ 2# everything under Electronics, one indexed comparisonWHERE 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,3GET /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.
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 filterableGET /public/products?name=KettleGET /public/products?price_min=400&price_max=800GET /public/products?category_id=<id>&sort_by=price&sort_order=asc# held back, so ignored rather than appliedGET /public/products?stock=0 -> all 24 rowsGET /public/products?cost_price=0 -> all 24 rowsGET /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.
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 oneDB: 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.
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 31 -> 200 X-RateLimit-Remaining: 22 -> 200 X-RateLimit-Remaining: 13 -> 200 X-RateLimit-Remaining: 04 -> 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.
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 whitelistingcurl -X OPTIONS .../api/v1/public/products -H "Origin: https://myshop.com"(no Access-Control-Allow-Origin)# whitelist it in the admin, no restartcurl -X PUT .../api/v1/settings -d '{"values":{"cors.origins":"https://myshop.com"}}'# immediatelyAccess-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.
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.
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, stockAdd 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.
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:
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.
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:
- name: statustype: selectoptions: [draft, submitted, approved, shipped, cancelled]workflow:initial: draftterminal: [shipped, cancelled]transitions:- action: submitfrom: [draft]to: submitted- action: approvefrom: [submitted]to: approvedpermission: orders.approve- action: cancelfrom: [draft, submitted, approved]to: cancelledconfirm: 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.
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:
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.
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.
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.
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.
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.
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.
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.
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.tsxThe 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.
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.
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.
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.
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 againThe 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.
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.
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 inputThe 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.
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" (...)
commitThree 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.
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.
PrepareStmtis on by default. A query that runs a thousand times is planned once per connection instead of a thousand times. Disable withDB_PREPARED_STATEMENTS=falseif you run pgbouncer in transaction mode, where server-side prepared statements do not survive.DB_SKIP_DEFAULT_TRANSACTION=truedrops 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.
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_URLunset — the local default, which is what most dev setups wantREDIS_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.
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.
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 /uploadsis multipart rather than JSON,POST /webhooks/:providertakes 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, androutes.gois 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.
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-factorscreen, 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-clipboardfor 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.
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 onbg-bg-secondary, some onbg-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, andw-fullwould stretch it across the row. - The confirm-to-delete field now uses the slot's own
invalidstate rather than carryingfocus:border-dangeralongside the slot'sfocus: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.
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.
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.
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 newverifyTOTPcompletes 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.
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
/docsalongside 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.
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_…orAuthorization: Bearer grit_…. The middleware populates exactly what the JWT middleware does, so every existing handler andRequireRolecheck 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_attracking. 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.
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.
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-emailconsumes the token;POST /auth/verify-email/sendre-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=trueto 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-emailpage 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.
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:prunejob trims entries pastAUDIT_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.
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.
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-deviceslists them (the status endpoint only ever returned a count, which you cannot act on) andDELETE /auth/totp/trusted-devices/:idrevokes 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.
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 searchedid::text, another Postgres-only form. Both are nowLOWER(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(orS3_PUBLIC_URL,B2_PUBLIC_URL,MINIO_PUBLIC_URL, or a sharedSTORAGE_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.
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:zipcould 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 ownaccepts. It was both too strict (rejecting a field's declared types) and too loose (a field declaredfile:pdfwould presign a PNG). Presign now honoursaccepts, 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_urlread as “Portfolio U R L”; it now reads “Portfolio URL”, andAPIKeyas “API Key”. - The auth response shape in the API reference was wrong — tokens are nested under
data.tokens, notdata.
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 Productnow also registers list, create, read, update and delete with typed request bodies and response schemas.grit remove resourcetakes 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.
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-weband@expo/metro-runtimeat the versions Expo SDK 54 expects.pnpm webnow bundles and serves. - Fixed a React duplication on web. The Metro resolver deduped
reactbut notreact-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 --fullhelp 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.
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:
buttonandinput, atcomponents/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/, sogrit swap button --revertis 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 swaprunstscafter 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.
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/yyyyinput 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; newminDate/maxDatefield 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
--accentas a hex, and Tailwind cannot inject an alpha channel into a barevar()— sobg-accent/10andtext-accent/80compiled away entirely. The selected row had no fill at all. These now usecolor-mix, which follows whichever theme is active.
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; setallowCreate: falseon 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 bothmodal-stepsandpage-steps; creating still submits once at the end, because a record that does not exist yet has nothing to PATCH against. SetperStepSave: falseon the form to keep the old single-submit behaviour.
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 listandgrit ui add. Components are written intocomponents/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.jsonworks 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 theJSXnamespace 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
mainprinted it again. Long-standing, affected every command.
10 new tests. Matrix 73/0.
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 testworks 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.
--e2eturns it on. - Respects your setup rather than second-guessing it. When the root
package.jsondefines atestscript, 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>=falsein.env, not withgrit newflags.
10 new tests. Verified on the api, single and triple architectures. Matrix 73/0.
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 underapps/api),grit_list_routes(every route with its full path, handler and access level, filterable by method or substring), andgrit_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 routeswas printing every path one segment short. Routes mount underr.Group("/api/" + APIVersion), and the parser only understood string literals — so the prefix evaluated to nothing and/api/v1/userswas reported as/users. Wrong in the worst way, because it looks right. The parser now resolves string constants, including insideconst (…)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:
openapiandrecent_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.
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 -lclean, 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 buildon 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.
Primary keys are now UUIDv7.
- A new
internal/idspackage, and every model uses it.ids.New()replacesuuid.New().String()in all 36 places Grit mints an identifier — scaffolded models, generated resources, the desktop app and its offline sync engine, and thesaved-viewsandmultitenantplugins. 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 idthat means “oldest first” without a second index oncreated_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/idsis one small file with one function; changeNew()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.Activecould never be stored as false. The sixth instance of thegorm:"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.
A linter that starts green, and a connection pooler.
.golangci.ymlin 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, unclosedsql.Rows, uncheckedRows.Err(), requests built with no context,nilreturned after an error check — plusgovetwith 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.
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 asfalsethrough a create: GORM omits zero-valued fields when the column has a default, so the database default wins. That silently flippedUser.Active,FormShare.EnabledandBackupSchedule.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.gonow 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 themedConfirmModal(admin) or the promise-baseduseConfirm()the desktop scaffold already shipped but two of its own pages ignored. - A ten-point “Nielsen Pass” added to
GRIT_STYLE_GUIDE.mdas 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.
Enterprise SSO — sign in with your customer's identity provider.
- OpenID Connect, one connection per customer. Add a connection in
System → Single sign-onwith 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_identitiestable matches on the IdP's immutablesubfirst, 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.
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
v1group inroutes.go, driven by anAPIVersionconstant. 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 av2group besidev1, leavev1answering 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, withDeprecation: trueand aLink: </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_VERSIONand 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/wsstays 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.
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 ininternal/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 withbackHref, or passbackHref={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 newtable.rowActionsextension point — give it alabelplus anhref(row)oronClick(row), optionallyvariant: "danger"and avisible(row)predicate. - Better browser print, too. Proper
@pagemargins, ink-friendly colors, repeated table headers across pages, and no rows split down the middle.
Matrix 73/0.
A developer-defined Generate button for form fields.
generateon a text / number field. Give a field agenerate: (values) => string | numberfunction 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. Usenumber:string:autowhen a value should be assigned silently on the server and hidden from the form; reach forgeneratewhen the user should see the field and trigger generation themselves. You wiregenerateby hand in the resource definition — the generator never emits it. Works in modal, full-page, and multi-step forms; both admins.
Matrix 73/0.
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.
Clickable table columns.
onClickon any column. Two behaviors are built in —onClick: "link"opens the row's detail page (with a hover open arrow), andonClick: "copy"copies the cell value to the clipboard with a check-mark flash — or passonClick: (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 withformat,badge, and customcell.- 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.
Auto-numbered fields in one modifier: number:string:auto.
- The
autofield modifier. Declare a field asnumber:string:auto:INVand Grit does everything an auto-generated identifier needs: it stands up the atomicinternal/sequencecounter package (and registers its table with AutoMigrate), generates the model'sBeforeCreatehook to fill the field asINV-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:autoderives 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?”
autois a shortcut overgrit generate sequence; the hook calls the genericsequence.Nextdirectly (never theserviceswrapper — that would be a models→services import cycle), so generated projects compile clean. Reach forgrit generate sequencedirectly 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
autoworks the same however you invoke it.
Matrix 73/0.
Searchable select fields, and a fixed Access Reviews icon.
- Select is now a searchable combobox. Every generated
selectfield — including command-generated ones like astatusdropdown — 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
UserCheckicon was missing from the sidebar's internal icon map, so the entry rendered blank. Added it; the icon now shows.
Matrix 73/0.
A radio field type, and “New child” buttons on detail pages.
radiofield type. A single choice rendered as a radio-button group — same Gostring, Zodz.enum, and TS union asselect, 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_toit; 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 newdefaultsprop 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.
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 twogrit g resourcecalls with an explicitbelongs_toif you'd rather build the pieces yourself. - Auto-numbering. Documents
grit generate sequence— atomic, gap-free numbers likeINV-202607-0001backed by a DB counter — and how to callNextInvoiceNumberfromBeforeCreateso 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-areaand 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.
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:textIt 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.
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. Gostring, Zodz.enum([…]), a TS string-literal union, and a dropdown in the admin form.check:v=Label|…— many choices. Godatatypes.JSONSlice[string],z.array(z.enum([…])), and a checkbox group.toggle— an on/off boolean rendered as a switch. A bare option value likein_progressis 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.)
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--fullflag. - 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.
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.
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 fromFIELD_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.
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-exportreturns 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/gdprhard-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 carryinggorm.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.
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.revokeevent 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.
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) withstatus_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_actionfor 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.
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
SHA256SUMSfile 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 verifythat 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.
govulncheckgates 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.
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-passwordreturns 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_KEYis 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.
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.
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-subscriptionwhsec_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.
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.
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.
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
viewnow 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
radioandcheckboxfield 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.
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>.viewgrant. 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.
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.
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).
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-srcandimg-src, defaulting to local MinIO; setNEXT_PUBLIC_STORAGE_URL/VITE_STORAGE_URLto 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 migrateno longer prints three scaryrecord not foundlines while seeding the default roles — that was the seeder's normal “does this role exist yet?” check, now quiet.
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.
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.
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.
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.
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.
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.
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 multitenantA 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.
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.
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.
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.
Roles & permissions API. Scaffolded projects now expose the endpoints the admin UI (shipping next) is built on:
GET /api/permissions— the catalog treeGET|POST /api/roles,GET|PUT|DELETE /api/roles/:idPUT /api/users/:id/roles— assign roles to a userGET /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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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 thinSeed()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 ... --seedemits the seeder alongside the resource. - Faker. Add
--faker(and--count N, default 10) to generate a loop that fills rows withgofakeit— 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 seedruns 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.
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.
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
.npmrcsetsnode-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
tsconfignow excludesvitest.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.overridesfield topnpm-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.
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.
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_atand 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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://wailsThe 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).
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.
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/androutes/_auth/, but a leading underscore makes a TanStack pathless layout — so_app/index.tsxresolved to/and collided withroutes/index.tsx. The generator errored ("Conflicting configuration paths") and never wroterouteTree.gen.ts, which cascaded into acreateFileRouteerror on every route. Renamed to real segments (routes/app/,routes/auth/), matching the/app/...and/auth/loginlinks the app already used. - The build script ran in the wrong order.
tsc -b && vite buildtypechecked before Vite's router plugin generatedrouteTree.gen.ts, so a fresh clone always failed. Nowvite build && tsc -b(tsc still gates the build). - Half the Wails bindings weren't typed. The
window.go.main.Appdeclaration invite-env.d.tslisted 13 methods and omitted every sync binding (LocalCreate,Sync,PendingCount,ResolveConflict…) plus the offline-mode ones — even thoughsync-client.tscalled them. All are declared now. - Wails couldn't generate bindings for
SyncResult. Itstime.Timefields made the generator printNot found: time.Timeand drop the models. They're RFC3339 strings now — identical JSON, sincetime.Timealready 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.
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.
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-AgentChrome/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_Basicmatched 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 transactionRestore 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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 stringshidden_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.
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 inmiddleware/activity.go(HTTP audit logger); CORS Access-Control-Allow-Headers extended to allow the hint. - Admin:
lib/api-client.tsfetches + caches the public IP, attaches the hint; activity page'sprettyIPhelper renders "localhost" for loopback so fall-back rows still read cleanly. - Web:
components/navbar.tsx+ auth variant gain the Admin button;components/dev-links.tsxnew file;app/page.tsxrenders<DevLinks />at the bottom. - Env:
NEXT_PUBLIC_ADMIN_URLdocumented in.envwith the default + a prod-deployment note.
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-authnow 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--forcefor 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.
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: SQLGROUP BY field ORDER BY COUNT(*) DESC LIMIT Nsum_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.
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 tosplitat render time. The PUT handler validates incoming values and silently drops anything that isn'tsplitortabs.
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.
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 existingcards/charts/tablesarrays: 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.
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.ComputeResourceStatsinapps/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 sojson:"-"columns likePasswordHashnever leak). - Endpoint:
GET /api/admin/dashboard/resource-stats/:resource— accepts the samecreated_since/created_from/created_toparams the resource list pages already use, so the wire shape matches. - Widgets:
ResourceStatCard,ResourceLatestTable, and a thinResourceWidgetsRowwrapper. 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).
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.
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.tsin. - Update
app/layout.tsxto render<AppChrome>instead of<Navbar /> ... <Footer />. - Update
middleware.tsto readgrit_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.tsxwith<UserMenu />.
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 nomiddleware.Auth. - Sentinel rate-limits each token aggressively by IP.
- The dispatcher service is the real security boundary — only resources with an explicit
casein 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.
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 threeJSONSlice[string]columns for cards / charts / tables plus adate_presettext 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) andPUT /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 checkinglayout.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.widgetsarray 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.
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,000Updated Product Desktop: changed name, priceUpdated Category Phones: image changedDeleted 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 successfulCreateservices.LogUpdate(...)afterUpdateandPatch(the grouped Save handler from v3.31.18 is logged the same way)services.LogDelete(...)afterDelete
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.
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 nouint— neither negatives nor decimalsfloat— 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.
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
ColumnFormatunion 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>.tsfiles now importFileReffromschemas/file-refwhen 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 againstresource.table.importdropped the redundant!== falsecomparison (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— updatebuildDefaults+ the file / files renderer fallbacks.apps/admin/components/forms/fields/{files,images,videos}-field.tsx— replace the(value ?? []).map()with theArray.isArrayguard.apps/admin/lib/resource.ts— add"file"and"files"toColumnFormat.apps/admin/components/tables/import-modal.tsx— simplify theimportCfgcheck.- For models with file columns, add
import type { FileRef } from "../schemas/file-ref";at the top ofpackages/shared/types/<model>.ts.
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 --filesOr 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.
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 atpage_size=200until 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.
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 defaultcreated_attarget 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_toso 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.
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+FileRefsfields, 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 stampsclaimed_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 withclaimed_at IS NULLolder thanminAge(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.Storagefield on the Handler struct, wired in routes viaStorage: svc.Storage.- Create handler:
files.ClaimRefscall 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.
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 uploadstotal_size— sum of bytesby_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.
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.
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) orfiles.FileRefs(multi), stored as JSON via GORM Value/Scan adapters in the newinternal/filespackage. - Zod schema: imports
FileRefSchemafrom the shared package — a single source of truth for the JSON shape. - Admin resource def: auto-emits
acceptsandmaxSizeMBso the form's upload endpoint enforces the per-field validation. - FormBuilder: dispatches
file/filestypes 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.
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.
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.
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.
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.gorewritten: hits Pulse's/overview,/runtime/current,/database/n1/ranked, and/errorsendpoints, then reshapes the responses into a flat{latency, traffic, errors, saturation, slowest_routes, n1_detections, recent_errors}envelope. No more{data: ...}wrapper.handlers/security.gorewritten: hits Sentinel's/ip/blocked,/analytics/summary?window=24h, and/threats(the prior/dashboard/summaryendpoint 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/summaryinstead of the nonexistent/performance/summary.
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:
- Open
apps/api/internal/services/form_share_dispatch.go. - Move any
case "X":blocks that landed above the function back inside theswitchbelow. - Rename the function parameter from
bodytofieldsif needed. - Rephrase the doc comment so it doesn't contain the literal marker string.
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
FormSubmissionmodel — one row per successful public submission. Captures share_id, resource_name, record_id, IP, User-Agent, timestamp. Soft-deletable for retention. PublicSubmitwrites 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)
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-shareWhat 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
--tokenwhen set; otherwiseprocess.env.NEXT_PUBLIC_FORM_TOKENat 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.
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.
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 thegrit_accessHttpOnly 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 thePROTECTED_PATHSandAUTH_PATHSarrays 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 optionalrolesprop.
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/meprobe. 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)
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>.goto determine the resource's primitive fields. Relationship pointers (Group *GrouporGroup 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
apiClientimport path by app —@/lib/api-clientfor admin,@/lib/apifor web. Resolves a pre-existing "Cannot find module" error in web-side resource hooks. - Scaffolded
apps/web/lib/api.tsnow re-exportsapiClient = apiso generated hooks resolve symmetrically across both apps. - Web package.json gains
@hookform/resolversas a dep (was only in admin before). - ParseGoStructs exported from the
internal/generatepackage for reuse by the newinternal/exposepackage.
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/--publicflags (post via the public form-share endpoint instead of via the auth'd hook) are still on the roadmap.
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'sExcludeRoutesso the WAF doesn't block public JSON bodies. - Marker-driven resource dispatch: every
grit generate resourceappends a case toservices/form_share_dispatch.gothat JSON-decodes the public payload into the resource's model + callsdb.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 viagrit expose formin 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
_passwordalongside the fields; rejected with 401 if mismatched.
Deferred to v3.31.21
- Audit trail: a
source_share_idcolumn 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.
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 aStackedCell({ 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
StackedCellis conditional — resources without a pack stay clean.
Patterns recognised today
name + email→ "Contact" columnfirst_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.
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
Patchhandler on every generated resource. Whitelists writable columns so the partial endpoint can't be tricked into settingid/created_at/deleted_at/versionfrom 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 asuseUpdateResourcebut calls PATCH and toasts "Saved" on success.GroupDefinitiontype onFormDefinition.groups. Each group is{ title, description?, fields: string[], scope?: "create" | "update" | "both" }.<UpdateGroups>component renders eachscope: "update"or"both"group as a separate Card on the Update page (whenformView: "page"+form.groupsare 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.
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
FormSheetcomponent (apps/admin/components/forms/form-sheet.tsx) — the long-form-friendly drawer, formerly the implementation of FormModal. FormModalrewritten as a proper centered dialog (max-w-md, backdrop blur, padding).ResourcePagedispatcher picks the right component based onresource.formView.ResourceDefinitiontype 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.
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>
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
/loginon 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 callsuseLogout(). Configurable viaNEXT_PUBLIC_SESSION_IDLE_MSandNEXT_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 underExcludeRoutesso 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) => ReactNoderenderer — 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 overformatandbadgewhen set. - grit sync prints a heads-up that it does NOT update the admin resource definition, pointing operators at
apps/admin/resources/<plural>.tswhen 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.
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).
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.
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.
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.
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.tsdropsjs-cookie, addswithCredentials: true, and echoes thegrit_csrfcookie intoX-CSRF-Tokenon every mutation.apps/admin/hooks/use-auth.tsimportsUser,LoginRequest,RegisterRequest,AuthResponse,ApiResponsefrom@repo/shared/typesinstead of declaring them inline.useMereturnsnullon 401 instead of throwing.useLogoutdoesn't clear tokens locally — the API does it viaSet-Cookie.- The admin root redirect page (
app/page.tsx) drops theCookies.get('access_token')check (which couldn't see HttpOnly cookies anyway) in favour of auseMe()probe. - The 401-refresh interceptor now POSTs
/api/auth/refreshwith an empty body — the API readsgrit_refreshfrom the cookie and issues a newgrit_accessviaSet-Cookie. profile deletedropsCookies.removecalls — the GoDeleteProfilehandler now callsClearAuthCookiesas part of its response.js-cookieand@types/js-cookiedropped fromapps/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
useBulkDeleteResourcesignatureids: number[]→ids: string[](Grit's models all use UUID primary keys).form-modal.tsx+form-page.tsx+ their-stepsvariants stop castingitem.id/editIdtoNumber— they pass the IDs through as strings, matchinguseUpdateResource/useResourceItem.relationship-select-field.tsx+multi-relationship-select-field.tsxuseString(item.id)instead of assertingas number.hooks/use-system.tsdropped its inlineUploadinterface and imports from@repo/shared/types;UploadListResponseis now an alias forPaginatedResponse<Upload>.- Admin icon map:
Cpu,Zap,Globeadded;Shieldwas imported but not re-exported — fixed. System observability / security pages corrected from@/lib/apito@/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.
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.tsnow importsBlog+PaginatedResponsefrom@repo/shared/types.use-auth.tsimportsUser,LoginRequest,RegisterRequest,AuthResponse,ApiResponsefrom the same barrel.lib/auth-provider.tsximportsUserfrom@repo/shared/typesinstead of a 10-line local copy.- Web app now has
@repo/shared: workspace:*in itspackage.json(was missing).next.config.tsgetstranspilePackages: ['@repo/shared']so SWC picks up the TS source.
Auth flow uses HttpOnly cookies end-to-end
- Axios client gets
withCredentials: trueso the browser actually attaches thegrit_access/grit_refreshcookies the API issues. - A request interceptor echoes the
grit_csrfcookie intoX-CSRF-Tokenon every state-changing request — required by the AutoCSRF middleware that v3.26.0 wired in. use-auth.tsdroppedjs-cookie,storeTokens/clearTokens/getAccessToken, and everyAuthorization: Bearerheader attachment.useMereturnsnullon 401 instead of throwing.- Login + register pages no longer call
Cookies.set('access_token'). The API sets HttpOnly cookies viaSet-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.
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 startin a web project now spawnsgo run cmd/server/main.go(inapps/api/) andpnpm 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 startstill callswails dev. The subcommandsgrit start serverandgrit start clientstill work if you only want one side.
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:6380andMINIO_ENDPOINT=http://localhost:9002.- The CLI "next steps" banner after
grit newnow 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.
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.ymlnow publishes Postgres on host port5434(not5432). Container port stays5432inside the Docker network. .envsetsPOSTGRES_PORT=5434so the Go API connects to the same host port.docker-compose.prod.ymlpinsPOSTGRES_PORT=5432in 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 excludedportrangediagnostic 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.
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_PASSWORDis now generated at scaffold time as a 48-hex-char random string (alongside JWT_SECRET, PULSE_PASSWORD, etc.) — evenAPP_ENV=productionis safe on first boot.docker-compose.ymlreads from.envvia$${VAR:-grit}substitution. No hardcoded credentials anywhere.- The Go API builds
DATABASE_URLfrom the samePOSTGRES_*parts at startup. SetDATABASE_URLonly 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=postgresin the api environment so the Go binary finds the postgres container on the docker network. No embeddedDATABASE_URLin the compose YAML anymore.
A fresh grit new → docker compose up -d → grit migrate now succeeds without any editing.
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 fullapps/web+apps/adminlayouts,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 -dbecause 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.ymlbinds every port to127.0.0.1, not the Docker default0.0.0.0. Coffee-shop wifi can no longer reach Postgres withgrit:gritcredentials.docker-compose.prod.ymldocumented as reverse-proxy-first. A top-of-file comment block spells out the security posture: nothing usesports:, onlyexpose:. 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.jsonnow writes the real CLI version. Previously hardcoded to3.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.
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 installcan write straight on top. We previously didos.Removefirst, which left the user stranded ifgo installfailed. - 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 ifgo installfails before writing the new one, so a flaky network or proxy issue can't leave the user with no usablegrit.
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 (withgo installkept as a secondary option for power users with Go installed). - Hero terminal animation now opens with
curl -fsSL https://gritframework.dev/install.sh | shinstead ofgo install.
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 newnow generates cryptographically random values (crypto/rand→ hex) forJWT_SECRET,SENTINEL_PASSWORD,SENTINEL_SECRET_KEY, andPULSE_PASSWORDwhen writing.env. Previously these shipped asyour-super-secret-...andadmin/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 manualopenssl randstep 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=falsealongside the SQLite switch.
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 withAlready on the latest version. No more wastedgo installruns. - No-Go-toolchain mode. If the
gobinary isn't on PATH,grit updatefalls 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-releaseflag 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-updatefor discoverability — same command, clearer intent thanupdate.
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 updateSQLite, 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.Connectnow branches on DSN prefix:sqlite://path,sqlite:path,sqlite::memory:, or Postgres for anything else. Usesgithub.com/glebarez/sqlite(pure Go, no CGO) so it works on Windows without a C toolchain. Existing Postgres setups are unchanged. - .env documentation. Both
.envand.env.examplenow 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=trueindemo/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.
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-appmain.gowas importing a function that didn't exist; project wouldn't compile out of the box. The new helper wraps the existingSchedulerand returns(*Scheduler, error)so callers can stop it on shutdown.- asynq worker now actually starts. The single-app
main.gowas queueing jobs without ever startingjobs.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", ...)triggeredhttp.FileServer's canonical-URL 301 rule and ping-ponged forever behind Traefik / Cloudflare (ERR_TOO_MANY_REDIRECTS). The scaffold now pre-readsindex.htmlonce and serves viac.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 todb.Where("id = ?", id).First(...). - Single-app layout cleaned up.
main.gomoved to the project root so//go:embed all:frontend/distresolves on a fresh clone; a placeholderfrontend/dist/index.htmlships sogo buildworks beforepnpm build;apps/api/Dockerfile+ the multi-appdocker-compose.prod.ymlare no longer generated in--singlemode (Dokploy was auto-detecting them and failing). A new root Dockerfile pins pnpm to 9.15.0,chowns beforeUSER(fixes Sentinel/Pulse "out of memory (14)" SQLite errors). - Auto-migrate + first-boot seed. Single-app
main.gonow runsmodels.Migrate(db)on startup (gate viaAUTO_MIGRATE=false) and seeds when the users table is empty (gate viaAUTO_SEED; off by default in production). Fresh-deploy → working-login is a single command. - Vite scaffold fixes —
postcss.config.cjs(was.js, broke ESMpackage.json);api.tsusesimport.meta.env.VITE_API_URLinstead ofprocess.env.NEXT_PUBLIC_API_URL;vite-env.d.tsdeclares the type sotsc --noEmitstops erroring; navbar/footer use TanStack Router'sLink+useRouterStateinstead ofnext/link/usePathname;@tanstack/router-cliin devDeps + a postinstall hook sorouteTree.gen.tsexists 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, exportslogin/register/me/refresh/logout/clearAuth. Handles the TOTP-challenge response shape too.lib/api.ts— auto-attachesAuthorization: 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 JWTexp, shows a Stay / Logout modal 30s before expiry. Stay callsrefresh().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-insafefetch.Get(ctx, url)validates scheme + host pre-flight AND re-checks the resolved IP at TCP-connect time vianet.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 —
SecurityHeadersmiddleware extended · strictContent-Security-Policy(default-src 'self'+ script allowlist +frame-ancestors 'none'+object-src 'none'), plusCross-Origin-Opener-PolicyandCross-Origin-Resource-Policy. Skipped on/docs,/studio,/sentinel,/pulsewhich serve vendored UIs. - A03 Supply chain ·
.github/dependabot.yml(Go modules + npm + GitHub Actions, weekly) and.github/workflows/security.ymlrunninggovulncheck+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 inlib/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.
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 UPDATElock 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 newAuditDroppedCount()helper for monitoring saturation. audit.VerifyChain— was loading the entireactivity_logtable 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), honourscontextcancellation, 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 underRLockthen 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).
responseCaptureswitches[]byteappend tobytes.Buffer— 3 allocations instead of one per Write chunk. - Generated service queries —
Updatedropped the redundant thirdFirst()afterUpdates()(Updates mutates the loaded struct in place);Deletedropped the preflightFirst()(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 usesFindInBatchesin 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. Newexport.CSVRows()helper for header-less subsequent batches.
Medium fixes
- Webhook Replay —
retry_countincrement is now atomic viagorm.Expr("retry_count + ?", 1). Two concurrent replays of the same event no longer race to write the same +1 result. - Flags
bucketForfor anonymous users —crypto/rand.Readinstead oftime.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() % Nfor randomness — biased by call frequency; usecrypto/rand.
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.RulesJSON holdsrollout_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.Variantsis 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
RulesJSON also returnsfalse— 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.
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
- Route hits → look up provider (404 if unknown).
- Read raw body + headers.
provider.Verify(secret, body, headers)— 401 on signature mismatch.provider.Extract(body, headers)returns(eventType, externalID).- Insert into
webhook_events— UNIQUE on(provider, external_id)means duplicate deliveries becomestatus=skippedno-ops. webhooks.Dispatch(ctx, event)runs the registered handler for(provider, eventType), falling back to a catch-all""handler if no specific match.- Handler success →
status=processed; handler error →status=failed+handler_errorrecorded. Provider always gets200once 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'st=...,v1=...scheme with 5-minute replay tolerance.GitHubVerifier— GitHub'sX-Hub-Signature-256: sha256=...header.- Roll your own
VerifyFuncfor anything else — it's justfunc(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— readsX-GitHub-Event+X-GitHub-Deliveryheaders.
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. Incrementsretry_countand 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).
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'sHash. - 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 UPDATElock 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 overprevHash || 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. ReturnsChainStatuswith 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/DELETEonactivity_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.
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 toc.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.
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.Listgains opt-in cursor mode viaConfig.CursorMode: true. Response carriesMeta.NextCursor+Meta.HasMoreinstead ofPage/Pages.- Detects
HasMoreby fetchingPageSize + 1rows — 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 extraCOUNT(*), 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+ namedurl/image/avatar/thumbnail/logo/cover/icon/banner/photo) getsize:500instead ofsize: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/messagegettype:text. - Money fields on
floattype (suffix_amount/_price/_total/_cost/_fee/_balance/_rent/_salary/_wage/_value/_revenue/_deposit+ namedamount/price/total/cost/fee/balance/subtotal) gettype:decimal(12,2)for fixed-precision storage. No more1.99 + 0.01 = 1.9999999.
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/exportpackage:CSV(w, items, opts)andXLSX(w, items, opts)with a typedColumn{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 tofmt.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 injectGET /api/<plural>/exportautomatically. - Honours the same
searchparam as List, so users can export a filtered subset. - Adds
github.com/xuri/excelize/v2 v2.8.1to scaffoldedgo.mod.
Activity audit log — #32
- New
models.ActivityLogwith user_id + method + path + status + payload digest (sha256, not raw body) + IP + user-agent + duration. UUID PK;created_atindexed 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) withpaginate.Listfiltering byuser_id,method, andpathprefix. 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 axioserr.messageplus a fallback sotoast.error(apiErrorMessage(err))is always meaningful. apiErrorCodereturns the envelope'scodestring ("VALIDATION_ERROR","VERSION_CONFLICT", etc.) for branching logic.apiErrorFieldssurfaces per-field validation details so forms can highlight specific inputs.- New
internal/respondpackage on the server side too:respond.NotFound / Validation / Forbidden / Conflict / Internalfor handlers, replacing ad-hoc inlinec.JSON(500, gin.H{...}).
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.tsxrewritten 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 dashboardOutletwith this.
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 intcolumn added to User, Upload, Blog. ABeforeUpdateGORM hook auto-increments on every server-side write. The resource generator emits both on every new model.POST /api/sync/pushaccepts a batch of changes; each entry includes the version the client believes the server has. On mismatch the response containsVERSION_CONFLICT+ the current server state, so the client can drive a merge UI.GET /api/sync/pull?model=X&since=cursorreturns every row in the table updated after the cursor, paginated, with a new cursor in the response.- New
internal/sync/registry.gomaps logical table names (e.g."buildings") toreflect.Typeso the handler decodes dynamic payloads. New resources auto-register via// grit:syncmarker.
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 nowbutton 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.
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 withCounter(the GORM-backed row),Config(name + prefix + reset + width), and an atomicNext(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 theModels()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 UPDATEon 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 neverFlags:
--prefix— alphabetic prefix (default: first 3 chars of the name, uppercased)--reset— when the counter resets:monthly(default),yearly, ornever--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.
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. OneHubper process; each user can have multiple connections (desktop + mobile + web). Hub.SendToUser(userID, evt),SendToUsers(ids, evt), andBroadcast(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.goupgrades 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 ownresource.<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
realtimeBusEventTarget — any component can subscribe. - Start from
AuthProviderafter tokens land, stop on logout. - New hook:
useRealtimeEvent<T>(type, callback)subscribes to a typed topic and unsubscribes on unmount. PlususeRealtimeAny()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
Blogmodel inapi_blog_files.goswaps fromgorm.Model(auto-incr uint) to a string UUID PK with aBeforeCreatehook. Service signatures (GetByID,Update,Delete) and handler param parsing all switch fromuinttostring. - Standalone desktop scaffold (
grit new-desktop): User, Blog, and Contact models all switch to string UUID PKs withBeforeCreatehooks. All Wails-bound App methods and underlying service signatures useid string. Frontend mutation typings follow. - Shared TS types:
User,Upload, andBloginterfaces all useid: string. URL builders inAPI_ROUTEStakeid: string. TheBlogSchemaZod schema usesz.string(). - Admin TS:
DataTableselection state, genericuseResourceItem/useUpdateResource/useDeleteResourcemutation typings,RelationshipSelectFieldsingle value,MultiRelationshipSelectFieldarray values, andhandleDeletecallbacks all switch fromnumbertostring.
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.
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=truerenders anEmptyStatewith the configured title/hint instead.EmptyState,DetailSection(small caps section header), andDetailField(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 intoreact-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 withisPendingsupport (button disables, label flips to "Saving...").
components/filter-chip.tsx — filter chips
FilterChip— toggleable pill, active state shows accent background; optionalonClearrenders an X to clear a single filter; optionalcountrenders a small count badge.FilterBar— horizontal scrollable wrapper. Drop intoListPane'sfiltersslot.
Tailwind tokens
- Added
listpanespacing token (22rem/ 352px) to the desktop Tailwind config sow-listpaneworks.
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 intoroutes.Setupas a global middleware. - Activates only when the request carries an
Idempotency-Keyheader and the method isPOST/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: trueresponse 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-Keyon 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()atfrontend/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.
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.DBquery.paginate.Bind(c)readspage,page_size,search,sort_by,sort_orderfrom the Gin query, clampspageto ≥ 1 andpage_sizeto[1, 100].paginate.Configwhitelists sortable columns and declares the searchable column set — requests for columns outside the whitelist fall back tocreated_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 inILIKEsearch — a leftover rough edge from issue #12.
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"toconfig.go,"gorm.io/gorm/logger"touser.go,"net/http"tomiddleware/logger.go. - Stray package prefix: removed
handlers.qualifier onIsTrustedDevice(same-package call). - User ID type consistency: normalized
UserIDandUploadIDtostringUUIDs across 2FA models, auth service, TOTP handler (c.GetString("user_id")replacesc.GetUint), jobs package, and upload handler.
Desktop scaffold fixes
keychain.gomoved frominternal/to the top level (the subdirectory file was declaringpackage main, which Go rejects).go.modmodule path fixed from<project>/apps/api/apps/desktopto<project>/apps/desktop.
Resource generator fixes
- Service signatures take
id stringinstead ofid 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(wereuint/number). - Initialism-aware
toPascalCase/toSnakeCase:owner_id→OwnerID(wasOwnerId),image_url→ImageURL(wasImageUrl),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 and
ShouldBindJSONmismatches). - Zod FK and M2M validators use
z.string().uuid()instead ofz.number().int(). - FK columns generate with
gorm:"size:36;index"(matches UUID PK width).
New: --desktop flag
- Desktop + mobile + API in one monorepo —
grit new myapp --mobile --desktopscaffolds a complete multi-client SaaS: Go API shared by an Expo mobile app AND a Wails desktop app. All three share the samepackages/sharedtypes 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.--desktopis 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 via
GetPlatform()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:⌘Kpalette,⌘,settings,⌘Llogout,Escto close. - More negative space — content padding is
32px(vs web's24px) for long focus sessions. Subtler shadows (OS chrome already provides elevation).
Style Guide
- New §14.5 Desktop App Patterns section in
GRIT_STYLE_GUIDE.mdcovering 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)Design System
- GRIT_STYLE_GUIDE.md — First official style guide for all Grit-scaffolded projects. Premium Minimal aesthetic (Linear / Vercel school), Grit purple
#6C5CE7primary, 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 atcomponents/layout/page-header.tsxwith 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 withstats: false.
Auth Pages
- New centered auth variant —
grit new myapp --style centeredscaffolds 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).
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_SECRETis shorter than 32 characters. - Sentinel WAF — Now runs in
ModeBlockin production (was alwaysModeLog). Development keepsModeLog.
Performance
- GORM AutoMigrate silence — Migration now uses a
logger.Silentsession 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 withAuthProvidercontext 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-browserwith 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.
Features
- Scaffold into current directory —
grit new .andgrit 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. --forceflag — Allows scaffolding into non-empty directories. Useful when a repo was cloned first (with README, .git, LICENSE) before scaffolding:grit new . --triple --vite --force.--hereflag — Explicit alternative togrit 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 --viteno longer shows the architecture/frontend selection prompt. Flags act as true shortcuts for non-interactive setup. - Module path upgrade to /v3 — Fixed
go install ...@latestdownloading v2.9.0 instead of v3.x. All import paths updated fromgrit/v2togrit/v3.
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 newStepWithCodecomponent 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), andgrit 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.
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.jsonproject manifest — Every scaffolded project now includes agrit.jsonfile at the root witharchitectureandfrontendfields. 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 at
src/routes/_dashboard/resources/usingcreateFileRouteinstead of Next.jsapp/(dashboard)/resources/page convention.
Features (Goravel-Inspired)
grit routes— List all registered API routes in a formatted table. Parsesroutes.goand 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 downcreates a.maintenancefile that triggers the new maintenance middleware, returning 503 for all requests.grit upremoves 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,--keyflags orDEPLOY_HOST/DEPLOY_DOMAIN/DEPLOY_KEY_FILEenv vars.- Maintenance middleware — All scaffolded projects now include a
Maintenance()Gin middleware that checks for a.maintenancefile on every request. Runs as the first global middleware.
Features
- Single app architecture —
grit new my-app --singlecreates a single Go binary that serves both the API and an embedded React SPA. Usesgo:embedto bake the built frontend into the binary at compile time. One file to deploy. Dev mode runs Go on:8080and Vite on:5173with API proxy. - Parameterized API paths — All Go API file generators now use
opts.APIRoot()andopts.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 withgo:embed frontend/dist/*and SPA fallback routinginternal/— Full Go backend (same as monorepo API)frontend/— React + Vite + TanStack Router SPAMakefile—make dev(parallel servers),make build(single binary)
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.
Features
- Interactive project creation —
grit new my-appnow 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
Optionsstruct now usesArchitectureandFrontendenum fields instead of boolean flags. Legacy flags (--api,--mobile,--full) still work via theNormalize()migration layer.
Features
- Two-Factor Authentication (TOTP) — Every
grit newproject 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 2FAPOST /api/auth/totp/verify— Verify TOTP code during login (public, uses pending token)POST /api/auth/totp/backup-codes/verify— Use backup code during loginPOST /api/auth/totp/disable— Disable 2FA (requires password)GET /api/auth/totp/status— Check 2FA status, remaining backup codes, trusted device countPOST /api/auth/totp/backup-codes— Regenerate backup codesDELETE /api/auth/totp/trusted-devices— Revoke all trusted devices
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/modelformat (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, andAI_MODELhave been replaced withAI_GATEWAY_API_KEY,AI_GATEWAY_MODEL, andAI_GATEWAY_URL. Update your.envfile accordingly. Get your API key from vercel.com/ai-gateway.
Features
- 10 Official Plugins — New
grit-pluginsecosystem 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.mdto 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.
Fixes
- GORM Studio (Desktop) — Replaced the broken custom HTML studio with the real
gorm-studiopackage. Desktop studio now runs on port 8080 at/studiousing Gin + gorm-studio, matching the web scaffold. Auto-opens browser on launch.
Features
- GRIT_SKILL.md — Desktop scaffolds now include a
GRIT_SKILL.mdfile 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.mdnow 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.
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
tscfrom the frontend build script. TanStack Router's Vite plugin generatesrouteTree.gen.tsduring the Vite build, so runningtscbefore Vite causedCannot find module './routeTree.gen'errors. - Title bar import path — Fixed the Wails binding import in
title-bar.tsxfrom a 2-level to 3-level relative path. - Auth hook file extension — Renamed
use-auth.tstouse-auth.tsxso 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.
Fixes
- Desktop auth hook file extension — Renamed the scaffolded
use-auth.tstouse-auth.tsxso TypeScript correctly handles the JSX in<AuthContext.Provider>. Previously,grit new-desktopprojects would fail to compile withTS1005: '>' expectederrors.
Documentation
- Added Desktop Handbook PDF download links to all 8 desktop documentation pages.
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 andRoute.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
lgbreakpoint. Opens a Sheet sidebar with all navigation links. Auto-closes on link click. - CGO-free SQLite — Replaced
gorm.io/driver/sqlite(requires CGO) withgithub.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 generatecommands.
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.
Features
- Native desktop apps (Wails) — New
grit new-desktopcommand 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 resourcenow 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) usinggrit: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 startfor desktop — Runninggrit startinside a desktop project launcheswails devwith hot-reload for both Go and React.grit compile— New command that runswails buildto 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 resourcefor 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 addfrom/rendpoints.
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.
Features
- Gzip response compression — All API responses are now compressed automatically via a custom
Gzip()middleware using the Go standard librarycompress/gzipatBestSpeed. JSON payloads shrink by 60–80%, reducing bandwidth on paginated list endpoints with zero external dependencies. - Request ID tracing — A
RequestID()middleware injects a uniqueX-Request-IDheader 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), andConnMaxIdleTime(10m). This prevents stale connections after network interruptions and avoids connection exhaustion under load. - Cache-Control headers on public blog endpoints — The
ListPublishedhandler now returnsCache-Control: public, max-age=300(5 minutes) andGetBySlugreturnsCache-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.
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 newprojects now includeerror.tsx,not-found.tsx, andglobal-error.tsxfor 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.ymlnow usesexposeinstead ofports,env_filefor secrets, MinIO service, named bridge network, build args forNEXT_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_URLas a build argument .envtemplate includes Docker Compose production variables (POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB,API_URL)
Features
- Default font changed to Onest — New projects scaffolded with
grit newnow use the Onest Google Font for all UI text instead of DM Sans. JetBrains Mono remains the code font. The font is loaded vianext/font/googlewith 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.
richtextadded to the FieldType union for better type safety in the code generator.
Bug Fixes
- OAuth callback fix — Fixed
TokenPairstruct 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.
Features
- Social Login (Google + GitHub) — Every
grit newproject 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 viaGOOGLE_CLIENT_ID,GITHUB_CLIENT_IDenvironment variables. - GORM Studio v1.0.1 — Updated to the first stable tagged release of GORM Studio.
Improvements
- User model now includes
Provider,GoogleID, andGithubIDfields 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).
Fixes
- gin-docs AuthConfig — Updated scaffold template to use the new
gindocs.AuthConfigstruct instead of the deprecatedgindocs.AuthBearerconstant, 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
AuthConfigstruct format
Features
- Pulse (Observability) — Every
grit newproject 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 viaPULSE_ENABLED. See Pulse docs.
Documentation
- New Pulse (Observability) page covering configuration, endpoints, health checks, alerting, Prometheus metrics, and data storage
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_IDenvironment 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
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
Features
- Security (Sentinel) — Every
grit newproject 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
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
richtextfield with Tiptap WYSIWYG editor (bold, italic, headings, lists, code blocks, links, undo/redo). string_arrayfield type — Store arrays of strings usingdatatypes.JSONSlice[string]. Works with PostgreSQL and SQLite. Maps tostring[]in TypeScript andz.array(z.string())in Zod.- Built-in blog example —
grit newnow 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-formin web app — Web app scaffold now includesreact-hook-formas a dependency, enabling standalone FormBuilder usage out of the box.
Bug Fixes
- Scalar API docs crash — Fixed
c.Stringtreating HTML as a format string. Now usesc.Datato avoid panics when Scalar HTML contains%characters in CSS/JS. - Blog route conflict — Admin blog CRUD routes moved from
/api/blogsto/api/admin/blogsto 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
Features
- Relationship support — New
belongs_toandmany_to_manyfield types for the code generator. Automatically creates foreign keys, junction tables, and relationship-aware form fields. - Relationship select fields — New
relationship-selectandmulti-relationship-selectform 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.
Features
- Full-page form view — New
formView: "page"option renders forms as dedicated pages instead of modals. slugfield 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 startcommands —grit start clientandgrit start serverfor running frontend and API separately.
Features
- Style variants —
--styleflag forgrit newwith 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.
