# 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.