# dhtml.config
`dhtml.config` sits at the root of every project. It is a plain text file in an INI-style format (decision D1): sections in `[brackets]`, settings as `key = value`, comments starting with `#`.
The file is checked on every upload. Every problem is reported with the file, the line, and a hint. Unknown keys get a "did you mean" suggestion.
## Format
```ini
# a comment on its own line
[server]
port = 3000 # a comment after a value (needs a space before the #)
routing = files
```
- Section and key names are lowercase words. Values are plain text.
- A `#` starts a comment when it is the first character on a line, or when it follows a space or tab. `item#1` and `k=#abc` keep their `#`; `k = #abc` is an empty value followed by a comment.
- To keep a `#` after a space, or leading and trailing spaces, put the value in double quotes: `/h = "text a # b"`. Inside quotes the only escapes are `\"` and `\\`.
- A key may appear once per section, and a section once per file. Repeating either is an error that names the first line.
- Files are UTF-8 text. A byte order mark and Windows line endings are fine. Non-text data, control characters (a CR is only allowed as part of CRLF; DEL and the C1 range are refused), lines over 8192 bytes, and files over 1 MiB are rejected.
- At most 20 problems are reported per file per run, followed by a line saying more were found.
## `[app]` (required)
| Key | Default | Meaning |
|---|---|---|
| `name` | none, required | App name. Letters, digits, `.`, `_`, `-`; starts with a letter or digit; up to 64 characters. |
| `version` | `0.1.0` | App version as `major.minor.patch`, each part a number up to 999999 with no leading zeros. dhtml adds the build number of every upload. |
| `environment` | `production` | `development` or `production`. |
| `url` | none | The address of your site, like `https://example.com`. Links in email (confirm an account, reset a password) are built from it, never from the request, so nobody can aim them elsewhere. `https`; no path, query or sign-in. Needed for `registration = on`. Also readable in pages as `config.app.url`. |
| `navigation` | `off` | `fast` makes page changes happen without a full load, through a small script the server adds to every page; `off` changes nothing. See [Fast navigation](templates.md#fast-navigation). |
## `[ai]` (optional)
Present only if the app uses AI calls (`` in an action, see [templates.md](templates.md#ai-calls)).
| Key | Default | Meaning |
|---|---|---|
| `provider` | none, required in this section | `anthropic` (the Anthropic Messages API), `openai` (the OpenAI chat API, and every service that speaks it: OpenRouter, Groq; set `base_url`), or `fake` (no key, no network: it answers with a fixed sentence built from the question, for tests and for trying the pages; the check warns when it is used on a live site). |
| `model` | `claude-sonnet-5-5` for `anthropic`; required for `openai` | Model name, exactly as the provider spells it: up to 100 letters, digits and `- _ . : /`. |
| `base_url` | the provider's own address | Another address for the API: a proxy or OpenRouter. It must be `https`. No user name, password, query or fragment. Redirects from the provider are never followed, so the key cannot be forwarded. |
| `max_tokens` | `1024` | The most a model may write per call, 1 to 32768. A `` can change it for one call. |
| `timeout` | `30` | Seconds before a call is given up, 1 to 300. A `` can change it for one call. |
| `key` | none | Where the API key lives, always written as `env:NAME`, for example `env:ANTHROPIC_API_KEY`. The key itself goes in the Secrets section of your site on dhtml.net. `openai` with a `base_url` on this machine needs no key. A literal key here is an error. Errors on `[ai]` lines never repeat the value, since people paste secrets into the wrong field. |
## `[auth]` (optional)
Accounts and sign-in. The tags that use them are in [templates](templates.md); the design is decision D19.
| Key | Default | Meaning |
|---|---|---|
| `registration` | `off` | `on` lets visitors make their own account with ``. It needs `[mail]` and `url` in `[app]`: an account exists only once the emailed link is confirmed. With it `off`, visitors cannot make accounts. |
| `admin_email` | none | `env:NAME`: the secret that holds the address of the site's owner. The owner opens `/_dhtml/claim`, gets a one-time link by mail at that address and nowhere else, chooses a password and becomes the admin (see [Claiming the owner account](templates.md#claiming-the-owner-account)). Needs `[mail]` and `url` in `[app]`, and the secret must be set. |
| `magic_login` | `off` | `on` lets visitors sign in with an emailed link: `` mails it, `` uses it (see [templates.md](templates.md#magic-links)). It needs `[mail]` and `url` in `[app]`. |
| `min_password` | `10` | Shortest password accepted, 8 to 200 bytes. The longest is always 1000. |
| `login_page` | `/login` | Where visitors without an account go from a protected page. A path on this site. |
| `confirm_page` | `/auth/confirm` | The page your app serves for the "confirm your address" link. It shows a button that posts to a `` action. |
| `reset_page` | `/auth/reset` | The page your app serves for the "reset your password" link. It shows a form that posts to a `` action. |
| `magic_page` | `/auth/magic` | The page your app serves for the sign-in link. It shows a button that posts the token to a `` action. |
## `[mail]` (optional)
How dhtml sends the mail that accounts need. Plain text only, built by dhtml.
| Key | Default | Meaning |
|---|---|---|
| `transport` | none, required in this section | `smtp`, or `log` (writes the mail to the console instead of sending it; for trying things out only: a live site refuses it). |
| `host` | none, required for `smtp` | The mail server's name, like `smtp.example.com`. |
| `port` | `587` (`465` with `security = tls`) | 1 to 65535. |
| `security` | `starttls` | `starttls` or `tls`. Mail is always encrypted; there is no plain option. |
| `username` | none | Sign-in name for the server. Goes with `password`. |
| `password` | none | Written as `env:NAME`, like `env:MAIL_PASSWORD`, with the password in the Secrets section of your site on dhtml.net. A literal password here is an error, and errors never repeat the value. |
| `from` | none, required for `smtp` | The sender address, like `noreply@example.com`. Just the address, no display name. |
| `from_name` | none | The name shown before the sender address, like `My Shop` (at most 60 characters; no quotes, brackets or backslashes). Mail then arrives from "My Shop" instead of the bare address. Needs `from`. Note that some providers, Gmail among them, replace the name with the one set on the account unless the address is a "send as" alias. |
| `notify` | none | The one address `` mails (a contact form, say). Just the address. Needed only by an action that has that step. See [templates.md](templates.md#telling-the-owner). |
| `limit_per_hour` | `60` | The most mail the site sends to visitors with `` in an hour, 1 to 100000. A mail over the limit is dropped and logged; the visitor's request still succeeds. There is also a fixed limit of 3 mails to one address in a day. See [templates.md](templates.md#mailing-the-visitor). |
## `[protect]` (optional)
Paths that need a signed-in user. Each line is `path = auth` or `path = admin`; a path ending in `/*` covers everything under it.
```ini
[protect]
/account = auth
/admin/* = admin
```
Visitors who are not signed in are sent to `login_page` with `?next=` set to where they were going; paths under `/api` answer 401 or 403 instead. Listing a path twice is an error. `/_dhtml` cannot be protected.
## `[http]` (optional)
What `` may call (decision D21). An app can reach nothing that is not listed here, and the check on every upload shows every call a project makes with the host it goes to.
| Key | Default | Meaning |
|---|---|---|
| `allow` | none | Comma-separated host names: `api.stripe.com, *.example.org`. `*.example.org` means every host under it, not `example.org` itself. Names only: no scheme, port or path. |
| `private` | `off` | `on` lets calls reach this machine and private networks, and use plain `http` there. Off, a host name that points at a private, loopback, link-local (cloud metadata) or reserved address is refused at connect time, whatever DNS says. Turn it on only for a service that really runs next to the app. |
Calls are https to the standard port, follow no redirects, and use no proxy from the environment. Refused addresses include loopback, private (`10/8`, `172.16/12`, `192.168/16`, `fc00::/7`), link-local (`169.254/16`, which holds the cloud metadata services, and `fe80::/10`), multicast, carrier-grade NAT, the documentation and benchmarking ranges, and the IPv6 forms that carry an IPv4 address inside (IPv4-mapped, NAT64, 6to4, Teredo, IPv4-compatible). The check is made on each address a name resolves to, and the connection is made only to one that passed.
## `[webhooks]` (optional)
Signed endpoints that other services call (decision D21). One line each: `name = scheme env:SECRET [options]`. An action uses one with ``; the signature is checked before anything else is read, and a call that fails the check changes nothing.
```ini
[webhooks]
stripe = stripe env:STRIPE_WEBHOOK_SECRET
github = github env:GITHUB_WEBHOOK_SECRET
shop = hmac env:SHOP_SECRET header=X-Shop-Signature encoding=base64 prefix=sha256=
```
| Scheme | Checks | Options |
|---|---|---|
| `stripe` | The `Stripe-Signature` header: HMAC-SHA256 of `timestamp.body`, with the timestamp no older than `tolerance` seconds (default 300) | `tolerance=N`, 1 to 3600 |
| `github` | The `X-Hub-Signature-256` header: `sha256=` and the hex HMAC-SHA256 of the body | none |
| `hmac` | A header you name, with the HMAC-SHA256 of the body in `hex` (default) or `base64`, after an optional `prefix` | `header=NAME` (required), `encoding=`, `prefix=` |
The secret is written as `env:NAME` and kept in the Secrets section of your site on dhtml.net; a literal secret is an error, and errors on these lines never repeat the value. An upload is refused when an action names a webhook that is not listed or whose secret is not set.
## `[cors]` (optional)
Which other sites a browser may call from (decision D23). Without this section no site can read the answers of your actions: a browser refuses to show them. One line per path: `path = origin, origin`.
```ini
[cors]
/api/v1/* = https://app.example.com, https://admin.example.com
/api/public/ping = https://app.example.com
```
- **The path** is exact (`/api/v1/notes`) or ends in `/*` for everything below it. An exact line wins over a wildcard, and the longest wildcard wins over a shorter one.
- **An origin** is one exact site: `https://app.example.com`, with a port if it is not the standard one, no path, no trailing slash, lower case. Only `https` is accepted. `*` and `null` are errors, whatever the line.
- **Only actions called with a key** (`apikey`, see [templates.md](templates.md#api-keys)) can be listed. An upload is refused if a line covers no such action, since a cookie-based action must never be readable from another site. Credentials are never allowed: dhtml never sends `Access-Control-Allow-Credentials`, and a key action reads no cookie.
- **What a listed origin gets**: the answer carries `Access-Control-Allow-Origin` with that origin (and `Vary: Origin`), failures included, and the preflight a browser sends before a call with an `Authorization` header is answered `204` with the action's method, the headers `Authorization` and `Content-Type`, and `Max-Age: 600`. Any other origin gets no CORS header (and `403` to a preflight). CORS is a rule for browsers: a program can call without it, with its key.
## `[channels]` (optional)
The channels that pages can listen to, for live updates (see [Live updates](templates.md#live-updates)). A channel that is not listed does not exist: nobody can listen to it, and an upload is refused if an action has a `` to it.
```ini
[channels]
board = auth # one channel, for signed-in visitors
news = public # anyone
staff = admin # admins
room-* = auth # every name starting with room-
inbox:* = user # inbox:42 is for user 42 only
```
- **The name** is lower-case letters, digits and `- _ : .`, up to 64 characters. A `*` at the end stands for every name that starts with the text before it; the longest matching line wins, and an exact line wins over a prefix.
- **The rule** is `public`, `auth` (signed in), `admin`, or `user`. `user` needs a name ending in `*`; the visitor's own id is added to it, so only that person can listen to `inbox:42`. It is the way to give each person a private channel.
- **Publishing** is done by actions (``), which have their own protections (`auth`, `admin`, `ratelimit`). The rules here say only who may listen.
- **Limits.** One visitor (or address) may hold 5 connections; the process holds 1000; a message is at most 4096 bytes; a listener that falls behind is dropped and its browser reconnects. A connection ends after 15 minutes and is opened again by the browser, which asks the rule and the session again; the session is also asked at every 25-second ping, so a sign-out or a ban ends an open stream within one ping. A channel name made from a value (`room-{{ id }}`) must fall under the same line as its fixed start; a value that turns it into another listed channel sends nothing.
- **One process.** Channels live in the program's memory. Several copies of the app behind one address would not share them.
## `[routes]`
Adds to or overrides file-based routing. Each line is `path = target`.
```ini
[routes]
/login = pages/auth/login.dhtml
/old-blog = redirect /blog
/docs = redirect https://example.com/docs
/health = text OK
/ping = json {"ok": true}
```
**Paths** start with `/` and use letters, digits and `- . _ ~ / :` only. `.` and `..` segments, doubled slashes, and query strings are errors. `/a` and `/a/` are the same route, so listing both is a duplicate.
**Targets** are one of:
| Target | Meaning |
|---|---|
| `pages/....dhtml` or `api/....dhtml` | A file under `server/`. Must end in `.dhtml`, use `/`, and stay inside `pages/` or `api/`. Layouts and partials cannot be routes. Every folder and file name uses letters, digits and `- _ . [ ]` only, so it means the same on every OS: no spaces, no `:` (Windows alternate data streams), no names starting or ending with a dot, and no Windows device names such as `CON` or `NUL`. `[name]` marks a URL parameter. |
| `redirect ` | Redirect to a path like `/blog` or an `http://` or `https://` address. Protocol-relative (`//host`) and other schemes such as `javascript:` are refused. |
| `text ` | Respond with plain text. |
| `json ` | Respond with JSON. The body must be valid JSON. |
## `[oauth]` (optional)
Sign in with Google (decision D41). Off unless `google_client_id` is written.
```ini
[oauth]
google_client_id = 123-abc.apps.googleusercontent.com
google_secret = env:GOOGLE_CLIENT_SECRET
```
| Key | Default | Meaning |
|---|---|---|
| `google_client_id` | none | The client id from the Google Cloud console (OAuth client of type "Web application"). Needs `url` in `[app]`. |
| `google_secret` | none | `env:NAME`: the secret, in the Secrets section of your site on dhtml.net, that holds the client secret. The secret itself is never written here and never repeated in an error. |
| `after` | `/` | A local path where a person lands after signing in, when no other page was asked for. |
In the Google console, add `https://YOUR-SITE/_dhtml/oauth/google/callback` (your `[app] url` plus that path) as an authorized redirect URI. A page links to `/_dhtml/oauth/google` (optionally `?next=/some/page`) to start; `{{ config.oauth.google }}` is true when it is set up. Only a verified Google address signs in. An account with that address is linked to the Google account and signed in; with `[auth] registration = on` a missing account is made. A suspended account is refused. The migrations make the table that remembers the link.
## `[lang]` (optional)
Several languages (decision D58): the words of the app live in one file per language, `server/lang/.lang`, and a page says `{{ "nav.home" | t }}`. Without `[lang]` nothing changes. The format of the files and the `t` filter are in [templates.md](templates.md#languages).
```ini
[lang]
default = en
available = en, nl, de
detect = path, cookie, accept
```
| Key | Default | Meaning |
|---|---|---|
| `default` | none (required) | The language that is shown when nothing else decides, and that fills in a text another language lacks. A lower-case code: `en`, `nl`, `pt-br`. |
| `available` | the default only | The languages the app offers, separated by commas (at most 20), each with a file `server/lang/.lang`. |
| `detect` | `cookie, accept` | The ways to choose the language of a request, tried in this order, the first that decides wins: `path` (`/nl/about` is the page `about` in Dutch; the default language has no prefix), `host` (`nl.example.com` or `example.nl`), `cookie` (a link with `?lang=nl` sets the cookie `lang`, which is read on the next requests), `accept` (the `Accept-Language` header of the browser). |
| `missing` | `warn` | `error` makes the check on every upload fail when a language lacks a key the default language has. |
## `.env`
Secrets (API keys, passwords, signing keys) are set in the Secrets section of your site's page on dhtml.net: one name and one value each. They are never in the project zip and never served. Settings point to them as `env:NAME` and never hold a secret themselves. Messages name a variable only when it is written the way variables are (capitals, digits and `_`, like `MAIL_PASSWORD`, at most 40 characters); anything else written after `env:`, such as a password pasted there by mistake, is not repeated in any message, log or page.
- Names use letters, digits and `_`, and do not start with a digit.
- Values are never expanded: `$HOME` stays `$HOME`.
- Error messages name the setting but never repeat the value.