# henri > henri is a Rails-like, server-side rendered JavaScript framework for Node.js: models, controllers, routes and React views, with real ORMs, background jobs, a JSON API layer and hot reload. Every documentation page of https://usehenri.io, concatenated. The index is at https://usehenri.io/llms.txt. The same pages ship with the framework (`henri docs`), where they match the version of the application reading them. --- # Getting started Source: https://usehenri.io/getting-started/ Install henri, create a project and start the development server. Four commands take you from an empty directory to an application answering on `http://localhost:3000/`, with a database, a resource and its pages. Measured on a warm package cache: eight seconds for `henri new` and six more for the first boot. The first run on a cold cache is longer, and the difference is the download — an application resolves about six hundred packages. ```bash pnpm add -g henri henri new my-app cd my-app henri server ``` There is no database to install. `henri new` writes a [Drizzle](/guides/models/#drizzle) store on sqlite at `.henri/app.db`, so nothing has to be running before `henri server` does. ## Requirements - **Node.js 22 or newer.** Anything older stops at the first command, with the version it found: ```text You are using Node.js v20.20.0 henri requires Node.js 22 or newer. ``` - **A package manager.** `henri new` uses the one it finds — the `packageManager` field of an existing `package.json`, its lockfile, then the manager that ran the command, then a probe — and prints its choice. `--pm pnpm|yarn|npm` forces it. :::note Releases published before 1.0 target Node 10 to 14. If you have an application written for henri 0.37, read [Upgrading](/upgrading/). ::: ### Without a global install `pnpm add -g henri` needs pnpm's global bin directory on your `PATH`, and on a machine where `pnpm setup` has never run it refuses rather than installing: ```text [ERROR] The configured global bin directory "…/Library/pnpm/bin" is not in PATH Run "pnpm setup" to update your shell configuration. ``` You can skip the global install entirely. `pnpm dlx henri new my-app` (or `npx henri new my-app`) scaffolds the application, and the application depends on `henri` itself — so `npm start`, `pnpm start` and `npx henri ` all work inside it with nothing installed globally: ```text > second-app@1.0.0 start > henri server ``` The rest of this page writes `henri` for short. ## What `henri new` writes ```text - Using pnpm (pnpm --version) - Copying new directory structure... - Building new package file... - Generating config/default.json, config/test.json and .env... - Listing better-sqlite3 under allowBuilds in pnpm-workspace.yaml... - Scaffolding the sample Task resource... > created controller "tasks.js" @ app/controllers/tasks.js > added route "resources tasks" @ config/routes.js > created view "index.jsx" @ app/views/pages/tasks/index.jsx > created view "_form.jsx" @ app/views/pages/tasks/_form.jsx > created view "new.jsx" @ app/views/pages/tasks/new.jsx > created view "edit.jsx" @ app/views/pages/tasks/edit.jsx > created view "show.jsx" @ app/views/pages/tasks/show.jsx > created test "tasks.test.js" @ test/tasks.test.js - Adding a Dockerfile... - Adding new readme file... - Writing AGENTS.md, CLAUDE.md and .mcp.json for coding agents... - Initialized a git repository - Installing packages using pnpm... ``` `--skip-install` and `--no-git` skip the last two steps, `-f` writes into an existing folder, `--renderer react` picks the [Next.js](/guides/views/#react) view engine instead of the default [Inertia](/guides/views/#inertia) one, and `--adapter` picks the [store](/guides/models/#adapters). The result: ```text ├── .dockerignore ├── .env <- HENRI_SECRET, ignored by git ├── .gitignore ├── .mcp.json <- runs `henri mcp` for a coding agent ├── AGENTS.md <- the conventions of this app; CLAUDE.md points at it ├── CLAUDE.md ├── Dockerfile ├── app │ ├── controllers │ │ ├── main.js <- main#home, the landing page │ │ └── tasks.js <- the resources actions of Task │ ├── helpers │ ├── jobs │ ├── models │ │ └── Task.js <- the `Task` global │ ├── views │ │ ├── assets │ │ ├── components │ │ ├── index.html <- the html shell Inertia renders into │ │ ├── jsconfig.json <- lets pages `import x from 'components/x'` │ │ ├── main.jsx <- the browser entry, imports the stylesheet │ │ ├── pages │ │ │ ├── index.jsx │ │ │ └── tasks <- index, new, edit, show and _form │ │ ├── public │ │ ├── ssr.jsx <- the server entry │ │ ├── styles │ │ │ └── index.css <- Tailwind CSS, the whole stylesheet │ │ └── vite.config.mjs <- @usehenri/inertia/vite + Tailwind │ └── workers ├── config │ ├── default.json <- stores, renderer, user model (committed) │ ├── routes.js <- 'get /': 'main#home', 'resources tasks': 'tasks' │ └── test.json <- the same store, on :memory: ├── db │ └── seeds.js <- seed data, run with `henri db:seed` ├── eslint.config.js ├── jsconfig.json <- editors: henri's types and .henri/types.d.ts ├── package.json ├── pnpm-workspace.yaml <- allowBuilds for pnpm; npm and yarn ignore it ├── README.md ├── test │ └── tasks.test.js <- run with `henri test` └── vitest.config.js ``` If you have a Ruby on Rails background, this should look familiar. The sample resource is a regular scaffold (`henri generate scaffold Task name:string! category:string:enum=urgent,high,medium,low done:boolean` writes the same files, bar the default the sample `category` carries); `henri destroy scaffold Task` removes it. Its form shows what the generator reads off the model: `name` is required so its input is, and `category` can only hold four values so it is a ``, a seed and a test want, and it is also how you reach a scope whose value is in a variable: ```js for (const state of Post.enums.status) { counts[state] = await Post.count(Post[state]()); } ``` Never index a model with a string that came from a request: `Post[req.query.state]()` is a method of the model, not a scope. That is what [`filters`](/guides/filtering/) is for. ### A predicate is on the record, and a page has none `isDraft()` is a method of a record, so it is gone by the time the record is JSON — a React or Inertia page receives the column and compares it itself. The value list crosses over, so a page that has to compare gets the strings from one place: ```jsx // pass the list, not a hand-written copy of it res.render('/posts', { data: { states: Post.enums.status, posts } }); ``` This is what a scaffolded page already does: `henri generate scaffold` writes a `new` and an `edit` action that send `Post.enums`, and a form whose `` of those values rather than a text input. Everything else a column can say — a `default`, `unique`, `index`, a `personal` mark — is written in the model file, which is where the generators read it: a `henri generate scaffold` or `crud` run over a model that already exists follows what that file says. `henri generate scaffold` and `crud` start with the same model and add the controller, routes and views; see the [CLI reference](/reference/cli/#generators). ## Querying The global is the ORM model. Nothing is wrapped: ```js // Mongoose (disk, mongoose) await Task.find({ done: false }).sort({ createdAt: -1 }); await Task.findById(id); await Task.create(req.permit('name', 'category')); // Sequelize (mssql) await Task.findAll({ where: { done: false } }); await Task.findByPk(id); await Task.create(req.permit('name', 'category')); // Drizzle (drizzle, postgresql, mysql), which answers to some of the names above await Task.where({ done: false }).order('createdAt desc'); await Task.findById(id); await Task.create(req.permit('name', 'category')); ``` Use [`req.permit()`](/guides/controllers/#reqpermitfields) rather than `req.body` when you create or update records. `henri generate scaffold|crud` reads the adapter of the default store from `config/default.json` and writes the controller against that API, so the sample resource of `henri new --adapter ` runs on the store it configured. ### Pagination `Model.paginate()` is the model half of [`req.pagination()`](/guides/api/#pagination): one call answering the records and the counters [`res.collection()`](/guides/api/#answering-hal) wants, on every adapter. ```js index: async (req, res) => { const { records: tasks, page, perPage, total } = await Task.paginate( req.pagination() ); return res.negotiate({ html: () => res.render('/tasks', { data: { tasks, page, perPage, total } }), json: () => res.collection(tasks, { page, perPage, total }), }); }, ``` It takes `page` and `perPage` (the rest of what `req.pagination()` returns is ignored, so the object can be handed over whole) and answers `{ records, page, perPage, total, pages }` — `pages` is the number of pages, at least 1. A missing or invalid number falls back to page 1 and 25 per page. Every other key is the adapter's own query: ```js // Mongoose: where, sort, select, populate, lean, withDeleted await Task.paginate({ page: 2, perPage: 20, sort: '-createdAt', where: { done: false }, }); // Sequelize (mssql): any findAndCountAll option await Task.paginate({ include: ['owner'], order: [['createdAt', 'DESC']], page: 2, }); // Drizzle: where, order, include, select, withDeleted; relations paginate too await Task.paginate({ order: '-createdAt', page: 2, where: { done: false } }); await Task.where({ done: false }).order('-createdAt').paginate({ page: 2 }); ``` `henri generate scaffold` and `crud` write the first form, so a generated index is one query on any store. ### Validation errors What [a validation](#validations) refuses, and what the ORM refuses on its own, arrive in one shape. The three reject an invalid write differently: a Mongoose `ValidationError` keyed by path, a Sequelize `SequelizeValidationError` holding an array, a Drizzle `ValidationError`, a MongoDB duplicate key or a `SequelizeUniqueConstraintError`. `henri.model.errors(error)` turns any of them into `{ field: message }`, and answers `null` for anything that is not a validation failure, so a controller can answer a 422 and let the rest bubble up: ```js try { post = await Post.create(req.permit('title', 'body')); } catch (error) { const errors = henri.model.errors(error); if (!errors) { throw error; // not a validation failure: a real error } return res.boom.badData(error.message, { errors }); } ``` An error with no field of its own (a model-level validation) is filed under `base`, like Rails' `errors[:base]`. The generated controllers use this helper, so a scaffold answers the same 422 body whatever the store. ## Seeds `db/seeds.js` is Rails' `db/seeds.rb`, and `henri db:seed` runs it. The command boots the models and the user module — no views, no router, no workers — then requires the file and awaits what it exports; a function receives the running henri instance, and the model globals are there as usual. The user module is part of the boot because creating a user hashes its password through `henri.user.encrypt()`, so seeds can write users like any other record. ```js // db/seeds.js module.exports = async (henri) => { for (const name of ['Write the seeds', 'Ship it']) { const existing = await Task.findOne({ name }); if (!existing) { await Task.create({ category: 'medium', name }); } } henri.pen.info('seeds', 'tasks are ready'); }; ``` Seeds are run again on every machine, after every reset and on every deploy, so write them idempotently: **find, then create**. `henri new` scaffolds the file with that example commented out. ```bash henri db:seed # runs db/seeds.js henri db:seed --file=db/demo.js # another file henri db:seed --production --json # against the production database ``` It works on every adapter (the migration commands of `henri db` do not: those need [Drizzle](#drizzle)). Failures exit with `1` and, with `--json`, print `{ "error": { command, message, hint, code, exitCode } }` on stderr like every other command. ## The user model When a model matches the configured `user` name (`user` by default, so `app/models/User.js`), the adapter adds five fields to it, on top of the `externalId` every model gets: | Field | Behaviour | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `email` | Required, unique, trimmed and lowercased on write, validated as an email address. | | `password` | Required. Hashed (argon2id, or bcrypt) before every write, including `updateOne()`, `findByIdAndUpdate()` and bulk operations, and checked against `config.user.password` first. Not selected by default: `User.findOne(query).select('+password')` on Mongoose, `User.scope('withPassword')` on SQL. See [Passwords](/guides/users/#passwords). | | `roles` | A list of strings, `config.baseRole` for a new user. Dropped from mass-assigned creates and updates: `User.create(req.body)` cannot grant a role. On SQL it is a `JSON` column (`TEXT` with a JSON getter on MSSQL). | | `confirmedAt` | When the address was confirmed, `null` until it is. Written by the [confirmation flow](/guides/users/#email-confirmation); never mass-assignable. | | `passwordChangedAt` | When the password last changed, `null` until it does. Every session opened before it stops resolving to a user, which is how a [reset](/guides/users/#the-password-reset) signs the other devices out. | and three methods: ```js await user.hasRole(['admin']); // true when the user owns every role await user.setRoles(['admin', 'editor']); // replaces the roles and saves await User.setRoles(id, ['admin']); // same, by id (null when not found) ``` An operation flagged unsafe may write `roles` directly: `doc.save({ unsafe: true })`, `User.create([doc], { unsafe: true })`, `User.updateOne(filter, update, { unsafe: true })`, or `doc.$locals.unsafe = true` before a save (Mongoose). Server side, `henri.user.findByEmail(email)` (lowercases its argument and returns the instance with its password hash), `henri.user.findById(id)` (without the hash), `henri.user.publicUser(user)`, `henri.user.validatePassword(password)`, `henri.user.encrypt(password)` and `henri.user.compare(password, hash)` work the same on every adapter. Login, sessions, CSRF and roles are described in [Users](/guides/users/). ## Adapters Each adapter is a package to install in the application; the name in `stores..adapter` selects it. `henri new --adapter drizzle|disk|mongoose|postgresql|mysql|mssql` (`--dialect sqlite|postgres|mysql` with `drizzle`) writes the store block, the dependencies and the driver of a new application; `drizzle` on sqlite is the default. All of them implement the same contract, documented in the [API reference](/reference/api/#store-adapters), and expose a few helpers on the store object, `henri.model.stores.`: ```js await henri.model.stores.default.ping(); // true when the database answers await henri.model.stores.default.transaction(async (t) => { ... }); // SQL transaction, or Mongoose session (needs a replica set) await henri.model.stores.default.query('SELECT 1 + ?', [1]); // SQL adapters only await henri.model.stores.default.describe(); // what the database holds ``` `describe()` is the one that reads the database back rather than the model files, which is why it is worth knowing about: the physical table name, the real column names (the `externalId` a model declares is `external_id` in every SQL store henri writes), the type the dialect chose, the nullability, the defaults, the values of an enum where the dialect keeps them, every index that exists, and the names of the tables no model claims. It reads and never writes, and `henri db:schema` prints it. On a `mongoose` store the answer says `enforced: false` and `read: "models"`: the collections and the indexes come from the server and are real, the fields are henri's own declaration, and MongoDB holds no document to them. Sessions are stored in the database of the user model's store: a `henriSessions` collection on MongoDB, a `henri_sessions` table on a drizzle store, a table created by connect-session-sequelize on an mssql one (the `session` key of the store configures any of them). **Which one.** MySQL and MariaDB are one package under two names, so the choice is smaller than the list looks: | The database | The adapter | The ORM | | ------------ | --------------------------------------------------------- | --------- | | sqlite | `drizzle` with `"dialect": "sqlite"` | Drizzle | | PostgreSQL | `postgresql`, or `drizzle` with `"dialect": "postgres"` | Drizzle | | MySQL | `mysql`, or `drizzle` with `"dialect": "mysql"` | Drizzle | | MariaDB | `mariadb` — the same package, with [two limits](#mariadb) | Drizzle | | SQL Server | `mssql` | Sequelize | | MongoDB | `mongoose`, or `disk` for a local one | Mongoose | Drizzle is henri's SQL data layer: it has the migrations, and `@usehenri/postgresql` and `@usehenri/mysql` are that adapter with the dialect and the driver already chosen. Sequelize is behind `mssql` alone, and the reason is narrow: Drizzle has no SQL Server dialect (drizzle-orm 0.45 ships pg, mysql, sqlite, singlestore and gel; drizzle-kit 0.31 generates for postgresql, mysql, sqlite, turso, singlestore and gel), so it is how henri reaches one. Everything an mssql store does differently from the rest -- no migrations, `henri db:status` instead -- follows from that. ### Drizzle henri's SQL data layer: [Drizzle ORM](https://orm.drizzle.team/) with generated, versioned migrations, on sqlite, PostgreSQL and MySQL. `henri new my-app` scaffolds an application on it, sqlite by default -- nothing to start, a file under `.henri/`, and a database that deploys as it is. `--dialect postgres` or `--dialect mysql` picks another one, and `--adapter postgresql` and `--adapter mysql` are the same adapter with the dialect and the driver chosen for you ([below](#postgresql)). To add it to an existing application, install it with the driver of your dialect: ```bash pnpm add @usehenri/drizzle better-sqlite3 # or pg, or mysql2 ``` `better-sqlite3` ships the compiled addon in its own tarball, for darwin, linux, linuxmusl and win32 on arm64 and x64, so there is nothing to build and no toolchain to install. It still carries a `binding.gyp`, which pnpm 11 refuses to meet without an answer, so a pnpm application lists `better-sqlite3: false` under `allowBuilds` in `pnpm-workspace.yaml` — skip the build, use the binary (`henri new` writes it). A platform with no prebuild flips it to `true` and builds, which needs python3, make and a C++ compiler. ```json { "stores": { "default": { "adapter": "drizzle", "dialect": "sqlite", "url": "file:.henri/app.db" } } } ``` `dialect` is `sqlite`, `postgres` or `mysql`; `url` is a file path for sqlite and a connection string otherwise. The model format above compiles to Drizzle tables: plural snake_case tables, snake_case columns, an `id` primary key, `createdAt`/`updatedAt` with `options.timestamps`. On top of the shared keys, fields accept `select: false`, `min`, `max`, `minLength`, `maxLength`, `match`, `validate`, `lowercase`, `trim` and `references`. The global is not a Mongoose or Sequelize model but the adapter's own, with one API that also answers to some of the Mongoose and Sequelize names: ```js const task = await Task.create({ title: 'Ship it' }); const open = await Task.where({ done: false }) .order('createdAt desc') .limit(20); const withOwner = await Task.where({ id }).include('owner').first(); await task.update({ done: true }); await Task.destroy({ done: true }); await henri.model.stores.default.transaction(async () => { /* every query in here joins the transaction */ }); ``` #### What it refuses It answers to the Mongoose and Sequelize names where they mean the same thing, and refuses them where they do not, rather than running something else. - **`Model.update(values, { where })`** is Sequelize's argument order and the opposite of this one. Read as written it means "update every row matching `values`, and set a column called `where`": the wrong rows, no error, and a count that says it worked. It is refused with `HENRI_MODEL_INVALID_QUERY`. This adapter takes `update(where, attrs)`. - **An option this adapter does not read** -- `attributes`, `fields`, `raw`, `transaction`, `individualHooks`, `plain`, `lock` -- is refused with `HENRI_MODEL_UNKNOWN_OPTION` rather than dropped. A dropped `fields` is a mass assignment somebody thought they had bounded; a dropped `transaction` is a write outside the transaction it was written into. - **A condition keyed by Sequelize's `Op` symbols** narrows nothing here, because `Object.keys()` does not see a symbol. It would answer every row, so it is refused. So is an empty condition object under a field (`{ name: {} }`), for the same reason. The operators this adapter reads are `eq`, `ne`, `not`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `notIn`, `like`, `notLike`, `ilike` and `between`. - **`instance.get({ plain: true })`** reads as an attribute named by an object. `toObject()` is the whole record as a plain object. - **A model file's `options`** takes `timestamps`, `paranoid`, `externalId`, `personal`, `retention` and `versioned`. One declaring `indexes`, `scopes`, `defaultScope`, `hooks`, `tableName`, `underscored` or `freezeTableName` fails the boot naming the key and what to write instead -- a model whose author believes it has an index it does not have is worse than one that will not start. (`hooks` and the table name are top level keys of the model file, not options.) Validation failures throw a `ValidationError` whose `errors[field].message` is what the generated controllers read, unique violations included. Models can export `beforeValidate`, `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDestroy` and `afterDestroy` hooks, and `associate(models)` declares `belongsTo`, `hasMany` and `hasOne` associations that `include()` loads eagerly. The user model gets the same email, password, roles, `confirmedAt` and `passwordChangedAt` behaviour as the other adapters, and sessions are stored in a `henri_sessions` table. Migrations live in `db/migrations` in the drizzle-kit layout, and `henri db` drives them like `rails db`: ```bash henri db:status # applied and pending migrations henri db:schema # what the database holds: tables, columns, types, indexes henri db:generate --name=add-priority # writes db/migrations/0001_add_priority.sql from the models henri db:migrate # applies the pending migrations henri db:rollback # undoes the last one (--step= for more) henri db:push # makes the database match the models, no migration (development) henri db:schema:dump # writes db/schema.sql from the database henri db:schema:load # creates that schema in an empty database ``` `db:status` and `db:schema` are two questions of the same database and neither is computed from the other: `db:status` says what is _wrong_ and says nothing when the answer is "nothing", `db:schema` says what is _there_. `db:schema` is the only one of the two a `mongoose` store can answer. In development the boot pushes the schema unless the store sets `"sync": false`; in production the boot applies the pending migrations when the store sets `"migrate": true` and warns about them otherwise. `henri db:push` refuses statements that lose data unless `--force` is passed; every command accepts `--store=` and `--json`. `henri db:seed` is the exception: it runs [`db/seeds.js`](#seeds) on any adapter. #### Migration safety drizzle-kit writes the difference between the models and the last snapshot, and it will happily write a statement that takes a production database down. henri reads the generated SQL back -- the SQL, not the model diff, because the SQL is what runs -- and says which statements are the ones that bite. Where that lands depends on who is standing there: - **`henri db:generate` warns.** Generating is a development act, the developer is right there, and the migration they just wrote is the thing they are still deciding about. It always writes the file. - **A production `henri db:migrate` refuses**, and so does a production boot with `"migrate": true` on the store, because that is the one place nobody is watching (`HENRI_MIGRATION_UNREVIEWED`). Nothing is applied -- not even the safe migrations queued ahead of the one it stopped on, because applying half of a deploy's schema change is its own outage. - **`henri db:status` and `henri doctor` report** every pending migration that would be refused, so somebody finds out before the deploy rather than during it. ##### What it checks, and where each one bites The classic set, with the dialect answers measured against a real PostgreSQL 17, MySQL 8.4 and SQLite 3.53 rather than assumed (`packages/drizzle/__tests__/engines.spec.js` is where they are pinned): | Check | sqlite | postgres | mysql | What runs into | | ----------------- | ------ | -------- | ----- | -------------------------------------------------------------------------------------------- | | `table.drop` | yes | yes | yes | The deploy window, not the lock: the old process is still reading it. | | `column.drop` | yes | yes | yes | The same. Instant on postgres and on MySQL 8, which is not the problem. | | `table.rename` | yes | yes | yes | Worse than a drop: the old code and the new code both break. | | `column.rename` | yes | yes | yes | The same. | | `column.not-null` | yes | yes | yes | Not the same failure on each: see below. | | `column.type` | yes | yes | yes | A table rewrite under a lock on postgres and mysql; on sqlite, a copy. | | `index.build` | -- | yes | -- | A postgres problem alone: see below. | | `table.recreate` | yes | -- | -- | sqlite's answer to `ALTER COLUMN`, which copies the table. | | `data.unbounded` | yes | yes | yes | A `DELETE` or an `UPDATE` with no `WHERE`, which drizzle-kit never writes and a person does. | Two of those are worth spelling out, because the dialects genuinely disagree. **A `NOT NULL` column with no default does not fail the same way.** sqlite (`Cannot add a NOT NULL column with default value NULL`) and postgres (`23502`) refuse the statement outright as soon as the table has one row, so the migration fails and the deploy stops. MySQL 8.4 **accepts it**, under `STRICT_TRANS_TABLES`, and writes an empty string into every existing row of a `varchar` and a zero into every `int`, without a warning. The loud failure is the kind one; the safe path on all three is the same, which is to add the column nullable, backfill it, and add the constraint in a later migration. **Building an index is a postgres problem.** `CREATE INDEX` holds a `ShareLock` there, so every `INSERT`, `UPDATE` and `DELETE` on the table waits for the build. MySQL 8 builds a secondary index online -- it accepts `ALGORITHM=INPLACE, LOCK=NONE`, which it only does when concurrent writes are really allowed -- and sqlite has no concurrent form at all. henri reports it on postgres and stays quiet on the other two, because a check whose message cannot name a safe path is a check people turn off. The safe path on postgres has a catch worth knowing, and henri's message says it: `CREATE INDEX CONCURRENTLY` **cannot go in the migration file**. drizzle applies every pending migration inside one transaction, and postgres refuses `CONCURRENTLY` in a transaction block (`25001`). So build the index against the database yourself, outside the migration, and `henri db:generate` will see that it is already there. ##### How the SQL is read By walking it, not by matching it. A regular expression over a whole file would be both slow and, worse, wrong: `INSERT INTO notes (body) VALUES ('run DROP COLUMN before the deploy')` is a safe statement that contains the text of a dangerous one, and refusing it is worse than not checking at all. So henri scans the file once, character by character, and knows the four things that are not code: line and block comments (nested on postgres, flat elsewhere), string literals with their `''` escape plus mysql's backslashes, mysql's `"double quoted"` strings, postgres's `E'...'` and `$tag$ dollar quoting $tag$`, quoted identifiers in all three flavours, and the `--> statement-breakpoint` line drizzle-kit writes. **A string literal's content is thrown away** by the scanner rather than skipped over, so nothing downstream can read one as SQL even by accident. Two rules exist only to keep a false refusal from happening, because a false refusal is what gets a checker turned off: - **A table created by the same migration has no rows**, so nothing done to it is reported. Without this, every first migration would warn about the indexes it creates beside its tables. - **sqlite's table rebuild is one finding, not two.** sqlite has no `ALTER COLUMN`, and drizzle-kit's answer is to create `__new_tasks`, copy every row into it, `DROP TABLE tasks` and rename. Read one statement at a time that is a dropped table and a renamed table; read as a migration it is one table being rebuilt, and that is what `table.recreate` says. The pattern is recognized by its shape -- a table created here, later renamed onto a table dropped here -- and not by drizzle-kit's `__new_` prefix. ##### Approving one The way through is a token in the configuration, the way [`config.retention.approved`](/guides/retention/) works: ```bash henri db:status # prints the token of every pending migration it found something in ``` ```json { "migrations": { "approved": ["0002_drop_email:9f3c1a2b4d5e"] } } ``` The digest covers **what was found**, not the file: reformatting the migration or adding a comment leaves the token alone, and another `DROP COLUMN` edited in afterwards makes a new one, so the approval goes stale exactly when the thing approved changes. It is a plain digest and not a keyed one, deliberately -- a token is committed and travels to production, where `config.secret` does not. A flag on the command would have been the other answer, and it is the worse one: `henri db:migrate --force` in a deploy script is written once and then turns the check off for **every future migration**, silently, which is the failure this feature exists to prevent. A token names one migration and expires with it. For the operator who cannot redeploy the configuration, the environment already reaches it, and says so at boot: ```bash HENRI_CONFIG_JSON__migrations='{"approved":["0002_drop_email:9f3c1a2b4d5e"]}' henri db:migrate ``` `"migrations": { "approve": false }` turns the gate off wholesale for an application whose review lives somewhere else. That is a configuration rather than a flag, so it is visible in the repository, and `henri audit` reports it in a production configuration (`migrations.unreviewed`). ##### What it does not check It reads what is in the file. A migration that is safe on its own and catastrophic next to the deploy it ships with is not something a file can show, and henri does not guess: the deploy order is yours. It also does not count rows -- `db:rollback` does that, because it knows exactly which rows an inverse would remove, and a forward migration does not. And a MySQL executable comment (`/*!40101 ... */`) is read as a comment, so a statement hidden in one is not seen; drizzle-kit writes none, and treating it as code would mean refusing statements that will not run on the server in front of you. #### Rolling back drizzle-kit generates forward-only SQL: a migration has no `down`. henri does not ask you to write one, and does not write one at `db:generate` time either. The inverse is computed when you roll back, by handing drizzle-kit the two snapshots `db/migrations/meta` already holds in the other order -- so nothing is stored that could go stale, and what runs is the inverse of the schema `henri db:status` believes in. ```bash henri db:rollback # the last migration henri db:rollback --step=2 # the last two, newest first henri db:rollback --force # and yes, drop the rows it names ``` Rolling back moves the database, not `db/migrations`: the `.sql` and its snapshot stay where they are, `db:status` reports the migration pending again, and `db:migrate` applies it again. It refuses three things, because a rollback that quietly does something else is worse than no rollback at all: - **A migration that removed a table or a column** (`HENRI_MIGRATION_IRREVERSIBLE`). Its inverse would recreate them empty, and an empty column is not the column that was dropped. There is no flag for this one: undoing a destructive migration is a restore from a backup, and henri will not pretend otherwise. - **A migration whose `.sql` changed since it was applied** (`HENRI_MIGRATION_EDITED`). The database records the sha256 of the file it ran; when the file on disk hashes to something else, henri does not know what ran and will not guess. - **A rollback that would drop rows that are there** (`HENRI_MIGRATION_DESTRUCTIVE`). Not "a statement that matches `DROP`": the tables and columns the inverse removes are counted first. Undoing the migration you applied a minute ago on a database nothing was written into needs no flag; one that would take 412 rows away says so and needs `--force`, the way `db:push` does. Every dialect commits DDL differently -- MySQL commits each statement on its own -- so a rollback of several migrations applies them one at a time and removes each one's row only once its statements ran. A failure half way through leaves the database and `db:status` agreeing about what really happened. #### The schema dump `henri db:schema:dump` writes `db/schema.sql`: the shape of the database as one file, the way Rails' `db/schema.rb` is. What a new developer loads instead of replaying every migration, and what a reviewer reads to see what the database actually looks like. It is **read from the database**, not from the migration chain. A dump built from the chain would agree with the chain by construction -- it would be a second copy of files already in the repository, and it could never be the thing that catches an `ALTER` somebody ran by hand or a `henri db:push` that was never turned into a migration. The cost is that a dump is written where a database is reachable: a developer's machine, or a CI job with a service container, never a checkout alone. Two runs against the same schema produce the same bytes. Tables are ordered by name, types, indexes and foreign keys by their statements, and columns by the position the database keeps them in -- the order a `SELECT *` answers in. Nothing carries a timestamp, a row count or a sequence value; MySQL is read through `information_schema` rather than `SHOW CREATE TABLE`, which prints the table's `AUTO_INCREMENT` counter and would move the file on every insert. The header names the migration the database was at, so the dump and `henri db:status` cannot disagree: ```sql -- henri schema dump -- -- The shape of the database, not its data. Written by -- "henri db:schema:dump" and read by "henri db:schema:load"; it is -- generated, so change the schema with a migration and dump again. -- -- dialect: postgres -- migration: 0003_speakers ``` **Loading it is supported.** `henri db:schema:load` creates everything the dump describes and records the migrations up to the one it names as applied, leaving anything newer pending for `henri db:migrate` -- which is how a test database is built without replaying the chain: ```bash NODE_ENV=test henri db:drop && NODE_ENV=test henri db:create NODE_ENV=test henri db:schema:load ``` A load refuses a table it is about to create that already exists (`HENRI_MIGRATION_DATABASE_NOT_EMPTY`), and never empties a database to get its way: `henri db:drop` and `henri db:create` are the commands that do that, and there is no `--force` here. A table the dump says nothing about -- one another tool owns in the same database -- is left alone rather than being in the way. The dump describes tables, columns with their types, defaults and nullability, primary keys, unique and check constraints, indexes, foreign keys, and the enum types and plain sequences of PostgreSQL. It does not describe views, triggers, stored routines, grants, partitions, extensions, or any data: a database that uses more than the first list is not fully described by its dump. The tables henri owns without a model -- the job queue's, the access trail's, the webhook endpoints', drizzle's own record of what it applied -- are left out, because they are not the application's schema and the code that owns them creates them. `henri_sessions` is in, because it is part of the store's schema. An `mssql` store answers neither command: it is on Sequelize, it has no migration history for a dump to name, and `henri db:status` is what reads it back instead. A `mongoose` store has no schema of its own to write down. Both exit with `1` and `HENRI_CLI_MIGRATIONS_UNSUPPORTED`. #### Pushing to MySQL On MySQL a push only creates the tables that do not exist yet: drizzle-kit does not alter a MySQL table on a push, so a table whose columns no longer match the model is reported (`the columns of the database and of the schema differ`) and left alone rather than altered or truncated. Change a MySQL schema with `henri db:generate` and `henri db:migrate`, which work on every dialect. sqlite and PostgreSQL push the whole diff. ### PostgreSQL `@usehenri/drizzle` with the dialect and the `pg` driver chosen, so an application installs one package and declares no driver, and the store needs no `dialect` key. ```bash pnpm add @usehenri/postgresql ``` ```json { "stores": { "default": { "adapter": "postgresql", "url": "postgres://user:password@localhost:5432/myapp" } } } ``` Everything under [Drizzle](#drizzle) is true of it: the model API, the schema format, `db/migrations` and the `henri db:` commands. `henri new my-app --adapter postgresql` scaffolds it. ### MySQL and MariaDB `@usehenri/drizzle` with the dialect and the `mysql2` driver chosen, the same way `@usehenri/postgresql` is. ```bash pnpm add @usehenri/mysql ``` ```json { "stores": { "default": { "adapter": "mysql", "url": "mysql://user:password@localhost:3306/myapp" } } } ``` Everything under [Drizzle](#drizzle) is true of it, including [what drizzle-kit will not alter on a MySQL push](#drizzle). `henri new my-app --adapter mysql` scaffolds it. Measured against MySQL 8.4. #### MariaDB `"adapter": "mariadb"` is the same package, and a `mariadb://` url is served by `mysql2`. MariaDB answers the MySQL wire protocol and henri compiles the same dialect for it, so most of what is on this page works there — but **two things do not, and neither is henri's to fix.** They are measured by `packages/drizzle/__tests__/mariadb.spec.js` against MariaDB 10.11 and 11.8, with drizzle-orm 0.45 and drizzle-kit 0.31: - **Eager loading does not work.** `include()` compiles to `LEFT JOIN LATERAL (...) ON TRUE` on drizzle-orm's MySQL dialect, and MariaDB has no `LATERAL` derived tables in any version. Every `include()` — and every `embeds` declaration, which reads through the same path — is a syntax error from the server. Load the association with a second query instead. - **`henri db:push` does not work, and neither does a development boot with the default `"sync"`.** drizzle-kit introspects before it pushes, and its check-constraint pass cannot read a MariaDB schema: MariaDB has no `JSON` type of its own (`JSON` is `LONGTEXT` with a `CHECK (json_valid(...))` next to it), so every `json` column — the user model's `roles`, on every application — leaves a check constraint the pass then reads the wrong column name out of. The first push of an empty database works and every one after it fails. henri catches it and answers `HENRI_MIGRATION_PUSH_FAILED` rather than letting the process end with no message. So a MariaDB store is set up with `"sync": false` and changed with `henri db:generate` then `henri db:migrate`, which do work: ```json { "stores": { "default": { "adapter": "mariadb", "sync": false, "url": "mariadb://user:password@localhost:3306/myapp" } } } ``` `henri db:generate` writes the migration whatever it can read back, and says so when it could not: the migration stays pending and `henri db:migrate` applies it. `henri db:schema:dump` and `henri db:schema:load` read and write MariaDB correctly, `henri db:status` lists what is pending, and `adapter.describe()` reports the columns in MariaDB's own spelling — `int(11)` where MySQL 8 says `int`, `longtext` where it says `json`. Everything else runs there and is exercised by the same suites (`pnpm test:sql:mariadb`): the model API, the schema format, the exact `decimal` and `bigint` types, time zones, validations, enums, slugs, filters, `res.csv()`, encrypted attributes, multi-tenancy, retention, the access trail, model versions, the call log with its `RANGE` partitions, and the background job queue with its claim, its concurrency slots and its batches. `henri new --adapter` does not offer `mariadb`. ### Disk A MongoDB instance started for you by [mongodb-memory-server](https://github.com/typegoose/mongodb-memory-server), persisted under `.henri/data` in the application directory (`path` changes it, `dbName` the database name, `henri` by default). Under `NODE_ENV=test` the data stays in memory, so every test run starts empty. Zero configuration, no server to install, a warning in production: it is a development store. ```bash pnpm add @usehenri/disk ``` ```json { "stores": { "default": { "adapter": "disk" } } } ``` The first boot downloads the MongoDB binary into `~/.cache/mongodb-binaries`. mongod listens on a port derived from the process id, between 20000 and 26999. That is below the ephemeral ports both Linux and macOS hand out, and it is per process, so several stores starting at the same moment — test workers, a suite running beside the application, a monorepo booting more than one application — never fight over the same port. Set `port` to pin one instead, to point a GUI or `mongosh` at the store: ```json { "stores": { "default": { "adapter": "disk", "port": 27100 } } } ``` A pinned port is used as given: the boot fails, naming the port, rather than moving the store somewhere the application did not say. ### MongoDB ```bash pnpm add @usehenri/mongoose ``` ```json { "stores": { "default": { "adapter": "mongoose", "url": "mongodb://localhost:27017/myapp", "opts": {} } } } ``` `host`, `port`, `database`, `username` and `password` are accepted instead of `url`. `opts` is passed to `mongoose.connect()`; `serverSelectionTimeoutMS` and `connectTimeoutMS` default to 10 seconds, so a wrong url fails the boot quickly. A store without `url` or `host` fails the boot. ### MSSQL The one adapter on [Sequelize](https://sequelize.org/), and the only way henri reaches SQL Server: Drizzle has no SQL Server dialect. drizzle-orm 0.45 ships pg, mysql, sqlite, singlestore and gel cores, and drizzle-kit 0.31 generates migrations for postgresql, mysql, sqlite, turso, singlestore and gel -- there is no mssql in either. That is the whole reason `@usehenri/sequelize` is still here, and it is not going anywhere while it stays true. ```bash pnpm add @usehenri/mssql ``` ```json { "stores": { "default": { "adapter": "mssql", "url": "mssql://user:password@localhost:1433/myapp" } } } ``` `host`, `port`, `database`, `username` and `password` are accepted instead of `url`; a store with none of them fails the boot. Every other key of the store (`pool`, `dialectOptions`, `logging`, ...) is forwarded to Sequelize. `logging` defaults to the `henri:sequelize` debug namespace (`henri server --debug=henri:sequelize` prints the queries) and credentials are redacted from that output. The global is a real Sequelize model, so `findAll`, `findByPk`, `Model.scope()`, `Op` in a where, `options: { indexes, scopes, hooks }` and the rest of that documentation apply, and `options: { paranoid: true }` is Sequelize's own, so `restore()`, `{ paranoid: false }` and `{ force: true }` behave exactly as it describes. None of that is true of the other SQL adapters, which are Drizzle. #### The schema of an mssql store **A unique column is a named constraint here, and only here.** SQL Server names an inline `UNIQUE` itself -- `UQ__Articles__32DD1E4C507CA19A` -- and Sequelize then cannot tell which column a violation was about: it looks the constraint name up among the ones it computed itself, misses, and reports the constraint name where the column belongs. So a duplicate slug used to answer `{ UQ__Articles__32DD1E4C507CA19A: 'UQ__Articles__32DD1E4C507CA19A must be unique' }` instead of the `{ slug: 'must be unique' }` [every other store answers](#validation-errors). henri writes `CONSTRAINT [Article_slug_unique] UNIQUE ([slug])` instead, which is what makes that lookup hit. It covers the columns a model declares and the ones henri adds (`externalId`, `slug`, `email`), it is the same name in every database, and it changes nothing on the other three dialects, which report the column on their own. This adapter has no migrations, and henri does not pretend otherwise: `sequelize.sync()` creates the tables that are **missing** and never alters a table that already exists. That is enough in development and it is not a way to change a live database, so henri is explicit about where each half applies. **In development** the boot syncs, unless the store sets `"sync": false`. **In production** the boot changes nothing. It reads the database back instead, compares it with the models and warns about every difference it finds. A store that really wants the old behaviour asks for it with `"sync": true`, and `henri audit` reports that as [`schema.autosync`](/guides/security/): it is DDL applied at boot, from whatever the models happen to say, with nobody reviewing it. **`henri db:status`** is the same comparison on demand, and the one command of the `db:` family this store answers. `db:generate`, `db:migrate`, `db:rollback`, `db:push`, `db:schema:dump` and `db:schema:load` all exit with `1` and [`HENRI_CLI_MIGRATIONS_UNSUPPORTED`](/reference/errors/#henri_cli_migrations_unsupported) here, saying so rather than doing half of it: there is no migration history to roll back, and no migration for a dump to name. ```bash henri db:status # what the database and the models disagree about henri db:status --sql # the DDL that would close it, for you to review henri db:status --json # `clean: false` and the differences, for CI ``` It reports a missing table, a missing column, a column whose type or nullability differs, a missing index, and a column that is in the database and in no model. It never writes a `DROP`: a column henri does not recognize may hold the only copy of something, and only you can know. Everything it writes is DDL for **you** to read and run; henri applies none of it. ``` Store default (mssql), compared with the models 3 difference(s): tasks.priority: the column is missing tasks.name: the database has TEXT instead of VARCHAR(255) tasks.legacy_note: the column is in the database and in no model henri does not change this schema for you: run it again with --sql for the DDL that would close the difference. ``` `henri db:generate`, `db:migrate` and `db:push` answer `HENRI_CLI_MIGRATIONS_UNSUPPORTED` on this store and point at the [drizzle adapter](#drizzle), which is where generated, reviewable, versioned migrations live. --- # Controllers Source: https://usehenri.io/guides/controllers/ Express handlers under app/controllers, autoloaded and reloaded on save, with before hooks, declared parameters, declared answers, implicit rendering and flash messages. Controllers live in `app/controllers`. Every `.js` file there is loaded on boot, reloaded on save and referenced from `config/routes.js` as `file#action`; a file in a subdirectory is prefixed with it (`app/controllers/admin/users.js` is `admin/users#index`). A controller is a plain object of Express handlers, `(req, res)` or `(req, res, next)`, sync or async, plus an optional `before` block, an optional `params` block saying what each action accepts, an optional `answers` block saying what each action answers, an optional `filters` block saying what a list may be narrowed and ordered by and an optional `embeds` block saying what may travel next to a record in `_embedded`. Models are globals, `henri` is a global, and `res.render()` hands data to the view. A `/** @type {import('@usehenri/core').Controller} */` line above `module.exports` is what gives `req` and `res` completion in an editor; the generators write it for you. See [Types](/reference/types/). ```js // app/controllers/tasks.js const FIELDS = ['name', 'category', 'done']; /** * Loads the task of `:id`, or answers a 404 and ends the request * * @param {object} req Express request * @param {object} res Express response * @returns {Promise} The 404 answer, or nothing */ const loadTask = async (req, res) => { // `:id` is the public identifier of the record; findById() takes it req.task = await Task.findById(req.params.id); if (!req.task) { // Not res.boom.notFound(): a policy that refuses this task answers a // 404 too, and the two have to read the same. See `res.notFound` below return res.notFound(`Task ${req.params.id} not found`); } }; module.exports = { before: { 'show,edit,update,destroy': loadTask }, // No res.render(): what an action returns is the data of its own page index: async () => ({ tasks: await Task.find() }), show: async (req) => ({ task: req.task }), create: async (req, res) => { let task; try { task = await Task.create(req.permit(...FIELDS)); } catch (error) { if (error.name !== 'ValidationError') { throw error; } return res.boom.badData(error.message, { errors: Object.fromEntries( Object.entries(error.errors).map(([field, e]) => [field, e.message]) ), }); } req.flash('notice', 'Task created'); return res.negotiate({ html: () => res.redirect(`/tasks/${task.externalId}`), json: () => res.resource(task, { status: 201 }), }); }, }; ``` This is the shape `henri generate scaffold` writes: a `before` hook loading the record once, `req.permit()` for the input, a `422` with one message per field when validation fails, a `404` when the record does not exist, and `res.negotiate()` so the same action serves a browser (redirect) and an API client (JSON). Errors thrown or rejected by a controller are logged and answered with a `500`; the message and stack are exposed in development only. Requests nothing claims get a `404`. Both are content negotiated: JSON for API clients (the `res.boom` shape below), an HTML page for browsers. ## `before` hooks `before` is henri's `before_action`: functions the router runs ahead of the actions they name, in declaration order, once the route is allowed (behind the [role guard](/guides/routes/#roles) and the `Idempotency-Key` replay). A hook is `(req, res, next)` or an async `(req, res)`, and **a hook that answers ends the request**: the action never runs. ```js module.exports = { before: { all: [authenticate], // every action show: loadTask, // one action 'edit,update,destroy': [loadTask, mustOwnIt], // several }, // ... }; ``` `all` (or `*`) selects every action, any other key is one action or a comma-separated list. The array form takes the Rails selectors instead: ```js module.exports = { before: [ authenticate, // every action { only: ['show', 'edit', 'update'], run: loadTask }, { except: ['index', 'new'], run: mustOwnIt }, ], // ... }; ``` A hook may also be given by name (`before: { show: 'loadTask' }`), which resolves to another export of the same controller. `before`, [`params`](#params-what-an-action-accepts), [`answers`](#answers-what-an-action-answers), [`filters`](#filters-what-a-list-may-be-narrowed-and-ordered-by) and [`embeds`](#embeds-what-travels-next-to-a-record) are never routable: they are the keys of a controller that are not actions. ## `params`: what an action accepts `req.permit()` picks fields by name and says nothing about what they hold. `params` says it: one block, keyed by action the way `before` is, one rule per field. What does not match never reaches the action. ```js module.exports = { params: { all: { format: { type: 'string', enum: ['html', 'json'] } }, create: { title: { type: 'string', required: true, maxLength: 120 }, year: { type: 'integer', min: 1400, max: 2100 }, tags: { type: 'array', of: 'uuid', maxLength: 5 }, }, 'index,search': { page: { type: 'integer', min: 1, default: 1 } }, }, index: async (req) => ({ tasks: await Task.paginate(req.permit()) }), create: async (req, res) => res.resource(await Task.create(req.permit()), { status: 201 }), }; ``` `all` (or `*`) is every action, any other key is one action or a comma-separated list, and the action's own key wins over the lists before it. A rule may be the type itself: `year: 'integer'` is `year: { type: 'integer' }`. | Key | What it does | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | `string`, `text`, `number`, `integer`, `float`, `decimal`, `bigint`, `boolean`, `date`, `json`, `uuid` — the [model types](/guides/models/) — and `array` | | `required` | the field has to be there | | `default` | what an absent field is worth; a function is called per request | | `enum` | the values accepted, checked after the coercion | | `min`, `max` | the bounds of a number; on a `decimal` or a `bigint` they are compared digit by digit and may be written out as strings themselves | | `minLength`, `maxLength` | the bounds of a length: characters of a string, items of a list | | `pattern` | a regular expression a string has to match | | `of` | the rule every item of a list follows; a list declares it | A rule henri cannot carry out fails the boot with `HENRI_PARAMS_DECLARATION_INVALID`, naming the controller, the action and the key: an unknown type, an unknown key (`requird`), a constraint the type does not take (`min` on a string), a `default` the rule itself refuses, a selector naming an action the controller does not export. A declaration that silently accepts everything is the mistake this feature exists to remove. [`henri generate scaffold|crud`](/reference/cli/#generators) writes one for `create` and `update`: the columns of the model, typed, with the values of an `enum` column. It stops there deliberately — `required` and the lengths stay on the model, where a job, a seed and a console are held to them too, and the generated file argues why. Adding a `maxLength`, a `min` or a field that is no column at all is the edit that block is written to invite. ### Coercion A query string is all strings. A form body is all strings. A JSON body is not, and that is the rule: - **A textual source** — the query string, a path parameter, a form body — is _parsed_ into the type. `?page=2` arrives as the number `2`, `?active=true` (or `on`, `yes`, `1`) as `true`, `?at=2024-01-02` as a `Date`. There is no other way for a client to say `2` there. - **A JSON body** is _checked_, never parsed: `{"page": "2"}` is a caller sending a string where the action declared a number, and it is refused. JSON can say `2`; it said `"2"`. The types JSON cannot carry are the exception and are read from a string there as well: a `date` is an ISO-8601 string, a `uuid` is a string, and so are a `decimal` and a `bigint`. The last two are the interesting case, because JSON _can_ express them — as a double, which is the value they exist to avoid. `{"price": 19.99}` is refused for that reason, and `{"price": "19.99"}` is what a client sends. From a textual source the digits are kept as a string rather than parsed into a number, so an [exact value](/guides/models/#exact-numbers) is the same string wherever it came from; `enum` matches by value there, so `'10'` matches a declared `'10.00'`, and `minLength`, `maxLength` and `pattern` are not constraints those two take — they measure text — so declaring one fails the boot. The rest follows: an empty string from a textual source is an absent field (a browser sends one for every input nobody touched) unless the type is `string` or `text`; `null` is an absent field everywhere; a single textual value is a one-item list when the type is `array`, while a JSON body has to send the list; and `?year=1&year=2` is a list arriving where a number was declared, which is refused rather than silently taking one of the two. What is accepted is written back where it came from, so `req.query.page` is the number and not the string, and `req.permit()` **with no field at all** answers everything the declaration accepted, defaults included. A field nobody declared is dropped, exactly as `req.permit('title')` drops it — there is no strict mode refusing an undeclared key, because a bookmarked url carrying `utm_source` is a link somebody shared and not an attack. ### When it does not match The action never runs. The answer is a `422` carrying one message per field, negotiated like every other answer henri gives: ```json { "statusCode": 422, "error": "Unprocessable Entity", "code": "HENRI_PARAMS_INVALID", "message": "the parameters are invalid", "data": { "errors": { "year": "must be a whole number", "title": "is required" } } } ``` `data.errors` is the `{ field: message }` shape [`henri.model.errors()`](/guides/models/) normalizes an ORM's validation failure to, so a form reads one thing whether the request was refused at the boundary or by the model. A browser that posted a form is sent back to the page it came from (`303`) with the messages in the flash, where a page reads them as `errors`; the values are not flashed, because henri cannot tell a password from a title. A browser that asked for anything else gets the `422` page. The check runs once the route is allowed — behind the [role guard](/guides/routes/#roles) and the [policy](/guides/policies/) — and **ahead of the `before` hooks**, so a hook that loads a record already sees the coerced value. It is registered whatever the verb, so an `index` declaring its query string answers `422` the same way a `create` declaring its body does. An action that declares nothing behaves exactly as it did: nothing is checked, nothing is coerced, and `req.permit(...)` is unchanged. ### It is also the description of the request [`henri openapi`](/guides/openapi/) reads this block: the fields become the query, path and body parameters of the operation, with the types, the bounds and the enums declared here, and the `422` becomes a response the document names. Declaring `params` is how an action gets a request body in the description that is the one it actually accepts, rather than the writable columns of its model. ## `answers`: what an action answers `params` declares what arrives. `answers` declares what leaves, in the same block shape and with the same vocabulary — and it sits next to a rule that needs no declaration at all, so read the floor first. ### The floor: every JSON answer is published and stripped `res.render()`, `res.resource()` and `res.collection()` have always gone through one gate on the way out: a foreign key leaves as the [`externalId`](/guides/models/#two-identifiers) of the row it names, and a field a model marked [`personal: { expose: false }`](/guides/privacy/) is dropped. **`res.json()` now goes through the same gate**, on every action of every controller, declared or not: ```js // the models say `gender` never leaves; this does not change that res.json({ rows: [{ email: user.email, gender: user.gender }] }); // => {"rows":[{"email":"ada@usehenri.io"}]} ``` That is the hole this closes. An object a controller builds by hand is not a record — the props of an Inertia page assembled in the action, a total next to a list — so nothing published it and nothing stripped it, and a field marked as never leaving left. There is no setting that turns the floor off: the mark is the model's word about a person, and a controller is not where it is overruled. `res.resource(record, { include })` and `res.render(view, { data, include })` are still how a private field is sent on purpose, and `expose: true` below is the third way. henri's own answers are untouched by it — the HAL envelope, the page options, the `res.boom.*` body, the 404 and 500 pages, an Inertia page object — because those were built through the gate already or are an envelope with a shape of their own. What the floor cannot do is see through a name. `res.json({ g: user.gender })` is a string as far as henri is concerned, and so is `{ ownerId: memo.ownerId }` — a plain object carries no model, so nothing knows that key names a row. Both are what a declaration is for. The other thing it cannot do is read bytes. `res.json()` and `res.jsonp()` are gated because that is where the value still is; `res.send(JSON.stringify(value))`, `res.write()` and a stream hand over a string, and a string is not an answer henri can walk. ### The declaration ```js module.exports = { answers: { all: { total: 'integer' }, index: { rows: { model: 'Memo', type: 'array' }, who: { from: 'User.email', type: 'string' }, }, show: { memo: { model: 'Memo' } }, }, index: async (req, res) => res.json({ rows: (await Memo.find()).map((memo) => ({ ownerId: memo.ownerId, title: memo.title, })), total: await Memo.count(), who: req.user.email, }), }; ``` `all` (or `*`) is every action, any other key is one action or a comma-separated list, and the action's own key wins over the lists before it — the selectors `before` and `params` already use. A rule may be the type itself: `total: 'integer'` is `total: { type: 'integer' }`. | Key | What it does | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | the [model types](/guides/models/), plus `array` and `record`. Implied by `model` (a record) and by `from` (`json`, the type that says nothing) | | `model` | the model whose records this field holds: one record on its own, a list of them with `type: 'array'`. **This is what publishes a hand-built object**: `ownerId` above leaves as the owner's `externalId` | | `from` | `'User.email'`, the column this value came from. The field then obeys that column's marks under whatever name the answer gives it, and `henri openapi` types it from the model file | | `of` | the rule every item of a list of scalars follows | | `required` | the field has to be in the answer | | `expose` | `true` says this action may carry a field marked `personal: { expose: false }` — the declared form of the `include` `res.resource()` takes | A rule henri cannot carry out fails the boot with `HENRI_ANSWERS_DECLARATION_INVALID`, naming the controller, the action and the field: an unknown type or key, a `model` that is not one of this application's, a `from` that is not `Model.field`, a list with neither `of` nor `model`, a selector naming an action the controller does not export, and a `from` pointing at a column marked `expose: false` without `expose: true` next to it. A declaration that reads like a guarantee and is quietly ignored is the mistake this exists to remove. There is no `enum`, no `min`, no `pattern` and no `default`: a declaration describes the shape of an answer, and henri does not validate an application's own values on the way out — the model did that on the way in, and a response that fails after the work is done turns a cosmetic mistake into an outage. ### What is not declared does not leave An action that declared answers sends the fields it declared, and nothing else: ```js answers: { peek: { title: 'string' } }, peek: async (req, res) => res.json({ secret: req.memo.body, title: req.memo.title }), // => {"title":"Minutes"} ``` That is the same rule `req.permit()` follows in the other direction — the declaration is the list, an undeclared key is dropped and never refused — and it is the safe direction: the field nobody declared is the field nobody thought about. It costs nothing to adopt, because it only applies to an action that declared: one that declares nothing keeps every byte it sends today. The other half goes the other way. A field that was **declared and is missing**, or that holds something other than what was declared, is a mistake in the declaration rather than something leaking, so it is reported once per route in the log and only refused with [`config.api.strict`](/configuration/) — the setting that already means "refuse what would otherwise be a warning about this application's API", where it answers a 500 carrying `HENRI_ANSWERS_MISMATCH`. The declaration is one level deep, like `params`: it describes the fields of the answer, not the inside of a record it names. What leaves inside a declared record is what the record holds, published and stripped by the floor. ### It is also the description of the answer [`henri openapi`](/guides/openapi/) refused to describe what a controller writes: such an operation carried the statuses henri produces, `x-henri.known: false` and no success status at all. A declared answer is exactly what it could not know, so an operation that has one carries a `200` with the schema — `$ref`ing the model's record schema for a field naming a model, the column's own schema for a field naming one, and `additionalProperties: false`, because the document says what the gate does. ## `filters`: what a list may be narrowed and ordered by The fourth reserved key. `params` says what an action accepts; `filters` says what a client may **filter and sort a list by** — which columns, with which operators, and in which order — and refuses everything else with the same `422`: ```js filters: { index: { where: { state: { enum: ['submitted', 'accepted'], type: 'string' }, title: { operators: ['contains'], type: 'string' }, }, sort: ['submittedAt', 'title'], default: '-submittedAt', }, }, index: async (req, res) => { const { order, where } = await req.filters(); return res.collection( ... ); }, ``` `req.filters()` intersects what the client asked for with what [the policy says the list is](/guides/policies/#scoping-a-list), so a filter narrows a list and can never widen it. Nothing undeclared is filterable, an order over a `text` or an `encrypted` column is refused at boot, and `henri openapi` describes the `filter[...]` parameters the same way it describes `params`. The whole of it is in [Filtering and sorting](/guides/filtering/). ## `embeds`: what travels next to a record The fifth reserved key, and the one that points at the answer: `embeds` says which of a record's **relations** may travel with it under `_embedded`, so a client that wants an invoice and its lines makes one request instead of two — and a page of twenty invoices makes one instead of twenty one. ```js embeds: { show: { customer: 'customerId', lines: { limit: 200, through: 'Line.invoiceId' }, }, index: { customer: 'customerId' }, }, show: async (req, res) => res.resource(req.invoice, { embed: ['lines'] }), ``` A relation is the **declared foreign key** it goes through: `'customerId'` is a key this model declared and the record it names is embedded, `'Line.invoiceId'` is a key another model declared at this one and the records naming it are. A client asks with `?embed=lines`, only for what the action declared, and anything else is a `422` before the action runs. Every embedded record goes through the same publish and strip as the record it hangs off — so a column marked `personal: { expose: false }` is no more reachable there than anywhere else — and is asked `show` against its own model's policy, one record at a time. The whole of it is in [Embedding relations](/guides/api/#embedding-relations). ## Implicit rendering An action that returns without answering renders its own page with what it returned, the way Rails renders `tasks/show` when the action falls through: ```js show: async (req) => ({ task: req.task }), // is res.render('/tasks/show', { data: { task: req.task } }) ``` The page is `//`, and `/` for `index` (`app/views/pages/tasks/index.js` is the `/tasks` page). A non-object return value renders the page with no data. Nothing changes for an action that answers explicitly — `res.render()`, `res.json()`, `res.resource()`, `res.redirect()`, `res.boom.*`, `next()` — even when it does not await the answer. `return false` opts out, for the rare action that answers later on its own. ## Flash messages `req.flash(type, message)` queues a message in the session; `req.flash(type)` reads and clears it, and `req.flash()` reads and clears the whole bag. A message survives exactly one redirect: ```js req.flash('notice', 'Task created'); res.redirect('/tasks'); ``` The page rendered next receives them as `flash` next to `data`, `user` and `paths` (`{ notice: ['Task created'] }`), which consumes them: `{{#each @flash.notice}}` in a Handlebars page, the `flash` prop of a React page (`useHenri().flash`), `useHenri().flash` with Inertia. A request that renders nothing leaves them alone, so a `POST` that answers JSON does not eat the notice of the page after it. Flash messages live in the express session, which exists only in an application with a user model. Without one, `req.flash()` is a no-op answering an empty bag rather than an error. ## `res.render(route, options)` `route` is the page to render: `/tasks/show` is `app/views/pages/tasks/show.jsx` with the Inertia renderer, `pages/tasks/show.js` with React, `pages/tasks/show.hbs` with Handlebars. `options` takes one of: - `data`: an object passed to the page as its `data` prop or template context - `graphql`: a query string; its result becomes `data` (see [GraphQL](/guides/graphql/)) Besides `data`, the view engine receives `user` (the public user or `null`), `paths` (the named routes the user may call), `query` (`req.query`), `csrf` (the CSRF token), `localUrl` (the server url), `flash` (the [flash messages](#flash-messages)), `errors` (GraphQL errors, if any) and `graphql` (`{ endpoint, query }`). A React page gets all of them as props except `query` (use `router.query`); Inertia pages and Handlebars templates get `query` too. A client asking for `application/json` gets the whole object as JSON instead of HTML; this is what the React helpers use to refresh a page. `res.hbs(route, options)` renders a Handlebars template whatever the configured renderer, with the same options. `data` and `graphql` are one or the other, never both: the query answers the page and the data would be thrown away, so the call is refused. So is a second argument that is not an object — it used to be read as a GraphQL query, and it said so only in development. See [Wrong calls](/reference/api/#wrong-calls). ## `req.permit(...fields)` Never hand `req.body` to a model as is: a client could set `roles` or any other column. `req.permit()` returns a plain object holding only the fields you list, taken from the query string, the body and the path parameters (a path parameter wins over the body, the body over the query string). Fields that were not sent are left out, and `__proto__`, `constructor` and `prototype` are never copied. ```js const data = req.permit('email', 'password', 'name'); const same = req.permit(['email', 'password', 'name']); // arrays are flattened ``` Every field has to be a name. One stray `undefined` — `req.permit(maybe)` where `maybe` turned out to be nothing — is refused rather than answering `{}`, which used to be a write that quietly saved nothing. `henri.params(req).permit(...)` is the same helper for code outside the middleware chain, and `henri.params(req).all()` the merged parameters, for reading only. An action that declared [`params`](#params-what-an-action-accepts) has had those fields checked and coerced before it ran, so `req.permit('year')` answers the number and not the string it arrived as, and `req.permit()` with no field answers the whole declaration. Without a declaration `req.permit()` answers `{}`: nothing is permitted unless it is named. ## `res.notFound` `res.notFound(why)` is the 404 for a record that is not there: the error page for a browser, the boom envelope for an API client, negotiated like every other answer henri writes. ```js if (!req.task) { return res.notFound(`Task ${req.params.id} not found`); } ``` `why` is for you. It is answered in development and in a test process, and dropped in production, where the body is `Not Found` and nothing else — which is what makes this 404 and the 404 a [policy refusal](/guides/policies/#the-message-of-a-404-refusal-stops-at-production) answers **one answer**. A 404 that says which of the two it was tells whoever asked that the record exists, and that is the whole thing the refusal's 404 was for. Prefer it to `res.boom.notFound()` for a record lookup, which says its message in every environment and answers JSON even to a browser — so the shape gives it away even when the words match. `res.boom` is still the right answer for the rest. ## `res.boom` `res.boom.(message, data)` answers a JSON error with a fixed shape. `message` defaults to the reason phrase and `data` is optional: ```js res.boom.notFound('No such task', { id: req.params.id }); // 404 { "statusCode": 404, "error": "Not Found", "message": "No such task", "data": { "id": "..." } } ``` | Helper | Status | | ------------------- | ------ | | `badRequest` | 400 | | `unauthorized` | 401 | | `forbidden` | 403 | | `notFound` | 404 | | `methodNotAllowed` | 405 | | `conflict` | 409 | | `badData` | 422 | | `tooManyRequests` | 429 | | `internal` | 500 | | `notImplemented` | 501 | | `badGateway` | 502 | | `serverUnavailable` | 503 | `message` is a string: the envelope promises one and the [OpenAPI description](/guides/openapi/) says so, so `res.boom.badData({ title: 'is required' })` is refused — the detail goes in `data`, which is the second argument. A third argument names the failure: `res.boom.notFound('No such task', null, 'HENRI_MODEL_UNKNOWN_STORE')` adds a `code` to the body. It is the same envelope the 404 and 500 handlers answer with, and a failure henri raises itself already carries its own — see [Error codes](/reference/errors/). The React `Form` reads `data.errors` of a `422` and shows each message under its field, and the `RequestError` thrown by `fetch()` exposes `message`, `statusCode` and `data`. ## `res.resource(record, options)` and `res.collection(records, options)` The JSON answers of an API: the public fields of a record (or a page of records under `_embedded`) with HAL `_links` built from the route helpers and filtered by the user's roles, `application/hal+json` when asked for, a `Location` header on `201`. `req.pagination()` reads `?page=` and `?per_page=` for the collection. See [JSON API](/guides/api/). ## `res.negotiate({ html, json })` Runs `html` for browsers and `json` for API clients (`application/json`, `application/hal+json`, `application/vnd.henri.v1+json`), which is how a scaffolded controller serves both a page and a HAL answer from one action: ```js return res.negotiate({ html: () => res.render('/tasks/show', { data: { task } }), json: () => res.resource(task), }); ``` One of the two is required. With neither, express answers `406 Not Acceptable`, which blames the client's `Accept` header for a mistake in the controller — so henri refuses the call instead. ## `res.format(handlers)` Express's content negotiation. henri's own answers put `json` before `html` so that a client accepting `*/*` (curl, `fetch()` with no `Accept`) gets JSON and only a browser gets HTML; do the same in your controllers when the order matters, and always give a `default`. ## What is on `henri` Inside a controller (or anywhere else) the `henri` global exposes the loaded modules: `henri.config`, `henri.pen` (the logger), `henri.mail`, `henri.graphql`, `henri.user`, `henri.router`, `henri.server.app` (the Express app), `henri.model.stores`, `henri.validator` ([validator.js](https://github.com/validatorjs/validator.js)) and `henri.gql`. See the [API reference](/reference/api/). `henri.addMiddleware(name, fn)` registers a function that receives the router before the routes are mounted, which is how you add your own Express middlewares. It must be called before the router starts, so from a model file or from a [module](/reference/under-the-hood/#writing-a-module) declaring `before: ['router']`, rather than a controller. --- # Routes Source: https://usehenri.io/guides/routes/ config/routes.js, HTTP verbs, roles, root, crud, resources, only and except, member and collection, namespaces and nested resources. Routes are defined in `config/routes.js`. Any page in `app/views/pages` is also rendered when no route matches first. A route is a key (a path, or an HTTP verb and a path) pointing to `controller#action`, or to an object with a `controller` and options (`roles`, `scope`, `only`, `except`, `member`, `collection`, `nested`). The keys and the options are typed: with the `/** @type {import('@usehenri/core').RoutesFile} */` line `henri new` writes, an editor completes them and refuses a verb or a resource action that does not exist. See [Types](/reference/types/). ```js // config/routes.js module.exports = { root: 'main#home', // GET / '/test': 'user#info', // defaults to 'get /test' '/abc/:id': 'moo#iii', // the controller does not exist: see below 'get /poo': 'user#postinfo', 'post /poo': 'user#create', 'get /secured': { controller: 'secure#index', roles: ['admin'], }, 'resources todo': { controller: 'todo', }, 'crud categories': { scope: 'api', controller: 'categories', except: ['destroy'], // the DELETE route is not created }, 'namespace admin': { 'resources users': { roles: ['admin'] }, // /admin/users -> admin/users }, }; ``` Keys are `verb /path` with any Express verb (`get`, `post`, `put`, `patch`, `delete`, `options`, `head`, ...), or one of the keywords `root`, `resources`, `crud` and `namespace`. When two keys expand to the same verb and path, the later one wins. `henri routes` prints the expanded table (verb, path, controller, path helper, roles and policy) without starting the server. While the server runs, `r` prints the loaded routes and `u` the ones whose controller is missing, and in development `GET /_routes` and `GET /_controllers` return the same as JSON, from the machine running the server only. In development, a route pointing to a missing controller answers `501 Not Implemented` so you notice; in production it is skipped. Requests nothing claims get a `404`, errors thrown by a controller a `500`; both are content negotiated (JSON `{ statusCode, error, message }` for API clients, an HTML page for browsers, with the stack in development only). ## Roles Give a route an array of roles and only authenticated users whose `roles` contain every one of them can reach it. Denied requests get a `401` (not logged in, `Authentication required`) or a `403` (missing role, with the required `roles` in `data`) as JSON; a browser asking for HTML is redirected to `config.user.loginPath`, `/login` by default. henri mounts `POST /login` but no login page: add one there (see [Users](/guides/users/#login-and-logout)). A route with `roles` in an application without a user model logs a warning at boot and denies every request. ## Policies `roles` says who may reach an endpoint. `policy` says who may act on the record behind it, from `app/policies/.js`, and the two compose -- a route may declare both, and the role answers first. ```js // `true` names the policy after the controller: proposals -> proposal 'resources proposals': { policy: true }, // or name another one 'get /proposals/mine': { controller: 'proposals#mine', policy: 'proposal' }, ``` The guard answers the actions that need no record (`index`, `new`, `create`) before the action runs, and leaves the rest to the controller, which has the record. A missing policy or a missing rule refuses. See [Policies](/guides/policies/). ## CRUD The `crud` keyword replaces the HTTP verb and creates the API routes for a resource: ```text // 'crud happy': 'life' GET /happy => life#index POST /happy => life#create PATCH /happy/:id => life#update PUT /happy/:id => life#update DELETE /happy/:id => life#destroy ``` ## Resources The `resources` keyword adds the view routes to CRUD: ```text // 'resources happy': 'life' GET /happy => life#index POST /happy => life#create PATCH /happy/:id => life#update PUT /happy/:id => life#update DELETE /happy/:id => life#destroy GET /happy/:id/edit => life#edit GET /happy/new => life#new GET /happy/:id => life#show ``` `henri generate scaffold Post` writes the `resources posts` key and a controller with the seven actions; `henri generate crud Post` the `crud posts` key and the four JSON actions. The controller defaults to the name of the resource, so `'resources posts': {}` is `'resources posts': { controller: 'posts' }`. ## Root `root` maps `GET /` to an action, like `'get /'` does: ```js module.exports = { root: 'main#home', // or { controller: 'main#home', roles: ['member'] } }; ``` ## Scope Add `scope` to prefix the generated routes: `scope: 'api'` turns `/happy` into `/api/happy`. ## Only and except (crud and resources only) `only` keeps the actions it lists, `except` drops them; both accept a string or an array, and the routes keep the order above. ```js module.exports = { 'resources posts': { only: ['index', 'show'] }, 'resources comments': { except: ['edit', 'new'] }, }; ``` `omit` is the former name of `except` and still works. Prefer `except`: `omit` is deprecated and will be dropped in a future major. ## Member and collection routes Actions outside the seven get a route with `member` (one record, under `/:id`) or `collection` (the whole resource): ```js module.exports = { 'resources posts': { collection: { 'get search': 'search' }, // GET /posts/search -> posts#search member: { 'post archive': 'archive' }, // POST /posts/:id/archive }, }; ``` Both accept an array of keys (`member: ['post archive', 'preview']`, the action is named after the segment, `get` by default), or an object whose value is the action (`'archive'`), another `controller#action`, or an object of route options (`{ action: 'archive', roles: ['admin'] }`, which inherits the options of the resource). The path helper is the usual `__path`, here `search_posts_path` and `archive_posts_path`. Collection routes are registered before `/:id` so they are reachable. Unlike the seven actions, these routes are not required to answer HAL (see [JSON API](/guides/api/)). ## Namespaces `namespace ` holds a routes object; every route inside is prefixed with `/` and every controller with `/`, which is a sub-directory of `app/controllers`: ```js module.exports = { 'namespace admin': { 'get /dashboard': 'dashboard#index', // GET /admin/dashboard -> admin/dashboard#index 'resources users': { roles: ['admin'] }, // /admin/users -> admin/users }, }; ``` Namespaces nest. Options are not inherited: each route inside declares its own `roles`. ## Nested resources `nested` holds a routes object expanded under one record of the parent: ```js module.exports = { 'resources posts': { nested: { 'resources comments': { only: ['index', 'create'] }, }, }, }; ``` gives `GET` and `POST /posts/:post_id/comments` on the `comments` controller. The parameter is the singular of the parent followed by `_id`; `param: 'slug'` renames it. The nested controller is not prefixed by the parent, only by the namespace it is in. ## API options Three more options tune the [JSON API](/guides/api/) per route: `version: 'v1'` refuses clients asking for another `application/vnd.henri.vN+json` version with a `406`, `rateLimit: { windowMs, max }` limits the route on its own (always enforced, even in development), and `idempotent: false` opts a mutating route out of the `Idempotency-Key` handling. ```js module.exports = { 'resources artworks': { controller: 'artworks', scope: 'api/v1', version: 'v1', }, 'post /reports': { controller: 'reports', action: 'create', rateLimit: { windowMs: 60000, max: 5 }, }, }; ``` ## When two entries claim the same route Later entries win, which is what lets a `resources` block be corrected by a line below it. When the winner is a different controller, henri says so at boot rather than leaving you to wonder why a route reaches the wrong place: ``` router ⚠ get /tasks is declared twice: "get /tasks" gives it to legacy#index, "resources tasks" overrides it with tasks#index ``` Repeating the same entry is silent, since nothing changed. A controller name is checked when the routes are expanded. `{ controller: 'ship ' }` fails at boot naming the route, rather than travelling to the loader and surfacing later as a missing controller, which sends you looking for a file that is right there. ## Named paths Every loaded route gets a name, `__path` (`show_todo_path`, `index_categories_path`), mapping to `{ method, route, roles }`. The rule holds whatever the route came from, so a member route of `posts` is `archive_posts_path` and a controller in a sub-directory keeps its folder: `admin/users#index` is `index_admin/users_path`. Pages rendered with `res.render()` (and the JSON answer of the same URL) receive them in `paths`, filtered by the roles of the current user and then by the [policies](/guides/policies/) that can answer without a record; a page served by the view engine's fallback, without a controller, only gets the routes that need no role. The React and Inertia helpers `pathFor()` and `getRoute()` build URLs from these names, so a renamed route does not break your links. --- # Views Source: https://usehenri.io/guides/views/ Inertia (Vite + React), React (Next.js), Handlebars and Vue renderers, all server-side rendered. Pick a renderer in your configuration. All of them render on the server; the Inertia and React renderers also push updates to the browser while you develop. A view engine is loaded from the application, so its packages must be installed there (`henri new` does it). ```json { "renderer": "inertia" } ``` `henri new` writes `inertia`. `henri new --renderer react` writes `react` and scaffolds the Next.js application instead; both render the same pages from the same controllers, and the differences are spelled out under each renderer below. Whatever the renderer, a controller renders with `res.render(route, { data })` and the page receives `data`, `user`, `paths`, `query`, `csrf`, `localUrl`, `errors` and `graphql` (see [Controllers](/guides/controllers/#resrenderroute-options)). Static files go in `app/views/public`, served at the root. ## Styles `henri new` scaffolds a styled application: [Tailwind CSS](https://tailwindcss.com) v4 is wired for the renderer you picked and the sample pages are written with it. `app/views/styles/index.css` is the whole stylesheet. ```css /* app/views/styles/index.css */ @import 'tailwindcss' source(none); @source '../pages/**/*.{js,jsx}'; @source '../components/**/*.{js,jsx}'; @layer base { :root { color-scheme: light dark; } body { @apply bg-white text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-50; } } ``` Tailwind v4 has no `tailwind.config.js`: the theme is CSS. Add `@theme { --color-brand: oklch(0.6 0.2 250); }` to that file and `bg-brand`, `text-brand` and `border-brand` exist. `source(none)` turns automatic file detection off, so Tailwind reads exactly the `@source` globs above and never walks `.next/` or `dist/`; add a line before writing classes somewhere else (`@source '../index.html';`, `@source '../../helpers/**/*.js';`). Dark mode follows the operating system, with no toggle and no class to set: `dark:` is Tailwind's default `prefers-color-scheme: dark` variant, and `color-scheme: light dark` hands the same preference to the native form controls, the scrollbars and the focus rings. Add `@custom-variant dark (&:where(.dark, .dark *));` if you would rather drive it from a class. How it is compiled depends on the renderer: | Renderer | Plugin | Wiring | | --------- | ---------------------- | ---------------------------------------------------------------------------------- | | `inertia` | `@tailwindcss/vite` | a plugin merged into `app/views/vite.config.mjs`; imported by `app/views/main.jsx` | | `react` | `@tailwindcss/postcss` | `app/views/postcss.config.mjs`; the stylesheet is imported by `pages/_app.js` | Both work the same in development and in production: `henri server` compiles the stylesheet on the fly, `henri build` writes it next to the bundle and the html links it, server-side rendered markup included. ### Adding Tailwind to an existing application With the Inertia renderer: ```bash pnpm add tailwindcss @tailwindcss/vite ``` ```js // app/views/vite.config.mjs import tailwindcss from '@tailwindcss/vite'; import { henriViteConfig } from '@usehenri/inertia/vite'; import { mergeConfig } from 'vite'; export default mergeConfig(henriViteConfig({ views: import.meta.dirname }), { plugins: [tailwindcss()], }); ``` Then `import './styles/index.css';` from `app/views/main.jsx`. With the React renderer: ```bash pnpm add tailwindcss @tailwindcss/postcss ``` ```js // app/views/postcss.config.mjs export default { plugins: { '@tailwindcss/postcss': {} } }; ``` Then `import '../styles/index.css';` from `app/views/pages/_app.js`. ### Opting out Nothing in henri depends on Tailwind; the classes are plain strings in the pages. Write your own CSS in `app/views/styles/index.css` (keep the import, it is the only stylesheet the pages load), then remove `tailwindcss` and, depending on the renderer, `@tailwindcss/postcss` with `app/views/postcss.config.mjs`, or `@tailwindcss/vite` with the plugin it adds to `app/views/vite.config.mjs`. The scaffolded pages, and everything `henri generate scaffold` writes afterwards, keep carrying Tailwind class names. Without Tailwind they are inert: rewrite the markup, or define the handful of classes you keep in your own stylesheet. `sass` stays installed, so renaming the file to `index.scss` (and the import with it) gets you Sass instead, and `*.module.scss` files next to a page keep working. ## Inertia The default renderer. It speaks the [Inertia.js](https://inertiajs.com/) protocol: your controllers keep rendering routes with `res.render()`, the page is a React 19 component bundled by [Vite](https://vite.dev/), and navigation between pages happens without a full page load. The first visit is server-side rendered; the Inertia client then asks for page objects (JSON) and swaps the component. What an application gives up by choosing it: Vite is a bundler, not a framework, so there is no file-system routing (`config/routes.js` is the router), no image or font optimization, no incremental static regeneration and no React Server Components. Everything the browser runs is a client component hydrating a server-rendered page. ```json { "renderer": "inertia" } ``` ```bash henri new my-app # or, in an existing application pnpm add @usehenri/inertia @inertiajs/react react react-dom vite @vitejs/plugin-react tailwindcss @tailwindcss/vite sass ``` The engine reads four files from `app/views`. `henri new` ships them and the engine creates the missing ones on first boot: | File | Role | | ----------------- | ------------------------------------------------------------------------------------------------------------------- | | `index.html` | The html shell. `` and `` receive the rendered page. | | `main.jsx` | The browser entry: `createInertiaApp` resolving `pages/**/*.jsx` with `resolvePage()`. | | `ssr.jsx` | The server entry: `render(page)` resolves to `{ head, body }`. | | `vite.config.mjs` | The shared configuration (`@usehenri/inertia/vite`: React plugin, aliases, builds) merged with the Tailwind plugin. | `res.render('/tasks/index', { data })` renders `app/views/pages/tasks/index.jsx` (`res.render('/tasks')` finds it too). The page reads what the controller sent through `useHenri()`: ```jsx // app/views/pages/tasks/index.jsx import { Form, Link, useHenri } from '@usehenri/inertia'; export default function Tasks() { const { data, pathFor, getRoute } = useHenri(); return (
{({ errors, processing }) => ( <> {errors.name &&

{errors.name}

} )}
    {data.tasks.map((task) => (
  • {task.name}
  • ))}
home
); } ``` `useHenri()` returns the same keys as the React renderer's `withHenri` (`data`, `user`, `paths`, `csrf`, `localUrl`, `flash`, `errors`, `graphql`, `pathFor`, `getRoute`, `fetch`, `hydrate`) plus `query`. They are the props of the Inertia page object, so `usePage().props` from `@inertiajs/react` holds the raw values. `hydrate()` is an Inertia partial reload of `data`; `fetch(target, payload)` is a JSON request outside the Inertia lifecycle (`GET` data goes in the query string) that sends the CSRF header and rejects with an error carrying `status` and `response`. Forms submit through Inertia's router: a controller answers with a redirect (`res.redirect('/tasks')`; the engine turns it into a `303` after `PUT`, `PATCH` and `DELETE`) and the client lands on the next page. To show validation errors, render the page again after `res.inertia.errors({ name: 'required' })`: they arrive in `errors`. `res.inertia.location(url)` redirects to an external url. `Form` wraps Inertia's form component: `action` accepts a `pathFor()` result, the method defaults to `POST`, and a hidden `_csrf` field carrying the page's token is added to its children (`csrf={false}` skips it, `csrf="..."` overrides it), so submissions pass henri's [CSRF check](/guides/users/#csrf) in an application with a user model; `fetch()` sends the `X-CSRF-Token` header, and the engine sets an `XSRF-TOKEN` cookie that Inertia's client echoes as `X-XSRF-TOKEN` (an alias henri accepts), so visits made directly with `router.post()` or `useForm().post()` pass as well. `Link`, `Head`, `router`, `usePage` and `useForm` are re-exported from `@inertiajs/react`. `assets`, `components`, `helpers` and `styles` resolve to the matching folders under `app/views`; global stylesheets are imported from `main.jsx`, which is where the [Tailwind stylesheet](#styles) is imported. Options go under the `inertia` key of your configuration: `ssr: false` renders everything in the browser (the page object is still embedded in the html), `id` changes the root element id (`app`), `entry`, `ssrEntry` and `template` rename the three files above. Hot module replacement in development rides on henri's http server, no second port; in development a page that fails to render on the server answers a `500` with the stack, in production it falls back to client rendering. `henri server --production` builds the client (`app/views/dist/client`, with a manifest whose hash is the Inertia asset version) and the server bundle (`app/views/dist/ssr`) on the first boot when the manifest is missing, or when `--force-build` is given, then serves `dist/client` with immutable cache headers. `henri build` runs the two Vite builds on their own, without booting henri. ## React :::caution Supported, and frozen. The engine is built on the Next.js pages router, which Next.js has deprecated: henri keeps it working, on Next.js 16, and does not follow it into the app router. The contract that hands a controller's data to a page (`withHenri` reading `req._henri` on the server) has no equivalent in the app router or in server components, so this engine stays where it is. Existing applications keep working and keep getting fixes; new ones are better off on [Inertia](#inertia), which is the default. ::: ### When Next.js removes the pages router The caution above says henri does not follow Next.js into the app router. This is the other half of that sentence: what happens to an application that is on this engine when the pages router finally goes. **Deprecated is not removed.** Next.js 16 ships the pages router and this engine runs on it. `next` is a peer dependency, which means the application owns the version: nothing henri does can move it, and nothing Next.js does reaches an application that has not upgraded. **What henri commits to.** The React engine keeps working, and keeps getting fixes, on the Next.js majors that carry the pages router. It is frozen in the sense that it gains no app-router features, not in the sense that it is abandoned. **When the pages router is removed from a Next.js major, henri does not chase it.** An application then has two answers, and neither of them is a surprise henri springs on the day: 1. **Stay.** Pin the last Next.js major that has the pages router. This needs nothing from henri — it is a version in the application's own `package.json` — and it is the right answer for an application that is finished, or one whose next change is a rewrite anyway. What it costs is the rest of that major's security fixes, on the application's own clock. 2. **Move to Inertia.** There is no automatic conversion and there will not be one: the two engines put the data in different places, so the pages are rewritten. What that actually means is small enough to write down: | React engine | Inertia | | -------------------------------------------------------------- | --------------------------------------------------------------------------- | | `export default withHenri(Page)` | a plain component; `const { data } = useHenri()` | | props on the page component | `useHenri()`, at any depth | | `next/link` | `Link` from `@usehenri/inertia` | | `@usehenri/react/forms` (`Form`, `Input`, `Select`, `useForm`) | `Form` from `@usehenri/inertia` | | `router.push('/tasks')` after a submit | the controller redirects; Inertia follows it | | a failed write is a `422` the form reads field by field | the controller calls `res.inertia.errors()` and renders the form page again | | `getRoute` / `pathFor` | `getRoute` / `pathFor`, unchanged | | `.js` pages under `app/views/pages` | `.jsx` pages in the same place | **What does not change**: `config/routes.js`, the controllers' `res.render()` calls, the models, the policies, the tests that go through `@usehenri/testing`. The router is henri's in both engines, which is why the move is a view-layer rewrite and not a port. **What would change this decision.** One thing: an app-router equivalent of the contract this engine rests on — a supported way for a server component to read what henri attached to the request, the way `withHenri` reads `req._henri`. Passing it through the url is not that; it would put a controller's data in a place the client chooses. If that seam appears, the calculation is worth redoing, and until it does, an app-router engine would be a second way to write a henri application that henri could not hold to the same promises. Choose it anyway when you want what Next.js gives you and Vite does not: `next/image` and `next/font`, the file-system page fallback (a `GET` no route matches still renders a page of the same name), the Next.js plugin ecosystem, or an existing pages-router application you are moving onto henri. What you give up is the app router, server components and streaming: a page under `app/views/app` would bypass `withHenri` and the controllers, and the engine warns when that directory exists. [Next.js](https://nextjs.org/) (16, pages router, Turbopack) renders the pages in `app/views/pages` and injects the data sent by your controllers. ```json { "renderer": "react" } ``` Install the peer dependencies in your project (`henri new --renderer react` already does): ```bash pnpm add @usehenri/react next react react-dom tailwindcss @tailwindcss/postcss sass ``` Two small files live next to your pages. `henri new --renderer react` ships them and the engine creates them on first boot when they (or their `.mjs`/`.ts` alternatives) are missing: ```js // app/views/next.config.js module.exports = require('@usehenri/react/engine/conf'); ``` ```json // app/views/jsconfig.json { "compilerOptions": { "baseUrl": "." } } ``` ### Pages ```jsx // app/views/pages/tasks/index.js import Link from 'next/link'; import withHenri from '@usehenri/react'; const Tasks = ({ data: { tasks = [] }, getRoute }) => (
    {tasks.map((task) => (
  • {task.name}
  • ))}
); export default withHenri(Tasks); ``` `withHenri` wraps a page component. On the server it reads what henri attached to the request (`req._henri`, never the url); on client-side navigation it fetches the same url as JSON. The page receives these props, and nested components read the same values from `useHenri()`: | Prop | Description | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `data` | What the controller passed to `res.render()`, or the GraphQL result. Follows navigation, and `hydrate()` overrides it until the next navigation. | | `user` | The logged-in user (`id`, `email`, `roles`, the `config.user.public` fields) or `null` | | `paths` | The routes this user may call, keyed like `index_tasks_path` | | `csrf` | The CSRF token, or `null` without a user model | | `errors` | GraphQL errors of the render, or `null` | | `flash` | The [flash messages](/guides/controllers/#flash-messages) of this render, by type (`{ notice: ['Saved'] }`) | | `graphql` | `{ endpoint, query }` of the render | | `localUrl` | The url the server listens on | | `error` | The error of the last `hydrate()` or client-side fetch, or `null` | | `pathFor` | `pathFor('show_tasks_path', id)` builds a url from a route name (see below) | | `getRoute` | `getRoute('show_tasks_path', id)` returns the url as a string, or `'route-not-found'` | | `fetch` | `fetch(target, body)` calls a route and resolves with the parsed answer (see below) | | `hydrate` | Fetches the current url as JSON again and updates `data`; keeps the current data when the answer is not a henri page | | `router` | The Next.js router (`withRouter`) | `pathFor(name)` returns the route entry, `{ method, route, roles }`; `pathFor(name, '42')` returns the route as a string with `:id` filled; `pathFor(name, { id: '42' })` returns `{ method, route }` with every parameter filled (the object stringifies to its route). An unknown name returns `undefined` and logs a warning. Parameters are replaced whole, so `:id` never touches `:identifier`. `fetch(target, body)` takes a url or a `pathFor()` result (its `method` is used) and goes through the native `fetch` with `Accept: application/json`, a JSON body, the same-origin cookies and the `X-CSRF-Token` header when there is a token. It resolves with the parsed body (JSON when the server sends JSON, text otherwise, `null` for a 204). A non-2xx answer rejects with a `RequestError`: `message`, `statusCode`, `error` and `data` from the boom body, `status`, `body` and `response` from the answer. ```jsx const destroy = async (id) => { await fetch(pathFor('destroy_tasks_path', { id })); // DELETE /tasks/:id await hydrate(); }; ``` ```jsx // app/views/components/nav.js import Link from 'next/link'; import { useHenri } from '@usehenri/react'; export default function Nav() { const { user, getRoute } = useHenri(); return ( {user ? user.email : 'Tasks'} ); } ``` Any folder under `app/views` is importable by name (`import Nav from 'components/nav'`, `import styles from 'styles/tasks.module.scss'`), thanks to `jsconfig.json`. Global stylesheets are imported from `app/views/pages/_app.js` (a Next.js rule), which is where the [Tailwind stylesheet](#styles) is imported; `sass` resolves imports from `app/views/styles`, `app/views` and `node_modules`. ### Forms `@usehenri/react/forms` exports `Form`, `Input`, `Select`, `Radio`, `Editor`, `Button`, `FormError`, the `useForm()` hook and `FormContext`. A form keeps its fields' data, validates on change with [validator.js](https://github.com/validatorjs/validator.js) rules, sanitizes on submit and posts through `fetch()`. ```jsx // app/views/pages/tasks/new.js import withHenri from '@usehenri/react'; import { Button, Form, FormError, Input, Select } from '@usehenri/react/forms'; const NewTask = ({ pathFor, router }) => (
router.push('/tasks')} > ` to a Handlebars form. Set `"csrf": false` in the configuration to turn the check off. ### Where the request came from The token alone does not survive everything: a sibling subdomain, or anything else that can write a cookie on the parent domain, can plant a token it knows and submit it. So the same requests — unsafe method, session cookie, no bearer token — must also come from somewhere the application recognizes, which browsers state in `Sec-Fetch-Site` and `Origin`: - `Sec-Fetch-Site: same-origin`, or `none` (typed, bookmarked, launched): allowed. - `same-site` or `cross-site`: the `Origin` must be this application's own origin or one you listed. `evil.example.com` posting to `app.example.com` is refused with `403 Cross-origin request refused`, valid token or not. - No fetch metadata at all (an older browser, a script): an `Origin` that does not match is refused; no `Origin` falls through to the token, which is what it always did. The origin compared against is what the browser saw: behind a reverse proxy it comes from `X-Forwarded-Host` and `X-Forwarded-Proto` when `config.trustProxy` allows it, not from the internal `Host` the proxy rewrote. Nothing here applies to a request that sends no session cookie, so a cross-origin API client keeps working untouched. A client that _does_ send credentials cross-origin needs its origin trusted; whatever `config.cors.origin` already allows is trusted for you: ```json { "csrf": { "origin": true, "trustedOrigins": ["https://admin.example.com"] } } ``` `"csrf": { "origin": false }` keeps the token check without this one. ## Roles Give a route an array of `roles` and only logged-in users owning every one of them reach it; see [Routes](/guides/routes/#roles). A new user gets `config.baseRole`; change roles with `user.setRoles()` or `User.setRoles(id, roles)`, never through a form (mass assignment drops `roles`). The `paths` sent to the views are filtered by the roles of the current user, so a page can show a link only when the user may follow it: ```jsx const { paths, pathFor } = useHenri(); { paths.admin_users_path && ( Admin ); } ``` A role answers "may this kind of person reach this endpoint". The question every application also has -- may this person read _this_ proposal -- is answered by a [policy](/guides/policies/), a file next to the model it is about. Policies compose with roles rather than replace them, and they filter `paths` and `_links` the same way, one level down. ## What leaves the server Only the public representation of a user reaches views, `req._henri.user` and JSON answers: `{ externalId, email, roles }` plus the fields listed in `config.user.public`. `henri.user.publicUser(user)` builds that object; use it whenever you send a user to a browser yourself. The identifier is the user's public one; the primary key stays on the server, like every record's (see [Identifiers](/guides/models/#identifiers)). A user model that opted out of it answers with `id` instead. Nobody (`null`, or an anonymous `req.user`) answers `null`. Anything that is not a record is refused rather than serialized: the object it used to build carried an identifier that named no row, and that object goes to a view and to a JSON body. ```json { "user": { "model": "user", "public": ["name", "avatar"], "loginPath": "/login", "afterLogin": "/", "sessionMaxAge": 2592000000 } } ``` ## Bearer tokens A passport `jwt` strategy is registered on `henri.passport` with the application secret (it reads `Authorization: Bearer ` and loads the user from the `id`, `_id` or `sub` claim), but core applies it to no route and issues no token. To accept tokens on a route, add the strategy yourself with `henri.addMiddleware()` or in the controller: `henri.passport.authenticate('jwt', { session: false })`. --- # Policies Source: https://usehenri.io/guides/policies/ Record-level authorization from app/policies, with henri.can(), req.authorize(), the policy guard on a route, and the links a page is not given. `roles` on a route answers one question: may this **kind of person** reach this endpoint. It is the right question for `/admin`, and it is the wrong one for `/proposals/42` — where what you actually want to know is whether **this** person may read **that** proposal. Getting that wrong is the first category of the OWASP Top 10, and it is usually not a missing feature but a forgotten `if`. A policy is a file next to the model it is about, one function per action: ```js // app/policies/proposal.js const owns = (user, proposal) => Boolean(user) && String(proposal.speakerId) === String(user.id); module.exports = { index: () => true, create: (user) => Boolean(user), new: (user) => Boolean(user), show: (user, proposal) => proposal.state === 'submitted' || owns(user, proposal), edit: (user, proposal) => owns(user, proposal), update: (user, proposal) => owns(user, proposal), destroy: (user, proposal) => owns(user, proposal), }; ``` `henri generate policy Proposal speakerId` writes that file with the seven actions of a resource stubbed on an ownership check, and a test next to it. ## Failing closed Three things can go wrong, and the safe answer is the same for all three: **no**. | What happened | What henri answers | | ------------------------------------- | -------------------------------------------------------- | | The model has no policy | `false`, with a line naming the file to write | | The policy has no rule for the action | `false`, with a line naming the rule to add | | The rule threw | `false`, and the exception is logged through `pen.error` | There is no "allow when undecided" setting to turn on, and henri never falls back to the roles. There is one quieter way to get an accidental yes, and it is why **only the boolean `true` allows**: ```js // Returns 'admin' for an admin and undefined for everybody else. A // truthiness test would read the same for the string 'nope'. admin: (user) => user.roles.find((role) => role === 'admin'), // never allows admin: (user) => user.roles.includes('admin'), // this is the rule you meant ``` A policy that leaves an action out refuses it, and the link to that action disappears from what leaves the server — which is how you find out, on the page, rather than in a report six months later. Write the rules you have routes for; do not leave them out and hope. ## Rules that need a record, and rules that do not `index`, `new` and `create` have no record to speak of; `show`, `edit`, `update` and `destroy` do. henri tells them apart by what the rule declares: > **A rule that takes a second parameter is never asked without a record.** `(user) => ...` is asked anywhere. `(user, proposal) => ...` only where a record is in hand. `(user, proposal = null) => ...` says "ask me either way" — the default is you opting in. That one predicate is what lets the same file answer the route guard (no record yet), the `_links` of a HAL resource (a record in hand) and the `paths` of a rendered page (no record) without a second vocabulary. ## Asking There is one way to ask, and it is the same one everywhere the answer is needed. ```js // Anywhere: a job, a worker, a mailer, the console await henri.can(user, 'update', proposal); // In a request: the user is already filled in await req.can('update', proposal); // The same, refusing when the answer is no const proposal = await req.authorize('update', await Proposal.findById(id)); ``` `req.authorize()` resolves with the record, so it reads as one line, and rejects with a `POLICY_DENIED` error carrying the status of a refusal (below) when it does not. henri answers that error the way it answers any other 4xx: the negotiated error page for a browser, the `res.boom` body for an API client. henri finds the policy from the record — Mongoose, Sequelize and Drizzle all say which model an instance belongs to — and falls back to what the route is about. A plain object says nothing, so name the policy yourself: ```js await henri.can(user, 'update', { speakerId: 1 }, 'proposal'); ``` ## Guarding a route `policy` on a route registers the guard, next to `roles` rather than instead of it: the role decides who may reach the endpoint, the policy who may act on the record, and a route may declare both. ```js // config/routes.js module.exports = { // `true` names the policy after the controller: proposals -> proposal 'resources proposals': { policy: true }, // or name another one 'get /proposals/mine': { controller: 'proposals#mine', policy: 'proposal' }, // both guards: the role answers first 'resources reviews': { only: ['index', 'create'], policy: true, roles: ['admin'], }, }; ``` There is a limit to what a gate can decide, and it is worth being precise about: **the record is not loaded yet**. So the guard answers the questions that have no record — `index`, `new`, `create`, and any rule that declares no record parameter — and refuses right there. For the rest it steps aside, and two things pick it up: - `res.resource()` asks the policy about the record it is about to send, and answers the refusal instead of the body. An action that already asked (with `req.can()` or `req.authorize()`) is trusted and not asked twice — it holds the record, which may be more than what is being sent. - `config.policies.verify` (on by default) reports, once per route, an action that answered successfully without ever asking. That is the "forgot one" case, and the line names the route and the rule. ## What a refusal answers | Who | Status | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | An anonymous visitor | `401`, and the login page in a browser: "log in and try again" leaks nothing. `config.policies.anonymous` is what changes it | | A signed-in user | `config.policies.status`, **`404`** by default | 404 is the default on purpose: a `403` tells whoever asked that the record is there, which is half of what they wanted. Set `"policies": { "status": 403 }` when your records are not secret and a clear refusal is friendlier, or ask for one call at a time: ```js // This proposal is public; it is the writing that is refused, and saying so // is more useful than pretending it does not exist await req.authorize('update', proposal, { status: 403 }); ``` ### The message of a 404 refusal stops at production A 404 that says `Not allowed to show this proposal` gives away in the body exactly what the status was chosen to hide. So the reason is answered outside production and dropped in it, the way the message of a route 404 is: ``` development / test production refused, record exists Not allowed to show this proposal Not Found record is not there Proposal not found Not Found ``` The two have to match, so **the other half is yours**: answer a record that is not there with `res.notFound()` rather than `res.boom.notFound()`. ```js const loadProposal = async (req, res) => { req.proposal = await Proposal.findById(req.params.id); if (!req.proposal) { // The reason reaches you and not the internet, and this negotiates — // res.boom answers JSON even to a browser, which gives it away by shape return res.notFound(`Proposal ${req.params.id} not found`); } }; ``` `henri generate scaffold` writes that line already. A refusal an application configured to answer `403` keeps its message everywhere: you decided to tell them, and the message is the useful half of that. The reason always reaches your logs, whatever the environment — the `policies denied` line carries it. ### An anonymous visitor, and what telling them costs The `401` above is uniform only when it is decided **before** anything is looked up. A rule that takes a record cannot be: reaching it means the record was loaded, and one that does not exist answered `404` in a `before` hook long before the policy was asked. So on a route guarded **only** by such a rule, an anonymous visitor gets the login page for an id that exists and a `404` for one that does not — which is the same oracle, one visitor further out. Two ways to close it, and the first is free: **Decide before the lookup.** A `roles` on the route turns an anonymous visitor away at the gate, and so does a rule that answers without a record (`show: (user) => Boolean(user)`). Both are uniform because neither has looked anything up, and this is the answer for most routes. **Or say that a refusal never names who is asking**, with `config.policies.anonymous`: ```json { "policies": { "anonymous": "uniform" } } ``` | Value | An anonymous refusal answers | | ------------------------- | ----------------------------------------------------------------------------------------------------- | | `"challenge"` _(default)_ | `401`, and a redirect to `user.loginPath` in a browser | | `"uniform"` | exactly what a signed-in stranger gets: `policies.status`, the same message rule, and **no redirect** | With `"uniform"` and the default `status`, a record somebody may not see and a record that is not there are one answer — same status, same body, same `Vary`, no `Location` — on both paths (`res.resource()` and a `req.authorize()` that throws), in JSON and in HTML. The `Location` is part of it on purpose: a `404` page that still said where to log in would give it away through a header instead of a body. It is **every** policy refusal, the route gate included — `index` on a policy-guarded resource answers the `404` too, even though the gate decided it before any lookup and gave nothing away. A key that meant one thing for a rule taking a record and another for a rule that does not would be a rule with a hole in it, and the hole is where the leak lives. :::caution[It costs the login page] A visitor who follows a bookmarked link to something they could see perfectly well once signed in gets a `404` instead of being asked to sign in, and nothing in that answer tells them signing in would help. **Giving them a way back is then yours**: a sign-in link in the layout, an application 404 page that says "signed in? try again", or a `roles` on the routes where being asked to log in is the right answer. That last one composes, and is usually what you want: `roles` refuses an anonymous visitor **before** the lookup, so it keeps the login page whatever this key says, and `"uniform"` then covers the signed-in stranger the role let through. Nothing about a signed-in user changes either way. ::: A caller still wins one call at a time — `req.authorize('update', proposal, { status: 403 })` answers `403` to anybody, signed in or not. ## The links a page is not given `_links` and `paths` are already filtered by role. Policies filter them again, and this is where the feature earns its keep: a view that cannot link where the reader may not go is what stops the leak, because nobody has to remember to hide a button. - **`_links`** (`res.resource()`, `res.collection()`): the record is in hand, so `edit`, `update` and `destroy` are asked about that record and `collection`, `create` and `new` about the collection. `self` is left alone — it names the representation the client is already holding, not something it may do — and so are the links your controller added itself, since henri does not know what action they are. - **`paths`** (the table `res.render()` gives a page): there is no record here, so only the rules that answer without one are asked. A `show` rule taking a proposal never removes `show_proposals_path`; that question is answered where the record is, on its own `_links`. A controller with no policy is not asked about at all, so an application that ships none sees exactly the table the roles built. ### When the answer is a presentation of the record A controller that presents its records before sending them hands `res.resource()` a plain object, and a presenter usually drops exactly what the rules read — the owner column is an internal foreign key. `subject` names what the policies are asked about: ```js // One record res.resource(present(proposal), { subject: proposal }); // A page of them: an array parallel to the one being sent, // or (item, index) => record res.collection(records.map(present), { subject: records }); ``` ## Scoping a list "May this person read this proposal" is not "which proposals may they read", and answering the first one row at a time does not answer the second. A policy says what a list is filtered by, and henri hands the value straight back: ```js // app/policies/proposal.js scope: (user) => ({ speakerId: user && user.id }), ``` ```js // app/controllers/proposals.js const proposals = await Proposal.where(await req.scope('proposal')); ``` **henri never looks inside that value.** It is a `where` for the ORM you chose, not something henri builds a query from — which is what keeps this a seam rather than a half-written query builder. A policy with no `scope` throws `HENRI_POLICY_SCOPE_REQUIRED` rather than quietly meaning "everything", because "everything they may see" has no safe default; `scope: () => ({})` is how a policy says everything on purpose. It is also the floor under a filtered index: [`req.filters()`](/guides/filtering/) intersects what a client asked for with this condition, with an `and`, so a filter can only ever narrow the list — and an action that reaches for it on a model whose policy declares no `scope` gets the same refusal `req.scope()` gives. ## `before`, and the shape of a rule A policy may export `before`, run ahead of every rule. A boolean is the answer of the whole policy; anything else falls through: ```js module.exports = { // An admin does not need the rest of the file to be consulted before: (user) => (user && user.roles.includes('admin') ? true : undefined), update: (user, proposal) => owns(user, proposal), }; ``` Every rule receives a third argument, the context: `{ action, policy, user, req, henri }`. `req` is null outside a request. Rules are asked a lot — once per link, per record, per page — so keep them synchronous and free of queries. Load what a rule needs in a `before` hook of the controller and put it on the record. ## Where policies live `app/policies/*.js`, loaded the way `app/models` is, one file per model and named after it. A namespace is kept: `admin/proposals` looks for `app/policies/admin/proposal.js`, and never borrows the policy of the `proposals` controller next door. `henri.policies` is the registry (`names()`, `get()`, `rule()`, `resolve()`), and a reload picks up a changed file like any other. ## Configuration ```json { "policies": { "status": 404, "verify": true } } ``` Writing the file is what turns policies on; the key only holds the two decisions an application may reasonably differ on. See [Configuration](/configuration/#the-policies-object). ## Testing a refusal The tests worth writing are the ones that prove a no. ```js const { henri, setup } = require('@usehenri/testing'); describe('the proposal policy', () => { beforeAll(() => setup()); test('somebody else gets nothing', async () => { expect(await henri.can(stranger, 'update', proposal, 'proposal')).toBe( false ); }); test('an action nobody wrote a rule for is refused', async () => { expect(await henri.can(owner, 'publish', proposal, 'proposal')).toBe(false); }); }); ``` `henri generate policy` writes that file for you. `henri audit` reports a policy that nothing asks — the rules written and the gate forgotten looks exactly like having solved the problem, so it is worth a finding (`policies.unenforced`, see [Security](/guides/security/)). The other way round is [`henri doctor`](/reference/cli/#doctor): a route declaring `policy: true` with no file behind it refuses every request, because the policies fail closed, and `routes.policy` says so before anyone finds out from a 404. --- # Filtering and sorting Source: https://usehenri.io/guides/filtering/ A declared filter and sort surface for an index action — what a client may narrow a list by, with which operators, and what it may order it by, refused with a 422 when it asks for anything else. Every index page ends up re-implementing this: ```js // Don't index: async (req, res) => { const where = {}; if (req.query.state) { where.state = req.query.state; } if (req.query.q) { where.title = { $like: `%${req.query.q}%` }; } return res.collection( await Proposal.where(where).order(req.query.sort || '-createdAt') ); }, ``` Three things are wrong with it and only one of them is obvious. The order comes straight from the query string, so `?sort=password` is a valid request. The `state` is whatever a client sent, so a filter can reach a row the page was never meant to show. And the whole thing has to be written again for the next index, slightly differently. The Rails answer to this is `ransack`, at a hundred and seventeen million downloads — and also, deservedly, near the top of the list of gems people find most frustrating, because `?q[email_cont]=@&s=password asc` reaches every column of every model and what an endpoint exposes stops being something anybody wrote down. henri takes the opposite position: > **Nothing undeclared is filterable, and nothing undeclared is sortable.** ## The declaration A controller says what an action's list may be narrowed by and ordered by, in a `filters` export next to [`params`](/guides/controllers/#params-what-an-action-accepts) and keyed by action the same way: ```js module.exports = { filters: { index: { where: { state: { enum: ['submitted', 'accepted'], type: 'string' }, level: { enum: ['beginner', 'intermediate', 'advanced'], type: 'string', }, submittedAt: { type: 'date' }, title: { operators: ['contains'], type: 'string' }, }, sort: ['submittedAt', 'title'], default: '-submittedAt', }, }, index: async (req, res) => { const { order, where } = await req.filters(); const { page, perPage, records, total } = await Proposal.paginate({ ...req.pagination(), order, where, }); return res.collection(records, { page, perPage, total }); }, }; ``` and a client writes it in the query string: ``` GET /proposals?filter[state]=accepted&filter[submittedAt][gte]=2026-01-01&sort=-submittedAt,title ``` A filter is a **parameter**, so it uses the vocabulary `params` already has (`type`, `enum`, `min`, `max`, `minLength`, `maxLength`, `pattern`) plus two keys of its own: | Key | What it is | | ----------- | ---------------------------------------------------------------------- | | `operators` | What may be asked of this field, on top of what its type already gives | | `column` | The column, when the name a client writes is not it | A rule may be the type itself (`state: 'string'`). `required` and `default` are refused: a filter is what a client _may_ ask for, never what it has to — the narrowing an action always applies belongs in the scope. The four keys of one action's declaration are `where`, `sort`, `default` and `model`. `sort` may be a list of column names or a map of the name a client writes to the column it means (`sort: { newest: 'createdAt' }`). `model` is only needed when the controller is not named after the model it lists. ## The operators Thirteen words, and they mean the same thing on all three adapters: | Operator | What it keeps | Shape | | ------------------------ | --------------------------------------------------------------------------- | ---------------------------- | | `eq` | rows where the column is the value (the default, written `filter[state]=x`) | one value | | `ne` | rows where it is not | one value | | `in` | rows where it is one of them | a comma-separated list | | `nin` | rows where it is none of them | a comma-separated list | | `lt`, `lte`, `gt`, `gte` | rows above or below | one value | | `between` | rows between the two, inclusive | exactly two, comma-separated | | `null` | rows with no value (`true`) or with one (`false`) | `true` or `false` | | `starts` | rows whose text starts with the value | one value | | `ends` | rows whose text ends with it | one value | | `contains` | rows whose text holds it, anywhere | one value | They are henri's vocabulary and not the ORM's: henri turns them into **the adapter's own condition** — Sequelize's `Op` symbols, the `$` spellings Mongoose and Drizzle share — and never into SQL. Two terms on one column are one comparison, so `?filter[at][gte]=x&filter[at][lte]=y` is a range and not the second clause overwriting the first. ### What a field gets by default, and why The line is what a database can answer with an index: - **every type** gets `eq`, `ne`, `in`, `nin` and `null`. An equality is an index lookup and a bounded amount of work whatever the column holds. - **an ordered type** — `date`, `number`, `integer`, `float`, `decimal`, `bigint` — also gets `lt`, `lte`, `gt`, `gte` and `between`. A range over an ordered column is a range scan, which is the other thing an index does. - **the three text operators are opt-in, per field, by name.** `contains` on an unindexed text column is a full scan an unauthenticated client can ask for as often as it likes, and there is no bound henri can put on it: the honest answer is that the application says which columns it has thought about. `starts` is a prefix, which an ordinary b-tree index answers. `ends` and `contains` are not: on PostgreSQL they want a trigram index (`CREATE INDEX ... USING gin (title gin_trgm_ops)`), on MySQL a full-text one, on MongoDB a text index — and henri cannot check that you made one. Declaring the operator is the application saying it knows. ### A value is a literal `%` and `_` in a text-operator value are **refused** with a 422 rather than escaped. They are the wildcards this whole surface exists to keep a client from sending, and the three dialects do not agree on an escape character (sqlite has none without an `ESCAPE` clause henri would have to write as SQL). On MongoDB the value becomes a fully escaped literal `$regex`, walked character by character, never a pattern from the request. An application that wants wildcard search writes that query itself. The three are **case-insensitive**: `ILIKE` on PostgreSQL, `$options: 'i'` on MongoDB, and `LIKE` elsewhere, which is insensitive under the collations these adapters open by default. A column whose collation is binary matches exactly — that is the database's decision rather than henri's. ## Ordering `?sort=-submittedAt,title` — most significant first, `-` for descending, at most `config.api.maxSort` of them (three). A column the action did not list is a 422. **henri appends the record's `externalId` to every order it builds.** A page is only stable when the order is total, and paging through a list ordered by `submittedAt` alone silently shows and hides the rows that share a timestamp. `externalId` is a uuid v7, unique and indexed on all three adapters, so the tiebreaker costs nothing and the page is exact. A model that opted out of `externalId` (`options: { externalId: false }`) gets no tiebreaker. `default` is what applies when the request asks for nothing, and it names columns the declaration already listed. ## What can never be declared These fail the **boot**, naming the controller, the action and the field (`HENRI_FILTER_DECLARATION_INVALID`) — not the request, because a filter surface that got there at request time would be a 500 waiting for the first client to ask for it: - **a column the model does not have.** A typo that filtered nothing would quietly answer the whole table. - **a randomised `encrypted` column**, and anything but an equality on a deterministic one. The adapters refuse exactly this (`HENRI_ENCRYPTION_NOT_QUERYABLE`); this inherits the refusal rather than routing around it. See [Encrypted attributes](/guides/encryption/). - **an order over an `encrypted` column**, either kind: the rows would come back ordered by ciphertext. - **an order over a `text` or a `json` column.** A `text` column has no bound and no order worth sorting by — the database answers it with a filesort over everything. A `string` is bounded and is fine. - **a `json` column, in a `where`**: there is no comparison henri can spell on three adapters. - **a column marked `personal: { expose: false }`.** henri promised that value never leaves; a filter over it hands it back one bit at a time, which is the same value with more steps. A field marked plain `personal: true` **is** filterable — it is in the answer already. See [Personal data](/guides/privacy/). - **a declared foreign key.** Its public value is another row's `externalId`, so matching it is a lookup henri would have to make per term — the same refusal [the derived GraphQL schema](/guides/graphql/) makes. Resolve it in the controller and put the key in the scope: ```js const scope = { state: PUBLIC_STATES }; if (req.query.event) { const edition = await Event.findById(req.query.event); scope.eventId = edition ? edition.id : 0; } const { order, where } = await req.filters({ scope }); ``` ## The scope wins `req.filters()` answers `policy.scope(user) AND (what the client asked for)`. An **and**, spelled for the adapter — never a merge of keys, so two conditions on one column intersect and never replace each other. **A client-supplied filter narrows a list and can never widen it.** That is the whole promise, and it is what makes the surface safe to expose: the worst a hostile query string can do is answer fewer rows. The scope is [`policy.scope(user)`](/guides/policies/#scoping-a-list) and it is asked for by default, which is what makes this safe to reach for: an action that calls `req.filters()` on a model whose policy declares no scope gets the refusal `henri.policies.scope()` already gives rather than "everything". ```js await req.filters(); // the policy of this route await req.filters({ policy: 'proposal' }); // another one await req.filters({ scope: { state: PUBLIC } }); // a condition of your own await req.filters({ scope: false }); // this list is public ``` `scope: false` is how an application says, once and in writing, that a list takes no scope at all. ## What `req.filters()` answers ```js const { model, order, sort, terms, where } = await req.filters(); ``` | Key | What it holds | | ------- | ------------------------------------------------------------------- | | `where` | the condition to query with, spelled for this model's adapter | | `order` | the order, spelled for the same, with the tiebreaker appended | | `sort` | what the request asked to order by (`{ column, descending, name }`) | | `terms` | what it asked to filter by (`{ column, name, operator, value }`) | | `model` | the model the declaration is about | `sort` and `terms` are for the page: a table header that shows which column is sorted, a set of chips that shows what is filtered, and the query string to put back in every link. ## The refusal A request that asks for something the action did not declare is a **422 before the action runs**, behind the role and the policy guards, with one message per term: ```json { "statusCode": 422, "error": "Unprocessable Entity", "message": "the filters are invalid", "code": "HENRI_FILTER_INVALID", "data": { "errors": { "filter[speakerId]": "is not a filter Proposal accepts here (level, state, submittedAt, title)", "sort": "cannot order by \"abstract\" (submittedAt, title)" } } } ``` It is negotiated like every other answer henri gives, so a browser gets the page and an API client gets the body above. An **empty value is an absent filter**: a browser sends `filter[state]=` for every select nobody touched, and an index page is a form. ## The links carry it Nothing to do. `res.collection()` builds `next` and `prev` out of the url as requested and only ever sets `page` and `per_page`, so the filter and the sort ride along: ``` Link: ; rel="next" ``` Page two of a filtered list is page two of the same list, which is the whole reason the tiebreaker above exists. ## `henri openapi` describes it A declared filter is a description of a request, which is what an OpenAPI operation wants, so [`henri openapi`](/guides/openapi/) writes one query parameter per comparison, spelled the way a client writes it: ```json { "name": "filter[state]", "in": "query", "required": false, "schema": { "type": "string", "enum": ["submitted", "accepted"] } }, { "name": "filter[title][contains]", "in": "query", "schema": { "type": "string" } }, { "name": "sort", "in": "query", "style": "form", "explode": false, "schema": { "type": "array", "maxItems": 3, "items": { "type": "string", "enum": ["submittedAt", "-submittedAt", "title", "-title"] } } } ``` A list operator (`in`, `nin`, `between`) is an array in OpenAPI's `form` style with no explode, which _is_ the comma-separated spelling. `x-henri.filters` carries the same thing as a summary — the model, the sortable columns and the operators of each field — and `x-henri.enforced` names `filters` on an operation that has them. ## Configuration | Key | Default | What it bounds | | ---------------- | ------- | -------------------------------------------------- | | `api.maxFilters` | `8` | the most `filter[...]` terms one request may carry | | `api.maxSort` | `3` | the most columns one request may order by | ## Deliberately not here - **No `or` between filters.** A client composing boolean algebra over your columns is `ransack`, and it is the thing this page exists to avoid. - **No free-text search across columns.** That is a search engine, and henri is not one. - **No cursor paging.** The tiebreaker makes offset paging exact; a cursor is a different feature with a different contract. - **No filtering across an association.** Resolve it in the controller and put the key in the scope, the way a reference is resolved above. - **No operator an application can add.** The vocabulary is closed, because every word in it has to mean the same thing on three adapters. --- # JSON API Source: https://usehenri.io/guides/api/ HAL answers with res.resource and res.collection, embedded relations, streamed CSV exports, pagination, Idempotency-Key, rate limiting, request ids, versioning, the health endpoints and graceful shutdown. Every henri controller can answer JSON, and the answers follow the conventions a Rails API gives you: hypermedia links ([HAL](https://datatracker.ietf.org/doc/html/draft-kelly-json-hal)), idempotent mutations, rate limits, request ids, secure headers, filtered logs, liveness and readiness endpoints and a shutdown that drains. Most of it is on by default and configured from `config/default.json`; the keys are listed in [Configuration](/configuration/#json-api). ## Answering HAL `res.resource(record, options)` answers one record and `res.collection(records, options)` a page of them. Both send the public fields of the record and add `_links`, built from the route helpers of the controller and filtered by the roles of the current user: a visitor who may not `DELETE` never sees the `destroy` link. With a [policy](/guides/policies/) for the model, they are filtered again against the record itself, so two people with the same role reading the same proposal get different links — and a controller that presents its records before sending them names what the rules should read with `subject`. ```js // app/controllers/tasks.js module.exports = { async index(req, res) { const { records: tasks, page, perPage, total, } = await Task.paginate(req.pagination()); return res.negotiate({ html: () => res.render('/tasks', { data: { tasks } }), json: () => res.collection(tasks, { page, perPage, total }), }); }, async create(req, res) { const task = await Task.create(req.permit('title', 'done')); return res.negotiate({ html: () => res.redirect(`/tasks/${task.externalId}`), json: () => res.resource(task, { status: 201 }), }); }, }; ``` A resource looks like this: ```json { "_links": { "self": { "href": "/tasks/0199a5c1-1f7e-7a3c-bb0d-2b1a4f6d9c11" }, "collection": { "href": "/tasks" }, "edit": { "href": "/tasks/0199a5c1-1f7e-7a3c-bb0d-2b1a4f6d9c11/edit" }, "update": { "href": "/tasks/0199a5c1-1f7e-7a3c-bb0d-2b1a4f6d9c11", "method": "PATCH" }, "destroy": { "href": "/tasks/0199a5c1-1f7e-7a3c-bb0d-2b1a4f6d9c11", "method": "DELETE" } }, "externalId": "0199a5c1-1f7e-7a3c-bb0d-2b1a4f6d9c11", "title": "Ship it", "done": false } ``` The identifier is the record's `externalId`, the public one every model carries; the primary key stays on the server and is not in the payload. `res.resource()`, `res.collection()`, the `Location` header of a `201` and every href of `_links` are built from it. So is every foreign key the model declared: a record that belongs to another answers with that record's `externalId`, not with its primary key, and the lookups behind a whole page are batched into one statement per model. A controller that presents its records before sending them calls `henri.model.publish()` first -- a plain object carries no model, so nothing downstream can tell a foreign key from any other number. See [Identifiers](/guides/models/#identifiers) and [Foreign keys](/guides/models/#foreign-keys). A collection embeds its items under `_embedded.` and carries the paging links and counters, plus `Link` and `X-Total-Count` headers: ```json { "_links": { "self": { "href": "/tasks?page=2&per_page=25" }, "first": { "href": "/tasks?page=1&per_page=25" }, "prev": { "href": "/tasks?page=1&per_page=25" }, "next": { "href": "/tasks?page=3&per_page=25" }, "last": { "href": "/tasks?page=4&per_page=25" }, "create": { "href": "/tasks", "method": "POST" }, "new": { "href": "/tasks/new" } }, "_embedded": { "tasks": [{ "...": "..." }] }, "count": 25, "page": 2, "perPage": 25, "total": 100 } ``` Options: `type` names the controller when it is not the route's (`res.resource(user, { type: 'users' })`), `links` adds your own (`{ rel: href }` or HAL link objects), `status` sets the status; a `201` also sets `Location` to the `self` link. The content type is `application/hal+json` when the client asks for it and `application/json` otherwise. Authenticated answers carry `Cache-Control: no-store`; every JSON answer has a weak `ETag` and `If-None-Match` gets a `304`. `res.negotiate({ html, json })` runs `html` for browsers (and clients accepting `*/*`) and `json` for `application/json`, `application/hal+json` and the versioned media type below. `res.render()` keeps answering `{ data, user, paths, ... }` to JSON clients and now adds `_links` too. Routes expanded from `resources` and `crud` are expected to answer HAL: a JSON answer without `_links` on one of them is reported once per route in the log, and refused with a `500` when `config.api.strict` is `true` — which is also what refuses an answer that does not match what the action [declared it answers](/guides/controllers/#answers-what-an-action-answers). The page object the [Inertia](/guides/views/#inertia) engine answers a client-side visit with (`X-Inertia`) is a rendered page, not an API answer, and is never checked. `henri generate scaffold` and `henri generate crud` write controllers in the shape above, and `henri generate test ` asserts the links when the name has a `resources` or `crud` route. ## Pagination `req.pagination()` reads `?page=` and `?per_page=` and returns `{ page, perPage, skip, limit, offset }`, defaulting to `config.api.perPage` (25) and capped at `config.api.maxPerPage` (100). Pass `page`, `perPage` and `total` to `res.collection()` and the paging links are computed for you. [`Model.paginate()`](/guides/models/#pagination) is the other half: `await Task.paginate(req.pagination())` answers `{ records, page, perPage, total, pages }` on every adapter, so an index action is one query instead of a find and a count. The paging links are built from the url as it was requested and only ever set `page` and `per_page`, so everything else it carries — a filter, a sort — rides along and page two of a filtered list is page two of the same list. ## Filtering and sorting An index action declares what a client may narrow and order its list by, in a `filters` block next to `params`, and everything else is a `422` before the action runs: ```js filters: { index: { where: { state: { enum: ['submitted', 'accepted'], type: 'string' } }, sort: ['submittedAt', 'title'], default: '-submittedAt', }, }, index: async (req, res) => { const { order, where } = await req.filters(); const { page, perPage, records, total } = await Task.paginate({ ...req.pagination(), order, where, }); return res.collection(records, { page, perPage, total }); }, ``` `?filter[state]=accepted&filter[submittedAt][gte]=2026-01-01&sort=-submittedAt` is the request. `req.filters()` intersects it with what [the policy says the list is](/guides/policies/#scoping-a-list), so a filter narrows a list and can never widen it, and it appends the record's `externalId` to the order so paging is exact. The whole of it — the operators, what can never be declared, and why a substring search is opt-in per field — is in [Filtering and sorting](/guides/filtering/). ## Embedding relations A client that wants an invoice and its lines makes two requests, and a page of twenty invoices makes twenty one. `_embedded` is HAL's answer to that, and an action says what may go in it in an `embeds` block next to `params` and `filters`: ```js // app/controllers/invoices.js module.exports = { embeds: { show: { customer: 'customerId', lines: { limit: 200, through: 'Line.invoiceId' }, }, index: { customer: 'customerId' }, }, show: async (req, res) => res.resource(req.invoice, { embed: ['lines'] }), }; ``` A relation is written as **the foreign key it goes through**, because that is the only thing henri can check: `'customerId'` is a key this model declared and the record it names is embedded; `'Line.invoiceId'` is a key another model declared at this one and the records naming it are. Both have to be [declared references](/guides/models/#foreign-keys) — `belongsTo()`, `references: { model }`, Mongoose's `ref` — and anything else fails the boot, because henri reads no field name to decide what points where. `{ through, limit, one }` is the whole vocabulary: `limit` caps a list per record, and `one: true` says the other model holds at most one of them. The answer carries them under `_embedded`, next to the record's own fields: ```json { "_links": { "self": { "href": "/invoices/0199a5c1-1f7e-7a3c-bb0d-2b1a4f6d9c11" } }, "externalId": "0199a5c1-1f7e-7a3c-bb0d-2b1a4f6d9c11", "amount": "129.90", "customerId": "0199a5c2-8d21-7b04-9f3e-6c2b0d7a1e55", "_embedded": { "lines": [ { "externalId": "0199a5c3-0f11-7c22-8a44-1d3e5b6c7d88", "label": "Seat" } ] } } ``` **A client asks with `?embed=`**, and only for what the action declared: `GET /invoices?embed=customer` embeds it, `?embed=notes` is a `422` before the action runs, and at most `config.api.maxEmbeds` (3) relations may be asked for at once. `res.resource(record, { embed })` is the caller's word and wins over the query string; `embed: []` embeds nothing. An action with no `embeds` block has no such surface at all, so `?embed=` there is a query parameter nothing reads. ### What an embedded record is, exactly The same thing `res.resource()` of that record would answer. The children are published and stripped in the **same call** as the records they hang off, so every rule that governs an answer governs an embedded one: - foreign keys leave as the `externalId` of the row they name, at every depth, and no primary key leaves at all; - a column marked `personal: { expose: false }` is dropped from an embedded record exactly as it is from the record itself — including on the [user model](/guides/privacy/), which is answered as the model left it rather than through `publicUser()`, so a field that must not leave says so on the model; - the identifier lookups of the parents and the children are batched together, one statement per model for the whole answer. **Every embedded record is asked `show` against its own model's [policy](/guides/policies/)**, one at a time, by the rule `_links` already follows: a model with a policy is asked about every record, a model with no policy is not asked at all. A record the policy refuses is **absent** — not a stub and not a `null`, because the request the client would otherwise have made would have been a `404`, and a `404` carries nothing. ### What it costs, and the bound **One statement per relation per answer**, whatever the page size: the parents' keys are collected, deduplicated and asked for once (`WHERE invoice_id IN (...)`, `{ $in: [...] }` on MongoDB), then grouped in memory. Twenty invoices embedding their lines is one query for the twenty. henri does not reuse an association the controller eager loaded, because an eager loaded list honours neither the `limit` nor the order this promises — so a controller that eager loads _and_ embeds pays for both. A list is capped per record at `limit`, or at `config.api.maxEmbedded` (25) when the declaration names none, and the rows come back ordered by the foreign key and then by the target's `externalId` (a uuid v7, so creation order), which makes the prefix a client gets the same prefix twice. `limit` is a promise about your data: a relation that holds more is reported once per route in the log and the client is served the prefix. It is not refused, even under `config.api.strict` — the answer is already built by then, and a request that fails because a customer has twenty six invoices instead of twenty five turns a cosmetic mistake into an outage. A relation that genuinely needs paging is a collection, and a collection has an endpoint of its own. Deliberately not here: `_links` on an embedded record (nothing declares which controller serves a model, and a guessed href is worse than none), nesting (`?embed=lines.product`), embedding from `res.render()`, and filtering or ordering an embedded relation from the query string. ## Exporting a CSV Every application grows an export endpoint, and the shape it grows is the same one every time: read the whole table, join the rows with commas, `res.send()`. That holds the file in memory, falls over on the row count that made anybody want an export, writes the primary key into the file, writes a column the model said must never leave, and hands a spreadsheet a cell that starts with `=`. `res.csv()` is henri's answer to all four: ```js // app/controllers/invoices.js report: async (req, res) => res.csv(Invoice, { filename: 'invoices' }), ``` **It streams.** There is no `Content-Length` — there is no number to put there without building the file first, which is the thing being avoided — so the answer is chunked, the rows are read a page at a time (`config.api.csv.batch`, 500) and `res.write()` returning `false` is awaited rather than ignored, so a slow client slows the reads instead of filling the process with a file nobody is taking. The pages are a **cursor**, not an `OFFSET`: `WHERE externalId > :last ORDER BY externalId` (the primary key on a model that opted out of the public one). An offset over a table that is being written to skips rows and repeats rows, and an export that quietly drops a row is worse than no export. A uuid v7 is also creation order, so the file is in the order the records were made; a client's `sort` has no say, because an export is a dump rather than a page. **The same exit gate.** Every page goes through the same `toPublic()` call `res.resource()` uses — publish, then strip — so a foreign key leaves as the `externalId` of the row it names, no primary key leaves at all, and a column marked `personal: { expose: false }` is not in the file. `include: ['phone']` is the same way back it is everywhere else. The columns are the **model's**, not the rows': the header comes from the schema plus what the adapter adds, minus what is hidden, so a file with no rows still has a header and two exports of the same model have the same columns whatever the rows happened to hold. `columns: ['title', 'amount']` narrows and reorders that list, and a name that is not one of them is refused before a byte is written. **It is not a per-record authorization surface.** A hundred thousand rows are not a hundred thousand policy questions, so `res.csv()` takes the position `req.filters()` takes: the list is what [`policy.scope(user)`](/guides/policies/#scoping-a-list) says it is, asked for by default. Hand it a condition (`where`, usually what `req.filters()` answered) and it is intersected with the scope; an application whose export is genuinely everything says so once, with `scope: false`, and guards the route with `roles` instead. ### Escaping, and the fifth character A cell is quoted when it holds a comma, a quote, a newline or a leading or trailing space, and a quote inside it is doubled — RFC 4180, written as a walk over the code points rather than as a pattern. RFC 4180 says nothing about the fifth one. A cell whose text starts with `=`, `+`, `-`, `@`, a tab or a carriage return is a **formula** in Excel, Sheets and LibreOffice; `=cmd|'/c calc'!A1` is the famous one and `=IMPORTXML(...)` quietly posts the row it sits next to at a url of somebody else's choosing. henri writes such a cell as text — quoted, with a leading apostrophe — and the rule is narrow on purpose, because the false positive matters: `-1.5` starts with `-`, and an export where every negative number has been mangled is not an export. So only a value that **is a string** is considered (a number, a date, a boolean is text henri wrote itself), and a string that is a **plain number** is left alone. It does change the bytes, which is why `config.api.csv.formulas: false` turns it off for an export a machine reads. ### The bound, and what happens when something breaks `config.api.csv.maxRows` (100000), checked with one `count()` **before the headers go out**, so an export too big to serve is a `413` carrying the bound and the number — an answer a client can narrow — rather than a file that stops in the middle. Once bytes really are on the wire there is no status left to send. henri **destroys the connection** instead of ending the response: a truncated CSV is a valid CSV, and a consumer has to be able to tell a file that stopped early from a file that ended, so the terminating chunk is never written and every conforming client reports a transport error. The failure is logged with the row count reached and goes to [`henri.reporter`](/guides/logs/#henrireporter). That answer is blunt, so the other half of it is making it rare: the headers go out with the first **chunk** (64kb) rather than the first row, so an export smaller than that — which is most of them — has written nothing when it fails and still gets an ordinary `500`. ## Pushing to a client The other answer that does not end: [`res.stream()`](/guides/streams/) is a server-sent event stream on the same http server, through the same session, role guard and policies. The policy is asked at subscribe time and again before every event, an event carrying a record leaves through the same `toPublic()` gate as everything on this page, and a broadcast reaches the subscribers of one process — which the guide says at the top, in a box. ## Idempotency Clients retrying a `POST`, `PUT`, `PATCH` or `DELETE` send an `Idempotency-Key` header (1 to 255 printable ASCII characters, otherwise a `400` carrying `HENRI_API_IDEMPOTENCY_KEY_INVALID`), with the same semantics as Stripe: | Situation | Answer | | ----------------------------------------------- | ---------------------------------------------------------------- | | First request with the key | Executed; status, headers and body stored for 24 hours | | Same key, same request, already answered | The stored answer, with `Idempotency-Replayed: true` | | Same key, same request, still in flight | `409` with `Retry-After: 1`, `HENRI_API_IDEMPOTENCY_IN_PROGRESS` | | Same key, different method, path or body | `422`, `HENRI_API_IDEMPOTENCY_KEY_REUSED` | | First answer was a `5xx` or the request aborted | Nothing stored, the client may retry | Keys are scoped to the user, the session or the ip, so two users may use the same key. Every mutating route from `config/routes.js` honours the header; `idempotent: false` on a route opts it out, and core's `/login` and `/logout` are never covered. `config.api.idempotency: false` turns the feature off. The answers live in this process's memory unless the application says otherwise, which stops being idempotent the moment it runs two processes: [`config.shared`](/configuration/#the-shared-object) names one backend for these keys, the rate limit and the sign-in lockout at once. `config.api.idempotency.store` still names a module of its own (exporting `{ get, set, delete }`, or a `(henri, { name }) => store` factory) and still wins over it, and `henri.api.idempotencyStore` can be replaced after the boot. A shared store that does not answer is the one case where the request is always refused — `503` with a `Retry-After` and `HENRI_STORE_SHARED_UNAVAILABLE`, whatever `shared.onError` says. Serving a mutating request whose first answer cannot be read is what the header exists to prevent. ## Rate limiting Requests are limited with [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit), per user id when logged in and per ip otherwise (`trustProxy` decides which ip). The answer is a `429` from `res.boom.tooManyRequests` carrying `HENRI_API_RATE_LIMITED`, with `data: { limit, retryAfter, windowMs }` and the draft-7 `RateLimit`, `RateLimit-Policy` and `Retry-After` headers. - Global: `config.rateLimit`, 600 requests per minute by default. It is not enforced in development, where the Next and Vite dev servers fetch hundreds of assets through the router; it is in test and production. - Authentication: `config.rateLimit.auth`, 10 `POST` per minute per ip on the login path and on `/register`, `/signup`, `/password`, `/forgot-password` and `/reset-password` (`paths` overrides the list). - Per route: `rateLimit: { windowMs, max }` in `config/routes.js`, always enforced. `config.rateLimit: false` disables everything, `auth: false` only the authentication limiter. In production a warning is logged when `trust proxy` is `true`, because a spoofed `X-Forwarded-For` then chooses the bucket. The counters are kept in this process unless the application says where else, which means a limit of 600 is really 600 per process: [`config.shared`](/configuration/#the-shared-object) names one backend for the limiter, the sign-in lockout and the idempotency keys at once (`pnpm add @usehenri/redis`), and `config.rateLimit.store` still names an express-rate-limit `Store` of its own and still wins over it. The boot line says which it is: ```text info api rate limit 600 requests per 60s per user or ip, counted in redis (fail closed) info api rate limit 600 requests per 60s per user or ip, counted in this process ``` When the shared store does not answer, `shared.onError` decides: `closed` (the default) refuses the request with a `503` and a `Retry-After`, `open` serves it uncounted. Either way it is logged, at most once every ten seconds. ## Request ids, headers and logs - `X-Request-Id` is accepted from the client or generated, exposed as `req.id`, echoed on every answer and written in every log line of the request, so a client can quote it in a bug report. - [helmet](https://helmetjs.github.io/) sets the secure headers, with a Content Security Policy that lets Next, Turbopack and Vite hot reloading work in development (`unsafe-inline`, `unsafe-eval`, `ws:` and `blob:` there only) and no HSTS outside production. henri drops the `https:` wildcard helmet leaves in `style-src` and `font-src`, so a stylesheet or a font from elsewhere is an origin you name. `config.helmet` is merged over these options; `false` disables helmet. Its default `Cross-Origin-Opener-Policy: same-origin` blocks OAuth popups: override it there when you need them. - `Permissions-Policy` denies the powerful browser features (camera, microphone, geolocation, payment, and the rest) for every application, since a header that is absent grants them. Name the ones you use with `config.helmet.permissionsPolicy` (`"geolocation=(self)"`), or `false` to send no header. It is henri's own, not one of helmet's, and is taken out of the options before they reach helmet. - `upgrade-insecure-requests` is sent only to requests that arrived over https (`req.secure`, which honours `trustProxy` and `X-Forwarded-Proto`). On a page served over plain http the directive would rewrite every later request to https, including the redirect a controller answers after a `POST`: the record is written, the browser fails to follow the redirect, and the page never updates. Add it through `config.helmet` if you want it on http too. - `config.filterParameters` (`password`, `token`, `secret`, `authorization`, matched as substrings like Rails' `filter_parameters`) are masked in everything `henri.pen` prints, query strings included. `henri.pen.redact(object)` applies the same masking to your own output. Setting the list replaces the defaults, so one name is masked whatever it says: anything containing `encryption`, which is where the [key that opens the encrypted columns](/guides/encryption/) lives. What a line looks like, and what is never masked, is [Logs and error reporting](/guides/logs/). - `config.bodyLimit` (`1mb`) bounds JSON and form bodies; `config.requestTimeout` (30 seconds) answers `503` to requests still running after it. ## Versioning A client asking for `Accept: application/vnd.henri.v1+json` gets JSON and `req.apiVersion` is `'v1'` (`null` otherwise). A route with `version: 'v1'` refuses other versions with a `406`. Put versioned routes under a `scope`: ```js module.exports = { 'resources artworks': { controller: 'artworks', scope: 'api/v1', version: 'v1', }, }; ``` ## Health checks Liveness and readiness are different questions with opposite consequences — a failed liveness probe restarts the container, a failed readiness probe takes it out of the load balancer — so henri answers them separately: | Path | Question | Answer | | -------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /livez` | Is the process running? | Always `200` while it can answer. It never touches a store: a database outage must not restart a process that is otherwise healthy. | | `GET /readyz` | Can it serve traffic? | `200`, or `503` while the boot is still running, while the process is shutting down, and when a store fails or takes more than two seconds. | | `GET /healthz` | The same as `/readyz` | The older, ambiguous name, answered as readiness for a proxy that only knows it. | | `GET /_henri/health` | The same as `/readyz` | Kept as an alias, so a deployment already pointing at it keeps working. | ```json { "status": "ok", "stores": { "default": { "adapter": "drizzle", "ok": true, "latency": 2 } }, "shared": { "adapter": "redis", "ok": true, "latency": 1 }, "uptime": 42 } ``` A `503` says `"status": "unavailable"` and a `reason` (`starting`, `shutting down`, `a store did not answer`, `the shared store did not answer`); a store that failed is `{ "adapter": "drizzle", "ok": false, "error": "timeout" }` — `timeout` or `unreachable`, never the driver's own message, which carries the connection string it could not reach. The message is in the log. `shared` only appears when [`config.shared`](/configuration/#the-shared-object) names one. A process whose counters cannot be counted is not ready either: with the default `onError: "closed"` it refuses every rate limited or idempotent request, so it should leave the load balancer until the store is back. All four run before the session and the limiters — and before the router, so an application route on one of those paths never sees the request — unauthenticated, so a load balancer can call them freely: it has no credentials. `/healthz` is the ambiguous one: the name says "health" without saying which of the two questions it answers, so one deployment wires it to liveness and the next to readiness. henri answers readiness there, which is what `/_henri/health` has always done and the safer of the two guesses. Point a liveness probe at `/livez` and leave `/healthz` to whatever cannot be configured. ## Graceful shutdown On `SIGINT` or `SIGTERM` the server drains before the modules stop, so a rolling deploy does not cut a request in half: 1. Readiness answers `503` while the port is still open, so a load balancer that polls has a chance to stop sending. `shutdown.delay` (`0`) keeps serving that long before the next step. 2. The listener closes — the port stops accepting — and the idle keep-alive sockets are hung up, which is what would otherwise hold the close open for their whole idle timeout. 3. The requests in flight run to their end, up to `shutdown.drain` (10 seconds). What is still open then is destroyed, and the log says how many. 4. `henri.stop()` stops the modules, backwards; the process exits with `1` when one of them failed. `shutdown.signals: false` leaves the signals to your application, which then calls `henri.server.shutdown('SIGTERM')` itself. A `henri jobs` runner never listens on a port and drains its own way: it stops claiming, finishes the jobs it holds and writes their outcomes. See [Shutdown](/configuration/#shutdown). ## The description of it all `henri openapi` writes the [OpenAPI 3.1 description](/guides/openapi/) of everything on this page for one application: its routes, its HAL envelopes, its error bodies, the statuses each guard answers and the endpoints henri mounts. It is generated from the routes and the models, and it says in the document itself where henri cannot know what a controller answers. ## Middleware order Knowing the order helps when adding your own with `henri.addMiddleware()`: request id, timeout, helmet, compression (production), cors, body parsers, cookies, `res.boom`, the API version reader, `req.pagination`, the health endpoints, static files, then the user module (permit, session, passport, CSRF), the authentication and global limiters, the router (per route: version guard, route limiter, role guard, idempotency, HAL guard, the action), the `404` and the error handler. --- # GraphQL Source: https://usehenri.io/guides/graphql/ Install @usehenri/graphql and henri derives a model's types, queries and resolvers from the schema it already declares - or write them yourself. ```bash npm install @usehenri/graphql # once, in your application ``` GraphQL lives in [`@usehenri/graphql`](https://www.npmjs.com/package/@usehenri/graphql). The package [ships a henri module](/reference/under-the-hood/#where-it-goes-a-module-that-arrives-from-a-package), so depending on it is all there is to do: it is in the boot as `henri.graphql`, at level 1. An application that does not depend on it has no `henri.graphql` at all and never loads Apollo Server. GraphQL was part of `@usehenri/core` until 1.2 — see [Upgrading](/upgrading/#graphql-moves-to-usehenrigraphql). Add a `graphql` key to a model and its types and resolvers are merged with every other model's and served by Apollo Server at `/_henri/gql` (change the path with the `graphql` configuration key). Introspection is on outside production. No model declaring `graphql` means no endpoint at all: this is opt-in twice over, by the dependency and by the models. `graphql: true` is the whole key most of the time: henri derives the type, the queries and the resolvers from the schema the model already declares, at boot, and `henri graphql` prints what that is. An object writes them by hand instead, which is what the key always was. Without the package, nothing is silent: a model that declares a `graphql` key fails the boot, and `res.render(view, { graphql })` fails the request. Both say to install `@usehenri/graphql`, and `henri doctor` reports it as a missing dependency. Everything else keeps working, and a page simply has no `graphql` key among its view options. ## Derived from the model A model already says what its records are. `graphql: true` is henri deriving the definition from that, at boot: ```js // app/models/Artwork.js module.exports = { graphql: true, options: { timestamps: true }, schema: { title: { type: 'string', required: true }, year: { type: 'integer' }, status: { type: 'string', enum: ['draft', 'published'] }, }, }; ``` is the schema below, served, with no SDL written anywhere: ```graphql enum ArtworkStatus { draft published } type Artwork { id: ID! createdAt: String! status: ArtworkStatus title: String! updatedAt: String! year: Int } type ArtworkPage { records: [Artwork!]! page: Int! perPage: Int! total: Int! pages: Int! } input ArtworkFilter { status: ArtworkStatus title: String year: Int } type Query { artwork(id: ID!): Artwork artworks(page: Int, perPage: Int, where: ArtworkFilter): ArtworkPage! } ``` `henri graphql` prints exactly that, without booting, and `henri graphql --summary` says what henri left out of each model and why. Paste what it prints into the model's own `types` if you would rather own the definition from here on — nothing stops you, and that is the whole difference between this and a generator. ### Why it is not written into the file A `henri generate graphql` would have written the SDL above into `app/models/Artwork.js` and handed it over. It would have been honest for about a week. The definition depends on things that keep moving — whether the model carries a public identifier, which fields are marked personal, which columns are encrypted, whether a relation is declared — and a copy in a file says what was true the day it was written while the JSON answer next to it says what is true now. henri already refuses that trade for [the OpenAPI document](/guides/openapi/), for the HAL `_links` and for [what a foreign key publishes as](/guides/models/#identifiers): the definition is derived, so the two cannot disagree. ### What each type becomes | henri schema | GraphQL | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `string`, `text` | `String` | | `integer` | `Int` | | `number`, `float` | `Float` | | `decimal`, `bigint` | `String` — the [exact value](/guides/models/#exact-numbers); a `Float` is a double and an `Int` is 32 bits, so either would undo the column in the answer | | `boolean` | `Boolean` | | `date` | `String`, ISO 8601 — the same string the JSON answer carries | | `uuid` | `String` | | `json` | nothing: a JSON column holds no shape GraphQL could state, so add the field and a scalar of your own | | `enum: [...]` | an `enum ` when every value is a GraphQL name, `String` when one is not (`in-progress`) | | a declared foreign key | `ID`, holding the `externalId` of the row it names | `required: true` makes the field non-null. The timestamps, `deletedAt` on a `paranoid` model and the columns henri adds to the user model are all there, because they are all in the JSON answer. ### `id` is the externalId The trap in generating any of this is the identifier. `id` is `ID!` and it is the [`externalId`](/guides/models/#identifiers) — the primary key is not a field, it is not an argument, and `artwork(id:)` resolves through `findById()`, which takes the public identifier and nothing else. A primary key gets the same `null` an unknown uuid gets. A declared foreign key (`belongsTo()`, `references: { model }`, Mongoose's `ref`) is an `ID` holding the target row's `externalId`, and a mutation writing one takes an `externalId` back and looks the row up. An undeclared column holding an id is a string to henri, here as everywhere else. There is no nested object field: `memo.owner` is not generated. Resolving one is another model's policy question and an N+1 in the same line of code, and henri answers neither on your behalf. Write it yourself — the `types` and `resolvers` you add are merged into what henri derived: ```js graphql: { generate: true, types: `extend type Memo { owner: User }`, resolvers: { Memo: { owner: (memo) => User.findById(memo.ownerId) } }, }, ``` ### What is never derived Nothing here is a list of field names; it is read off the marks the model already carries. | The mark | What it means here | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `personal: { expose: false }` | Not a field at all. `res.render()`, `res.resource()` and `res.collection()` drop those names from every answer, so a GraphQL field for one would be a hole in the side of the same wall. | | `personal: true` | A field, never an argument. A value you may read on a record you are allowed to see is not one anybody may search by. | | `encrypted: true` (randomised) | Never an argument: henri refuses that query with `HENRI_ENCRYPTION_NOT_QUERYABLE`, and a field the framework will not query has no business being a queryable one. Deterministic may be one. | | `graphql: { except: [...] }` | Whatever else you want left out. | The user's `password` needs no special case: [`base/privacy.js`](/guides/privacy/) marks it `expose: false` on every application that has a user model, so the same rule leaves it out. So does `roles`, from a mutation input — it is changed with `setRoles()` and never by mass assignment. ### Queries by default, mutations on request `graphql: true` derives the type and two queries. It derives no mutation, because `deleteArtwork` on the endpoint of an application that never asked for one is a hole. Ask, and say which: ```js graphql: { generate: true, mutations: true }, // all three graphql: { generate: true, mutations: ['create', 'update'] }, ``` which adds `createArtwork(input:)`, `updateArtwork(id:, input:)` and `deleteArtwork(id:)`. The input is the model's writable columns, all optional, references included; the columns henri writes itself are not in it. The whole block, with everything it takes: ```js graphql: { generate: true, // derive the definition from the schema name: 'Artwork', // the type name; defaults to the model's queries: true, // artwork(id:) and artworks(page:, perPage:, where:) filters: true, // the `where` argument of the list query mutations: false, // true, or any of 'create', 'update', 'destroy' except: ['internal'], // fields to leave out whatever the schema says types: `...`, // your own SDL, merged in resolvers: { ... }, // your own resolvers; they win over the derived ones }, ``` ### It goes through the policies, always Every derived resolver asks [a policy](/guides/policies/), and there is no setting that turns that off: - `artwork(id:)` loads the record and asks `show`. A refusal and a row that is not there both answer `null`: an error saying "you may not read this one" is a lookup oracle. - `artworks` asks `index` without a record, and then asks the policy what the list _is_ — `scope(user)` is the condition it filters by. A `where` argument narrows that condition and can never widen it. - `createArtwork` asks `create`, `updateArtwork` and `deleteArtwork` load the record and ask `update` and `destroy`. Policies fail closed, so **a model with `graphql: true` and no `app/policies/.js` serves an empty page and a null record.** That is the intended reading: opting a model into GraphQL is not opting it out of authorization. A policy with no `scope(user)` raises `HENRI_API_GRAPHQL_SCOPE_REQUIRED` on a list query rather than quietly meaning "every row" — `scope: () => ({})` is how a policy says everything. `henri doctor` reports both before a query ever arrives. Everything a resolver answers goes through the two functions every other answer goes through: `henri.model.publish()` (the foreign keys become the `externalId` of the row they name, the internal ids go) and `henri.privacy.strip()` (the fields marked `expose: false` go, at every depth). ## Writing your own A model that would rather say it itself writes `types` and `resolvers`, which is what the `graphql` key always was and still is. Nothing is derived unless `generate` asks for it, so this block is untouched: ```js // app/models/Task.js module.exports = { schema: { description: { type: 'string', required: true }, type: { ref: 'Type', required: true, type: 'string' }, location: { ref: 'Location', required: true, type: 'string' }, reference: { type: 'string', required: true }, notes: { type: 'string' }, oos: { type: 'boolean', default: false }, }, options: { timestamps: true, }, graphql: { types: ` type Task { id: ID! reference: String! description: String! location: Location type: Type notes: String oos: Boolean } type Query { tasks: [Task] task(id: ID!): Task } `, resolvers: { Query: { tasks: async () => Task.find().populate('type location').exec(), task: async (_, { id }) => Task.findById(id).populate('type'), }, }, }, }; ``` Two things are yours to get right here that a derived definition gets right on its own, and `henri doctor` reports one of them (`graphql.exposed`): - **the identifier**. `id: ID!` above is the `externalId`, which is what `findById()` takes and what every other answer henri builds carries. A type publishing `_id` hands out the primary key one query away from everything the [public identifier](/guides/models/#identifiers) exists to stop. - **the fields that never leave the server**. A resolver of your own is the one way past `personal: { expose: false }`; henri strips what it serializes and cannot strip what you wrote. If the merged schema does not compile, the error is printed and the GraphQL service stays off until you fix it. Everything else keeps working. The schema is rebuilt when the models reload. ## Query The schema is queryable anywhere with `henri.graphql.run(query, variables, contextValue)`, which resolves with `{ data, errors }` (or the string `No graphql schema found.` when no model defines one), and directly as an argument to `res.render()`: ```js // app/controllers/tasks.js // henri.gql does nothing but help editors highlight and format queries const { gql } = henri; module.exports = { index: async (req, res) => { return res.render('/tasks', { graphql: gql` { tasks { id reference description type { id name } location { id name } } locations { id name } } `, }); }, }; ``` The query result becomes the `data` prop of the page and `errors` holds the GraphQL errors, if any. The page also receives `graphql.endpoint` and `graphql.query`, so the client can rerun the same query later. Queries run this way carry `{ req, res }` as their context like any other, so a derived resolver asks the same policies with the same user. They pass no endpoint guard: they are your code, not a request. ## Bounding the endpoint A rate limit caps how many requests arrive. It says nothing about what one request costs, and one GraphQL request can cost arbitrarily much: a hundred aliases of the same expensive field, a fragment spread a thousand times, or a walk down a cycle your schema happens to contain. Four bounds are enforced before a single resolver runs, and a query past any of them is refused as a validation error. | Key | Default | Refuses | | --------------- | ------- | ----------------------------------------------------------------------------------------------------- | | `maxAliases` | `15` | Alias amplification. This is the universal one: it needs no cycle and no deep schema. | | `maxComplexity` | `1000` | Queries selecting more fields than this in total, fragments expanded — what a fragment bomb inflates. | | `maxDepth` | `10` | Nesting. Only reachable when your own schema offers somewhere deep to go. | | `maxTokens` | `5000` | Documents this large, before they are parsed. | Set any of them to `false` to lift it. The endpoint also has Apollo's own CSRF prevention on, so a `POST` must be `application/json` or carry `apollo-require-preflight`: a plain form on another site cannot reach it. graphql 16 cannot cancel an execution that has started, so henri does the next best thing: every field checks first, and a query whose client disconnected or whose request already timed out (`config.requestTimeout`) stops at the next field instead of running to completion for nobody. ## Guarding the endpoint The endpoint is open to anyone who can reach it unless you say otherwise. Your resolvers receive `{ req, res }` and can check `req.user` themselves, which is the right place for per-field rules; `config.graphql` guards the whole endpoint: ```json { "graphql": { "endpoint": "/_henri/gql", "authenticated": true, "roles": ["admin"], "loopbackOnly": false, "introspection": false, "maxDepth": 10, "maxAliases": 15, "maxComplexity": 1000, "maxTokens": 5000 } } ``` - `authenticated`: anonymous requests get `401`. - `roles`: a signed-in user missing any of them gets `403`. Asking for a role implies `authenticated`. - `loopbackOnly`: anything but the loopback interface gets `404`, the way `/_routes` and `/_mailers` behave. Useful when the endpoint is a development tool rather than an API. - `introspection`: on outside production by default. `"graphql": "/path"` still means what it always did: the endpoint path, with every default above. Queries run from a controller with `res.render({ graphql })` or `henri.graphql.run()` go through the same limits, and past no guard — they are your code, not a request. --- # OpenAPI Source: https://usehenri.io/guides/openapi/ henri openapi writes the OpenAPI 3.1 description of what an application exposes, from its routes, its models and its configuration — and says, in the document, what it cannot know. ```bash henri openapi # the document, on stdout henri openapi --out openapi.json # written to a file the application commits henri openapi --summary # what it covers, and what henri cannot know ``` `henri openapi` writes the [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0.html) description of the HTTP surface an application exposes. It is generated, never hand-written: it reads `config/routes.js` the way the router reads it, `app/models` the way the adapters read it, and `config/.json` the way the boot reads it. It starts no server and opens no database, so it runs in CI and in a container that has neither. Most frameworks cannot generate a good one, because they know nothing about their own answers: the description ends up being a second, hand-maintained copy of the application that drifts the moment a controller changes. henri is unusually well placed here, because a large part of what an application answers is henri's own doing — the HAL envelopes, the paging, the error body, the guards — and that part can be derived rather than guessed. The other part cannot, and this page is mostly about that. ## What the document says Everything below comes from the application, never from a convention henri hopes it follows. | In the document | Read from | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The paths and the verbs | `config/routes.js`, expanded by [`base/routes.js`](/guides/routes/) — the same table `henri routes` prints | | The path parameters | The `:id` of the route, typed as the record's `externalId` when the model carries one | | The query and body parameters | The [`params`](/guides/controllers/#params-what-an-action-accepts) an action declared, where it declared any — the types, the bounds, the enums and what is required | | The `filter[...]` and `sort` | The [`filters`](/guides/filtering/) an action declared: one parameter per comparison, and the columns a client may order by | | The `embed` | The [`embeds`](/guides/api/#embedding-relations) an action declared: an enum of the relation names, capped at `config.api.maxEmbeds` | | The schemas | `app/models`, plus the columns the adapters add (`externalId`, `createdAt`/`updatedAt`, `deletedAt`, and the user's `email`, `roles`, `confirmedAt`, `passwordChangedAt`) | | The HAL envelopes | [`res.resource()` and `res.collection()`](/guides/api/#answering-hal), for the routes expanded from `resources` and `crud` | | The error envelope | [`res.boom.*`](/guides/api/) and the 404/500 handlers, with the `code` of the [error catalogue](/reference/errors/) | | Which statuses a route can answer | Its own guards: `roles`, `policy`, `version`, `Idempotency-Key`, the rate limit | | `page` and `per_page` | `config.api.perPage` and `config.api.maxPerPage` | | The security schemes | The session cookie and the bearer token the [user module](/guides/users/) mounts, and the `X-CSRF-Token` a mutating request needs | | `POST /login`, the account flows | `config.user`: they are in the document only when henri actually mounts them | | The identity endpoints | `config.user.identities`: the providers are the `enum` of the path parameter, and every refusal is described by the `data.reason` it carries | | `/livez`, `/readyz`, `/healthz` | Always: every henri application answers them | Every operation also carries `x-henri.enforced`, a list naming what henri actually checks on that route — `_links` on the ones expanded from `resources` and `crud`, `params` on the ones whose action declared any, `answers` on the ones whose action declared what it sends, `filters` on the ones that declared those, `embeds` on the ones that declared what may travel next to a record — as opposed to what it expects. A field marked `personal: { expose: false }` is in **no** schema, because [privacy](/guides/privacy/) strips that name from every answer henri builds, at every depth. Neither is `password`. A declared foreign key is typed as the `externalId` of the row it names, because that is what [`base/references.js`](/guides/models/#identifiers) publishes — and `null`, because a key that names no row resolves to nothing. A [`decimal` and a `bigint`](/guides/models/#exact-numbers) are `{ "type": "string" }` with a `pattern` (`^-?\d+(\.\d+)?$` and `^-?\d+$`) and a `format` (`decimal`, `int64`), because a string is what the JSON answer carries and a JSON number is a double. `minimum` and `maximum` are deliberately left off those two: a numeric bound on a value the document has just called a string describes nothing. The model still enforces it. ## What an action declared it accepts A controller that declares [`params`](/guides/controllers/#params-what-an-action-accepts) has told henri the shape of the request it takes, which is exactly what an OpenAPI operation wants. So the document is built from it rather than around it: - a field the route templates (`:id`) is that **path parameter**, typed by the rule; - on a verb with no body, everything else is a **query parameter**; - on a mutating verb, everything else is the **request body** — the declaration, not the model's columns — with `required` where the rule says so and `additionalProperties: true`, because an undeclared key is dropped rather than refused and the action may still permit one of the model's columns by name; - and the operation answers **422**, on every verb. That last one is the reason this exists. The check is registered next to the guards whatever the verb, so an `index` declaring `page: { type: 'integer', min: 1 }` answers `422` on `?page=banana` — an answer the document used to deny, because it tied `422` to the idempotency of a mutating route. Which `422` it is now has a name of its own: | Component | What answered it | | --------------------- | ------------------------------------------------------------------------------------ | | `InvalidParameters` | The parameter check: `HENRI_PARAMS_INVALID`, one message per field in `data.errors` | | `IdempotencyMismatch` | The `Idempotency-Key` was already used for a different method, url or body | | `UnprocessableEntity` | Both can happen on this operation; `code` says which | | `ValidationFailed` | A value one of the account endpoints henri mounts refused (a duplicate address, say) | A generated client reading a parameter refusal as a replayed key is exactly the kind of drift this document exists not to have. ### And what a list may be filtered and ordered by The [`filters`](/guides/filtering/) block is the same kind of statement about the same request, so it is described the same way: one query parameter per comparison, named the way a client writes it. ```json { "name": "filter[state]", "in": "query", "schema": { "type": "string", "enum": ["submitted", "accepted"] } }, { "name": "filter[submittedAt][gte]", "in": "query", "schema": { "type": "string", "format": "date-time" } }, { "name": "filter[state][in]", "in": "query", "style": "form", "explode": false, "schema": { "type": "array", "items": { "type": "string", "enum": ["submitted", "accepted"] } } } ``` A list operator (`in`, `nin`, `between`) is an array in the `form` style with no explode, which _is_ the comma-separated spelling a query string uses; `between` carries `minItems: 2, maxItems: 2`. The order is one `sort` parameter whose enum holds both spellings of every declared column (`title`, `-title`) and whose `maxItems` is `config.api.maxSort`. `x-henri.filters` carries the same thing as a summary — the model, the sortable columns, and the operators of each field — and the operation answers the `InvalidParameters` 422 above, with `HENRI_FILTER_INVALID` as its code. ### And what may travel next to a record The [`embeds`](/guides/api/#embedding-relations) block is a third statement about the same request, and it is the smallest one to describe: a single `embed` parameter whose enum is the relations the action declared, in the `form` style with no explode — the comma-separated spelling — and whose `maxItems` is `config.api.maxEmbeds`. ```json { "name": "embed", "in": "query", "style": "form", "explode": false, "schema": { "type": "array", "maxItems": 3, "items": { "type": "string", "enum": ["customer", "lines"] } } } ``` Only the names are described. What an embedded record looks like is what the model's own schema already says, and how many of them arrive depends on the data rather than on the declaration — so the document states the vocabulary and stops there. `x-henri.embeds` carries the same list, and the operation answers the `InvalidParameters` 422 with `HENRI_EMBED_INVALID` as its code. This is the one declaration the command reads **without** resolving a model: binding a relation to the foreign key it goes through needs the reference table, which needs a boot, and the names do not. ### Where the declarations are read They live in a controller file, and the block holds regular expressions and functions, so there is nothing to scan for. **`henri openapi` loads the controller with `require()`** — which is what reading the action names already did — and compiles the block with the same `declarations()` the boot compiles it with. A booted application hands over what `henri.controllers.accepts()` already holds. One compiler, one answer: `GET /_openapi.json` and `henri openapi` agree operation for operation, and the suites check that. The command still starts no server and opens no database, but loading a controller does run whatever that file runs at import time. A henri controller is a plain object of handlers, so that is normally nothing; a file that reaches for a model global while it loads throws, and then: ```json "x-henri": { "params": { "read": false } } ``` The operation declares no parameters of its own and no `422` for them, its description says why, and `info.x-henri.params.unread` lists every such action — `henri openapi --summary` prints them. That is the one place the two ways of building this document can differ, so it is in the document rather than left to be discovered. ## What it refuses to say A controller can answer anything. `res.render()` is not JSON. A hand-written route points at an action henri never wrapped. A generated document that describes an answer henri does not give is worse than no document at all, so the rule is: **an honest `unknown` beats a wrong `object`.** ### An operation henri did not wrap A hand-written route (`get /about`), a `member` or `collection` route of a resource, the root of a namespace: these carry **no success status at all**. What they carry is the failures henri answers before the action runs — a 401 from the role guard, a 404 from the policy, a 406 from the version guard, a 409 from an idempotency replay — plus a `default` response saying so in words: ```json "default": { "description": "Not described: what this action answers is the controller's own. A failure henri answers itself uses the error envelope; anything else is this application's.", "content": { "application/json": { "schema": {} } } } ``` `{}` is the JSON Schema for "any value". It is the only correct answer, and the operation's `x-henri.known` is `false` so a reader — a person or an agent — never has to work that out from prose. ### An operation that said what it answers There is one way to move an operation out of that column without changing what it answers: the action declares it. An [`answers`](/guides/controllers/#answers-what-an-action-answers) block is a description of what leaves, which is exactly what this document could not know, so an operation that has one carries a `200` built from it — a field naming a model `$ref`s that model's record schema, a field naming a column carries that column's schema, and `additionalProperties` is `false` because henri drops what the action did not declare. `x-henri.known` is then `true`, `x-henri.answers` lists the fields and `x-henri.enforced` names `answers`. The `default` response stays, for the statuses the action chooses itself. ### Which of two shapes a guarded route answers The routes expanded from `resources` and `crud` are the ones henri does guard — but what it guards is `_links`, and nothing else. Two of henri's own answers carry them: the HAL envelope of `res.resource()` and `res.collection()`, and the page options of `res.render()` and of the implicit render (an action that returns an object without answering). A `resources` route that renders a page answers the second one, and it is a perfectly ordinary thing to do — the showcase's `events#index` does it. So a successful response names both, HAL first (`anyOf`), and the description says which is which: `_embedded` is what tells them apart. Promising one and letting an application answer the other is exactly the drift this document exists not to have. ### A form page `new` and `edit` are the same story with the odds reversed, so they name both shapes too — and they are counted as unknown, because a form page is a body henri does not write. ### A required field Nothing in a response schema is `required`, and every schema is open (`additionalProperties: true`). An action may present its records before sending them — the showcase does, and its proposals carry an embedded `speaker`, `event` and `track` that are in no model. A response schema that claimed `title` is always there would be wrong for every application that presents a summary. What the schema does say is: _these columns, with these types, when they are there._ Each property's description says whether the model requires the column. The one exception is `_links`, and only when `config.api.strict` is on: with it, henri refuses a `resources` JSON answer that has none, so `required` is the truth. ### A request body an action did not declare `req.permit('title')` names the fields and henri never sees that list. Without a [`params`](/guides/controllers/#params-what-an-action-accepts) declaration the input schema is the model's writable columns — no `required`, `additionalProperties: true` — because a column the model requires may well be set by the server (the speaker of a proposal is the session, never the body). A **foreign key gets no type at all**: whether a form posts an `externalId`, a primary key or nothing is the application's decision. With a declaration the body is the declaration, which is the shape henri itself checks; the model's columns stay in `components.schemas` as `Input` and the operation says so. ### An action that does not exist A route pointing at an action the controller does not export answers 501 in development and is never registered in production. The document says exactly that, rather than describing an endpoint nobody can call. ## Reading the coverage Every operation carries an `x-henri` object, and `info.x-henri.coverage` totals them: ```bash $ henri openapi --summary OpenAPI 3.1.0 for @usehenri/showcase 0.0.0 47 operations, 36 paths 27 described from the routes, the models and henri's own endpoints 20 whose answer henri cannot know What henri cannot know (the controller writes the body; only the failures henri answers itself are described): GET / main#home GET /about main#about ... ``` Twenty of forty-seven is not a failure of the tool: it is what an application with pages, member routes and form actions actually looks like. Moving an operation from one column to the other is a matter of answering it with `res.resource()` or `res.collection()` from a `resources` route, which is what henri asks for anyway — or of declaring its answer, for the ones that legitimately send something else. ## Where the document lives **As a file.** `henri openapi --out openapi.json` writes it where the application commits it, and the diff of that file in a pull request is the diff of the API. Regenerating it in CI and failing on a change nobody meant is a two-line job. **As an endpoint, in development only.** A booted application answers `GET /_openapi.json`, next to `/_routes` and `/_controllers` and under the same rule: **development only, and from the loopback interface only.** The document names every route of the application, the roles that guard each one and the policy behind it, which is a map an anonymous visitor has no business reading. An application that wants to publish it puts the generated file in `app/views/public/`, where it is served as a static file, or serves it from a route of its own — a deliberate decision rather than a default. ## For coding agents The [MCP server](/guides/agents/) exposes it as the `openapi` tool. It is the fastest way for an agent to learn the HTTP surface of an application: one call, every path, its guards, its request body and the answers henri produces — and, where henri cannot know, a mark saying so instead of a shape to be misled by. ## What is out of scope - **A UI.** No Swagger UI, no Redoc, no bundled viewer. The document is a file; point whatever you like at it. - **Request validation.** henri does not validate a request against the document, and the arrow points the other way: [`params`](/guides/controllers/#params-what-an-action-accepts) is the gate and the document is written from it. A second, generated gate reading this file back would be a second source of truth. - **Client generation.** The document is standard; the generators for it already exist and are better than anything shipped here would be. - **GraphQL.** It has a schema of its own. `config.graphql` names where to ask for it, and the description says so in `info.description` rather than describing the endpoint. ## Exit codes `henri openapi` exits `0`, `3` outside an application, `2` on `--out` without a file name, and `1` with `HENRI_API_DESCRIPTION_UNWRITABLE` when the file cannot be written. See [Errors](/reference/errors/). --- # Mail Source: https://usehenri.io/guides/mail/ Mailers with views, layouts and previews, delivered with nodemailer. A mailer is a file in `app/mailers` whose functions describe a message: who it goes to, what it is about and the data its view needs. henri renders it with the mail views in `app/views/mailers` and delivers it through nodemailer. In development you can look at every message in the browser without sending anything. ```bash henri generate mailer welcome confirm ``` writes `app/mailers/welcome.js`, `app/views/mailers/welcome/confirm.hbs` and the shared layout, and ```js await henri.mailers.welcome.confirm(user).deliver(); ``` sends it. ## The mailer ```js // app/mailers/welcome.js module.exports = { // Merged into every message of this mailer defaults: { from: 'Acme ', }, confirm: (user, token) => ({ to: user.email, subject: 'Confirm your address', data: { token, user }, }), // Sample data for the previews: the arguments of the action previews: { confirm: () => [{ email: 'ada@example.com', name: 'Ada' }, 'abc123'], }, }; ``` Every exported function is an action; `defaults` and `previews` describe the mailer instead. Files in sub-directories keep their path in the name (`app/mailers/admin/digest.js` is `admin/digest`). An action returns the message. `data` is the context of the view, `view` and `layout` pick them; everything else is handed to nodemailer as it is — `to`, `cc`, `bcc`, `replyTo`, `subject`, `attachments`, `headers`, `priority`. Nothing is sent while the action runs: it returns a message, and the caller decides when it goes out. Actions are called through `henri.mailers`: ```js const message = henri.mailers.welcome.confirm(user, token); await message.deliver(); // render and send now await message.deliverLater(); // hand it to the delivery handler const { html, text, subject } = await message.render(); // render only ``` `henri.mailers.deliver('welcome', 'confirm', user, token)` is the one-liner, and the only way to reach a mailer whose name has a slash in it (`henri.mailers.message('admin/digest', 'daily', [today])`). ## The views A message renders `app/views/mailers//` — Handlebars, the environment henri gives every application whatever its renderer, with the same partials your pages use (`app/views/partials`). Email is its own medium: no scripts, inline styles, tables for layout, so mail views are written on their own rather than reused from the pages. `{{@mailer}}`, `{{@action}}` and `{{@localUrl}}` are available as data variables. ```hbs

Hello {{user.name}}

Confirm your address with the code {{token}}.

Confirm

``` A view engine that knows how to render mail itself takes over: an engine exposing `renderMail({ view, layout, data, meta })` and answering `{ html, text }` (or a string) is asked first. Neither the React nor the Inertia engine implements it today, so both render mail through Handlebars. The view is read when the message is built, so a missing one fails the request that sends the mail — a signup, a password reset — rather than the boot. [`henri doctor`](/reference/cli/#doctor) reports a mailer action with no view (`mailers.view`); a mailer whose actions hand back their own `html`, or name their own `view`, is left alone, because a file cannot say which action does. ### The layout `app/views/mailers/layouts/mailer.hbs` wraps every message. `{{{body}}}` is where the view goes; the signature, the footer and the unsubscribe link are written once. ```hbs {{{body}}}

Sent by Acme

``` `config.mailers.layout` picks another name, an action can pick its own with `layout: 'newsletter'`, and `layout: false` renders the view alone. ### The plain text part Every message is multipart. The text part is **derived from the rich one** unless you write it: no `text` view means the html is turned into readable text — blocks become blank lines, list items get a dash, links keep their target (`Confirm (https://acme.com/confirm/abc123)`), entities are decoded. Two copies of the same copy is exactly the pair that drifts apart, so henri only asks for one. When the plain part deserves its own wording, write it next to the view and it wins: ``` app/views/mailers/welcome/confirm.hbs the rich part app/views/mailers/welcome/confirm.text.hbs the plain part, authored app/views/mailers/layouts/mailer.hbs the layout app/views/mailers/layouts/mailer.text.hbs the layout of the plain part ``` An action that sets `text` itself wins over both. ### Styling, and inlining CSS **henri does not inline CSS, and it is not going to.** Write the styles as `style=""` attributes: it is what the layout henri ships does, what `henri generate mailer` writes, and what every mail client agrees on. A `