# Dynamic sites Sites extend the static Pages feature (`src/server/pages/*`) with dynamic Workers, per-site access control, branch previews and agent-friendly scratch deploys. 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/1300_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. | Reused from `pages/domains.ts`, 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, exactly like Pages. 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 | `----.` | | 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. **Why subdomains, not paths.** Pages 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` reuses `pages/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 Pages or 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 `pages_sites` and `sites`, and a stale unverified claim on either side (72 h) can be taken over from the other. Scratch sites can't add a domain. ## 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`. - `SitesDeployWorkflow` reuses `pages/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: