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.<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 (sites/static.ts). 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> |
| Pull request preview (open same-repo PR #N) | pr-<N>--<repo>--<handle>.<domain> |
| Commit preview (opt-in per site, last 5 per PR) | <sha7>--<repo>--<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.
Pull request and commit aliases. pr-<N>--<site> 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 <sha7>--<site> 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-<digits> or seven hex characters) previews at branch-<slug>--<site> 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 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 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.<host> 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:<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.- A run on
refs/heads/<branch>deploys production (the site branch) or that branch's preview. Merge-queue refs never deploy. - A
pull_requestrun (refs/pull/<N>/head) deploys the site's artifact (defaultgithub-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 (
activateSiteDeploymentsupersedes older ones). Activation moves thepr-<N>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/<head>(CI-P2), not whoever triggered or reran the run.
- A run on
SitesDeployWorkflowusessites/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.
- 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, 10 MiB).
Caps:
- Bodies up to 10 MiB, 500 files and 25 MiB unpacked.
- 3 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.
- Every response of an unclaimed site,
_worker.jsoutput included, is rewritten byhardenScratchResponse(src/server/sites/harden.ts): the baseline security headers are forced over anything the worker set, the CSP isform-action 'self'(no posting credentials off-site),x-robots-tagisnoindex, nofollow, noarchive, and HTML pages get a fixed "Unclaimed scratch site" banner. Claimed sites' workers may replace a security header but a missing one is put back (ensureSecurityHeaders). - The per-IP daily cap and rate limit key on
ipBucket(src/lib/ip.ts), so an IPv6 client counts as its whole /64. - Operator task:
SITES_DOMAINmust be on the Public Suffix List (see "PSL submission"); until it is, scratch sites are not isolated from each other at the cookie and storage level. 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).
-
claimUrlcarries the token in the fragment (/sites/claim#token=...), so it never reaches server logs, proxies orReferer. A nonce'd script copies it into the form and strips it from the address bar; without JavaScript the token can be pasted. Old?token=links still work. -
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 and stays private by default, so the site switches to "Sign in with gitfor". A checkbox makes it public instead.
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 collected by the object GC (see "Object GC").
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 static limits (STATIC_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: 2k requests, 10k CPU ms and 100 MiB egress per scratch site per day. Over the cap, the dispatcher answers 429 until the next UTC day or a claim.
- Store: per-request counters go to
site_usage(Durable Object counters, flushed to D1), readable atGET /api/v1/user/site-usageand shown on the Sites settings page. Each site scope's alarm folds each finished UTC day intocompute.site_requests,compute.site_cpuandnetwork.sites(rollupScopeSiteUsage), billed to the owner or toscratch:site:<id>. Claiming a scratch site moves that account's usage to the claimer (claimScratchUsage), and later rollups of pre-claim days follow it there.
Cost controls
- Same-day hard caps. After the Counters object flushes a site's counters to D1, it re-reads the owner's usage this period (rolled-up meters plus every day the rollup has not folded yet) and sets the serving stop (
siteCapKey) as soon as a hosting meter reaches its hard cap. It reads D1 and writes only KV, at most once per account every 30 s per isolate, so traffic adds no D1 writes (caps.ts). - Object GC. Sites store content-addressed objects under
pages/objects/(a prefix kept from the removed Pages feature). A daily, resumable mark-and-sweep (sites/gc.ts) marks every hash in a retained manifest (active, superseded within the history limit, previews and commit previews) inr2_gc_marks, then deletes unmarked objects uploaded more than 7 days before the run started. Deploys rewrite any reused object older than 3 days, so an object an in-flight deploy relies on is never old enough to sweep. Before deleting, the sweep re-reads each object and skips anything re-uploaded since the listing. A retained deployment whose manifest can't be read aborts the run, and a KV lease keeps overlapping cron ticks from interleaving phases. - Kill switches.
SITES_ENABLED="false"makes every site host (and scratch uploads) answer 503. An admin can suspend hosting for one account from/admin, which stops all its Sites until resumed; plan changes don't clear it. - Global daily ceiling.
SITES_DAILY_CPU_MS_LIMITandSITES_DAILY_REQUEST_LIMIT(0 = unlimited) bound the summed site counters for the UTC day. Once either is crossed (checked every cron tick and after counter flushes), dynamic sites answer 503 for the rest of the day, static sites keep serving, and site admins get one notification.
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
- 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.