# Dynamic sites Sites are the forge's hosting: static files plus dynamic Workers, per-site access control, branch previews and agent-friendly scratch deploys. They replaced the earlier static Pages feature, which was removed (migration `0049`). Everything is served on a separate registrable domain, `SITES_DOMAIN` (for example `gitforstatic.dev`). Code lives in `src/server/sites/*`. The data model is in `migrations/0012_sites.sql`. ## Research summary | Primitive | What it gives us | Decision | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Workers for Platforms dispatch namespaces | Each customer script is uploaded once through the API and invoked from a dispatch Worker with `env.DISPATCHER.get(name)`. Outbound Workers and custom limits (`cpuMs`, `subRequests`) are set per call. Scripts are long-lived and versioned by Cloudflare. | Supported later as a backend for very large sites. Uploading a script per deploy needs an account API token in the request path, and every preview would become a separate script to garbage-collect. | | Dynamic Worker Loader (`worker_loaders` binding) | `env.LOADER.get(id, () => code)` builds an isolate from modules provided at runtime and caches it by `id`. `env` contains only what we pass, `globalOutbound` is either `null` (no network) or a `Fetcher` we control, and `limits: { cpuMs, subRequests }` caps each isolate. | **Chosen runtime.** Deploys are immutable module sets in R2, the loader `id` is `deploymentId:envVersion`, so a rollback or env change is just a new id. Nothing outside the bundle needs to be uploaded. | | Workers Builds and Previews with Artifacts | Cloudflare builds a Worker when an Artifacts repo is pushed, deploys the production branch, and gives every other branch an isolated preview. | Same model, implemented in-forge: the `cf.artifacts.repo.pushed` event already drives `PushWorkflow`, which now also calls `deploySitesForPush`. Building is left to CI (workflow-artifact source), so the forge never runs a bundler on untrusted input. | | Cloudflare for SaaS custom hostnames | Custom hostnames on a SaaS zone whose fallback origin is this Worker, with TXT DCV certificates. | `sites/domains.ts` (zone `CUSTOM_DOMAINS_ZONE_ID`), plus a forge-issued TXT ownership proof (`_gitfor-site.`). | | Public Suffix List | Browsers treat each label below a PSL entry as its own site: no shared cookies, no `document.domain` merging, separate storage partitions. | Submit `gitforstatic.dev` to the private section. See "PSL submission". | ### Runtime and isolation model - **Static fast path.** A deployment without `_worker.js` is served directly by the dispatcher from the manifest in KV and content-addressed objects in R2 (`sites/static.ts`). No isolate is spun up. - **Dynamic path.** A deployment with `_worker.js` (one module) or a `_worker.js/` folder (`index.js` plus sibling `.js`, `.mjs`, `.wasm`, `.json` and `.txt` modules) is a dynamic site. This is the Cloudflare Pages "advanced mode" convention, so existing projects work. Worker modules are never served as static files. - The dispatcher calls `env.LOADER.get(...)` with: - `env`: only the site's own variables and decrypted secrets, plus `ASSETS`, a loopback `SiteAssets` entrypoint bound to the deployment (`ctx.exports.SiteAssets({ props })`). There are no D1, KV, R2, queue or secrets-store bindings. - `globalOutbound`: `ctx.exports.SiteOutbound({ props })`. It strips `cf-*`/`x-forwarded-*` headers, doesn't follow redirects, and refuses the forge host, the sites domain, loopback, private, link-local and `.internal`/`.local` hosts, and non-HTTP(S) schemes. Scratch sites get `globalOutbound: null`, so `fetch()` and `connect()` throw. - `limits`: `cpuMs` and `subRequests` from `SITE_LIMITS` (claimed) or `SCRATCH_LIMITS` (scratch). - User code only ever sees a sanitized request: the dispatcher strips its own access cookies and the OIDC state cookie, and adds `x-gitfor-user` when the visitor signed in. Any `Set-Cookie` from user code that targets a reserved cookie name is dropped. - The dispatcher runs **before** user code for every request. Access control can't be bypassed by the site. ## Domains | Site | Host | | ---------------------------------------------------------------------- | --------------------------------------------------------------- | | User or org site (repo named after the handle, or `.`) | `.` | | Project site | `--.` | | Branch preview | `----.` | | User-site branch preview | `----.` | | Pull request preview (open same-repo PR #N) | `pr-----.` | | Commit preview (opt-in per site, last 5 per PR) | `----.` | | Scratch site | `--scratch.` (`scratch` is a reserved account name) | Each label is slugged to `[a-z0-9-]`, runs of `-` collapse to one, and labels over 63 characters are truncated with a hash suffix. A slug therefore never contains `--`, which makes `--` an unambiguous separator. D1 enforces unique labels, so if two sources ever slug to the same label, the second deploy fails instead of taking over. **Pull request and commit aliases.** `pr---` is a KV pointer, not a deployment: whenever a branch preview goes live, every open pull request of the same repository whose head is that branch has its alias moved onto the new deployment, so the link in the pull request comment always shows the latest push. Fork pull requests never get one, because fork code never deploys (PAGES-1). When the pull request closes or merges the alias is deleted (the close hook, with the repository's RepoState alarm as a safety net). A site may also turn on commit previews: each previewed commit of an open pull request also gets an immutable `--` host pinned to that deployment, and only the newest 5 per pull request are kept. Aliases are tracked in `site_pr_aliases` so unpublishing, relabeling or deleting the site removes them. A branch whose slug looks like an alias head (`pr-` or seven hex characters) previews at `branch---` instead, so it can never take an alias host. Over-long labels go through the same `fit()` truncation and hash as every other label. **Why subdomains, not paths.** The removed Pages feature served every project and PR preview under one owner origin (PAGES-1). A path gives no isolation: `localStorage`, IndexedDB, service workers, non-HttpOnly cookies and same-origin `fetch` are all shared. One label per repo and per preview gives each its own origin. With the PSL entry, each also gets its own _site_ (cookie and storage partition), so `a--alice.gitforstatic.dev` can't read or toss cookies for `b--alice.gitforstatic.dev` or `bob.gitforstatic.dev`. Previews of a fork branch never deploy, because they come from the base repo's branches and trusted runs only. `sitesDomain()` refuses a domain that equals, contains or shares an approximate registrable domain with the forge host. The forge's session cookie is a host-only `__Host-` cookie on another registrable domain, so browsers never send it to a site, and site code can't read it. The e2e test checks this. ### PSL submission `gitforstatic.dev` is registered for this. Open a pull request against [`publicsuffix/list`](https://github.com/publicsuffix/list) with exactly this content. **Patch** to `public_suffix_list.dat`, in the `===BEGIN PRIVATE DOMAINS===` section. Keep it in alphabetical order by the organization line: ```text // gitfor : https://gitfor.dev // Submitted by gitfor maintainers gitforstatic.dev ``` **PR title:** `Add gitforstatic.dev (gitfor sites)` **PR body:** ```markdown - [x] Description of Organization - [x] Robust Reason for PSL Inclusion - [x] DNS verification via dig - [x] Run Syntax Checker (make test) - [x] Each domain listed in the PRIVATE section has and shall maintain at least 2 years remaining on registration **Organization:** gitfor is an open-source git forge (https://gitfor.dev). gitforstatic.dev hosts user and agent sites built from repositories on the forge. **Reason for inclusion:** every direct subdomain of gitforstatic.dev (``, `--`, `----`, `--scratch`) serves independent, untrusted content from a different user or anonymous agent, including server-side code. Without the entry, one site can set cookies for `gitforstatic.dev` that every sibling receives (cookie tossing, session fixation of sign-in cookies), and browsers treat siblings as same-site for SameSite cookies, storage partitioning and the Storage Access API. We already serve each site on its own origin, strip `Domain=` from server-set cookies and HMAC-bind our own access cookies to the host, but client-side `document.cookie` writes can only be confined by the PSL. The forge itself runs on a different registrable domain, so no forge cookies are affected. There are no deeper levels: we never issue `a.b.gitforstatic.dev`. **Number of users:** every repository with a site, plus short-lived scratch sites (24 h). **DNS verification:** dig +short TXT _psl.gitforstatic.dev "https://github.com/publicsuffix/list/pull/" ``` Before you open the PR: 1. Create the `_psl.gitforstatic.dev` TXT record. It holds the PR URL, so update it once the PR number is known. 2. Confirm the registration runs at least two years past the submission date, and keep auto-renew on. 3. Run `make test` in the PSL repo. After the merge, browsers pick the entry up over several releases, so keep the server-side defenses below permanently. **Hardening until (and after) inclusion:** - **Dispatcher cookies:** host-only (no `Domain`), `__Host-` on HTTPS. Their HMAC binds them to the host, site and access version, so a tossed cookie never verifies. - **User `Set-Cookie` headers:** rewritten by `sanitizeSetCookie`. `Domain=` is stripped, which makes the cookie host-only. A `Domain` that names a parent or a foreign host drops the cookie entirely. Reserved `gitfor_*` names are always dropped. - **Residual gap:** script on a static or dynamic page can still run `document.cookie = "x=1; domain=gitforstatic.dev"`, and only the PSL entry stops that. Such cookies can't affect sign-in, because reserved cookies are stripped before user code and MAC-verified by the dispatcher. Sites that care should only trust cookies they set host-only. ### Custom domains `setSiteDomain` uses `sites/domains.ts` and keeps every PAGES-2 fix: - TXT DCV certificates. - No host mapping until verified. - Unverified claims expire after 72 h. - Forge-related hosts (`isForgeRelatedHost`) and hosts under the sites domain are refused. On top of that, the site gets a random `domain_token`, and `refreshSiteDomain` only writes the KV mapping when the hostname and certificate are active **and** `_gitfor-site.` TXT equals the token. The TXT record is resolved over DNS-over-HTTPS. A domain is unique across `sites`, and a stale unverified claim (72 h) can be taken over by another site. Scratch sites can't add a domain. Custom domains are a paid add-on ($5 per domain per month, at most 25 per account; docs/billing.md). Adding one needs an active add-on, or, without billing, the plan's `customDomains` flag or an admin grant. The Cloudflare custom hostname is always deleted when the claim stops being useful: an unverified claim older than 72 h (cron, `domain-sweep.ts`; a domain that was ever fully verified has `domain_verified_at` and is never expired this way), the add-on lapsing past its 7-day grace, the site being disabled, and the repository, organization or account being deleted. ## Access control The dispatcher enforces the mode stored in the site's KV metadata (`sites:meta:`). - **public**: no check. - **password**: a scrypt hash (`N=16384, r=8, p=1`). The form at `/__gitfor/login` is rate-limited per site and IP through `AUTH_LIMITER`. - **gitfor**: OIDC authorization-code flow with the forge as the IdP. 1. The site host sets a random `state` cookie and redirects to `/sites/authorize?site&host&state`. 2. The forge requires a session. It checks that `host` belongs to the site and that the user is a repo reader (`audience=readers`) or an org member or collaborator with write access (`audience=members`). 3. The forge shows a consent screen ("continue as @user"), confirmed by a same-origin POST, so a site can't silently learn who a visitor is. 4. The forge stores a single-use code in KV (60 s) and redirects to `https:///__gitfor/callback`. 5. The dispatcher consumes the code, checks host, site and state, and sets the site cookie. KV get-then-delete isn't atomic, so a code could be redeemed twice within 60 s. That is accepted, because redeeming one also needs the browser's host-bound, MAC'd state cookie. The forge session cookie never leaves the forge origin. The token exchange happens in-process, because the IdP and the relying party are the same Worker. Third-party RP discovery is a follow-up. - **oidc**: an external IdP. - Flow: discovery, then authorization code with PKCE (S256), `state` and `nonce`. The `id_token` is verified against JWKS (RS256 or ES256), with checks on `iss`, `aud`, `exp` and `nonce`. The email must be verified and match the allow list (`a@x.com` or `@x.com`). - The issuer must be HTTPS on a public host. - The client secret is encrypted with `SECRETS_KEY`. The access cookie is `__Host-gitfor_site` (`gitfor_site` on plain-HTTP dev): - It is HttpOnly, `SameSite=Lax`, `Path=/`, and has no `Domain`. - Its value is `v1....`, where the HMAC uses `SITES_COOKIE_SECRET` (falling back to `SECRETS_KEY`) over the host as well. A cookie copied to another host doesn't verify. - Changing the access mode or password bumps `access_version`, which is in the MAC, so existing cookies are revoked. Site metadata is edge-cached in KV for 30 s, so revocation and disabling can take up to about 30 s per colo. - `next` redirects are resolved against a dummy origin and rejected unless they stay same-origin. Control characters and backslashes are refused. - Gated static responses are `Cache-Control: private` with `Vary: Cookie`. ## Deploys - `deploySitesForPush`, called from `PushWorkflow`: - The configured branch deploys to production. - Any other branch of the same repo deploys a preview when previews are on. The cap is 5 previews per push and `deploysPerSitePerHour` per site. - Open same-repo PRs from that branch get one comment with the preview link. `site_preview_comments` stores which PRs have one, so the comment isn't repeated. - `deploySitesForRun`: a successful CI run's artifact. It keeps the PAGES-1 guard: `source_repo_id === repo_id` and `untrusted === 0`. - A run on `refs/heads/` deploys production (the site branch) or that branch's preview. Merge-queue refs never deploy. - A `pull_request` run (`refs/pull//head`) deploys the site's artifact (default `github-pages`) as the preview of the pull request's head branch, the same host a push or branch run of that head uses. It only deploys while the pull request is open, its head is in the same repository, and the run built the current head SHA, so a stale run can't move the preview back. It never deploys production, even when the head is the site branch. - Because both paths share one label, the newest deployment wins (`activateSiteDeployment` supersedes older ones). Activation moves the `pr-` alias, adds a commit preview when enabled, and posts the preview comment once. Closing or merging the pull request drops the aliases as before. - Preview variables follow the SHA's pusher on `refs/heads/` (CI-P2), not whoever triggered or reran the run. - `SitesDeployWorkflow` uses `sites/collect.ts` (bounded git walk, content-addressed upload, streaming tar extraction with size, count and path checks). It then splits worker modules from static files and writes `sites:manifest:` and the slot pointer `sites:slot: