Wardlock docs

Product specification

This is Wardlock's v1 behaviour. Every v1 roadmap item is built; each section names the roadmap item that delivered it, and anything not built says so. Sites are public unless their owner protects them; a protected path can be opened with share links, a password, an email code or OAuth sign-in. Where a rule is a proposal awaiting confirmation, it says so; items must not silently change these rules.

Terms

Tokens

Share links and API tokens use one format, wl_<public id>_<secret>. The server keeps only a keyed hash, so a raw token is shown exactly once, at creation or rotation.

Format and storage

Share links and API tokens use one format: wl_<public id>_<secret>. The public ID is 10 lowercase base32 characters and names the token in logs, the dashboard and the access log. The secret is 256 random bits, base64url-encoded. The server stores only an HMAC-SHA-256 of the whole token keyed by WARDLOCK_SECRET, so a raw token is visible exactly once, at creation or rotation. Details: wardlock-access and SECURITY-MODEL.

Owners and sign-in (C1.1, C1.6, C1.7)

Bootstrap, invitations, sign-in codes and passkeys in detail
  • Bootstrap. When no owner exists, wardlockd serve prints a one-time setup URL to the log (https://<dashboard host>/setup?code=…, single use, valid 60 minutes, reprinted on each start until used). Alternatively the operator runs wardlockd admin create --email you@example.com, which creates the owner offline and prints a one-time sign-in URL.
  • One-time URL paths on the dashboard host, which the dashboard serves (D3.1): /setup?code=<code> (the setup URL; the page asks for the owner's email and calls completeSetup) and /sign-in?code=<code> (admin-create and invitation links; the page calls redeemSignInLink once the owner presses Continue past the acceptable use policy). Both use https://<dashboard host> without a port, except a localhost/*.localhost dashboard host, which uses http:// and the listen port.
  • Invitations. An owner invites another by email address; the invitee gets a one-time sign-in link valid for 7 days and can afterwards sign in by email code. The response is the same whether or not the address already belongs to an owner (who then gets nothing). The admin-create sign-in URL is valid for 60 minutes. With WARDLOCK_INVITES=operator (A2.13) the invite operation answers 403 invites_restricted and only wardlockd admin create adds owners; the default, owners, lets any owner invite. An invitation to a suspended owner's address sends nothing.
  • Dashboard sign-in uses a one-time email code (6 digits, valid 10 minutes, 5 attempts, rate-limited) or a passkey (C1.6). There are no owner passwords. Sending a new code retires the previous one. Default limits: 5 sends per address and 20 per IP per hour, 30 verifications per IP per 15 minutes, 10 setup or sign-in-link redemptions per IP per 15 minutes, 20 invitations per owner per hour; over a limit the API answers 429 with Retry-After. When a limiter's table of IPs or addresses is full (100,000 keys), keys it does not hold are counted in a fixed-size overflow sketch under the same limits rather than refused (A2.9; SECURITY-MODEL "Limiter tables under flooding").
  • Passkeys (C1.6). A signed-in owner can add WebAuthn passkeys from the dashboard's Passkeys page and sign in with one instead of an email code. The relying party is the dashboard host and only the dashboard origin is accepted, so changing WARDLOCK_DASHBOARD_HOST makes existing passkeys unusable (owners sign in by email code and add new ones). Passkeys are discoverable credentials, so sign-in asks for no email address; user verification is preferred, not required, and no attestation is requested. Each challenge is valid for 5 minutes and is used once, whatever the outcome; a response for another origin or relying party, a bad signature, an unknown or removed passkey and a signature counter that did not increase are all refused with the same invalid_passkey error. An owner has at most 20 passkeys, each with a name (by default the browser and system that created it, such as "Chrome on macOS"), its creation time and its last sign-in. Removing a passkey stops it signing in at once and does not end sessions it started. Passkeys are managed only from a dashboard session, never with an API token. Passkey sign-in starts are limited per IP at the same rate as email-code verifications (30 per 15 minutes), counted separately, with at most 5 sign-ins open at once per IPv4 address or IPv6 /48. Adding a passkey is limited to 10 starts per owner per 15 minutes, with at most 3 open at once; registrations and sign-ins are held in separate tables, so adding passkeys never delays signing in. If two sign-ins with the same passkey race, only the first is accepted.
API token rules and the CLI login flow
  • API token rules (C1.7): the deploy scope covers creating sites and deploys, uploads, activation and rollback; manage covers deleting sites, custom domains and access policies; either reads. Tokens never manage owners, sessions or other tokens (those operations answer 403 to a token). A request with Authorization: Bearer is authenticated by the token alone, even if it also carries the dashboard cookie. A missing, malformed, unknown, wrong, expired or revoked token gets the same 401; after 30 rejected tokens from one IP (IPv6 by /64) in 15 minutes, every token from that IP is refused (429) until the window ends; when the limiter's table is full, a rejected token from an IP it does not hold is counted in the limiter's overflow sketch under the same limit (A2.9). Revocation and expiry take effect on the next request. Owners list only usable tokens (not revoked, not expired), newest first; another owner's token ID gets the same 404 as a missing one. Names are 1–100 characters; an expiry must be in the future.
  • CLI login (C1.7): wardlock login --server URL starts a device-code style flow. The CLI shows a short user code and opens the browser at https://<dashboard host>/cli-login?code=<user code> (only when stdout is a terminal and without --json); the signed-in owner checks the code matches, chooses scopes (Deploy is ticked by default and Manage is not, for least privilege) and approves or denies. A signed-out owner signs in first and returns to the page. The user code is 8 letters without vowels or look-alikes (BDFH-KMPR), accepted in any case with or without the hyphen; the device code is a 256-bit secret stored only as an HMAC. A login lasts 15 minutes; the CLI polls every 5 seconds, a poll less than 4 seconds after the previous one answers 429 slow_down (the CLI then waits 5 seconds longer each time), and each IP may poll 60 times a minute and start 20 logins an hour; each owner may approve or deny 30 times in 15 minutes. The first poll after approval mints the token (never expiring, named after the CLI, wardlock <version> on <host>, unless the owner named it) and delivers it once; later polls, denied, expired and unknown logins answer 410 login_expired. An unknown, expired or already decided code, including one another owner decided, answers 400 invalid_code. Finished requests are deleted a day after they expire. The token is stored in the OS keychain where available (macOS Keychain, Windows Credential Manager, Secret Service on Linux), otherwise in ~/.config/wardlock/credentials.json ($XDG_CONFIG_HOME/wardlock/ when set) with mode 0600 in a 0700 directory; the file also records the server, which later commands use by default. wardlock logout revokes the token on the server and deletes it locally; if the server cannot be reached it still deletes it and says to revoke it in the dashboard. WARDLOCK_TOKEN is never stored or revoked by these commands. Over plain http to anything but a loopback address or *.localhost, login and logout warn on stderr that the token travels unencrypted.

Dashboard (D3.1)

Signed-in owners manage everything from the dashboard on the dashboard host. Only built screens appear.

Page What it is for
/sign-in, /setup Sign in by email code or passkey; set up the first owner
/sites The owner's sites, each with its policy and when it last went live
/sites/<name> The site's address, deploy history and rollback, access policies, share links, custom domains, Open site and teardown
/sites/<name>/links/<id> One share link and its access log (D3.3)
/sites/<name>/access-log The whole site's access log (A2.12)
/passkeys Add and remove passkeys (C1.6)
/cli-login Approve or deny a wardlock login (C1.7)
/acceptable-use The acceptable use policy, readable signed in or out (O4.13)
Screen by screen
  • Signed-out visitors to any dashboard page are sent to /sign-in?next=<path> and returned to that path (same origin only) after signing in.
  • /sign-in offers "Sign in with a passkey" under the email form when the browser supports passkeys (C1.6). It asks for an email address, sends a code and then asks for it; a wrong, expired or exhausted code says so beside the field and offers a new code. The page says a code was sent "if the address belongs to an owner", matching the API's non-revealing answer.
  • /sign-in?code= and /setup?code= read the one-time code once and remove it from the address bar and history; a used or expired code explains what to do next.
  • Acceptable use policy (O4.13). /setup and /sign-in?code= say "By continuing you agree to the acceptable use policy" under their button, linking to /acceptable-use in a new tab (so the one-time code the page holds is not lost). There is no checkbox: creating the first owner, or continuing past a sign-in link, is the agreement. Sign-in links come only from invitations and wardlockd admin create, so whoever opens one is a new owner: /sign-in?code= shows "Welcome to Wardlock" with a Continue button and calls redeemSignInLink only when it is pressed. /acceptable-use renders docs/ACCEPTABLE-USE.md as bundled into the dashboard at build time, so it always matches the running version; it needs no session and makes no API call.
  • The dashboard rechecks the session when the window regains focus and treats any 401 from an owner call as an ended session (expired, revoked or signed out elsewhere): it returns to sign-in with "Your session has ended" and comes back to the same page afterwards.
  • /sites (D3.2) lists the owner's sites by name, each with its hostname, its default policy as a word (Public, or Protected with a lock glyph, at the same visual weight) and when its live deploy went live ("Not deployed yet" before the first). With no sites it says so.
  • /sites/<name> (D3.2) shows the site's address with a Copy button, its default policy and any path policies, and its deploy history newest first: each deploy's status (Live, Not live or Not activated), short ID (the last eight characters of its ID), message, time, file count and size. Any other deploy that was once live can be rolled back to after a confirmation showing the live deploy (and when it went live) beside the one that replaces it; if the refresh after a rollback fails, the page keeps the result and offers to refresh again. Teardown asks the owner to type the site's name, then deletes it and returns to /sites. A missing site, another owner's site and an invalid name all show the same "Site not found" page.
  • Access policies (D3.4), on /sites/<name> above its share links, lists the site default and each path policy with its access (Public or Protected), its ways in and its allowlist, and says that the longest matching path decides each request and that policies do not combine; a Check a path field names the policy that decides a typed path, decoded and cleaned as the edge cleans a request (dot segments resolved, repeated slashes collapsed, a trailing slash kept), and says that /.wardlock/ is never covered. The owner edits the default or a path policy, adds a path policy for a folder (one that already has a policy is refused) and deletes a path policy after a confirmation naming what its paths fall back to. The editor chooses Public or Protected and, for Protected, any of share links, password, email code and sign-in with GitHub, Google or single sign-on (at least one). The visitor password is write-only: its field always starts empty, a blank field keeps the current password, and once saved it is never shown or put back in a field. The allowlist (addresses and domains, one per line) appears with email code or sign-in, and GitHub organisations with sign-in; the server's reason for refusing a change is shown beside the field it names. Saving a change says when visitors who got in under the old settings must get in again.
  • Custom domains (D3.5), on /sites/<name> below its share links: a Hostname field adds a domain (domain_taken and invalid_hostname are shown on the field). Each domain shows its status as a word (Pending, Active or Lapsed), what that means for visitors, when DNS was last checked and is next checked, a pending claim's removal date, and the last failure in plain words (no record, CNAME points elsewhere, TXT value differs, DNS lookup failed) with the server's detail and the next step. The CNAME record's name and target are shown with Copy buttons, exactly as the API gives them; the TXT record is behind a disclosure explaining it is for apex domains and CDN-proxied records. An active domain keeps its records behind a disclosure. Check now runs one check and shows the result, or how long to wait when it is refused as too soon. Remove asks first and reminds the owner to delete the DNS records.
  • Share links (D3.3), on /sites/<name>: create a link (label, expiry or never, use limit, path scope) and copy its URL, which is shown once; list, revoke and rotate links (a rotated link's new URL is also shown once). Each link's page, /sites/<name>/links/<id>, shows its details and its access log; a revoked link's past visits stay readable.
  • Access log (A2.12): /sites/<name>/access-log, reached from the site page, lists the site's visits newest first with time, path, status, how the visitor got in (with the share link's label when it came through one) and the truncated IP, paged like the link access log.
  • Taken down (A2.13): a site the operator has taken down, or whose owner is suspended, shows a "Taken down" badge on /sites and /sites/<name>, and the site page says "The operator of this server has taken this site down…". The site stays readable and its policies and share links editable, but its custom domains are frozen: the Add domain field is replaced by a note that the domains stay as they are, and Remove, a path policy's Delete, Open site, Roll back and Tear down are hidden (Check now still runs).
  • /passkeys (C1.6) lists the owner's passkeys with when each was added and last used, adds one with the browser's passkey prompt, and removes one after a confirmation naming it.
  • The signed-in shell has a sidebar (a top bar with a Menu button below 48rem) listing only built screens, the signed-in owner's address and Sign out.

Sites (C1.2)

Name rules and reserved names
  • Site names are DNS labels: 1–63 characters of a-z, 0-9 and -, not starting or ending with -. Reserved names: auth, www, api, admin, app, dashboard, mail, wardlock, anything starting with xn--, and, when the dashboard host sits under the base domain, the label directly under the base domain on the way to the dashboard host (dash for dash.<base domain> or a.dash.<base domain>).

Custom domains (C1.9, D3.5)

The goal is that adding one DNS record is all an owner has to do. The record names the site itself, so it works wherever Wardlock is hosted and proves which site the domain belongs to.

Adding, records and verification in detail
  • Adding. An owner adds a hostname to one of their sites from the dashboard (D3.5) or wardlock domain add (C1.9). Hostnames are stored lowercase, as A-labels (punycode), with no trailing dot or port, at most 253 characters. Rejected: IP addresses, wildcards, the base domain and anything under it, the dashboard host, and the auth origin. A hostname belongs to at most one site on the instance; adding one already attached to any site fails with domain_taken, without saying which site or owner holds it.
  • Records shown. Wardlock answers with the records to create:
    • Routing: CNAME <hostname> → <name>.<base domain>. That target already points at the server through the sites wildcard, so it needs no knowledge of the server's address.
    • Verification fallback: TXT _wardlock.<hostname> → wardlock-verify=<token>, a random token unique to this claim (a new claim after removal gets a new token). It is needed only when the CNAME cannot be seen: apex domains, which cannot have a CNAME and instead use A/AAAA records (or the provider's ALIAS/ANAME/flattening) pointing at the server; and CDN-proxied records such as Cloudflare's orange cloud, which hide the CNAME.
  • Verification. A hostname is verified when resolving it follows a CNAME chain that reaches <name>.<base domain> (the chain must end there, as it does when the sites wildcard has A/AAAA records), or when _wardlock.<hostname> has the claim's TXT token. Lookups go to the resolvers in WARDLOCK_DNS_RESOLVERS (default: the system resolver) with caching disabled where the resolver allows it. A new hostname is checked straight away, then with backoff (1 minute doubling to 1 hour) for 48 hours; after that it stays pending until the owner asks to verify again. An owner's request runs one check at once (refused with 429, error code checked_recently and Retry-After within 10 seconds of the previous check) and does not restart the backoff. checked_recently is the only cooldown answer: any other 429 on a check, such as rate_limited for a refused API token, is reported as the API's error, and wardlock domain add --wait stops on it rather than waiting. A claim still pending 7 days after it was added is deleted, exactly as if its owner had removed it: the hostname is free to claim again, and a new claim gets a new token. The CLI and dashboard show the last check time and a plain reason when it fails (no record, CNAME points elsewhere, TXT token mismatch, DNS error).
Statuses, serving, certificates and removal in detail
  • Statuses. pending (added, not yet verified), active (verified: served and eligible for a certificate), lapsed (was active, has failed re-checks for 72 hours in a row: no longer served and no certificate issued or renewed). Active and lapsed hostnames are re-checked every 24 hours; a lapsed hostname that passes again becomes active without owner action.
  • Serving (C1.3). An active hostname serves its site exactly as <name>.<base domain> does, with the same policies. Site sessions are host-only, so a visitor signs in separately on each of a site's hostnames. Share links work on any of the site's active hostnames. Pending and lapsed hostnames get the same response as an unknown host.
  • Certificates (C1.4). In automatic TLS mode, on-demand TLS issues and renews only for active hostnames. Behind a proxy, the proxy issues certificates and asks Wardlock first through /.wardlock/tls-ask?domain=<hostname> (see Reserved path), which answers 200 for an active hostname and 404 otherwise.
  • Removing. Removing a hostname stops serving it immediately and deletes its certificate from Wardlock's storage (as does a lapse, or tearing the site down). The owner should also delete the DNS record: a CNAME left pointing at <name>.<base domain> can be claimed by whoever later holds that site name (see SECURITY-MODEL).

Deploys and rollback (C1.2)

Upload protocol, garbage collection and path rules
  • A deploy uploads a directory. The client sends a manifest of {path, sha256, size}; the server replies with the hashes it does not yet hold for that owner (each owner uploads a file once, even if another owner already published the same content); the client uploads only those; then the client activates the deploy. Unchanged files are never re-uploaded. The protocol is in ARCHITECTURE "Deploy upload protocol" and the operations in packages/api-spec/openapi.yaml.
  • Garbage collection (C1.8) runs hourly inside the server and on demand with wardlockd gc. It first prunes each site's deploys beyond the history limit, oldest first: never the active deploy, and never a deploy not yet activated that was created within the grace period (24 hours by default), since its upload may still be in progress. It then deletes file bodies that no remaining deploy lists, unless they were stored, or uploaded again by any owner, within the grace period. Deleting a body removes every owner's record of having uploaded it, so the next deploy that needs it asks for it again. Content another owner's deploy still lists is kept, and so is each owner's record of having uploaded it. Each run logs the deploys pruned, the bodies deleted and the bytes freed.
  • Paths are normalised to forward slashes, must be valid UTF-8, must not contain .., empty segments, control characters or invisible format characters (Unicode Cf, such as zero-width spaces and bidi overrides), and must not start with .wardlock/. Such deploys are rejected with the offending path named. The server keeps paths byte for byte without Unicode normalisation; the CLI (C1.5) sends them in NFC.
Video files and blocked file types (A2.14)
  • Which files are video. A file is a video file when its extension, in any letter case, is one of .3g2, .3gp, .asf, .avi, .f4v, .flv, .m2ts, .m2v, .m4v, .mkv, .mov, .mp4, .mpeg, .mpg, .mts, .ogv, .qt, .vob, .webm or .wmv. Detection is by this fixed list only, never by the host's MIME table, so every server agrees; .ts is left out because it is far more often TypeScript than an MPEG transport stream. The manifest carries no content type to check.
  • The video limit. WARDLOCK_MAX_VIDEO_SIZE (default 10 MiB) applies to video files on top of the general per-file limit, whichever is lower; 0 refuses every video file. A deploy with a video file over it is refused when it is created with 413 limit_exceeded, the file's path as target and a message naming the file, its size and the limit.
  • Blocked file types. WARDLOCK_BLOCKED_EXTENSIONS is a comma-separated list of extensions (empty by default: nothing is blocked). A deploy listing a file with one of them, in any letter case, is refused when it is created with 422 file_type_blocked, the file's path as target and a message naming the file and its extension. Only the last extension counts: setup.exe.txt is a .txt file. DEPLOYMENT "Deploy storage and limits" suggests a list of executables and installers.
  • In the CLI. wardlock deploy shows the server's message and, when one file is at fault, how to leave it out with .wardlockignore. Nothing is uploaded.

Owner quotas (A2.14)

An operator can limit what each owner uses. All three quotas are off (unlimited) unless the operator sets them, and wardlockd admin quota can give one owner their own values.

Quota Counts Checked when Refusal
Storage Bytes of every distinct file the owner's kept deploys list, each once A deploy is created 413 limit_exceeded, target files
Sites The owner's sites A site is created 413 limit_exceeded, target name
Deploys in 24 hours Deploys the owner created in the 24 hours before this one A deploy is created 413 limit_exceeded, target deploys

Each refusal's message says which quota and its limit; nothing is written and a refused deploy counts towards neither quota. Operating them: DEPLOYMENT.

How usage is counted
  • Storage is the sum of the sizes of the distinct files listed by any of the owner's deploys, pending or activated, on any of their sites. A file listed by several deploys, paths or sites counts once, so an unchanged re-deploy costs nothing and only new or changed files count. Another owner storing the same content counts it against their own quota, never this owner's. Server-made renditions (A2.6) do not count. A deploy counts the moment it is created, before any upload, so two deploys started together cannot both use the same room. Storage comes back when a deploy is deleted: pruned by garbage collection beyond the history limit, or with its site when the owner or operator deletes the site. A deploy of a taken-down site or a suspended owner's site is kept, and keeps counting, until the operator acts (A2.13).
  • Deploys in 24 hours is a rolling window: a deploy stops counting 24 hours after it was created. Deleting a site or garbage collection does not hand deploys back.
  • Limits and overrides. The instance's values come from WARDLOCK_MAX_OWNER_STORAGE, WARDLOCK_MAX_OWNER_SITES and WARDLOCK_MAX_OWNER_DEPLOYS_PER_DAY; 0, the default, is unlimited. The operator can override any of them for one owner (again 0 is unlimited) or clear an override so the owner follows the instance again. Lowering a limit below what an owner already uses deletes nothing: it refuses their next deploy that adds files, or their next site, until usage is back under it. A deploy of only files the owner already stores is never refused for storage.
  • Concurrency. Each check runs in the same transaction as the write it guards, with the owner's row locked, so concurrent deploys or site creations by one owner cannot together pass a limit.
  • Cost. The server keeps each owner's stored bytes as a running total, with a count per owner and file of the deploys listing it, updated in the transactions that create and delete deploys. Checking storage costs one lookup per distinct file in the new manifest, never a sum over the owner's files.

Serving (C1.3)

Request rules in full
  • / and /dir/ serve index.html; /page serves page.html when page does not exist (clean URLs); /page.html is served as is. /dir without its slash redirects (301, to the absolute path, query kept) to /dir/ when dir/index.html exists and neither dir nor dir.html does. Only GET and HEAD are served (405 otherwise).
  • A missing path serves the deploy's 404.html with status 404 if present, otherwise a plain 404. With the SPA fallback option (recorded on each deploy from --spa or wardlock.json, so rollback restores it), missing paths without a file extension serve /index.html with status 200.
  • Content-Type comes from the file's extension: the types browsers show as text or a page (.html, .htm, .shtml text/html, .xhtml, .xht, .xhtm, .svg, .txt, .text text/plain, .md, .markdown text/markdown, .csv, .json, .xml) are pinned in the server, so every host serves them alike; others come from Go's table and the host's mime.types, and an unknown extension is application/octet-stream, never sniffed.
  • Responses carry ETag (content hash) and Last-Modified (activation time), honour conditional requests and byte Range requests, and serve precompressed .br or .gz siblings when the client accepts them: the sibling with the highest Accept-Encoding q wins, br on a tie; q=0, or a q that does not parse or lies outside 0–1, refuses a coding. A file with a sibling answers with Vary: Accept-Encoding, and a sibling has its own ETag. A precompressed sibling honours a single byte range; a multi-range request for one is answered 200 with the whole sibling. 404.html is sent whole, without conditional or range handling. A file whose extension has no known type is sent as application/octet-stream, never sniffed.
  • An unknown host, a nested subdomain, a pending or lapsed custom domain and a site with no active deploy all get the same plain 404, for every method and path, /.wardlock/ included. Deploy files under .wardlock/ (in any letter case) are never served, even as 404.html.
Long-term caching for hashed assets: the rule and the trade-off
  • Long-term caching for hashed assets (C1.10). A deploy may opt in with immutableAssets (recorded on each deploy from --immutable-assets or wardlock.json, like the SPA fallback, so rollback restores matching headers; off by default). While such a deploy is active, a hashed asset answering its own path with status 200 is sent with Cache-Control: public, max-age=31536000, immutable, or private, max-age=31536000, immutable on protected sites; its precompressed siblings, 304s and range answers carry the same header. Everything else keeps the headers above: other files, the request of a hashed asset by another path (a clean URL), ?format=md|txt renditions, the SPA fallback, 404.html and plain 404s, redirects, and every response that is no-store (one that sets or clears a cookie, the HTML a share-link token in the URL strips, server errors, and the login, interstitial, callback and other /.wardlock/ pages). While content screening applies to a site, a file it has not screened yet is sent no-cache instead, and gets the year once it has a verdict.
    • The rule: a deploy path is a hashed asset when it starts with assets/ (case-sensitive, at any depth below it, such as assets/fonts/inter.5d1c2e8f.woff2) and its file name, without its last extension, ends in - or . followed by 8 or more characters from [A-Za-z0-9_], with at least one character before that separator. The extension must be non-empty. So Vite's and Rollup's index-BdK3j2Lp.js and webpack's and Parcel's main.3f2a9c1b.js match; app.js, index-BdK3j2L.js (7 characters), main.3f2a9c1b.chunk.js (the hash is not last), vendor-BdK3j2Lp.js outside /assets/ and a sibling requested by its own name (index-BdK3j2Lp.js.br) do not.
    • The trade-off: the rule looks only at names, not at whether a segment really is a hash, so it is deliberately narrow to /assets/, and it still has false positives and negatives. A descriptive name with a long final word (assets/my-component.js, assets/logo.original.png) matches and is cached for a year, so a later deploy that changes such a file under the same name reaches returning visitors only when their cache expires or is cleared. A hash containing - (Rollup's base64url can produce one) may leave too short a segment and simply keeps revalidating, which is safe. That is why the option is per deploy and the owner's call: turn it on only when every file under /assets/ is renamed whenever its content changes, which Vite, webpack and Parcel production builds do by default. On a protected site the browser keeps the private copy for its lifetime, so revoking access does not remove assets a visitor already fetched; pages are still checked on every request.

Policies (A2.1)

Matching rules and edge cases
  • The policy for a request is the one with the longest matching prefix, falling back to the site default. Policies do not stack: a public /assets/ under a protected site is public. A prefix also governs its directory's path without the slash (/docs/ governs /docs), and matching is case-sensitive on the decoded, dot-resolved request path. This errs on the strict side: with a protected default and a public /docs/, the clean URL /docs (which may answer with docs.html) is allowed by /docs/ but its file is checked against the default, so a visitor without a session gets a plain 404 there, while /docs/ itself is public. Conversely, with a protected /docs/ under a public default, /docs asks for sign-in even when it would answer with the public docs.html, which stays reachable at /docs.html. A file that answers a request from another path (404.html, the SPA index.html, a clean URL's page.html) must also be allowed by its own path's policy, or the request gets a plain 404 instead of its content. So must a directory's index.html when a policy of its own (on /dir/index.html/, the way to protect that one file) governs it (A2.7): if that policy refuses the visitor, /dir/ answers a plain 404 with the protected-site headers rather than a sign-in challenge, and the visitor gets the sign-in prompt by requesting /dir/index.html itself.
Decisions, versions, refusals and the Open site button
  • Decision (A2.1): a new site's default policy is Public with no methods, and so is the default of a site created before A2.1 (it is stored on first use). Owners opt into protection per site or per path. The reason: deploys keep working as before, and protecting a site is only useful once a way in exists (share links from A2.2, then password, email code and OAuth), so protection is opt-in rather than something every new site starts behind.
  • Each policy has a version, starting at 1 and increased by every change to it (setting identical settings changes nothing). Changing a policy takes effect on the next request; a site session is bound to the policy and version it was created under, so a change ends every session of that policy, and deleting a path policy ends its sessions.
  • A request a protected policy does not allow gets 401: browsers (requests that accept text/html) see the login page at the same URL, other clients a short plain-text body with WWW-Authenticate: Bearer. Neither says whether the path exists.
  • Owners read and set policies through the API (listPolicies, setPolicy, deletePolicy); wardlock policy set (added with A2.3) sets them from the CLI.
  • A protected policy that enables share links accepts a valid link token for a path in the link's scope (A2.2; see Share links), checked on every request that carries one.
  • Owners signed in to the dashboard are not automatically signed in to sites (they are on another domain). The site page's Open site button (D3.6) opens the site in a new tab: a public site (its / policy is public) at its address; any other site through a new share link created with createShareLink, labelled "Opened from dashboard", expiring in 10 minutes, with no use limit and scoped to the whole site. The link appears in the link list and access log like any other and can be revoked. The tab is opened from the click itself, so pop-up blockers allow it, and without a reference back to the dashboard; if the link cannot be created the tab is closed and the page shows an error. There is no separate owner session on sites.

A share link is https://<site host><path>?t=<token>.

Details (A2.2)
  • Operations. listShareLinks, createShareLink, revokeShareLink and rotateShareLink under /api/v0/sites/{site}/links (see packages/api-spec/openapi.yaml); links are addressed by public ID. Reading needs either API token scope, the rest manage. A link can be created whatever the site's policies are, and works wherever a protected policy that enables share links governs a path in its scope. The response to creation or rotation is the only time the token and its URL are shown, and it carries Cache-Control: no-store; the URL is https://<site host><path>?t=<token> (the path percent-encoded).
  • Validation. Labels are at most 200 characters with no control or invisible characters; an expiry must be in the future; a use limit is 1 to 1,000,000; the path follows the policy path rules. Anything else answers 400 invalid_share_link.
  • Sessions. A valid ?t= in a browser navigation (a GET that accepts HTML, with Sec-Fetch-Mode: navigate or no Sec-Fetch-Mode) creates a site session (one per policy, as SPEC "Site sessions" says) unless the request already carries a good session. Other clients (curl, bots, crawlers, HEAD, scripts' subresource fetches) and Authorization: Bearer get the content on every request that carries the token and create no session, so they leave no session rows behind. The session is stored only if the link still has the checked token and is unrevoked and unexpired at that moment, so a rotation or revocation that races the request leaves no session (the request is then refused). A refused token falls back to the request's cookie, so a stale ?t= in a bookmark does not undo a good session. The session lasts 7 days, capped at the link's expiry, and is checked against its link on every request, so revocation, rotation and expiry end it at once; it lets the visitor in only within the link's path scope.
  • Stripping. Only HTML responses to a token in the URL carry the strip script; they are sent whole and uncompressed, without ETag or range handling, with Cache-Control: private, no-store. Responses to Authorization: Bearer, non-HTML files and public pages (which ignore t, so a site's own ?t= keeps working) are never altered. A clean-URL redirect (/dir to /dir/) keeps the query, token included, so cookie-less clients that follow it still get the page.
  • Use limits. On GET (and HEAD), a use-limited link answers 200 with the "Click to view" page at the same URL, for every client. Its button posts a signed ticket, never the token, to /.wardlock/view; the ticket is valid for 15 minutes and only while the link keeps its current token. Like logout, the POST must come from a page on the same host (Origin naming the host, or without Origin, Sec-Fetch-Site: same-origin; 403 otherwise), so another site cannot post a ticket for a link of its own and swap the visitor's session. Each POST spends one use: a ticket is a confirmation, not a one-off pass, so pressing the button again (or reloading the POST) within 15 minutes spends another, just as opening the link again and pressing would. The POST answers 303 back to the requested path and query without t, setting the session. A ticket for a link that has expired, been revoked or rotated, or is out of uses answers 410 with "This link no longer works". Uses are counted only for use-limited links; a visitor who already has a session is let in without spending one.
  • Rotation keeps the link's public ID, so listings, the CLI and the access log keep naming the same link.
  • Revocation removes the link from listings; revoking or rotating it again answers 404.
  • CLI. wardlock share create prints the URL alone on stdout (its summary goes to stderr) or, with --json, the API's CreatedShareLink; rotate does the same with the new URL. These two outputs are the only places the CLI ever shows a token. --expires takes never (the default, as in the API) or a whole number of minutes, hours, days or weeks (30m, 1h, 7d, 2w); --path defaults to /; without --uses a link has no use limit. share list shows each link's ID, label, path, expiry, uses (N of M for use-limited links), last access and status. revoke and rotate take the link's ID, and a missing or already revoked link is a failure (exit 1) that says so. Bad flags (an --expires or --uses that does not parse, a malformed link ID) are usage errors (exit 2) found before contacting the server. With --json: create and rotate print the API's CreatedShareLink (shareLink, token, url); list prints {"site": <Site>, "links": [<ShareLink>…]}; revoke prints {"revoked": true, "site": <Site>, "publicId": "…"}. site is the API's Site object, as in wardlock list DOMAIN and teardown.
  • Last access is updated at most once a minute per link, by token or session requests.
  • Guessing. Malformed tokens, unknown public IDs, wrong secrets and other sites' tokens count against the client IP (30 per 15 minutes) and the site (300 per 15 minutes); over either, every token request from that IP, or for that site, answers 429 with Retry-After until the window ends. Once a site has had 150 refusals in the window, each network (IPv4 /24, IPv6 /48) is held to 30 of them, and over its share every token request from that network answers 429 the same way (A2.9). Existing sessions are unaffected. Expired, revoked and used-up tokens are refused without counting. Every refusal looks the same (the login page or the bare 401), and the log names only the public ID.

Password (A2.3)

Setting, signing in, limits and load
  • Setting (A2.3): owners set the password through the API or wardlock policy set --password (read from a prompt or stdin, never a flag). It is write-only: the API never returns it or its hash. A password has 8 to 1,024 bytes of valid UTF-8, counting at least 8 characters. Setting the password method again without a new password keeps the current one; a new password (even the same text) or switching the method off is a change, so it increases the policy's version and also revokes the policy's password sessions. Turning the method back on needs a new password. A sign-in whose check races a password change gets no session: the session is stored only while the policy still has the password and version that were checked.
  • Signing in: a browser on a protected page sees the password form on the login page at the same URL (401); /.wardlock/login?next=/path/ shows the form for that path's policy. The form posts to /.wardlock/login from the same host (other origins get 403) and checks the password of the policy governing next, so a path policy's password grants only that policy. Success answers 303 to next with the session cookie; next is only ever a path on the site (anything with a scheme, host, backslash, query, fragment or under /.wardlock/ returns to /). A wrong password, or a policy without one, shows the page again with the same message and status 401. Login pages are Cache-Control: no-store.
  • Limits: 10 attempts per client (IPv4 address or IPv6 /64) across all sites and 100 per site, per 15 minutes; every attempt counts, right or wrong, before the password is checked, so parallel guesses cannot slip under the limit. Over a limit the form answers 429 with Retry-After, even for the right password. Once a site has had 50 attempts in the window, each network (IPv4 /24, IPv6 /48) may make at most 10 of the site's 100, counting its earlier ones; attempts over that share answer 429 and do not count against the site (A2.9). A flood of guesses from six or more networks (one spending up to the 50-attempt mark, then five at 10 each) can still hold off password sign-in on that site for as long as it keeps going, and it reopens within one window once fewer than six keep going. Other methods, and sessions already issued, are unaffected; an owner under such an attack can add share links meanwhile.
  • Load: each check runs Argon2id at 19 MiB, so the server runs at most max(2, GOMAXPROCS/2) checks at once across all sites, after the limits. An attempt that waits more than a second for a slot answers 429 with Retry-After: 1 and is not checked, so a burst of attempts costs bounded memory (slots × 19 MiB) rather than one derivation per request.

Email code (A2.4)

Allowlist rules, codes, forms and limits
  • Allowlist. Each policy has one allowlist, shared by the email-code and OAuth methods. Its entries are exact email addresses and email domains: example.com matches addresses at that domain only, and *.example.com matches addresses at any of its subdomains but not at example.com itself (list both for both). It may also list GitHub organisations (A2.5): their logins (letters, digits and single hyphens, at most 39 characters, compared without case), which let in members signing in with GitHub through OAuth and nothing else. Entries are stored normalised (lower case, international domains in their xn-- form, sorted, without duplicates), at most 1,000 per policy. A protected policy keeps its allowlist when a method is switched off; making a policy public clears it. Any change to the allowlist is a change to the policy: it increases the version and also revokes the policy's email-code and OAuth sessions outright.
  • The visitor enters their address at /.wardlock/login (a protected page shows the same form at its own URL); if the policy governing the page lets the address in, Wardlock sends a 6-digit code valid for 10 minutes (5 attempts). The response is the same whether or not the address is allowed (status, page and the work done before answering: the allowlist check, the stored code and the mail all happen after the response), so the allowlist cannot be probed. Sending a new code replaces the previous one; a code opens only the policy it was sent for, works once, and stops working if the policy changes before it is used.
  • The visitor then enters the code; success answers 303 to the page with a site session whose subject is the verified address, bound to that policy and its version. A wrong, expired, replaced or used-up code shows the code form again with one message and status 401. The forms follow the password form's rules: same-origin posts only (403 otherwise), next only ever a path on the site, Cache-Control: no-store. Codes and addresses are never stored (only HMACs keyed by the server key) and never logged outside the dev:// sink, which logs the recipient and subject (never the code) and writes the message to .local/mail/.
  • Limits: sends are limited to 20 per client (IPv4 address or IPv6 /64), 5 per address (across all sites, so no inbox is flooded) and 200 per site, per hour; code entries to 30 per client, 10 per address and 300 per site, per 15 minutes, on top of each code's 5 attempts. Every well-formed request counts, allowed address or not, before the allowlist or the code is checked (a value that is not an address is refused first, uncounted). Over a limit the form answers 429 with Retry-After, even for the right code. Once a site has had half its sends (100) or entries (150) in the window, each network (IPv4 /24, IPv6 /48) is held to a tenth of the site's limit (20 sends, 30 entries), counting its earlier ones, and requests over that share do not count against the site (A2.9). A flood from six or more networks (one spending up to the mark, then five at their shares) can still hold off email-code sign-in on a site for as long as it keeps going, and it reopens within one window once fewer than six keep going; sessions already issued and other methods are unaffected. Mail goes through SMTP, or the dev:// sink in development (F0.4).

OAuth and OIDC (A2.5)

Enabling, the sign-in flow, limits, matched address and several tabs
  • Providers: GitHub, Google and generic OIDC (issuer URL plus client ID and secret), configured by the operator (DEPLOYMENT "OAuth and OIDC"), not through the API. A policy enabling OAuth uses the policy's shared allowlist: verified email addresses and email domains as for email codes, and GitHub organisations, which count only for accounts signed in with GitHub. Enabling OAuth needs at least one allowlist entry and at least one configured provider (400 invalid_policy otherwise). Owners set it through the API (setPolicy with the oauth method and allowlist.githubOrgs; an allowlist without githubOrgs keeps the current organisations) or wardlock policy set --oauth [--github-org LIST].
  • Signing in (A2.5): when the governing policy enables OAuth, the login page lists a "Continue with …" button per configured provider. Each posts (from the same host, as the password form does) method=oauth, the provider and next to /.wardlock/login, which answers 303 to the provider and sets the flow's binding cookie __Host-wardlock_oauth on the site host (host-only, Secure, HttpOnly, SameSite=Lax, 10 minutes). The provider returns to https://auth.<base domain>/callback/<provider>; the auth origin checks state (single use, 10 minutes), redeems the provider's code with the PKCE verifier, verifies an OIDC ID token's signature, issuer, audience, expiry and nonce, reads the account's verified emails (and, for GitHub with organisations listed, its active memberships of those organisations), checks the allowlist, and answers 303 to https://<site host>/.wardlock/callback?code=…. The code is single use (any attempt spends it), valid for 60 seconds, and bound to the site, the host, the policy and its version, and the binding cookie. The callback clears the binding cookie and, with a good code, sets the session (method oauth, subject the matched verified email, or the account's primary verified email when it matched by organisation) and answers 303 to next; otherwise it shows the login page again with "That sign-in has expired or was already used" (400). The auth origin never sets a cookie; its error pages (expired flow, cancelled sign-in, no verified email, account not allowed, provider failure) are no-store and link back to the site's login page when the flow is known. Starting is limited to 30 per client per 15 minutes, and at most 1,000 sign-ins may be in progress per site (50,000 in all; 429 beyond either), so a flood aimed at one site leaves the others working. Once a site has 500 in progress, each network (IPv4 /24, IPv6 /48) may start at most 100 per 10 minutes there (A2.9), so filling a site's 1,000 takes six networks (one starting 500 before the mark, five starting 100 each); OAuth sign-in reopens within one window once fewer than six keep going. Sign-ins in progress live in memory, so a restart drops them.
  • Which address (A2.5): every verified address the provider reports may match the allowlist (for GitHub, every verified address on the account, primary or not); the session's subject is the first one that matches, or the primary verified address when the account matched only by organisation.
  • Several tabs (A2.5): each start sets the binding cookie afresh, so starting a second OAuth sign-in on the same site, in another tab, replaces the first tab's cookie: finishing the first tab's sign-in then shows "That sign-in has expired or was already used", and it must start again.

Site sessions

Lifetime, logout, the OAuth cookie and the cookie format
  • Default lifetime 7 days, capped by the link's expiry. A POST to /.wardlock/logout ends it and clears the cookie; it must come from a page on the same host (Sec-Fetch-Site: same-origin when the browser sends it, and an Origin naming the host when it sends one other than null; Wardlock's pages send Referrer-Policy: no-referrer, so browsers post them with Origin: null and fetch metadata decides; a request with neither is refused; the same rule covers the login form and the /.wardlock/view button), so another site, a sibling site included, cannot sign visitors out (403 otherwise).
  • OAuth sign-in (A2.5) briefly adds a second host-only cookie, __Host-wardlock_oauth, binding a flow to the browser that started it; /.wardlock/callback clears it (SPEC "OAuth and OIDC").
  • The cookie is __Host-wardlock_site, a value signed with an HMAC of the server key that names the site, the policy, its version and the expiry, plus a 256-bit session token whose HMAC is the only copy stored. A session satisfies only the policy it was created under, and only while that policy enables the session's method. A tampered, forged, expired, revoked or stale cookie, or one from another site, is ignored and cleared. One cookie per site host: signing in under another policy replaces it.

AI-readable renditions (A2.6)

Generation, serving and headers in detail
  • When a deploy is activated, Wardlock converts every HTML file in it (.html or .htm, in any letter case) to Markdown and to plain text and stores both as blobs referenced from the deploy's manifest, so rollback restores them with the deploy. Activation fails rather than publish a deploy whose renditions could not be generated. HTML files over 5 MiB get no renditions (they are served as HTML even with ?format=) and never fail activation; the server converts at most two pages at a time, so rendering memory stays bounded however many deploys activate at once. Deploys activated before A2.6 have no renditions; requests for them serve the HTML until the site is deployed again.
  • ?format=md serves the Markdown (text/markdown; charset=utf-8), ?format=txt the text (text/plain; charset=utf-8), wherever the HTML file would answer: its own path, a clean URL, a directory's index.html, the SPA fallback and 404.html (with status 404). Renditions have their own ETag (their content hash), honour conditional and range requests like other files and are never precompressed. Only the first format parameter counts; any other value, a non-HTML file and an HTML file without renditions serve the file itself.
  • Renditions go through the same policy check, for the same path, as the HTML, so ?t=…&format=md (or Authorization: Bearer with ?format=md) works without cookies and a protected rendition is never sent without access; responses carry the same cache and protected-site headers. As for the page, only a browser navigation with ?t= gets a session cookie, and a rotated or revoked token gets nothing. The share-link script that removes t from the address bar is only ever added to HTML, never to a rendition.
  • HTML responses with status 200 that have renditions include Link: </path?format=md>; rel="alternate"; type="text/markdown", </path?format=txt>; rel="alternate"; type="text/plain". The link is the request path alone, without the request's query, so it never carries a share-link token.

Conversion contract

Both renditions hold the same content, read from the page's <body>; the Markdown keeps its structure as CommonMark with GitHub tables and strikethrough, and the text as plain lines. Each ends in one newline, or is empty when the page has no content. Input is read as UTF-8; each run of invalid bytes becomes U+FFFD, since renditions are served as charset=utf-8. Golden files for every page in fixtures/sites/ are in apps/server/internal/deploys/testdata/renditions/.

Element by element, links, token redaction and deep nesting
HTML Markdown Plain text
Dropped with their content: script, style, noscript, template, head, forms and their controls, iframe, object, embed, svg, math, canvas, audio, video, map, dialog, and any element hidden or aria-hidden="true" — —
<title>, when the body has no h1 # Title first The title first
h1–h6 # to ###### headings on one line The heading on its own line
Paragraphs and other blocks (p, div, section, nav, dt, dd, figcaption, caption…) Paragraphs separated by a blank line The same
br A backslash line break A newline
ul, ol (with start), nested lists - or N. items, nested content indented under the marker The same
pre A fenced code block, longer than any backtick run inside, with the language from a language-x or lang-x class The code as is
code, kbd, samp, tt Code spans The text
strong/b, em/i, del/s/strike **…**, *…*, ~~…~~ The text
blockquote > quoted The same
table (rows from thead, tbody, tfoot; the first row is the header; caption as a paragraph before it) A pipe table, | escaped in cells One line per row, cells separated by tabs
hr --- ---
a href [text](url), or <url> when it has no text or its text is its own URL (then the cleaned URL) text (url), or the cleaned URL alone in those cases
img with alt ![alt](url); images without alt are dropped [alt]
Text Whitespace collapsed as a browser lays it out; a backslash escapes \, `, *, _, [, ], <, >, ~, a & that would start an entity or character reference (&copy;, &#169;) and, at the start of a line, #, |, -, +, = and 1. Whitespace collapsed
  • Links and image sources are resolved against the page's address on the site's base-domain hostname (https://<name>.<base domain>/…, or http://…:port in local mode), so they are absolute; a same-origin <base href> is honoured. That origin (the base domain, and the port in local mode) is fixed when the deploy is activated: changing WARDLOCK_BASE_DOMAIN or the listen port changes it only for later deploys. Only http, https, mailto and tel references are kept: a link with any other scheme (javascript:, data:…) keeps only its text, and an image only its alt text. Credentials in URLs are dropped.
  • No tokens: a token is wl_ (in any letter case), a 10-character lowercase base32 public ID, _ and a 43-character base64url secret, not run on from or into other letters, digits, _ or - (so WL_CONNECTION_TIMEOUT is not one). The t parameter is removed from every link to the site itself, and every query parameter (by name or value) or fragment that holds a token, also after percent-decoding (as when nested in ?next=/a?t=…), is removed from every link. Any other token anywhere in a rendition (body text, link text, alt text, code, titles, URL paths) has its secret replaced with redacted, keeping the prefix and public ID that name it. Text is read as a reader sees it, so a token split by formatting (wl_abcdefghij_<b>…</b>) or a comment is redacted too, and so is a token-shaped string the rendition only forms by joining pieces. The token a visitor used to fetch a page never reaches a rendition, since renditions are generated at deploy time.
  • Pages nested more than 512 elements deep, which the HTML parser refuses, are kept as their text in one paragraph; structure nested more than 128 elements deep is kept as text.

Access log (A2.8)

Every request for a published site's content records one entry: time, path, status, how the visitor got in (or none when refused), the share link's public ID and label when it came through one, the client IP truncated to a prefix, and the User-Agent. Tokens are never stored. Owners read a whole site's entries or one share link's in the dashboard and the API; entries are kept 90 days by default, and those of a taken-down or suspended owner's site until it is available again. Configuration: DEPLOYMENT.

What is logged, reading it and retention

Every request for a published site's content (after the access decision, allowed or refused) records one entry: time, site, host, path without query string, status, how it got in (public, link, password, email, oauth, or none when refused), the share link's public ID when it came through a link, the client IP truncated to a configurable prefix (IPv4 /24 and IPv6 /48 by default, or not at all) and the User-Agent. Tokens are never stored: a token in the path or User-Agent is reduced to its public ID. /.wardlock/ pages, health checks and the dashboard are not logged. Entries are kept for a configurable number of days (90 by default, or forever) and outlive the site they name. Retention never prunes the entries of a taken-down site or a suspended owner's sites, which are evidence; they are pruned by age again once the site is restored or the owner reinstated, or once the site is torn down and no longer exists. Owners read a whole site's entries or one share link's, newest first, paged like other lists and readable with either API token scope: listSiteAccessLog (GET /api/v0/sites/{site}/access-log, A2.12) on the site's "Access log" page in the dashboard, and listShareLinkAccessLog (GET /api/v0/sites/{site}/links/{linkId}/access-log, D3.3) on the link's page. An entry that came through a link carries the link's label when it has one; a revoked link's entries and label stay readable. A missing site or link and another owner's answer the same 404. Only a site that exists is read, so a torn-down site's entries can no longer be listed, even after its name is reused; they wait for retention. There is no CLI command for either. Logging never slows serving: under database pressure entries are dropped and counted. Configuration: DEPLOYMENT.

Operator controls and abuse reports (A2.13)

The operator of a server can act on abuse from the server host, without the dashboard. A running server picks up each change within about 2 seconds.

Command (wardlockd admin …) Effect
list-owners, list-sites List owners (email, status, created, ID) and sites (name, owner, status, ID)
suspend-owner ADDRESS Signs the owner out, refuses their sign-in and API tokens, and makes all their sites unavailable
reinstate-owner ADDRESS Lets the owner sign in again; their tokens and sites work again
takedown-site SITE Every page of the site answers "This site is unavailable"; deploys, files and access log are kept
restore-site SITE Serves the same active deploy again
teardown-site SITE Deletes a taken-down site as an owner's teardown would
quota ADDRESS [flags] Shows an owner's quotas and usage; --storage, --sites, --deploys-per-day override them
moderation … Reviews screened files and exempts owners: Content screening

With WARDLOCK_ABUSE_EMAIL set, visitors can report a site from a "Report this site" link on Wardlock's own pages, which opens a form on the dashboard host (/report), never on the site's origin; reports are emailed to that address. Operating a server, the commands and the abuse procedure: DEPLOYMENT. What owners may publish: the acceptable use policy (O4.13).

Suspension, takedown and the unavailable page in detail
  • Identifying a site. SITE is a site name or any hostname the site answers on (its base-domain host or an active custom domain). teardown-site only accepts a taken-down site, so a typo cannot delete a serving one, and deletes only while the site is still taken down: a restore-site racing it wins, and the command then says the site is not taken down.
  • Suspending an owner sets suspended_at and, in one transaction, revokes every dashboard session (permanently) and denies approved but uncollected CLI logins. A new dashboard session or CLI-login approval is stored only if the owner is not suspended, checked in the same SQL statement, so nothing created while a suspension races it survives reinstatement. While suspended, API tokens are refused, and sign-in by email code (no code is sent, as for an unknown address), sign-in link and passkey fails with the same neutral error as a wrong code or passkey, so a suspension is not revealed to someone guessing addresses. Invitations to the address send nothing. All the owner's sites answer the unavailable page. Reinstating clears it: tokens and sites work again, and the owner signs in again (old sessions stay revoked).
  • Takedown sets taken_down_at. Every path on every host of the site (base-domain and custom domains), whatever the policy, cookie, share link or Bearer token, answers 403 with the unavailable page; only /.wardlock/health still answers. The page is neutral: it says the site was taken offline by the operator of this server, with no reason and no owner. It sends Cache-Control: no-store, X-Robots-Tag: noindex, Referrer-Policy: no-referrer, X-Frame-Options: DENY, a strict Content-Security-Policy with no scripts and Clear-Site-Data: "cache", "storage", so a service worker, cached pages or storage the site left in a visitor's browser cannot keep answering in its place (cookies are left alone; SECURITY-MODEL "Public instances and abuse"). A taken-down site with nothing published answers exactly like a missing site (the unknown-host 404).
  • What the owner can still do. Read the site, create and update policies, and create, rotate and revoke share links (revoking keeps the record). Creating deploys, uploading blobs to a pending deploy, activating, rolling back, deleting the site, adding or removing custom domains (verification still runs) and deleting a policy answer 409 site_unavailable. Domains are frozen so the site's hosts stay as they were reported, and deleting a policy would delete the sessions it issued, which record who had access. The dashboard hides these actions. Garbage collection skips every deploy of a taken-down site or a suspended owner's site, and access-log retention skips their entries, so nothing is lost while the operator investigates. Restore serves the same active deploy again.
  • Admin teardown deletes the site's domains, deploys, policies and links like an owner teardown; garbage collection later removes files nothing references, and the site's access-log entries are pruned by age again. The command runs in another process, so in automatic TLS mode the running server's unavailable-site watch, which remembers each unavailable site's custom domains, removes their certificates when the site disappears. Only a site taken down and torn down within the watch's 2-second interval, or while the server was stopped, keeps its certificates until they expire, unrenewed.
  • Storage. Migration 0008_operator_controls adds nullable owners.suspended_at and sites.taken_down_at (UTC Unix milliseconds). A running server polls for changes, so the commands work whether or not it is running.
  • Logging. Operator actions log IDs only (owner_id, site_id), such as "operator suspended owner" and "operator took down site".
Abuse reports (/report on the dashboard host)
  • Off by default. With WARDLOCK_ABUSE_EMAIL unset no page shows a report link and /report answers 404. Set to a plain address (no display name), a "Report this site" link appears in the footer of the login page, the share-link "Click to view" interstitial and the unavailable page, never on the site's own pages, leading to https://<dashboard host>/report?site=<host>&page=<path>.
  • Why the dashboard host. A reported site's own scripts and service workers run on its origin, so a form there could be scripted, faked or flooded by the very site being reported. The form is server-rendered by the edge before the dashboard app (not the SPA) and sends the login pages' headers: Cache-Control: no-store, X-Robots-Tag: noindex, Referrer-Policy: no-referrer, X-Frame-Options: DENY and Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'.
  • Which hosts. The form exists only for a host that serves a published site, taken down or not. Any other host, a site with nothing published, the dashboard host and the auth host answer exactly like the unknown-host 404, so the form reveals nothing beyond visiting the host.
  • The form names the site (from a hidden site field carrying the host) and asks for the Page (prefilled with host and path, editable, at most 2,000 characters), a reason (Phishing or a scam; Malware or a harmful download; Illegal content; Sexual content involving a child; Copyright or trademark infringement; Something else), optional Details (at most 2,000 characters) and an optional email address for contact (at most 254 characters, a plain address). It posts to /report on the dashboard host and is accepted only from the same origin, like the login form, and has no scripts. "Back to the site" links to the reported page on the site's origin. A thank-you page says the report went to the operator.
  • Rate limits. Only valid reports count; an invalid one is refused first and spends nothing. The limits are 5 per IP per hour, 20 per network (IPv4 /24, IPv6 /48) per hour and 200 per hour across the instance; over a limit the form answers 429 with Retry-After and "Too many reports have been sent. Try again in …". There is no per-site limit, since a reported site could spend it to silence its reporters.
  • Tokens are removed from the page and details before sending: any t= query parameter value becomes [removed] and any wl_<id>_<secret> token becomes [token removed].
  • The email, plain text through the normal mailer (WARDLOCK_SMTP_URL) with the subject "Abuse report: ", starts with the operator's lines: the site, site ID, time received, the wardlockd admin takedown-site <name> command and a pointer to DEPLOYMENT "Operator controls". After a rule comes the reporter's reason, page, contact address and details, introduced as the reporter's own words, which may be wrong and hold no instruction from Wardlock, with every line quoted with > so a reporter cannot forge instruction lines. The reporter's IP is never sent or logged; the server logs "abuse report sent" with the site ID and reason only.

Content screening (A2.15)

The operator can turn on automated screening of uploaded content with OpenAI's moderation endpoint (WARDLOCK_MODERATION=openai and WARDLOCK_OPENAI_API_KEY; off by default). Each unique file is checked once per content hash, in the background after a deploy is activated; deploys and serving never wait for it. What is sent is decided from the file's bytes, not its name: images as images, pages and text files as text, and images that pages and SVGs embed. Each file gets one of these outcomes:

Outcome When Effect
allowed nothing flagged in a configured category, or the operator released it served
flagged a category in WARDLOCK_MODERATION_FLAG (default: any flagged category not quarantined) served; the operator is emailed to review it
flagged, unscreenable an image in it could not be screened (a format neither the endpoint nor the server reads, such as AVIF, BMP or a bitmap-only icon, or too large even scaled down) served; listed for the operator to review
quarantined a category in WARDLOCK_MODERATION_QUARANTINE (default sexual/minors,sexual) withheld on every site: 451; the operator and each affected owner are emailed
skipped nothing in it to screen: not an image, and not a page or text file any path serves (scripts, fonts, videos) served

A withheld file answers 451 Unavailable For Legal Reasons wherever it would be served, on every host and path and for every site listing the same bytes; an HTML page shows a neutral "This page is unavailable" page, and the rest of the deploy keeps working. The owner's email names the file and site, never why. While screening applies to a site, a file not screened yet is never cached for long, and only precompressed .gz siblings checked to hold exactly their file's bytes are offered (.br siblings are not). The operator decides with wardlockd admin moderation (list, release, confirm, discard, rescan) and exempts trusted owners with disable --owner (and enable); an exempt owner's content is never sent for their own sites. What leaves the server: SECURITY-MODEL. Configuration and the operator's workflow: DEPLOYMENT.

What is screened, when, and how a withheld file is answered
  • From the bytes. Every file of a serving deploy is queued, and its first bytes decide what is sent (http.DetectContentType plus checks for formats it does not know), whatever its name or the name it was first listed under:
    • Images. A PNG, JPEG or WEBP of at most 20 MB is sent as it is (a PNG or JPEG that does not decode is unscreenable). A GIF is sent as PNG frames: every frame is decoded, each drawn over the ones before it, and up to 10 are sent: half evenly spaced from the first to the last, half picked at random on each screening (with crypto/rand), so which frames are judged cannot be predicted; frames are split out and decoded one at a time, a truncated file or trailing junk after the last whole frame is ignored, and an animation of more than 2,000 frames (or 1,000 million decoded pixels, or a minute of decoding) is unscreenable. A PNG, JPEG or GIF frame over 20 MB is decoded and sent scaled down (nearest neighbour, longest side 2,048 pixels, as a JPEG over white); a canvas over 40 million pixels is not decoded. An ICO or CUR is sent as the largest PNG image it holds. Anything else that is an image (AVIF, HEIF, BMP, TIFF, JPEG XL, a bitmap-only icon, a WEBP over 20 MB, an image over 64 MiB) is unscreenable.
    • Text. When any path serving the bytes is one the edge serves as displayed text (.html, .htm, .shtml, .xhtml, .xht, .xhtm, .svg, .txt, .text, .md, .markdown, .csv, .json, .xml, any case; these media types are pinned in the server, not read from the host's mime.types), or the bytes are HTML, XML or SVG, their text is sent: what the rendition conversion extracts from HTML (of the first 5 MiB), plus the text of SVG elements, all character data of an XML or SVG file, and plain text as it is; in pieces of at most 10,000 characters, at most 10, so text beyond 100,000 characters is not screened. Nothing is sent for empty text. The source of scripts and stylesheets is not screened as text (only the images they embed) unless such a path serves them.
    • Embedded images. data: URIs of any media type (base64, percent-encoded or plain, such as data:image/svg+xml,%3Csvg…, ;utf8,<svg… or data:;base64,…) anywhere in such a file, or in any other text file such as a stylesheet or script, are decoded and their bytes sniffed; images and markup are screened, anything else ignored. Empty payloads, ones a script completes at runtime ("data:image/png;base64,"+b) and ones never closed are skipped; only a non-empty payload claiming to be an image that does not decode makes the file unscreenable. What is screened: raster images by the rules above, SVGs as text plus their own embedded images, two levels deep. At most 16 per file and 64 MiB of decoded images in all; more makes the file unscreenable.
    • Gzip files are decompressed (one member, nothing after it, at most 1 GiB) and the SHA-256 of the result recorded, for the edge's sibling check below.
    • A hash screened without its text is queued again when a path serving it as text appears (unless it is quarantined already).
  • Once per hash. Results are stored per SHA-256 (moderation_results): later deploys, paths, sites and owners listing the same bytes reuse the verdict. An activation (or any change to a site's served state) queues that site's files at once; a sweep of 100 sites per pass catches the rest in turn (an exemption ended, a site restored). Only active deploys on serving sites are queued, never pending deploys, taken-down sites or suspended owners' sites, nor exempt owners' sites. The queue is persistent, so a restart resumes it.
  • Failures. Server errors and network failures are retried per file with exponential back-off from 30 seconds to 6 hours, never sooner than the endpoint's Retry-After (which also pauses every other attempt). A failure that is not the file's (a refused key, a rate limit, or a 400 that does not name the input, such as an unknown model) pauses the whole worker with the same back-off, never longer than 6 hours, logs at error and counts in wardlock_moderation_provider_errors_total; the file's attempts are not counted. Only a 400 naming the input (its parameter is the input, or its code is about an image) or a 413 makes that input unscreenable. At most two files are screened at once. wardlock_moderation_backlog counts files waiting.
  • The policy uses the endpoint's own per-category flagged decisions, not score thresholds. A file is quarantined if any flagged category is in the quarantine list, else flagged if any is in the flag list (any, when it is unset), else allowed; a file with an unscreenable part that is not quarantined is flagged unscreenable. Category names are OpenAI's.
  • Serving a withheld file. The edge checks the file's hash against an in-memory list before serving it, after the access decision, so a visitor without access sees the login page as before. The list is loaded before the server listens (it does not start without it), reloaded every 5 seconds, and updated at once by the worker; a reload never drops a hash the worker added while it ran. Every request that would serve it answers 451 with Cache-Control: no-store and no ETag: the file's own path, its clean URL, 404.html and the SPA fallback answering from another path, ?format=md|txt renditions, .br/.gz siblings of it, HEAD, Range and conditional requests, cookie-less share-link and Bearer requests, on base-domain and custom-domain hosts alike. An HTML file gets the neutral page (no scripts, the login pages' headers, a link to the site's home page); anything else the text "This file is unavailable.", with the protected-site headers on a protected site.
  • Serving while screening applies (screening on and the site's owner not exempt). A file not screened yet is sent with Cache-Control: public, no-cache (private, no-cache on a protected site) instead of the long-term caching a hashed asset may opt into, so no cache serves it without asking again once it is withheld. A .gz sibling is offered only after screening found it decompresses to exactly its file's bytes (the edge reloads the site when that is recorded, by a later rescan too); a .br sibling is never offered, since the server has no brotli decoder to check it, so the file itself or a checked .gz is sent instead. Requested by their own paths, siblings are served as ordinary (compressed) files.
  • Shared bytes. Blobs are shared across owners, so the verdict applies to the bytes wherever they appear; each owner whose site serves them is told.
  • Operator decisions. release makes a flagged or quarantined file allowed (served again everywhere); confirm keeps a quarantined file withheld and its stored copy indefinitely; discard keeps it withheld and lets garbage collection remove the stored copy once no deploy lists it; rescan queues it again. A rescan (or a text check queued later) that the endpoint cannot judge (nothing to send, rejected, unscreenable) keeps the verdict and decisions, and a rescan never lowers a quarantine while any input of the file could not be judged; otherwise a new verdict replaces the old one and keeps the operator's decision only while the status stays the same. Until discard or release, garbage collection keeps every quarantined file's stored copy even when no deploy lists it, as evidence. list shows flagged (including unscreenable) and quarantined files with every site and path serving them.
  • Notices. Notices go out once per change of status, never for a rescan that keeps it. For a newly quarantined file the operator gets a plain-text email at WARDLOCK_ABUSE_EMAIL (logged instead when unset) at once, with the hash, categories and sites; for sexual/minors it says to preserve and report the file and offers no release. Flagged files, unscreenable ones included, go in one digest per screening pass. For a newly quarantined file, each owner whose site serves it gets one email per site listing the withheld paths and the operator's contact address, never the categories; owners are not emailed for taken-down sites or while suspended.
  • Storage. Migration 0010_content_screening adds owners.moderation_exempt (0 for every owner, so new and existing owners follow the instance default) and moderation_results.

Reserved path

/.wardlock/ on every published site is served by Wardlock and can never be overridden by a deploy; policies never apply under it. It holds the sign-in pages (login, callback, logout, view), health and the proxy's tls-ask. The abuse report form is not here: it is on the dashboard host (A2.13).

Each path and when it answers

/.wardlock/ (matched in any letter case, as deploy validation rejects it in any case) on every published site is served by Wardlock and can never be overridden by a deploy: login, callback, logout, view (POST, the use-limited interstitial's button; A2.2), health and tls-ask (C1.9: answered only in behind-a-proxy mode and only to requests from a trusted-proxy address that carry no forwarding headers, so never to a visitor the proxy forwards, on any Host; 404 otherwise). F0.1 implements health; since C1.3 it answers only on sites with an active deploy (a host with nothing published is an unknown host). A2.1 adds login (GET, the login page for the site default; it lists sign-in methods as A2.3 to A2.5 add them) and logout (POST). On a taken-down site only health answers (A2.13); the abuse report form is served on the dashboard host, not under /.wardlock/, so a site's own scripts cannot reach it. Policies never apply under /.wardlock/.

Protected-site response headers

Every response a protected policy governs is kept out of search engines and shared caches (X-Robots-Tag: noindex, Referrer-Policy: no-referrer, Cross-Origin-Resource-Policy: same-origin, Cache-Control: private or no-store, Vary: Cookie). The login page also sends a restrictive Content-Security-Policy and X-Frame-Options: DENY. Wardlock never blocks AI fetchers in robots.txt; access is the policy's job.

Every header and when it is sent

X-Robots-Tag: noindex, Referrer-Policy: no-referrer, Cross-Origin-Resource-Policy: same-origin (A2.7), Cache-Control: private and Vary: Cookie on every response a protected policy governs, including its 404s and redirects; any such response that sets or clears a cookie, and any server error, sends Cache-Control: no-store instead; the login page and 401 send no-store and also Vary: Accept, since browsers get the page and other clients the bare 401. The share-link interstitial and the 429 for token guessing also send no-store, and the interstitial has the login page's Content-Security-Policy and X-Frame-Options: DENY. Every /.wardlock/ page sends the robots, referrer and resource-policy headers with Cache-Control: no-store whatever the policy (the proxy's /.wardlock/tls-ask question, answered before any site is looked up, sends only no-store), and the login page also sends a restrictive Content-Security-Policy and X-Frame-Options: DENY. That policy is default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'. When the governing policy enables OAuth (A2.5), form-action also names exactly the origins the provider buttons' redirect chain passes through, since browsers apply form-action to a form's redirects: each configured provider's authorisation endpoint (https://github.com, https://accounts.google.com, a generic OIDC issuer's origin and, once discovery has run, its authorization_endpoint's) and the auth origin. Never a whole scheme such as https:, so the password and email forms still post only to the site itself. The auth origin's error page sends the same policy with form-action 'self'. Wardlock never blocks AI fetchers in robots.txt; access is the policy's job.

CLI (C1.5, C1.7, C1.9)

Global flags: --server URL, --json (machine-readable output, no prompts, for CI) and --help. Exit codes: 0 success, 1 failure, 2 usage error. WARDLOCK_TOKEN overrides stored credentials for CI.

Global flags, choosing the server and errors

Global flags: --server URL (default from wardlock.json, then WARDLOCK_SERVER, then the server saved at login; login skips wardlock.json, and version uses only WARDLOCK_SERVER), --json (machine-readable output, no prompts, for CI), --help. Flags may come before or after arguments, and --json and --server also before the command (wardlock --json list); -- ends them. Exit codes: 0 success, 1 failure, 2 usage error (unknown flag or command, missing or malformed argument, invalid wardlock.json, or a confirmation that cannot be asked for). Without a TTY or with --json, the CLI never prompts. WARDLOCK_TOKEN overrides stored credentials for CI; it needs a server from --server, wardlock.json, WARDLOCK_SERVER or the last login. With a stored login, a different server from any of those is refused rather than sent the token. Errors go to stderr as wardlock: <message>, naming the server for network failures (could not reach <server>: connection refused) and suggesting the fix for 401 (log in again) and 403 (a token without the scope).

Command Behaviour
wardlock login [--server URL] Browser login described above.
wardlock logout Revoke and forget the stored token.
wardlock deploy [DIR] [DOMAIN] [--message M] [--spa] [--immutable-assets] Upload DIR (default from wardlock.json, else .) to DOMAIN (docs, docs.<base domain> or a custom domain). Shows progress (files hashed, uploaded, bytes) and prints the URL. Creates the site if needed. --spa turns on the SPA fallback and --immutable-assets long-term caching for hashed assets (see Serving) for this deploy.
wardlock list [DOMAIN] Without DOMAIN, the owner's sites with policy and last deploy; with DOMAIN, that site's deploys.
wardlock rollback DOMAIN [DEPLOY_ID] Reactivate the given deploy, or the previous one.
wardlock teardown DOMAIN [--yes] Delete the site after confirmation (--yes required without a TTY).
wardlock share create DOMAIN [--path P] [--label L] [--expires 7d|never] [--uses N] Create a link and print its URL once.
wardlock share list DOMAIN Links with label, public ID, scope, expiry, uses, last access.
wardlock share revoke DOMAIN LINK_ID Revoke by public ID.
wardlock share rotate DOMAIN LINK_ID Rotate and print the new URL once.
wardlock policy set DOMAIN [--path P] (--public | --protected [--links] [--password] [--email] [--oauth] [--allow LIST] [--github-org LIST]) Set the default or a path policy. Passwords are read from a prompt or stdin, never a flag. --allow replaces the allowlist's addresses and domains with comma-separated entries (repeatable; --allow '' empties them); without it they are kept. --github-org replaces its GitHub organisations the same way; without it they are kept.
wardlock domain add DOMAIN HOST [--wait] Attach custom hostname HOST to the site DOMAIN and print the records to create. --wait re-checks until HOST is active or 10 minutes pass.
wardlock domain list DOMAIN The site's custom hostnames with status, last check and failure reason.
wardlock domain verify DOMAIN HOST Check HOST's DNS now and print the result.
wardlock domain remove DOMAIN HOST [--yes] Detach HOST after confirmation (--yes required without a TTY) and remind the owner to delete the DNS record.
wardlock version [--server URL] Built in F0.1.

Only built commands appear in --help.

DOMAIN, deploy, list, rollback, teardown and logout in detail

DOMAIN (C1.5) is a site name or a hostname, in any letter case and with or without a trailing dot. A hostname is looked up among the owner's sites (<name>.<base domain> or a custom domain). When none answers to it, deploy creates the site if the hostname is <name>.<base domain> with <name> a valid site name. The API does not report the base domain, so the CLI infers it from the owner's existing sites (each hostname minus <name>.; they must all agree). With no sites to infer from, or a hostname that is not one label under the base domain, deploy stops with an error suggesting the bare name, which always works. Another owner's site is reported exactly like a missing one.

deploy (C1.5) walks DIR in lexical order and sends each path relative to it with forward slashes, in NFC, so a name the file system stores decomposed (macOS) is published as browsers request it. File names that are not valid UTF-8, or that contain \ on Linux or macOS, are refused before anything is sent. Symbolic links (including ones to directories) and other non-regular files are skipped with a note on stderr, and nothing outside DIR is read; DIR itself may be a symbolic link. --message is at most 500 characters (Unicode code points, as the API counts them, not bytes). Files are hashed in parallel, then the missing ones upload eight at a time. An upload that fails with a network error or 500, 502, 503 or 504 is retried twice (after 1 and 2 seconds); 429 is retried after its Retry-After seconds, capped at 30. Other failures, 501 included, stop the deploy before activation, so the live deploy never changes. A file that changes between hashing and upload stops the deploy with a request to run it again. A deploy whose files the server already holds uploads nothing, but still records a new deploy in history. Progress (files hashed, files and bytes uploaded) goes to stderr: one line redrawn with a carriage return on a terminal (no escape sequences, so older Windows consoles show it correctly), otherwise one line per phase. The result, ending with the site's URL, goes to stdout.

list (C1.5) shows the site's default policy as public or protected (a site with no / policy is public) and times in local time. rollback takes a full deploy ID or the short ID list shows (the last eight to twelve characters, in any case); an ambiguous short ID is refused. teardown asks, on a terminal, for the site's name to be typed; anything else cancels with exit status 1 and nothing deleted. Given --server (for example as a global flag), logout refuses, changing nothing, when the stored login is for another server.

--json output (C1.5)

With --json, stdout carries exactly one JSON object and nothing else; on failure stdout is empty and the exit status and stderr say why. Site, Deploy, Activation and Domain are the API schemas in packages/api-spec/openapi.yaml, so optional fields (activeDeployId, activatedAt, message) are absent when unset. New fields may be added; existing ones keep their meaning.

Output per command
Command Output
deploy An Activation (site, deploy) plus siteCreated (boolean), uploaded ({files, bytes}: what this run sent; {0, 0} when nothing changed) and skipped (paths not deployed because they are not regular files).
list {sites: [Site + policy]}: each Site with policy, public or protected.
list DOMAIN {site: Site, deploys: [Deploy]}, newest first.
rollback The Activation: the site and the deploy now live.
teardown {deleted: true, site: Site}: the site as it was before deletion.
login {server, email, tokenId, publicId, name, scopes, storage}; storage is keychain or file. The token itself is never printed.
logout {loggedOut, server}; server is absent when nothing was stored.
version {version, server?: {url, version}}.
domain add {site: Site, domain: Domain}: the domain as added, or with --wait as last checked. --wait that times out is a failure (exit 1, empty stdout).
domain list {site: Site, domains: [Domain]}, by hostname.
domain verify {site: Site, domain: Domain} after the check. It exits 0 whenever the check ran, whatever it found; 429 checked_recently (checked moments ago) is a failure.
domain remove {removed: true, site: Site, hostname}; the reminder to delete the DNS record goes to stderr.

wardlock.json and .wardlockignore (C1.5)

wardlock.json in the directory you run the CLI from:

{
  "server": "https://wardlock.example.com",
  "site": "docs",
  "dir": "dist",
  "spa": false,
  "immutableAssets": false
}

All fields are optional; flags and arguments override them (--spa=false overrides "spa": true, --immutable-assets=false overrides "immutableAssets": true). server applies to every command except login, logout and version; site and dir (relative to this directory) only to deploy, since rollback and teardown always name their site. Unknown fields, wrong types and anything but one JSON object are usage errors naming the file, so typos are caught.

.wardlockignore uses .gitignore syntax, relative to the deployed directory. .git/, node_modules/, .env*, .DS_Store and the config files are never deployed; other dotfiles are left out unless re-included with !, except .well-known/, which is included.

.wardlockignore in detail

.wardlockignore uses .gitignore syntax, relative to the deployed directory: # comments, ! to re-include, a trailing / for directories only, a leading or inner / to anchor to the directory, *, ?, ** and bracket expressions ([abc], [a-z], [!abc] or [^abc], and POSIX classes such as [[:alpha:]]), which never match /; an unclosed [ is a literal, as in Git. Patterns are normalised to NFC like paths. Only the file at the top of the deployed directory is read. As in Git, a file inside an ignored directory cannot be re-included. Always ignored, at any depth and even if re-included: .git/, node_modules/, .DS_Store, wardlock.json, .wardlockignore, .env*. Other dotfiles and dot-directories are ignored unless listed with ! (for example !.htaccess), except .well-known/, which is included.

Not in v1

Teams and shared ownership, an MCP server, hosted service features (billing, and abuse handling beyond the operator controls of A2.13), Postgres or other database backends, multiple server instances. See ROADMAP "Not planned yet". File bodies can be stored in an S3-compatible bucket (C1.11; DEPLOYMENT "S3-compatible blob storage").