import { env } from "cloudflare:workers"; import { validSitesDomain } from "./naming"; /** Bounds on what one deployment may collect from a branch folder or a workflow artifact. */ export const STATIC_LIMITS = { maxFiles: 5000, maxFileBytes: 25 * 1024 * 1024, maxTotalBytes: 512 * 1024 * 1024, maxArtifactBytes: 1024 * 1024 * 1024, uploadBatch: 100, } as const; /** One manifest file entry: git blob sha (the R2 content address) and size. */ export type ManifestFile = [sha: string, size: number]; export type Manifest = { files: Record }; /** Content-addressed deployment objects; the `pages/` prefix predates Sites and is kept so stored objects stay valid. */ export function objectKey(sha: string): string { return `pages/objects/${sha}`; } export const SITE_LIMITS = { cpuMs: 50, subRequests: 50, deploysPerSitePerHour: 30, previewsPerPush: 5, envEntries: 64, envValueBytes: 4096, historyLimit: 50, commitPreviewsPerPull: 5, sessionSeconds: 12 * 60 * 60, } as const; export const SCRATCH_LIMITS = { ttlMs: 24 * 60 * 60 * 1000, maxUploadBytes: 10 * 1024 * 1024, maxFiles: 500, maxTotalBytes: 10 * 1024 * 1024, perIpPerDay: 3, cpuMs: 10, subRequests: 0, reportsToDisable: 3, requestsPerDay: 2_000, cpuMsPerDay: 10_000, bytesOutPerDay: 100 * 1024 * 1024, maxPushBytes: 10 * 1024 * 1024, maxPushRefs: 10, } as const; export const SCRATCH_ACCOUNT_ID = "sitesscratchaccount0"; /** Anonymous scratch repositories: owned by the reserved account, which has no members and no sessions. */ export function isScratchRepo(repo: { owner_id: string }): boolean { return repo.owner_id === SCRATCH_ACCOUNT_ID; } export type SiteAccess = "public" | "password" | "gitfor" | "oidc"; export type SiteEnvironment = "production" | "preview"; export type SiteRow = { id: string; owner_id: string | null; repo_id: string | null; label: string; source: "branch" | "workflow" | "upload"; branch: string; folder: string; artifact_name: string; spa: number; previews: number; /** Admin opt-in: preview deployments created by non-admins also receive preview-marked variables. */ preview_env: number; /** Opt-in immutable `--` previews for pull request commits. */ commit_previews: number; access: SiteAccess; audience: "readers" | "members"; password_hash: string | null; oidc_issuer: string | null; oidc_client_id: string | null; oidc_secret_ct: string | null; oidc_secret_iv: string | null; oidc_allowed: string | null; access_version: number; env_version: number; custom_domain: string | null; custom_hostname_id: string | null; domain_status: string | null; domain_claimed_at: number | null; domain_token: string | null; domain_checked_at: number | null; push_token_hash: string | null; active_deployment_id: string | null; scratch: number; claim_token_hash: string | null; creator_ip_hash: string | null; expires_at: number | null; disabled_reason: string | null; paused_at: number | null; created_at: number; updated_at: number; }; export type SiteDeploymentRow = { id: string; site_id: string; environment: SiteEnvironment; label: string; branch: string | null; source: "branch" | "artifact" | "upload"; ref: string; sha: string; artifact_id: string | null; kind: "static" | "worker"; status: "queued" | "building" | "active" | "failed" | "superseded"; file_count: number; total_bytes: number; error: string | null; creator_id: string | null; /** 1 when the creator held admin on the repo at deploy time; gates preview variables (CI-A3). */ creator_admin: number; created_at: number; completed_at: number | null; }; /** Everything the dispatcher needs per request, cached in KV so the hot path never touches D1. */ export type SiteMeta = { id: string; label: string; repoId: string | null; ownerId: string | null; access: SiteAccess; audience: "readers" | "members"; passwordHash: string | null; oidc: { issuer: string; clientId: string; secretCt: string; secretIv: string; allowed: string } | null; accessVersion: number; envVersion: number; /** Absent in metas published before the column existed; treated as false. */ previewEnv?: boolean; scratch: boolean; expiresAt: number | null; disabled: string | null; spa: boolean; customDomain: string | null; customHostnameId: string | null; }; export type SiteManifest = { files: Record; worker: Record | null }; export type SlotPointer = { siteId: string; deploymentId: string }; export const metaKey = (siteId: string): string => `sites:meta:${siteId}`; export const slotKey = (label: string): string => `sites:slot:${label}`; export const customHostKey = (host: string): string => `sites:custom:${host.toLowerCase()}`; export const manifestKey = (deploymentId: string): string => `sites:manifest:${deploymentId}`; export const codeKey = (hash: string): string => `sites:code:${hash}`; function forgeHost(): string { return new URL(env.PUBLIC_URL).hostname.toLowerCase(); } /** The sites apex, or null when dynamic sites are disabled or the domain could share cookies with the forge. */ export function sitesDomain(): string | null { return validSitesDomain(env.SITES_DOMAIN, forgeHost()); } export function isForgeHostname(host: string): boolean { return host.toLowerCase() === forgeHost(); } /** Sites mirror the forge's scheme and port so plain-http `*.localhost` dev works. */ export function siteOrigin(host: string): string { const forge = new URL(env.PUBLIC_URL); const local = host.endsWith(".localhost"); return local ? `${forge.protocol}//${host}${forge.port ? `:${forge.port}` : ""}` : `https://${host}`; } export function labelUrl(label: string): string | null { const domain = sitesDomain(); return domain ? `${siteOrigin(`${label}.${domain}`)}/` : null; } export function cookieSecret(): string { const secret = env.SITES_COOKIE_SECRET || env.SECRETS_KEY; if (!secret) throw new Error("SITES_COOKIE_SECRET or SECRETS_KEY must be configured for site access control"); return secret; } export function toMeta(site: SiteRow): SiteMeta { return { id: site.id, label: site.label, repoId: site.repo_id, ownerId: site.owner_id, access: site.access, audience: site.audience, passwordHash: site.password_hash, oidc: site.oidc_issuer && site.oidc_client_id && site.oidc_secret_ct && site.oidc_secret_iv ? { issuer: site.oidc_issuer, clientId: site.oidc_client_id, secretCt: site.oidc_secret_ct, secretIv: site.oidc_secret_iv, allowed: site.oidc_allowed ?? "", } : null, accessVersion: site.access_version, envVersion: site.env_version, previewEnv: site.preview_env === 1, scratch: site.scratch === 1, expiresAt: site.expires_at, disabled: site.disabled_reason, spa: site.spa === 1, customDomain: site.custom_domain, customHostnameId: site.custom_hostname_id, }; } export async function publishMeta(site: SiteRow): Promise { await env.KV.put(metaKey(site.id), JSON.stringify(toMeta(site))); } /** Second-level labels that form public suffixes under country TLDs (`co.uk`, `com.au`, ...). */ const COUNTRY_SECOND_LEVELS = new Set(["co", "com", "net", "org", "gov", "edu", "ac", "ne", "or", "go", "gv"]); /** Approximates the registrable domain (eTLD+1) without a public suffix list. */ function registrableDomain(hostname: string): string { const labels = hostname.toLowerCase().split("."); const [secondLevel, tld] = labels.slice(-2); const multiPartSuffix = labels.length > 2 && tld?.length === 2 && COUNTRY_SECOND_LEVELS.has(secondLevel ?? ""); return labels.slice(multiPartSuffix ? -3 : -2).join("."); } /** * Custom domains on the forge's own registrable domain could set cookies for the forge host, so the forge * host, its parent domains and any sibling under the same registrable domain are refused. */ export function isForgeRelatedHost(hostname: string): boolean { const host = hostname.toLowerCase(); const forge = forgeHost(); if (!forge) return false; const site = registrableDomain(forge); return host === forge || host === site || host.endsWith(`.${site}`) || forge.endsWith(`.${host}`); } const HOSTNAME = /^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; export function isValidHostname(hostname: string): boolean { return HOSTNAME.test(hostname); }