Configuration
Configuration is JSON in the config directory. henri loads config/<NODE_ENV>.json and falls back to config/default.json; with NODE_ENV unset it looks for config/dev.json first. A typical application commits default.json and adds production.json or test.json for the keys that differ.
{ "stores": { "default": { "adapter": "drizzle", "dialect": "sqlite", "url": "file:.henri/app.db" }, "reporting": { "adapter": "postgresql", "url": "postgres://localhost:5432/myapp_reporting" } }, "renderer": "inertia", "user": "user", "baseRole": "guest"}The file is parsed on boot: a syntax error is reported with its line, and the boot stops when no file can be loaded. The loaded object is frozen, then checked against henri’s schema before any other module starts.
Every key below is declared in @usehenri/core, so an editor completes them as you type. henri.config.get() and a hand-built configuration object take the same shape, import('@usehenri/core').Configuration — see Types.
| Key | Default | Description |
|---|---|---|
port |
3000 |
Port to listen on. In development a busy port is replaced by the next free one; under NODE_ENV=test the kernel assigns one. |
host |
see below | Interface to bind: 127.0.0.1 outside production, 0.0.0.0 in production. HENRI_HOST (what henri server --host sets) wins over the file. |
cors |
off | true enables cors with its defaults; an object is passed to it as options. |
renderer |
template |
View engine: inertia, react or template (Handlebars), whatever the case. henri new writes inertia. See Views. |
inertia |
Options of the Inertia renderer: ssr, id, entry, ssrEntry, template. See Views. |
|
assets |
same origin | { "prefix": "https://cdn.example.com" }: where the files the production build wrote are loaded from. henri names the origin in the Content Security Policy itself. See Views. |
experimental |
Opt-in to unmaintained renderers: { "vue": true }. |
|
stores |
Named database stores, see below. A model picks one with its store key or uses default. |
|
migrations |
What a migration may do to a database that has rows in it, see below. henri db:generate warns whatever this says. See Models. |
|
secret |
Session and token secret. Required as soon as a user model exists; usually provided by HENRI_SECRET. |
|
url |
the local url | Canonical address of the application (https://example.com), used for the links inside the mails henri sends. Set it in production. |
user |
user |
Name of the user model, or an object (below). See Users. |
baseRole |
Role, or list of roles, given to every new user. | |
externalIds |
What henri does with the internal identifier of a record: which one a lookup takes, and what a foreign key serializes as, below. | |
policies |
Record-level authorization: what a refusal answers, signed in or not, and whether an unasked policy is reported, see below. See Policies. | |
tenancy |
false |
Multi-tenancy: where the tenant of a request comes from, and what a request naming somebody else’s answers. It turns nothing on for a model: a model does, with options: { tenant: true }. See Multi-tenancy. |
trustProxy |
true |
Express trust proxy: true, a hop count or a list of addresses; X-Forwarded-* headers are honoured. Set false without a proxy. |
csrf |
true |
false disables the CSRF protection; an object configures the origin check, below. |
graphql |
/_henri/gql |
Path of the GraphQL endpoint, or an object with its limits and access rules, below; needs @usehenri/graphql. See GraphQL. |
mail |
Nodemailer transport options, or "test" for an Ethereal test account. See Mail. |
|
mailers |
Defaults of the mailers: from, layout and previews, see below. |
|
i18n |
The catalogues of config/locales and the locale of a request; false translates nothing. See i18n and below. |
|
api |
Pagination, strict HAL and idempotency settings of the JSON API, see below. | |
jobs |
Settings of the job queue, see below; needs @usehenri/jobs. The queue also loads when app/jobs holds a file. |
|
rateLimit |
600/min |
Global, authentication and shared-store rate limits, see below. false disables them, true keeps the defaults. |
shared |
The backend the rate limit, the sign-in lockout and the idempotency keys count in, so two processes share one set, see below. | |
cache |
on | henri.cache: how long an entry lives, how much of it is kept and where, see below. false turns the cache off. |
flags |
Where the state of the feature flags is kept and how often a process re-reads it, see below. The flags themselves are declared in config/flags.js. |
|
helmet |
on | Options merged over henri’s helmet defaults; false disables it. |
csp |
off | Content Security Policy settings henri owns beside helmet: add and nonce, see below. See Security. |
filterParameters |
see below | Parameter names masked in the logs; false masks everything but encryption. |
logs |
auto |
What a log line looks like: format is pretty, json or auto — json in production, the pretty lines everywhere else. See Logs. |
telemetry |
see below | OpenTelemetry spans and metrics: enabled, metrics, propagate, spans, see below. On when @opentelemetry/api is installed. See Telemetry. |
encryption |
The keys that open the fields the models marked encrypted, see below. See Encrypted attributes. |
|
privacy |
What henri does with the fields the models marked personal, see below. See Personal data. |
|
retention |
What runs the retention sweep and what it may do, see below. How long a model keeps its records is said in the model. See Retention. | |
trail |
off | The append-only record of who read or changed personal data, see below. See The access trail. |
timeZone |
UTC |
The zone a server renders a moment in, never what it stores; a name, or { default, from }, see below. See Time zones. |
calls |
off | The calls the application answered and the calls it made, joined by the request id, see below. See Call logs. |
queries |
outside prod | What the adapters report running, and the repeated model calls the detector counts, see below. false records nothing. See N+1 detection. |
versions |
{} |
Where the history of the models that say versioned is kept, and for how long. It turns nothing on: a model does. See Model versions. |
bodyLimit |
1mb |
Maximum size of a JSON or form body. |
uploads |
File uploads: where they go, the limits and the accepted types, see below; needs @usehenri/uploads. false accepts no file. |
|
requestTimeout |
30000 |
Milliseconds before a running request is answered 503; false disables it. |
streams |
The bounds of the server-sent event streams this process holds, see below. It turns nothing on – a controller calling res.stream() does – and it does not fan a broadcast out. See Streams. |
|
shutdown |
What a SIGTERM does before the modules stop: delay, drain and signals, see below. |
|
maintenance |
Where the switch that closes the application lives and what a visitor is told, see below. false gives the application no switch. See Maintenance mode. |
|
errors |
What henri does with the code of a failure: url, a template holding {code}. See Error codes. |
|
webhooks |
Settings of the outbound webhooks, see below; needs @usehenri/webhooks, which delivers through the queue. |
The externalIds object
Section titled “The externalIds object”Every record carries an externalId – a UUID v7 – and the numeric primary
key stays on the server. This key holds the two decisions that follow from
that, and both defaults are the safe ones.
| Key | Default | Description |
|---|---|---|
lookup |
'external' |
Which identifier Model.findById() resolves. The default takes the externalId and nothing else, so GET /proposals/4812 answers the same 404 an unknown uuid answers. 'any' restores the primary key lookup of henri 1.2. |
references |
true |
Replace a declared foreign key with the externalId of the row it names, on the way out. false sends the number the database holds, so a record carries another row’s primary key. |
{ "externalIds": { "lookup": "external", "references": true }}findByKey() always takes the primary key, whatever lookup says: it is
the lookup for server-side code that legitimately holds one (the subject of
a session, a row you just joined), and findById() is the one that takes
what arrived from outside. A model that opted out with
options: { externalId: false } is unaffected by either key.
Turning either of them off is reported by henri audit.
See Models for what a foreign key has
to declare before henri can translate it.
The policies object
Section titled “The policies object”What henri does with the answer of a policy in app/policies. The key is
never what turns policies on – writing the file is – and it is only about
the decisions an application may reasonably differ on. See
Policies.
| Key | Default | Description |
|---|---|---|
anonymous |
"challenge" |
What a refusal answers somebody who is not signed in. "challenge" is a 401, and the login page in a browser. "uniform" answers them exactly what a signed-in stranger gets – status, the same message rule, no redirect – so a record they may not see cannot be told from one that is not there. See the cost below. |
status |
404 |
What a refusal answers a signed-in user: 404 says nothing about whether the record exists, 403 says it is there and off limits. |
verify |
true |
Report a route that declared a policy henri could not answer without the record, whose action then answered without ever asking. false turns the line off. |
{ "policies": { "anonymous": "uniform", "status": 404, "verify": true }}"anonymous": "uniform" costs the login page: a visitor following a
bookmarked link to a record they may perfectly well see once signed in gets a
404 rather than being asked to sign in, and giving them a way back is the
application’s job. It applies to every policy refusal, the route gate
included. A roles on the route is untouched – it refuses before any lookup,
so it keeps the login page whatever this key says – and is the answer for a
route where being asked to sign in is the right thing.
The mailers object
Section titled “The mailers object”Defaults for the mailers in app/mailers. Every key is optional.
| Key | Default | Description |
|---|---|---|
from |
Sender used by every message that does not set one (the mailer’s own defaults.from wins over it). |
|
layout |
"mailer" |
Name of the layout in app/views/mailers/layouts. false renders the views without a layout. |
previews |
true |
false turns the development preview routes off. Nothing turns them on outside development: they only exist when NODE_ENV is neither production nor test, and only answer requests from the machine running the server. |
{ "mailers": { "from": "Acme <no-reply@acme.com>", "layout": "mailer" }}The i18n object
Section titled “The i18n object”What henri does about locales, and nothing unless there is something to do: an application with no config/locales directory and no i18n block reads no catalogue, mounts no middleware, sets nothing on a request and prints no boot line. The guide is i18n.
| Key | Default | Description |
|---|---|---|
path |
"config/locales" |
Where the catalogues live. <locale>.json, or <locale>/<namespace>.json for one file per area. |
locales |
the files on disk | The locales this application has. Naming them is how an unfinished translation is kept out of production. |
default |
"en" |
The locale everything falls back to. It has to be one of locales. |
fallback |
true |
true falls back to default; false makes a key missing in one locale missing whatever the others hold; a locale, or a list of them, falls back to those. A regional locale always falls back to its language first (fr-CA, then fr). |
from |
Where the locale of a request is read, see below. | |
missing |
"auto" |
What a key nobody translated does: warn says it once and answers the key, key answers the key quietly, throw raises HENRI_LOCALE_TRANSLATION_MISSING, and auto is warn outside production and key in it. No mode ever guesses a sentence from the key. |
client |
"auto" |
How the catalogue reaches a browser: auto embeds it in a document and leaves it out of an XHR answer, always puts it in every answer, false keeps the strings on the server. |
serverOnly |
["mailers"] |
Key prefixes that never leave the server. The strings of a mail are written for a recipient, not for a reader. |
The from object
Section titled “The from object”The order is fixed and is the order of this table, after an explicit req.setLocale(). Each key turns one step off (false) or renames what it reads. The step that answered is on the request, as req.localeSource.
| Key | Default | Description |
|---|---|---|
user |
null |
The column of the user model holding their locale. Also what a mail asks when it has the recipient and no request. |
query |
"locale" |
Query parameter (?locale=fr). |
cookie |
"henri.locale" |
A cookie henri reads and never writes: a language switcher is an action of the application. |
header |
true |
Accept-Language, negotiated by q value, fr-CA matching a catalogue named fr. |
The path is not on that list, on purpose: /fr/notes is a routing decision, and henri’s route table is what builds both the url and the helper that prints it. The guide says what to write instead.
{ "i18n": { "locales": ["en", "fr"], "default": "en", "from": { "user": "locale" }, "missing": "throw" }}Stores
Section titled “Stores”Each entry of stores names an adapter and how to reach its database. The adapter package (@usehenri/<adapter>) must be installed in the application.
drizzle, postgresql, mysql and mariadb are all @usehenri/drizzle: the last three are that adapter with the dialect and the driver chosen, so a store on one of them needs no dialect key and the application declares no driver. mssql is the one adapter on Sequelize, because Drizzle has no SQL Server dialect. A mariadb store needs "sync": false and cannot eager load; the two reasons are measured in Models.
| Key | Adapters | Description |
|---|---|---|
adapter |
all | drizzle, postgresql, mysql, mariadb, mssql, mongoose or disk. |
url |
mongoose, SQL | Connection string. Required unless host is given (mariadb:// is accepted by the mysql adapter). |
host, port, database, username, password |
mongoose, SQL | Alternative to url. On mongoose, host may also be a full mongodb:// url. |
opts |
mongoose | Options passed to mongoose.connect(); henri sets connectTimeoutMS and serverSelectionTimeoutMS to 10 seconds. |
session |
mongoose, SQL | Options of the session store (connect-mongo, whose collection is henriSessions; the henri_sessions table on drizzle; connect-session-sequelize on mssql). |
sessions |
drizzle | true creates the session table even when the application has no user model. |
path, dbName, port |
disk | Data directory, relative to the application (.henri/data), database name (henri), and the port mongod listens on (one derived from the process id, between 20000 and 26999, so parallel boots never collide). |
logging, pool, dialectOptions, … |
mssql | Every other key is forwarded to Sequelize. logging defaults to the henri:sequelize debug namespace. A drizzle store takes pool (the driver’s options) and nothing else. |
dialect |
drizzle | sqlite, postgres or mysql; the app installs the driver (better-sqlite3, pg or mysql2). The postgresql, mysql and mariadb adapters fix it and ignore this key. |
sync, migrate |
SQL | sync: false stops the development boot from bringing the schema up, and is what a mariadb store needs. On an mssql store, a production boot never does, and sync: true is what asks it to create the tables that are missing. migrate: true (drizzle) applies db/migrations on a production boot; on mysql a push only creates the missing tables (see Models). |
See Models for each adapter.
The migrations object
Section titled “The migrations object”What a migration is allowed to do to a database that has rows in it.
henri db:generate reads the SQL it just wrote and warns whatever this
says; this is what a production henri db:migrate reads, and what a
production boot with "migrate": true on the store reads with it. See
Migration safety.
{ "migrations": { "approve": true, "approved": ["0002_add_priority:9f3c1a2b4d5e"] }}| Key | Default | Description |
|---|---|---|
approve |
true |
Whether a migration henri found something in has to be approved before it runs in production. false makes the deployment the review, and henri audit reports it (migrations.unreviewed). |
approved |
[] |
The tokens of the approved migrations. henri db:status prints one per pending migration it has something to say about; the digest covers what was found, so reformatting the file keeps the token and another drop edited in replaces it. |
JSON API
Section titled “JSON API”The keys of the JSON API, all optional:
| Key | Default | Description |
|---|---|---|
api.perPage, api.maxPerPage |
25, 100 |
Page size read by req.pagination() and its upper bound. |
api.maxFilters |
8 |
The most filter[...] terms one request may carry on a declared index. |
api.maxSort |
3 |
The most columns one request may order by (?sort=a,-b). |
api.maxEmbeds |
3 |
The most relations one request may ask to embed (?embed=lines,customer). |
api.maxEmbedded |
25 |
The most records one _embedded relation carries per record, when the controller declares no limit of its own. |
api.csv |
{ "batch": 500, "formulas": true, "maxRows": 100000 } |
What res.csv() reads at once, whether a cell starting with =, +, - or @ is written as text, and the most rows one export may carry. |
api.strict |
false |
Refuse (500) a JSON answer without _links on a resources/crud route, and one that does not match what the action declared it answers, instead of logging it. |
api.idempotency |
{ "ttl": 86400000, "store": null } |
How long answers are kept for Idempotency-Key replays and the module exporting a shared { get, set, delete } store; false disables the feature. |
Rate limits
Section titled “Rate limits”rateLimit is false to lift every limit, true for the defaults, or an object. Nothing is enforced in development.
| Key | Default | Description |
|---|---|---|
rateLimit.windowMs, rateLimit.max |
60000, 600 |
The global limit per user or ip. limit is accepted as an alias of max, the name express-rate-limit 8 uses. |
rateLimit.auth |
{ "windowMs": 60000, "max": 10 } |
The limit on POST to the login and register-style paths (paths overrides the list); false disables it. |
rateLimit.store |
Module exporting an express-rate-limit Store (or a (henri, { name }) => store factory) shared between processes. |
The shared object
Section titled “The shared object”Three guards keep a number per key: the rate limit, the sign-in lockout and the idempotency keys. Each accepts a store of its own, and each of them is kept in the process’s memory when nothing names one — so an application running two processes silently gets two of everything: a rate limit that is twice what it says, a lockout an attacker escapes by being routed elsewhere, and an idempotency key that stops being idempotent.
shared is the one place to say where they are counted instead:
{ "shared": { "adapter": "redis", "url": "redis://127.0.0.1:6379", "prefix": "lineup:", "onError": "closed" }}pnpm add @usehenri/redis| Key | Default | Description |
|---|---|---|
adapter |
Required. An adapter name — redis resolves @usehenri/redis from the application — or the module id of a backend of your own. |
|
url |
Connection string (redis://, rediss:// for TLS). Set it with HENRI_CONFIG__shared__url, not in config/: it carries a password. |
|
prefix |
"henri:" |
What every key is prefixed with. Two applications sharing one server need one prefix each. |
onError |
"closed" |
What a guarded request does when the backend does not answer: closed refuses it with a 503 and a Retry-After, open serves it uncounted. The keys are always closed. |
enabled |
true |
false keeps the block and counts in this process again, which is what HENRI_CONFIG__shared__enabled=false is for. |
Anything else in the block reaches the driver, so db, tls, password, sentinels and the rest of ioredis’ options work.
The three per-feature keys still work and still win, key by key: rateLimit.store, user.lockout.store and api.idempotency.store name a module of their own for whoever wants one backend for the limiter and another for the keys.
When the backend is down
Section titled “When the backend is down”It will be, at some point, and the right answer is not the same for all three:
- the limiter and the lockout follow
onError.closedis the default: a guard that cannot count is not a guard, so the request is refused with a503and aRetry-After: 1. A deployment that would rather stay up than stay counted saysopen, which serves the request uncounted. - the idempotency keys are always closed, whatever
onErrorsays. Serving a mutating request whose first answer cannot be read is the one thingIdempotency-Keyexists to prevent, so there is no second answer to offer and no switch pretending there is.
Either way it is said out loud: every fallthrough is logged, at most once every ten seconds per feature so that a long outage does not become the log. A backend that is unreachable at boot does not fail the boot — the driver keeps reconnecting, GET /readyz answers 503 with "shared": { "ok": false } so the process leaves the load balancer, and the requests meanwhile follow onError.
Sessions stay in the database
Section titled “Sessions stay in the database”shared does not touch the sessions. They go through the store adapter (connect-mongo, the drizzle session table, or connect-session-sequelize on mssql), which every process already reads and writes, so they were never one of the counters this closes. Moving them would trade a store that is durable for one that is faster and empties on a restart, and would make signing in depend on Redis being up — a real trade, and one a deployment makes for itself with express-session, not one henri makes for it.
The cache object
Section titled “The cache object”henri.cache is there in every application, with nothing to configure: this process’s memory, bounded, with a five minute default lifetime. Naming a backend in shared moves it there — one block for the counters and the cache both — and cache is what tunes it. See Caching.
{ "cache": { "ttl": "5m", "maxEntries": 1000, "maxSize": "32mb", "maxEntrySize": "256kb" }}| Key | Default | Description |
|---|---|---|
ttl |
"5m" |
How long an entry lives when a call does not say. Every entry has one: there is no way to keep a value forever, by accident or on purpose. |
maxEntries |
1000 |
Entries the memory backend may hold; the least recently used goes first. Nothing to do with a shared backend, which has its own memory policy. |
maxSize |
"32mb" |
Everything the memory backend may hold, encoded. It evicts to stay under this and under maxEntries, so the cache cannot become a leak. |
maxEntrySize |
"256kb" |
What one value may weigh, encoded, on any backend. A bigger one is not cached: set answers false, it is logged once, nothing is truncated, and the entry it was replacing is forgotten. |
store |
Module exporting a { get, set, delete } store (or a (henri, { name }) => store factory) for whoever wants the cache somewhere the counters are not. Wins over shared. |
|
enabled |
true |
false keeps the block and turns the cache off: every fetch then runs its function, which is what HENRI_CONFIG__cache__enabled=false is for. |
false in place of the object does the same as enabled: false.
A cache is a correctness hazard, not only a speed feature, so two things are said out loud rather than left to be discovered:
- A backend that is down is a miss, whatever
shared.onErrorsays. The counters block because a guard that cannot count is not a guard; the cache holds no truth, so refusing a request over a copy would turn an optimization into an outage. Every fallthrough is logged, at most once every ten seconds. - henri invalidates nothing. No model callback, no query cache, no route. A value stays until its TTL runs out or something calls
henri.cache.delete()— and with the memory backend that delete reaches one process, which is the reason a deployment running several of them wantsshared.
The flags object
Section titled “The flags object”The feature flags are declared in config/flags.js — one file listing every flag the application has — and this block is only about their state: where it is kept, and how long a flip takes to reach the other processes. See Feature flags.
{ "flags": { "store": "shared", "refresh": "10s" }}| Key | Default | Description |
|---|---|---|
store |
"shared" (the backend of shared), "memory" (this process), or the path of a file. Unset means "shared" when there is a backend, .henri/flags.json otherwise, and "memory" under NODE_ENV=test. |
|
refresh |
"10s" |
How often a process re-reads the store, which is the staleness window of every flag: a flip reaches everything else within it, and the process that flipped it sees it at once. One second is the floor. |
enabled |
true |
false keeps the block; every flag then answers its declared default, nothing is read and nothing can be flipped. |
Two things are said out loud rather than left to be discovered:
- The boot line names the store and its limit —
shared with every process,.henri/flags.json, this machine only, orthis process only. A file is what an application gets withoutshared, becausehenri flags:onin one terminal reaching the server in the next one is the point of the command;memorymakes that command a no-op that reports success, so henri warns about it in production. - A store that cannot be read flips nothing. A failed poll keeps the snapshot the process already has and says so at most once a minute. The flags stop moving while the backend is down; they never move back to their defaults, which is why this does not go through
henri.cache.
Headers, logs and limits
Section titled “Headers, logs and limits”| Key | Default | Description |
|---|---|---|
helmet |
Options merged over henri’s defaults (a development CSP that allows hot reloading, no HSTS outside production, upgrade-insecure-requests only on https requests), plus permissionsPolicy (a Permissions-Policy value, or false); false disables helmet. A policy directive written here replaces henri’s array — csp.add is how you add one source and keep the rest. |
|
csp.add |
Sources to add to the policy henri built, by directive ({ "script-src": ["https://plausible.io"] }). The origin of assets.prefix, the nonce and the development sources stay. See Content Security Policy. |
|
csp.nonce |
false |
true gives every response a nonce, names it in script-src and takes 'unsafe-inline' out of that directive. See Content Security Policy. |
filterParameters |
["password", "token", "secret", "authorization"] |
Substrings of the parameter names masked in everything henri.pen prints; false masks everything but encryption, whose name is masked whatever this says, since the list replaces the defaults and the key that opens the encrypted columns must not depend on it. |
logs.format |
"auto" |
"auto" writes json in production and the pretty lines everywhere else; "json" and "pretty" say it outright. One object per line, with the module, the level and the request id as fields. See Logs and error reporting. |
bodyLimit |
"1mb" |
Passed to the JSON and urlencoded body parsers; a string ("1mb") or a number of bytes. |
requestTimeout |
30000 |
Milliseconds before a 503; false disables the timeout. |
The telemetry object
Section titled “The telemetry object”OpenTelemetry spans and metrics. henri ships no SDK, no exporter and no
sampler: @opentelemetry/api is a peer dependency the application
installs, next to whichever pipeline the deployment already runs. See
Telemetry.
Leave the key out and telemetry follows the package: on when
@opentelemetry/api resolves from the application, off when it does not,
and an application without it pays nothing – the middleware is not mounted,
the adapter is not wrapped and no instrument is created.
{ "telemetry": { "metrics": true, "propagate": true, "spans": ["http", "jobs", "stores"] }}| Key | Default | Description |
|---|---|---|
enabled |
Absent means on when @opentelemetry/api is installed and off when it is not. true says the application requires it, and a boot without the package then fails with HENRI_TELEMETRY_UNAVAILABLE instead of going quiet. |
|
metrics |
true |
Register the instruments: the request duration, the queue depth and its claim latency, and the cache counters. false leaves the spans. |
propagate |
true |
Write traceparent onto the requests henri makes for the application – a webhook delivery. An incoming traceparent is always honoured, whatever this says. |
spans |
"all" |
Which boundaries get a span: "all", false for none, or a list of boot, http, jobs, mail, stores, views and webhooks. |
"telemetry": false instruments nothing and never even looks for the
package.
The encryption object
Section titled “The encryption object”The keys that open the fields the models marked encrypted. Which fields
those are is said in the models themselves; see
Encrypted attributes.
The keys are secrets, and this is the one configuration object that must
never be written in config/*.json, which is committed. Their home is the
encrypted credentials of the environment (henri credentials:edit), whose
values are merged into the configuration key by key, or
HENRI_ENCRYPTION_KEYS – comma separated, primary first. henri audit
reports a key found in a configuration file.
{ "encryption": { "keys": [ "a2f1...64 hex characters", "the previous key, until it is rotated out" ], "readPlaintext": false }}| Key | Default | Description |
|---|---|---|
keys |
The key, or the keys with the one that writes first, each 64 hexadecimal characters (openssl rand -hex 32). Every key decrypts, only the first encrypts: that is what makes a rotation a deploy and not a migration. |
|
readPlaintext |
false |
Whether a column declared encrypted may answer with a value that is not encrypted. true is what makes a backfill possible on a table that is already full; take it out once henri encryption:status is clean. |
The privacy object
Section titled “The privacy object”What henri does with the fields the models marked personal. Which fields
those are is said in the models themselves; see
Personal data.
{ "privacy": { "expose": true, "onErase": "anonymize", "receipts": "privacy" }}| Key | Default | Description |
|---|---|---|
expose |
true |
Whether a personal field may leave the server in an answer henri builds. false drops every one of them unless the field says personal: { expose: true }. |
onErase |
"anonymize" |
What happens to the records of an erased person, for the models that do not say it themselves: anonymize, delete, orphan or retain. |
receipts |
"privacy" |
The directory henri privacy:erase writes its receipt to – the proof that it ran, holding an HMAC of the identity rather than the identity. false keeps none beyond what the command printed. |
The retention object
Section titled “The retention object”How long a model keeps its records is said in the model
(options: { retention }); this is what runs the sweep and what it is
allowed to do. See Retention.
{ "retention": { "approve": true, "approved": ["Proposal:drafts:9f3c1a2b4d5e"], "batch": 1000, "receipts": "privacy", "schedule": "0 3 * * *" }}| Key | Default | Description |
|---|---|---|
approve |
true |
Whether a rule has to be approved before it writes anything. An unapproved rule is planned, counted and reported, and writes nothing. false makes the deployment the review. |
approved |
[] |
The tokens of the approved rules. henri retention prints one per rule; a rule whose after, from, action or where changes gets a new token and is pending again. |
batch |
1000 |
How many records one rule may take in one sweep. What is left over is reported as remaining and taken by the next run. false lifts the bound. |
receipts |
"privacy" |
The directory a sweep writes its receipt to. false keeps none beyond what the command printed. |
schedule |
off | A cron expression ("0 3 * * *") or an interval ("1d") the henri/retention job runs on; needs @usehenri/jobs. Without it, run henri retention:sweep --yes from cron. |
The trail object
Section titled “The trail object”The append-only record of who read or changed personal data. It is off until this key says otherwise, and off means no table is created and no statement is issued. See The access trail.
{ "trail": { "keep": "1y", "reads": "personal", "store": "default", "table": "henri_trail" }}| Key | Default | Description |
|---|---|---|
keep |
"1y" |
How long an entry is kept. A trail of who touched personal data is itself personal data, so it has its own retention; false keeps them forever, which is a decision to make on purpose. |
reads |
off | "personal" records the answers carrying a model with a personal field, "all" every answer henri serializes, false none. Every recorded read costs a round trip and an insert. |
store |
"default" |
Which of stores the table lives in. |
table |
"henri_trail" |
The table henri creates at boot and only ever INSERTs into and SELECTs from. |
The calls object
Section titled “The calls object”The calls the application answered and the calls it made, joined by the request id. It is off until this key says otherwise, and off means no table is created and no middleware is mounted. It holds values, which the access trail deliberately does not: read Call logs before turning it on.
{ "calls": { "address": { "header": "cf-connecting-ip", "from": ["10.0.0.0/8"] }, "keep": "30d", "sample": 0.05, "maxPerSecond": 100, "maxBody": "8kb", "partition": "day", "always": ["error"], "ignore": ["/assets"], "store": "default", "table": "henri_calls" }}| Key | Default | Description |
|---|---|---|
address |
{} |
What is recorded about the client’s address: { anonymize, header, from }, or false for none. See the client’s address. |
keep |
"30d" |
How long a row is kept. The retention sweep prunes them; false keeps them forever, which on this table is a decision to make on purpose. |
sample |
1 |
The share of requests recorded, from 0 to 1. The decision is a hash of the request id seeded with secret, so the inbound call and every outbound call it caused agree, in every process. |
maxPerSecond |
100 |
The absolute per-process ceiling on rows a second, or false for none. Sampling is proportional and a burst is not, so this is what a spike runs into. |
always |
["error"] |
The outcomes sampling never drops: "error", "client-error", "aborted". They are recorded without their bodies. |
maxBody |
"8kb" |
How much of a body is stored before it is cut and marked truncated. false stores no body. |
bodies |
true |
false keeps the timings, the statuses and the headers and captures no body at all. |
partition |
off | "day" or "month" on PostgreSQL and MySQL: the sweep then drops a partition instead of deleting rows. Anything else fails the boot with HENRI_CALLS_PARTITION_UNSUPPORTED. |
partitionsAhead |
7 |
How many periods are kept ready in front of the clock. |
inbound |
true |
false stops henri mounting the middleware at all. |
outbound |
true |
false makes henri.calls.track() and henri.calls.outbound() no-ops. |
ignore |
[] |
Path prefixes that are never recorded. The health probes never are, whatever this says. |
buffer |
1000 |
How many rows may wait to be written. Past it a row is dropped and counted rather than queued forever. |
batch |
500 |
How many buffered rows trigger a flush before the timer does. |
flush |
1000 |
How often the buffer is written, in milliseconds. |
sweep |
5000 |
How many rows one pass of the delete path takes at a time. |
store |
"default" |
Which of stores the table lives in. |
table |
"henri_calls" |
The table henri creates at boot. Changing partition afterwards needs a migration of your own. |
The queries object
Section titled “The queries object”What the adapters report running, and the N+1 detector on top of it. The seam
emits one event per model call – Proposal.findById, Track.find,
User.paginate – and the detector counts the ones that repeat inside a single
request. It is on in development and in test and off in production, because a
production request should not pay to tell a developer something the developer
is not there to read.
false records nothing anywhere: no hook is registered on any adapter, no
middleware is mounted, and nothing is allocated per query.
| Key | Default | Description |
|---|---|---|
enabled |
outside prod | Absent means on outside production. true is the production opt-in: every model call is timed and counted. |
callsites |
with detect |
Whether an event carries the line the model call was made on. It costs an Error allocation per call, so it follows the detector unless set. |
detect |
{} |
The detector; false keeps the events and reports nothing, so henri.queries.onQuery() still works. |
detect.threshold |
5 |
How many times the same model call has to run in one request before it is reported. It counts model calls, never statements. At least 2. |
detect.log |
true |
One warning per request naming the call, the count, the line and what to do instead. |
detect.header |
true |
X-Henri-Queries on the answer, in development only: counts and the names of the repeated calls, never a value. |
detect.raise |
false |
Throw HENRI_QUERIES_N_PLUS_ONE the moment the threshold is crossed, so the stack names the call. This is what makes a test suite fail on an N+1. |
detect.ignore |
[] |
Calls the detector never counts: Model, Model.operation or *.operation, where an operation is one of count, delete, insert, other, raw, select, update. |
See N+1 detection for what an event carries, what it deliberately does not, and how it joins the request id.
The versions object
Section titled “The versions object”Where the history of the models that say options: { versioned: true } is
kept, and how long it is kept for. This key turns nothing on: a model
does, and an application with no versioned model creates no table and pays
nothing whatever this says. See Model versions.
{ "versions": { "keep": "2y", "onErase": "follow", "store": "default", "table": "henri_versions" }}| Key | Default | Description |
|---|---|---|
keep |
off | How long a version is kept, pruned by the retention sweep. Unset keeps it for as long as the application does. A version holds the old values of a record, personal ones included, so setting this is a decision worth making on purpose. |
onErase |
"follow" |
What henri privacy:erase does to the versions of the records it touched. "follow" takes the versions of a deleted record away and empties the erased values out of the versions of a record that survives; "delete" takes them all; "retain" leaves them and says so in the receipt. |
store |
"default" |
Which of stores the table lives in. |
table |
"henri_versions" |
The table henri creates on the first boot where a model says versioned. |
Uploads
Section titled “Uploads”uploads is read by @usehenri/uploads, which the application installs; without the package nothing parses a multipart body. false accepts no file. Every key is optional, and every limit is enforced by the parser as it reads rather than checked once the file is on disk.
| Key | Default | Description |
|---|---|---|
uploads.storage |
"local" |
"local" for the disk, "s3" for an object store (@usehenri/s3), or the module id of a HenriStorage resolved from the application. An object ({ "adapter": "s3", "bucket": … }) names one and carries its settings. |
uploads.root |
"storage/uploads" |
Where the local storage keeps files, relative to the application. It must be outside app/views/public, which express.static serves. |
uploads.maxFileSize |
"10mb" |
Largest single file; false removes the bound. |
uploads.maxTotalSize |
"25mb" |
Largest multipart body, all parts together. Checked against Content-Length before the parser is built, then counted as the bytes arrive. |
uploads.maxFiles |
10 |
How many files one request may carry. |
uploads.maxFields |
100 |
How many non-file fields one request may carry. |
uploads.maxFieldNameSize |
100 |
Longest field name, in bytes. |
uploads.maxFieldSize |
bodyLimit |
Largest non-file part. It defaults to bodyLimit so one text field costs the same whichever encoding a form was posted with. |
uploads.maxFilenameLength |
255 |
How much of the original name is kept as metadata. The stored name is generated, so this never bounds a path. |
uploads.allow |
Media types that may be kept ("image/png", "image/*"), matched against what the bytes say. Without it every type is accepted. |
|
uploads.sniff |
true |
Decide the type from the first bytes. false trusts the Content-Type the client sent, which is not evidence; henri audit reports it. |
uploads.paths |
Path prefixes a multipart body is read under (["/artworks"]). Without it, any route that takes a body may receive one. |
|
uploads.urls |
false |
Signed urls: { "expiresIn": 300 } turns them on, false hands out none and henri.uploads.url() refuses. See the guide. |
uploads.urls.expiresIn |
300 |
How long a signed url lasts, in seconds. A week (604800) at most, which is what S3 honours. |
uploads.urls.path |
"/_uploads" |
Where the route that verifies henri’s own signed urls is mounted. Unused by a storage that signs its own, such as s3. |
uploads.urls.cdn |
A base url henri’s own signed urls are built against. The host is outside henri’s signature, so a cache may sit in front of the route. | |
uploads.variants |
Derived images, by name ({ "thumb": { "width": 320, "height": 320 } }); each takes width, height, fit, format and quality. Needs sharp in the application. See the guide. |
{ "uploads": { "allow": ["image/png", "image/jpeg", "application/pdf"], "maxFileSize": "5mb", "maxFiles": 3, "paths": ["/artworks"] }}The streams object
Section titled “The streams object”The bounds of the server-sent event streams one process holds
(res.stream(), henri.streams). Nothing here turns streams on and
nothing here fans a broadcast out: henri.streams.publish() reaches the
subscribers of this process and henri has no cross-process fan-out. See
Streams.
| Key | Default | Description |
|---|---|---|
streams.heartbeat |
25000 |
Milliseconds between the comment frames that keep an idle stream from being closed by a proxy, and how a client that went away is noticed. false sends none. |
streams.maxAge |
900000 |
Milliseconds a stream may hold the record, the session and the policy answer it opened with before henri ends it and the client reconnects. false never ends one, and then nothing bounds how stale those three may be. |
streams.maxBuffer |
1048576 |
Unread bytes a subscriber may hold before its stream is closed rather than being allowed to grow into this process’s memory. false removes the bound. |
streams.maxOpen |
1000 |
How many streams this process holds at once. Past it a subscription is answered 503 with a Retry-After, which is better than reaching the file descriptor limit. false removes the bound. |
streams.retry |
3000 |
The reconnection delay henri suggests to the client, jittered when henri is the one closing so a deploy does not bring every subscriber back in the same millisecond. false suggests nothing. |
Shutdown
Section titled “Shutdown”SIGINT and SIGTERM drain the server before the modules stop: readiness answers 503, the port closes, the requests in flight finish, and only then does henri.stop() run. The answers that never end are ended first: every open stream is closed once readiness is 503, so a subscriber reconnects to a process that is still accepting instead of being destroyed at the deadline. See Health checks.
| Key | Default | Description |
|---|---|---|
shutdown.delay |
0 |
Milliseconds between readiness turning 503 and the port closing. A proxy that only polls /readyz wants a couple of its intervals here; a platform that stops routing before the signal needs none. |
shutdown.drain |
10000 |
Milliseconds the requests in flight get once the port is closed. What is still open then has its socket destroyed, with a line in the log saying how many. |
shutdown.signals |
true |
Whether henri installs the SIGINT and SIGTERM handlers. false leaves the signals alone; call henri.server.shutdown('SIGTERM') from your own handler. |
Keep delay + drain under the termination grace period of your platform (30 seconds on Kubernetes), so the process leaves before it is killed.
The maintenance object
Section titled “The maintenance object”Where the switch that closes the application lives, and what a visitor is told while it is thrown. Nothing here turns maintenance on – henri maintenance:on does, from a shell, without a deploy. See Maintenance mode.
| Key | Default | Description |
|---|---|---|
maintenance.switch |
auto |
Where the switch lives: auto is the shared store when config.shared names one and a file otherwise. shared and file pin one; the boot line says which is in play. |
maintenance.file |
.henri/maintenance.json |
The file the switch is written to when it is not in the shared store. It reaches every process on that machine and no other. |
maintenance.poll |
1000 |
Milliseconds a running process may go without re-reading the switch. Zero re-reads it on every request; the reads are deduplicated, so a burst costs one. |
maintenance.message |
see below | What a visitor is told, unless henri maintenance:on --message says otherwise for one window. |
maintenance.retryAfter |
300 |
The Retry-After of the 503, in seconds. --retry-after says it for one window. |
maintenance.page |
app/views/maintenance.html |
An html page of your own, read as it is – the view engine is not involved – with {{message}}, {{retryAfter}} and {{since}} replaced by the escaped values. henri ships a page for when the file is not there. |
maintenance.readyz |
ready |
What /readyz answers while the application is closed. It stays ready on purpose; unavailable is for a deployment that wants every process pulled out of the load balancer at once. |
maintenance.bypass |
token |
What gets through besides the signed url henri maintenance:on prints. loopback also lets anything connecting from this machine through, which behind a proxy is everybody – henri audit reports it. |
maintenance: false gives the application no switch at all: no middleware, no read, and henri maintenance:on refuses with HENRI_MAINTENANCE_DISABLED.
Environment and .env
Section titled “Environment and .env”On boot henri reads .env in the application directory (KEY=value lines, optional export, quotes stripped, # comments; variables already set in the environment win), then applies what the environment says over the file it loaded. Every key is reachable, so a container image needs no configuration file written at start time: the committed config/production.json describes the application and the environment carries what changes between deployments.
Precedence
Section titled “Precedence”Lowest first, one story for every variable:
- the configuration file:
config/<NODE_ENV>.json, orconfig/default.jsonwhen it does not exist (the two are never merged); - the encrypted credentials of that environment, when the application has some;
- the named shorthands, in the table below;
HENRI_CONFIG__<key>, which names the key it sets and wins over everything.
Nothing is applied twice and nothing is merged into a value: the last writer of a key replaces it.
The shorthands
Section titled “The shorthands”| Variable | Sets |
|---|---|
HENRI_SECRET |
secret, so the secret stays out of config. |
HENRI_HOST |
host (what henri server --host sets). |
DATABASE_URL |
stores.default.url, the connection string of the default store. |
NODE_ENV |
Selects the configuration file. henri server --production sets it. |
DATABASE_URL carries no HENRI_ prefix on purpose: it is the name Heroku, Render, Fly, Railway, Neon and Supabase already set for you, and the one Rails has read for a decade. A prefixed alias would mean writing HENRI_DATABASE_URL=$DATABASE_URL in every deployment, which is the papercut this removes. There is one name, not both.
It applies only when the configuration already declares a stores.default: a url does not say which adapter to load, so the file stays the place where the store is declared. When it does not, the boot prints DATABASE_URL => ignored: the configuration has no "stores.default" and moves on. A store other than the default is reached by its path, below.
An empty shorthand (DATABASE_URL=) is treated as unset.
Any other key
Section titled “Any other key”HENRI_CONFIG__<key> sets one configuration key. __ separates the path segments, since a shell variable name cannot hold a dot:
HENRI_CONFIG__port=8080HENRI_CONFIG__baseRole=memberHENRI_CONFIG__stores__reporting__url=postgres://user:pw@warehouse/reportsHENRI_CONFIG__user__afterLogin=/dashboardHENRI_CONFIG_JSON__rateLimit='{"windowMs":60000,"max":100}'The segments are the configuration keys verbatim, so their case matters: HENRI_CONFIG__baseRole, never HENRI_CONFIG__BASEROLE.
The type comes from the file, then from the schema; henri never guesses it. When the configuration already has a value at that path, the variable is read as its type: a number for port, true/false for a boolean, JSON for an object. When it does not, the type is the one the schema declares for that key, so HENRI_CONFIG__port=8080 is the number 8080 in an application whose file never names a port. A key henri does not own and the file does not have is a string — a connection string stays a connection string even when it looks like a number. HENRI_CONFIG_JSON__<key> parses the value as JSON in every case, which is how you set a nested object, an array, or a key no file declares.
A value that does not fit its key fails the boot, naming the variable and the type it expected. A variable set to nothing fails too (HENRI_CONFIG__port is set but empty: give it a value or unset it): a missing variable is simply not an override and never becomes an empty string. The value itself is never part of an error message — it may be a secret.
What the boot prints
Section titled “What the boot prints”Every key the environment provided is printed, so nobody debugs a value they cannot see:
config ✏ from the environment => secret = [FILTERED] => HENRI_SECRETconfig ✏ from the environment => stores.default.url = postgres://henri:[FILTERED]@db:5432/app => DATABASE_URLconfig ✏ from the environment => port = 8080 => HENRI_CONFIG__portA key whose name matches filterParameters (password, token, secret, authorization by default) is masked, and so is the password of a connection string, which no filter list would ever name, and anything named encryption whatever the list says. The match is the same substring rule as everywhere else in the logs, so a key the defaults do not cover — mail.auth.pass, an apiKey — belongs in filterParameters if it is to be masked here too. henri.config.fromEnv holds the same list as { key, variable } pairs — the paths and the variable names, never the values.
Encrypted credentials
Section titled “Encrypted credentials”Rails’ credentials:edit, in henri. config/credentials/<env>.json.enc holds the secrets of one environment, encrypted, and is committed with the application; the key that opens it never is. A deployment then carries one secret instead of twenty, and adding a secret to staging is a commit rather than a round of environment variables.
henri credentials:edit # the development environmenthenri credentials:edit --env production # creates the key and the filehenri credentials:show --env production --json # the key paths, no valueshenri credentials:rotate --env production # a new key, same valuesedit decrypts into a file only you can read, opens EDITOR (or VISUAL) on it, and encrypts what comes back when the editor closes. The plaintext is removed on every exit path, including an editor that fails and an interrupted process, and what you save must be a JSON object or the credentials are left as they were.
JSON, not YAML. henri’s configuration is JSON, and the decrypted object is applied over it key by key, so the two files are written the same way and henri needs no parser it does not already have.
{ "secret": "a4f1...", "mail": { "auth": { "user": "postmaster@example.com", "pass": "..." } }}The key is HENRI_CREDENTIALS_KEY, or config/credentials/<env>.key (64 hexadecimal characters, what openssl rand -hex 32 prints). The variable wins. henri new ignores config/credentials/*.key from the first commit, henri credentials:edit adds the line when it generates a key, and henri doctor reports a key that is not ignored or that reached the git index. When the file exists and no key can be found, the boot stops with config/credentials/production.json.enc needs a key: set HENRI_CREDENTIALS_KEY, or put it back in config/credentials/production.key — never a silent boot without secrets.
Rotating the key is henri credentials:rotate: the file is re-encrypted under a fresh key and the values are untouched, which is what to run when a key may have leaked. The current key has to open the file first and the new file is read back before the new key is stored, so a rotation cannot lose the contents. The new key is written to config/credentials/<env>.key, or printed once when HENRI_CREDENTIALS_KEY held the old one, because a deployment that deliberately has no key file should not be given one. Everything holding the old key needs the new one before its next boot.
The cipher is AES-256-GCM, from node’s own crypto. The envelope is one line, henri:v1:<iv>:<tag>:<ciphertext>, all base64, and the environment name is authenticated along with the content: a modified file, a wrong key, and a production.json.enc renamed to staging.json.enc all fail loudly instead of decrypting to nonsense. No error message quotes the file, the key or a decrypted value.
Where it sits in the precedence: over the configuration file, under the environment. The credentials are committed with the application, like the configuration files, and the environment is the deployment, so a container can still override with DATABASE_URL or HENRI_CONFIG__<key>. Each leaf of the decrypted object replaces that one key, so { "mail": { "auth": { "pass": "x" } } } leaves the rest of mail alone, and the values are then read like any other configuration:
henri.config.get('mail.auth.pass');The boot prints the paths the credentials provided and where the key came from — the names only, since every value in that file is a secret:
config ✏ from the credentials => secret, mail.auth.pass => key: HENRI_CREDENTIALS_KEYhenri.config.fromCredentials holds the same paths.
Rotating secret — in the credentials or in HENRI_SECRET — signs every session out, because the session cookies are signed with it. It also invalidates every password reset and email confirmation link that has not been used yet: those links carry a token signed with the same secret rather than a row in a table (see Users). Neither is a reason not to rotate it; both are a reason to expect the support mail, and to rotate at a quiet hour.
The jobs object
Section titled “The jobs object”Everything the job queue reads, all of it optional. The queue is @usehenri/jobs, which the application installs itself; the key is validated either way, and read by the package. It only loads when this key is there or when app/jobs holds a file.
{ "jobs": { "store": "default", "concurrency": 5, "maxAttempts": 5, "backoff": { "base": "5s", "factor": 4, "max": "1h", "jitter": 0.15 }, "recurring": { "nightly-cleanup": { "job": "cleanup", "cron": "0 3 * * *" }, "refresh-stats": { "job": "stats/refresh", "every": "15m", "queue": "low" } } }}| Key | Default | Description |
|---|---|---|
store |
default |
Which store of stores holds the queue. |
priority |
0 |
Priority of a job that names none; the higher, the sooner it is claimed. |
table |
henri_jobs |
Table (or collection) name; the schedules live in <table>_schedules, the concurrency slots in <table>_limits and the batches in <table>_batches. Letters, digits and underscores only. |
queue |
default |
Queue of a job that names none. |
queues |
all | Queues a runner takes from when henri jobs is given no --queue. |
concurrency |
5 |
How many jobs one runner performs at once. |
maxAttempts |
5 |
Attempts before a job goes to the dead letter queue. |
timeout |
none | How long one attempt may take, for jobs that set none. |
backoff |
see above | Wait before the next attempt: base × factor^(attempt − 1), capped at max, spread by jitter (0 to 1). |
pollInterval |
1s |
How often a runner looks for work when the queue is empty (never under 50ms). |
stuckAfter |
5m |
Without a heartbeat for that long, a running job is taken to belong to a dead runner and put back. Keep it above the longest timeout. |
keepCompleted |
1d |
How long finished jobs are kept before a runner prunes them. 0 keeps them forever. |
maxArgsBytes |
524288 |
Size limit of the serialized arguments of one job; over it the enqueue fails instead of storing a truncated payload. |
mailQueue |
mailers |
Queue of the messages deliverLater() hands over. |
install |
true |
Create the tables at boot. Set false in production and run henri jobs:install in the deploy. |
recurring |
{} |
Schedules, by name: job (defaults to the name), cron or every, and optionally args, queue and priority. Cron is read in UTC. |
Every duration is a number of milliseconds or a string: '250ms', '30s', '5m', '2h', '1d', '1w'.
The webhooks object
Section titled “The webhooks object”Everything outbound webhooks read, all of it optional. The package is @usehenri/webhooks, which the application installs itself; the key is validated either way. Deliveries are jobs, so the queue has to be there too.
{ "webhooks": { "store": "default", "queue": "webhooks", "maxAttempts": 8, "timeout": "10s", "backoff": { "base": "10s", "factor": 3, "max": "6h", "jitter": 0.2 } }}| Key | Default | Description |
|---|---|---|
store |
default |
Which store of stores holds the endpoints. |
table |
henri_webhooks |
Table (or collection) name. Letters, digits and underscores only. |
install |
true |
Create the table at boot. Set false in production and run henri webhooks:install in the deploy. |
queue |
webhooks |
The queue the deliveries go to, so a slow receiver never delays the rest of the work. |
maxAttempts |
8 |
Attempts before a delivery goes to the dead letter queue. Eight of the default backoff is about three days. |
timeout |
10s |
How long one delivery may take, resolution, connection and answer included. |
backoff |
see above | Wait before the next attempt: base × factor^(attempt − 1), capped at max, spread by jitter (0 to 1). |
maxFanout |
1000 |
How many deliveries one emit() may enqueue before it refuses rather than writing them inside a request. |
allowPrivate |
false |
true lets a delivery reach a loopback, private or link-local address. That is what a webhook url is normally refused for: development only, and henri audit reports it. |
allowHttp |
false |
true lets a delivery go to a plaintext http url, payload and signature in the clear. Development only, and henri audit reports it. |
Every duration is a number of milliseconds or a string: '250ms', '30s', '5m', '2h', '1d', '1w'.
The user object
Section titled “The user object”user is the name of the model to treat as the user model. It also accepts an object:
{ "user": { "model": "user", "public": ["name", "avatar"], "loginPath": "/login", "afterLogin": "/", "sessionMaxAge": 2592000000, "password": { "minLength": 12 }, "lockout": { "max": 10, "windowMs": 900000 } }}public lists the fields, besides externalId, email and roles, that views and JSON answers may see; loginPath is where browsers are sent when a route denies them; afterLogin where they land after a form login; sessionMaxAge the session lifetime in milliseconds (30 days). Four more blocks are the endpoints henri mounts when they are asked for: signup, passwordReset and confirmation (the account flows), and identities (the identity providers). See Users.
user.password
Section titled “user.password”How passwords are checked and hashed. See Passwords.
| Key | Default | Description |
|---|---|---|
password.minLength |
12 |
Shortest password accepted. Never below 8. Checked when a password is set, never when one is verified. |
password.maxBytes |
72 |
Longest, in bytes: bcrypt ignores everything past 72 and henri will not truncate silently. |
password.algorithm |
auto |
auto (argon2id when @node-rs/argon2 resolves, bcrypt otherwise), argon2id (fails the boot when it does not) or bcrypt. |
password.bcryptRounds |
12 |
bcrypt work factor. Never below 10. |
password.memoryCost |
19456 |
argon2id memory in kibibytes. |
password.timeCost |
2 |
argon2id iterations. |
password.parallelism |
1 |
argon2id lanes. |
password.pepper |
off | A server-side key mixed into every hash, its own, never config.secret. HENRI_PASSWORD_PEPPER sets it. Losing it makes every password unverifiable. |
password.binding |
on | Binds a hash to the externalId of its row, so one copied onto another row stops verifying. { enabled, allowUnbound }; see Bound hashes. |
user.lockout
Section titled “user.lockout”The per-account sign-in lockout; false turns it off. See Sign-in lockout.
| Key | Default | Description |
|---|---|---|
lockout.max |
10 |
Failed attempts one account may receive per window, whoever sends them. |
lockout.windowMs |
900000 |
The window, 15 minutes. |
lockout.store |
memory | An express-rate-limit store shared between processes; in memory and per process by default. |
user.identities
Section titled “user.identities”Signing in with somebody else’s identity provider. Off until providers names one; henri ships no provider list and no provider secrets. See Identity providers.
{ "user": { "identities": { "providers": { "acme": { "authorizationUrl": "https://acme.example/oauth/authorize", "tokenUrl": "https://acme.example/oauth/token", "userinfoUrl": "https://acme.example/oauth/userinfo", "clientId": "...", "clientSecret": "...", "scope": ["openid", "email"] } } } }}| Key | Default | Description |
|---|---|---|
identities.providers |
{} |
The providers, by the name a url calls them. One is enough to mount the endpoints. |
identities.merge |
refuse |
What a callback does when its verified address already has an account. refuse says to sign in and link; verified links it, needs the provider trusted, and henri audit reports the pair. |
identities.path |
/auth |
Prefix of POST <path>/:provider, GET <path>/:provider/callback and POST <path>/:provider/unlink. |
identities.after |
/ |
Where a browser lands once a provider was linked. |
identities.signup |
true |
Open an account for a verified address that belongs to nobody; false refuses instead. |
identities.allowHttp |
false |
true lets a provider be reached over plaintext http, client secret and access token in the clear. Development only, and henri audit reports it. |
identities.stateExpiresIn |
10m |
How long one sign-in attempt stays valid. The state is minted per attempt, kept in the session, and single use. |
identities.timeout |
10s |
How long a provider has to answer the token and userinfo requests. |
identities.table |
henri_identities |
Table (or collection) name. Letters, digits and underscores only. |
identities.enabled |
true |
false leaves the endpoints unmounted without removing the providers. |
Each provider takes authorizationUrl, tokenUrl, userinfoUrl, clientId and clientSecret (all required), plus scope, claims (subject, email, verified), label, allows (signin or verify), auth (basic or post), pkce, trusted and params. The client secret belongs in the encrypted credentials or in the environment, never here: henri audit reports one written in a configuration file the way it reports an encryption key there. A provider that cannot be used as configured fails the boot naming every problem at once.
The csrf object
Section titled “The csrf object”csrf is true, false (no protection at all) or an object. See CSRF.
| Key | Default | Description |
|---|---|---|
csrf.origin |
true |
Verify Sec-Fetch-Site and Origin alongside the token. false keeps the token check alone. |
csrf.trustedOrigins |
config.cors.origin |
Other origins allowed to send an unsafe request with a session cookie. Whatever cors already allows is trusted without repeating it. |
The graphql object
Section titled “The graphql object”graphql is the endpoint path, or an object. See GraphQL.
| Key | Default | Description |
|---|---|---|
graphql.endpoint |
/_henri/gql |
Path of the endpoint. |
graphql.authenticated |
false |
Anonymous requests get 401. |
graphql.roles |
[] |
Roles required; missing one is 403. Implies authenticated. |
graphql.loopbackOnly |
false |
Anything but the loopback interface gets 404. |
graphql.introspection |
outside prod | Schema introspection. |
graphql.maxAliases |
15 |
Most aliases one query may use; false lifts the limit. |
graphql.maxComplexity |
1000 |
Most fields one query may select, fragments expanded; false lifts the limit. |
graphql.maxDepth |
10 |
Deepest query accepted; false lifts the limit. |
graphql.maxTokens |
5000 |
Most tokens one document may hold; false lifts the limit. |
Validation
Section titled “Validation”Every key above is declared in a schema (@usehenri/core, src/base/config-schema.js) with the type it accepts. On boot, once the file has loaded and the credentials and the environment have been applied over it, the whole configuration goes through that schema — before any other module starts, so a wrong value fails on the first line of the boot rather than three modules in, where the message would name the reader instead of the mistake.
Every problem is reported, not the first one. Somebody fixing a configuration file should not discover its faults one boot at a time.
A value henri cannot use fails the boot, naming the key, what was expected, what arrived, and where the value came from — the file, the credentials, or the variable:
config ✖ "port" must be a port number between 1 and 65535, but it is the string "eight thousand" => from config/production.jsonconfig ✖ "stores.default.adapter" must be one of disk, drizzle, mariadb, mongoose, mssql, mysql, postgresql, but it is the string "redis" => from HENRI_CONFIG__stores__default__adapterconfig ✖ "secret" must be a string, but it is a number => from the credentials (config/credentials/production.json.enc)The source matters: port must be a number is unhelpful when the culprit is an environment variable three deployments away. A value the filters name, and anything the credentials provided, is printed as its type alone; the password of a connection string is always masked.
And what to do about it is on the next line. Nearly every key of the schema carries one, and it says the thing the expectation could not: the command that fixes it, the other key that decides the same thing, the package the value needs, or the consequence of the value that arrived.
config ✖ "filterParameters[1]" must be a string, but it is the number 7 => from config/default.jsonconfig ✖ The list replaces the defaults rather than adding to them, so name password, token, secret and authorization again next to your own; they are matched as substrings, and "encryption" is masked whatever this saysAn item of a list inherits the hint of the list, the way a value of a union inherits the union’s: what to do about retention.approved is what to do about the token that is wrong inside it. Two keys carry no hint on purpose — where “a path to land on once the address is confirmed” leaves nothing to add, henri says nothing rather than padding.
henri server exits 1 and henri server --json prints the same thing as { "error": { "code": "HENRI_CONFIG_INVALID", "message", "hint", "problems": [...] } }, where each problem is { key, level, message, expected, received, source, hint }. See Error codes.
An unknown key is a warning, never a failure. An application may carry keys of its own — henri.config.get() is how it reads them — so henri says it ignores the key and boots:
config ⚠ "appName" is not a henri configuration key => from config/default.jsonUnless it is a near miss of one henri owns, which is the mistake actually being made:
config ⚠ "renderers" is not a henri configuration key: did you mean "renderer"? => from config/default.jsonInside a store this is the only unknown-key warning there is: everything a store adapter does not declare is forwarded to the driver (logging, pool, dialectOptions), so only adaptor or urls are worth a word.
henri doctor runs the same schema over every config/*.json, without booting and without a database, which is the fastest way to find a broken configuration — and the one a coding agent reaches for:
henri doctorhenri doctor --json # problems as { check, level, code, message, file, hint }The checks are config.invalid (an error), config.adapter (an unknown store adapter) and config.unknown (a warning). Being a file check, it sees neither the environment nor the credentials; the boot does.
Two more read one file against the others, which the schema cannot: config.store, when jobs.store, webhooks.store or trail.store names a store that is not in the same file, and deps.declared, when a store adapter one environment configures is in no package.json — an environment file replaces default.json whole, so each one has to hold together on its own, and the one that does not is usually production.json.
The account flows
Section titled “The account flows”Three more keys mount registration, the password reset and the address confirmation. Each is true for the defaults, false (or absent) to leave the endpoints unmounted, or an object of settings; henri generate authentication writes them along with the pages.
{ "user": { "model": "user", "signup": { "fields": ["name"], "after": "/" }, "passwordReset": { "expiresIn": "1h" }, "confirmation": { "required": true } }}| Key | Default | Description |
|---|---|---|
signup.path |
/signup |
Where POST creates an account. |
signup.fields |
[] |
Attributes a signup form may set besides email and password. roles is never one of them. |
signup.after |
/ |
Where a browser lands after a successful signup. |
signup.login |
true |
Open a session for the new account. |
passwordReset.path |
/password |
Prefix of <path>/forgot, <path>/reset/:token and <path>/reset. |
passwordReset.expiresIn |
1h |
How long a reset link stays valid. Milliseconds, or '1h', '30m', '3d'. |
passwordReset.after |
/ |
Where a browser lands once the password changed. |
passwordReset.login |
true |
Sign the account in after the reset. |
confirmation.path |
/confirm |
GET <path>/:token confirms, POST <path> mails the link again. |
confirmation.emailPath |
/account/email |
Where a signed-in account asks to change its address. |
confirmation.expiresIn |
3d |
How long a confirmation link stays valid. |
confirmation.after |
/ |
Where a browser lands once the address is confirmed. |
confirmation.required |
false |
Keep unconfirmed accounts from opening a session. |
confirmation.requirePassword |
true |
Ask for the current password before mailing an address change. |
The links these flows mail carry signed tokens rather than stored ones, so rotating secret invalidates every link that has not been used yet (and every session, which is signed with the same secret). See Users.
Reading the configuration in your code
Section titled “Reading the configuration in your code”The configuration is exposed on the global henri object. Keys use dots (and brackets for arrays):
henri.config.get('stores.default.adapter'); // throws if the key is missinghenri.config.get('mail', true); // returns false instead of throwinghenri.config.has('baseRole');