327 lines · 31.6 KB

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.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 <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:

  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=<own host> 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.<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/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 <forge>/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://<host>/__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.<siteId>.<exp>.<subject>.<hmac>, 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/<branch> deploys production (the site branch) or that branch's preview. Merge-queue refs never deploy.
    • A pull_request run (refs/pull/<N>/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-<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.
  • 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:<id> and the slot pointer sites:slot:<label>.
  • Rollbacks re-point the production slot at an older active/superseded deployment. Manifests and objects are immutable.
  • Variables and secrets live in site_env, encrypted with SECRETS_KEY (at most 64 entries, 4 KiB each). Secrets are write-only in the UI. Every change bumps env_version, which changes the loader isolate id.

Lifecycle and reconciliation

The dispatcher reads only KV, so D1 changes must be mirrored:

  • deleteRepository calls teardownSiteForRepo before the cascade. It deletes every label slot, the meta, the custom-host mapping and the SaaS hostname.
  • syncSiteForRepo runs after repo settings changes, and on repo transfer. syncSitesForOwner runs 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 failed by 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. main goes 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_site takes { files }.
  • create_scratch_repo returns a remote and push token.
  • deploy_site takes { owner, repo, ref? } for claimed and normal repo sites, and needs contents: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 scratch org, which has no members.
  • Only the push token reaches them. scratchGitPermission in the git proxy checks it in constant time against sites.push_token_hash and 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, scratchPushPolicy allows only refs/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_LIMITER per IP.
  • Requests go through GIT_LIMITER per site (120 per minute).
  • Workers run with cpuMs: 10, subRequests: 0 and globalOutbound: null.
  • No custom domains and no password or OIDC gating while unclaimed.
  • Every response of an unclaimed site, _worker.js output included, is rewritten by hardenScratchResponse (src/server/sites/harden.ts): the baseline security headers are forced over anything the worker set, the CSP is form-action 'self' (no posting credentials off-site), x-robots-tag is noindex, 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_DOMAIN must 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/report on 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).

  • claimUrl carries the token in the fragment (/sites/claim#token=...), so it never reaches server logs, proxies or Referer. 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. syncSiteForRepo then moves the site to <id>--<handle>.

  • The push token is revoked, the TTL is cleared, claimed limits apply, and onScratchClaimed lets 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 sweepOrphanScratchRepos after 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 SiteTail tail 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 at GET /api/v1/user/site-usage and shown on the Sites settings page. Each site scope's alarm folds each finished UTC day into compute.site_requests, compute.site_cpu and network.sites (rollupScopeSiteUsage), billed to the owner or to scratch: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) in r2_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_LIMIT and SITES_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 to main and 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, and Domain=<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.js is 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_artifacts joined on the run's repo.