dhtml

Reference · docs/config.md

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

KeyDefaultMeaning
namenone, requiredApp name. Letters, digits, ., _, -; starts with a letter or digit; up to 64 characters.
version0.1.0App version as major.minor.patch, each part a number up to 999999 with no leading zeros. dhtml adds the build number of every upload.
environmentproductiondevelopment or production.
urlnoneThe 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.
navigationofffast makes page changes happen without a full load, through a small script the server adds to every page; off changes nothing. See Fast navigation.

[ai] (optional) #

Present only if the app uses AI calls (<d:ai> in an action, see templates.md).

KeyDefaultMeaning
providernone, required in this sectionanthropic (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).
modelclaude-sonnet-5-5 for anthropic; required for openaiModel name, exactly as the provider spells it: up to 100 letters, digits and - _ . : /.
base_urlthe provider's own addressAnother 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_tokens1024The most a model may write per call, 1 to 32768. A <d:ai max_tokens="..."> can change it for one call.
timeout30Seconds before a call is given up, 1 to 300. A <d:ai timeout="..."> can change it for one call.
keynoneWhere 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; the design is decision D19.

KeyDefaultMeaning
registrationoffon lets visitors make their own account with <d:register />. 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_emailnoneenv: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). Needs [mail] and url in [app], and the secret must be set.
magic_loginoffon lets visitors sign in with an emailed link: <d:magic /> mails it, <d:magiclogin /> uses it (see templates.md). It needs [mail] and url in [app].
min_password10Shortest password accepted, 8 to 200 bytes. The longest is always 1000.
login_page/loginWhere visitors without an account go from a protected page. A path on this site.
confirm_page/auth/confirmThe page your app serves for the "confirm your address" link. It shows a button that posts to a <d:confirm /> action.
reset_page/auth/resetThe page your app serves for the "reset your password" link. It shows a form that posts to a <d:reset /> action.
magic_page/auth/magicThe page your app serves for the sign-in link. It shows a button that posts the token to a <d:magiclogin /> action.

[mail] (optional) #

How dhtml sends the mail that accounts need. Plain text only, built by dhtml.

KeyDefaultMeaning
transportnone, required in this sectionsmtp, or log (writes the mail to the console instead of sending it; for trying things out only: a live site refuses it).
hostnone, required for smtpThe mail server's name, like smtp.example.com.
port587 (465 with security = tls)1 to 65535.
securitystarttlsstarttls or tls. Mail is always encrypted; there is no plain option.
usernamenoneSign-in name for the server. Goes with password.
passwordnoneWritten 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.
fromnone, required for smtpThe sender address, like noreply@example.com. Just the address, no display name.
from_namenoneThe 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.
notifynoneThe one address <d:notify /> mails (a contact form, say). Just the address. Needed only by an action that has that step. See templates.md.
limit_per_hour60The most mail the site sends to visitors with <d:mail /> 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.

[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 <d:http> 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.

KeyDefaultMeaning
allownoneComma-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.
privateoffon 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 <d:action webhook="name">; 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=
SchemeChecksOptions
stripeThe Stripe-Signature header: HMAC-SHA256 of timestamp.body, with the timestamp no older than tolerance seconds (default 300)tolerance=N, 1 to 3600
githubThe X-Hub-Signature-256 header: sha256= and the hex HMAC-SHA256 of the bodynone
hmacA header you name, with the HMAC-SHA256 of the body in hex (default) or base64, after an optional prefixheader=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) 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). A channel that is not listed does not exist: nobody can listen to it, and an upload is refused if an action has a <d:publish /> 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 (<d:publish />), 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:

TargetMeaning
pages/....dhtml or api/....dhtmlA 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 <target>Redirect to a path like /blog or an http:// or https:// address. Protocol-relative (//host) and other schemes such as javascript: are refused.
text <body>Respond with plain text.
json <body>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
KeyDefaultMeaning
google_client_idnoneThe client id from the Google Cloud console (OAuth client of type "Web application"). Needs url in [app].
google_secretnoneenv: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/<code>.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.

ini
[lang]
default   = en
available = en, nl, de
detect    = path, cookie, accept
KeyDefaultMeaning
defaultnone (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.
availablethe default onlyThe languages the app offers, separated by commas (at most 20), each with a file server/lang/<code>.lang.
detectcookie, acceptThe 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).
missingwarnerror 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.