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/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. | Reused from pages/domains.ts, plus a forge-issued TXT ownership proof (_gitfor-site.<host>). |
| 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.jsis 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.jsplus sibling.js,.mjs,.wasm,.jsonand.txtmodules) 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, plusASSETS, a loopbackSiteAssetsentrypoint 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 stripscf-*/x-forwarded-*headers, doesn't follow redirects, and refuses the forge host, the sites domain, loopback, private, link-local and.internal/.localhosts, and non-HTTP(S) schemes. Scratch sites getglobalOutbound: null, sofetch()andconnect()throw.limits:cpuMsandsubRequestsfromSITE_LIMITS(claimed) orSCRATCH_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-userwhen the visitor signed in. AnySet-Cookiefrom 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 <handle>.<domain>) |
<handle>.<domain> |
| Project site | <repo>--<handle>.<domain> |
| Branch preview | <branch>--<repo>--<handle>.<domain> |
| User-site branch preview | <branch>--<handle>--<handle>.<domain> |
| Scratch site | <id>--scratch.<domain> (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 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:
// gitfor : https://gitfor.dev
// Submitted by gitfor maintainers <psl@gitfor.dev>
gitforstatic.dev
PR title: Add gitforstatic.dev (gitfor sites)
PR body:
- [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 (`<handle>`, `<repo>--<handle>`,
`<branch>--<repo>--<handle>`, `<id>--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/<this PR number>"
Before you open the PR:
- Create the
_psl.gitforstatic.devTXT record. It holds the PR URL, so update it once the PR number is known. - Confirm the registration runs at least two years past the submission date, and keep auto-renew on.
- Run
make testin 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-Cookieheaders: rewritten bysanitizeSetCookie.Domain=<own host>is stripped, which makes the cookie host-only. ADomainthat names a parent or a foreign host drops the cookie entirely. Reservedgitfor_*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.<host> 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:<id>).
- public: no check.
- password: a scrypt hash (
N=16384, r=8, p=1). The form at/__gitfor/loginis rate-limited per site and IP throughAUTH_LIMITER. - gitfor: OIDC authorization-code flow with the forge as the IdP.
- The site host sets a random
statecookie and redirects to<forge>/sites/authorize?site&host&state. - The forge requires a session. It checks that
hostbelongs to the site and that the user is a repo reader (audience=readers) or an org member or collaborator with write access (audience=members). - 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.
- The forge stores a single-use code in KV (60 s) and redirects to
https://<host>/__gitfor/callback. - 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.
- The site host sets a random
- oidc: an external IdP.
- Flow: discovery, then authorization code with PKCE (S256),
stateandnonce. Theid_tokenis verified against JWKS (RS256 or ES256), with checks oniss,aud,expandnonce. The email must be verified and match the allow list (a@x.comor@x.com). - The issuer must be HTTPS on a public host.
- The client secret is encrypted with
SECRETS_KEY.
- Flow: discovery, then authorization code with PKCE (S256),
The access cookie is __Host-gitfor_site (gitfor_site on plain-HTTP dev):
- It is HttpOnly,
SameSite=Lax,Path=/, and has noDomain. - Its value is
v1.<siteId>.<exp>.<subject>.<hmac>, where the HMAC usesSITES_COOKIE_SECRET(falling back toSECRETS_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. nextredirects 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: privatewithVary: Cookie.
Deploys
deploySitesForPush, called fromPushWorkflow:- 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
deploysPerSitePerHourper site. - Open same-repo PRs from that branch get one comment with the preview link.
site_preview_commentsstores 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_idanduntrusted === 0.SitesDeployWorkflowreusespages/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 writessites:manifest:<id>and the slot pointersites:slot:<label>.- Rollbacks re-point the production slot at an older
active/supersededdeployment. Manifests and objects are immutable. - Variables and secrets live in
site_env, encrypted withSECRETS_KEY(at most 64 entries, 4 KiB each). Secrets are write-only in the UI. Every change bumpsenv_version, which changes the loader isolate id.
Lifecycle and reconciliation
The dispatcher reads only KV, so D1 changes must be mirrored:
deleteRepositorycallsteardownSiteForRepobefore the cascade. It deletes every label slot, the meta, the custom-host mapping and the SaaS hostname.syncSiteForReporuns after repo settings changes, and on repo transfer.syncSitesForOwnerruns after an account rename. They do two things:- Re-derive the label. Previews are dropped and rebuilt on the next push. If the new label is taken, the site is disabled rather than left serving under a stale namespace.
- Force sign-in (
gitfor) when a repo with a public site becomes private.
- Pages and private repositories: when a repo with Pages becomes private, its active Pages deployments are marked superseded, its queued and building ones fail, and its KV slots are deleted. A deployment can only become active while its row is still queued or building and the repo is public. Rollback is refused while the repo is private.
- Making it public again republishes nothing. The owner republishes with a new deploy or an explicit rollback to an earlier deployment.
- Approved content only: content built while the repo was private never becomes public, because Pages can't build or activate a private repo. Only deployments that were published while the repo was public can be rolled back to.
- Why no auto-republish: this is stricter than "re-serve the last published deployment automatically", and it avoids surprising the owner.
- Deleting a branch removes its preview.
reconcileSites, run by cron:- Walks repo sites in batches through the same sync.
- Re-checks a few custom domains per run (ordered by
domain_checked_at, which never touches the claim's staleness clock), so a lapsed hostname or TXT proof is unmapped automatically. - Deletes KV meta whose D1 row is gone. A pointer whose site no longer exists may be taken over by a new site. Preview slots of such a site are left as inert pointers. Nothing serves them without meta.
- Deploys that fail with an unexpected error are marked
failedby the Workflow, so they don't hold their label.
Scratch (agent) deploys
Three ways in. None of them need an account; each returns a live URL, a TTL and a one-time claim token.
One-liner (git):
eval "$(curl -s -X POST https://gitfor.dev/api/v1/scratch | jq -r '"R=\(.remote) T=\(.pushToken) U=\(.url) C=\(.claimUrl)"')" \
&& git push "https://x:$T@${R#https://}" HEAD:main && echo "live at $U claim at $C"
POST /api/v1/scratch with an empty body creates scratch/<id> and returns { url, remote, pushToken, repo, claimUrl, claimToken, expiresAt }.
- Every push deploys.
maingoes to<id>--scratch.<domain>, and any other branch to<branch>--<id>--scratch.<domain>. - The push token is the HTTP Basic password, with any username.
One-liner (tarball):
tar czf - -C dist . | curl -s --data-binary @- -H 'content-type: application/gzip' https://gitfor.dev/api/v1/scratch
POST https://<SITES_DOMAIN>/__scratch takes the same body. Adding ?mode=git there creates a git scratch repo.
JSON: {"files": {"index.html": "..."}} to /api/v1/scratch.
MCP: three tools, defined in src/server/mcp/tools/sites.ts.
create_scratch_sitetakes{ files }.create_scratch_reporeturns a remote and push token.deploy_sitetakes{ owner, repo, ref? }for claimed and normal repo sites, and needscontents:write.
All three wrap src/server/sites/api.ts.
/api/v1/scratch is token-only: it never reads cookies, so it is exempt from the same-origin check. /__scratch is on the sites apex, which has no forge authority at all.
Git scratch repositories:
- They are private repos under the reserved
scratchorg, which has no members. - Only the push token reaches them.
scratchGitPermissionin the git proxy checks it in constant time againstsites.push_token_hashand refuses it once the site has expired, been claimed or been disabled. - Pushes still go through the normal hidden-ref filter, branch and tag protection, and secret push protection.
- On top of that,
scratchPushPolicyallows onlyrefs/heads/*, at most 10 refs per push and 10 MiB of pack per push. The pack limit is counted on the stream, so chunked pushes are capped too. - Deploys use the scratch file and size caps (500 files, 25 MiB).
Caps:
- Bodies up to 10 MiB, 500 files and 25 MiB unpacked.
- 5 scratch sites per IP per day, and the
HEAVY_LIMITERper IP. - Requests go through
GIT_LIMITERper site (120 per minute). - Workers run with
cpuMs: 10,subRequests: 0andglobalOutbound: null. - No custom domains and no password or OIDC gating while unclaimed.
POST /__gitfor/reporton any site records an abuse report. Reporter IPs are stored as an HMAC. 3 reports from distinct IPs disable a scratch site, and an admin can disable any site.
Claiming: a signed-in user opens claimUrl, and a same-origin POST confirms it (isSameOriginRequest in src/server.ts).
- The token hash is compared and cleared in one conditional update, so a claim token works once.
- An upload site's label becomes
<id>--<handle>. - A git scratch site's repository moves to the user, public by default. A checkbox keeps it private, in which case the site switches to "Sign in with gitfor".
syncSiteForRepothen moves the site to<id>--<handle>. - The push token is revoked, the TTL is cleared, claimed limits apply, and
onScratchClaimedlets metering move the usage.
Sweep (sweepScratchSites, run by cron):
- Expired, unclaimed sites lose their KV entries and D1 row first, so they stop serving immediately.
- Scratch repositories whose site is gone are then deleted by
sweepOrphanScratchReposafter a 10-minute grace period. That also catches repos left behind by a sweep that was cut off. - Content-addressed R2 objects are shared and left for a future GC, like Pages.
Billing model
| Tier | What you get |
|---|---|
| Scratch (anonymous) | Free and time-boxed. Live for 24 h with the hard caps above. |
| Claimed, free | Static sites free within the Pages limits. Dynamic sites include a monthly allowance (for example 100k requests and 1M CPU-ms per account). |
| Usage-based | Beyond the allowance, the account pays per million requests, per CPU-ms and per GB egress. That mirrors Workers Standard pricing plus margin. Custom domains are per hostname per month, mirroring Cloudflare for SaaS. |
Metering: every Sites call to metering goes through src/server/sites/metering.ts. The shared metering module replaces that file's bodies on merge.
- Requests and egress bytes: counted by the dispatcher on the streamed response, keyed by
cf-ray. - CPU ms: reported by a
SiteTailtail Worker attached to every dynamic isolate (TraceItem.cpuTime). - Storage: the sum of active deployment bytes per account (
accountStorageBytes). - Attribution: claimed sites are billed to their owner. Unclaimed scratch sites are billed to
scratch:site:<id>, which is the free tier. - Free-tier caps: 10k requests, 60k CPU ms and 1 GiB egress per scratch site per day. Over the cap, the dispatcher answers 429 until the next UTC day or a claim.
- Interim store: until the shared module lands, usage goes to
site_usage. It is readable atGET /api/v1/user/site-usageand shown on the Sites settings page.
Local development and e2e
Chrome resolves *.localhost to loopback. The sites domain is therefore gitforstatic.localhost, and loopback forges skip the registrable-domain check because localhost has no eTLD+1. The shared dev server on :3000 runs master, so a branch is tested on its own port.
Add this to .dev.vars:
SITES_DOMAIN=gitforstatic.localhost
PUBLIC_URL=http://localhost:3317
SCRATCH_SITES_PER_IP_PER_DAY=1000
Then run:
bun run db:migrate && bunx vite dev --port 3317
bun test src/server/sites
SITES_E2E_URL=http://localhost:3317 bun run e2e/sites.e2e.ts
SITES_E2E_URL=http://localhost:3317 bun run e2e/sites-browser.e2e.ts
SITES_E2E_URL=http://localhost:3317 bun run e2e/sites-scratch-git.e2e.ts
e2e/sites-scratch-git.e2e.ts runs git against the dev server. It checks the following:
POST /api/v1/scratch, then a push tomainand a push to a branch preview.- A wrong token is refused, and a tag push is refused.
- Metering rows are recorded.
- The sweep removes the expired site and its repository.
It reaches D1 through the dev server's Local Explorer API and fires cron with /cdn-cgi/handler/scheduled. The browser suite also covers two more things:
- Cookie tossing:
Domain=<parent>is dropped, andDomain=<own host>is kept host-only. - Claiming a git scratch site, which moves the repository to the user.
e2e/sites.e2e.ts sends a Host header to the forge's address, so it needs no hosts-file entries. It checks the following:
- Two scratch deploys get distinct origins.
- Static assets are served through
env.ASSETS, and_worker.jsis never served. - Dispatcher and forge cookies are stripped before user code.
- Scratch workers get no bindings or network.
- Reserved
Set-Cookies are dropped, and site cookies are host-only. - Abuse reports are recorded.
- Claiming requires a session.
- Unknown hosts 404.
e2e/sites-browser.e2e.ts drives headless Chrome over CDP with a throwaway profile (CHROME_PATH overrides the binary). It checks the following:
- A cookie set on the forge (including
Domain=localhost) is not visible to a site, over HTTP or from script. - A cookie set by one site is not visible to another.
- A new user can claim a scratch site, which moves to
<id>--<user>, and the old host stops serving. - The claimed site's outbound fetch to the forge is refused.
For tools that don't resolve *.localhost, add hosts entries such as 127.0.0.1 <label>.gitforstatic.localhost.
Security checklist against docs/security-triage/ci-pages.md
- PAGES-1: previews only come from same-repo branches and trusted runs, and each preview has its own origin.
- PAGES-2: TXT DCV, mapping written only after verification (plus the forge TXT token), 72 h claim expiry, forge-related hosts refused, mapping removed when the hostname is no longer active.
- CI-*: sites never get job tokens or secrets from CI. Workflow artifacts are read from
job_artifactsjoined on the run's repo.