Skip to content

Commit 8b1ffb4

Browse files
authored
Merge pull request #2108 from Automattic/feat/2078-elastic-site-registry
Add elastic Cloudflare site allocation
2 parents c2caf9a + c52c2b1 commit 8b1ffb4

9 files changed

Lines changed: 127 additions & 18 deletions

docs/cloudflare-provisioning-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@ The Worker compares SHA-256 token digests and never accepts plaintext API creden
2121
- `GET /v1/sites/{siteId}/operations/{operationId}` requires `operations:read`.
2222
- `POST /v1/sites/{siteId}/administrator-claim` exchanges its one-time bearer capability for the persistent site-scoped `admin` credential after provisioning succeeds. It does not accept an API bearer token.
2323

24-
All responses use versioned WP Codebox provisioning schemas. Site IDs are allocated only from `WORDPRESS_SITE_CONTEXTS`; caller hostnames, DNS, and Cloudflare control APIs are not inputs. The allocation transaction reserves the same shipped D1 site identity used by legacy/operator operations, so both interfaces contend on one hostname and an existing site cannot be taken over. D1 stores allocation ownership, immutable artifact identity and import options, and API operation links. The scheduler resumes incomplete allocations for its selected site before it runs an operation; it verifies staged bytes and converges the conditional destination copy, operation, and API link without blocking publication or cron when recovery fails.
24+
All responses use versioned WP Codebox provisioning schemas. With `WORDPRESS_PREVIEW_DOMAIN` and the secret `WORDPRESS_PREVIEW_HOST_SECRET` configured, `POST /v1/sites` allocates a collision-resistant signed site ID and returns the canonical `https://{siteId}.{preview-domain}` origin. Configure the suffix as a Worker wildcard route before serving those origins; no per-site Worker configuration is needed. The Worker validates that wildcard hostname and derives the site ID before any D1, coordinator, PHP, or SQLite work; anonymous published reads therefore remain R2/cache-only. D1 transactionally stores allocation ownership, lifecycle timestamps, the canonical hostname, immutable artifact identity, import options, and API operation links. `wp_codebox_site_aliases` is reserved for custom-domain aliases and is never consulted by wildcard routing. Without a preview domain, the legacy finite `WORDPRESS_SITE_CONTEXTS` allocator remains available for existing configured deployments.
2525

2626
`POST /v1/sites` validates `WORDPRESS_ADMIN_CLAIM_SECRET` and `WORDPRESS_ADMIN_PASSWORD` before allocation. Its create/replay response includes the deterministic pending capability while the configured root still derives the stored digest. Site reads expose only claim state, expiry, and the fixed endpoint. D1 stores capability and derived-credential digests, never either plaintext value. Scheduled allocation recovery issues a missing claim before provisioning work can run. Redemption requires the current site-scoped credential to match the digest pinned before bootstrap, then atomically consumes the capability exactly once and transfers that persistent administrator credential; it is not a one-time browser session. Rotating either root preserves the pending record but requires restoring the matching root before replay or redemption.

packages/runtime-cloudflare/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The Worker forwards browser cookies directly to Playground and disables Playgrou
2020

2121
## Site Identity
2222

23-
The Worker resolves a validated `SiteContext` from the exact request hostname before reading a cache or constructing a coordinator. `WORDPRESS_SITE_CONTEXTS` is a JSON array of `{ "id", "hostname", "origin" }` records. Site IDs use lowercase letters, digits, and single hyphens; hostnames must be canonical lowercase DNS names or loopback addresses; origins must match the configured hostname and contain no path, query, or credentials. HTTPS is required except for loopback and `*.localhost` development origins. Unknown hostnames fail with `421` and never fall back to another site.
23+
The Worker resolves a validated `SiteContext` from the exact request hostname before reading a cache or constructing a coordinator. `WORDPRESS_SITE_CONTEXTS` is a JSON array of `{ "id", "hostname", "origin" }` records and retains the production `default` mapping. Configure `WORDPRESS_PREVIEW_DOMAIN` plus the 32-byte-or-longer secret `WORDPRESS_PREVIEW_HOST_SECRET` to allocate elastic preview sites at signed `https://{siteId}.{preview-domain}` wildcard origins. Their hostname proves the random identity and generation without D1, so malformed, suffix-confused, forged, and stale-generation preview names fail with `421` before state access. D1 records canonical allocations and optional aliases, but aliases never participate in preview-host resolution. Site IDs use lowercase letters, digits, and single hyphens; hostnames must be canonical lowercase DNS names or loopback addresses; origins must match the configured hostname and contain no path, query, or credentials. HTTPS is required except for loopback and `*.localhost` development origins. Unknown hostnames fail with `421` and never fall back to another site.
2424

2525
Every mutable R2 key is rooted below `sites/{siteId}/`, D1 tables partition rows by site ID, Durable Object names include the site ID, and runtime and publication caches include it in their identity. The existing production hostname remains explicitly mapped to `default`, preserving its `sites/default/...` objects and credentials. Non-default sites derive distinct operator tokens and bootstrap admin passwords from the corresponding root Worker secrets with a versioned HMAC domain separator; the mapping itself contains no credentials. WordPress auth keys and salts are independently site-derived from `WORDPRESS_AUTH_SECRET`.
2626

packages/runtime-cloudflare/src/d1-operation-repository.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export class D1OperationRepository {
7070
return row ? this.hydrate(row) : null
7171
}
7272

73+
async activeSites(): Promise<SiteContext[]> {
74+
await ensureSchema(this.database)
75+
const rows = await this.database.prepare("SELECT site_id, hostname, origin FROM wp_codebox_sites WHERE state = 'active' ORDER BY site_id").all<{ site_id: string; hostname: string; origin: string }>()
76+
return rows.results.map((row) => ({ id: row.site_id, hostname: row.hostname, origin: row.origin }))
77+
}
78+
7379
async claimNext(siteId: string, now = Date.now()): Promise<(StaticArtifactOperation & { claimToken: string }) | null> {
7480
await ensureSchema(this.database)
7581
const candidate = await this.database.prepare(`SELECT operation_id FROM wp_codebox_operations WHERE site_id = ? AND (state = 'queued' OR (state = 'retryable' AND retry_at <= ?) OR (state = 'running' AND claim_expires_at <= ?)) ORDER BY created_at LIMIT 1`).bind(siteId, now, now).first<{ operation_id: string }>()
@@ -180,7 +186,10 @@ async function ensureSchema(database: D1Database): Promise<void> {
180186
const existing = schemaReady.get(database as object)
181187
if (!existing) {
182188
const pending = (async () => {
183-
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_sites (site_id TEXT PRIMARY KEY, hostname TEXT NOT NULL, origin TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active')), created_at INTEGER NOT NULL, activated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`).run()
189+
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_sites (site_id TEXT PRIMARY KEY, hostname TEXT NOT NULL, origin TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL CHECK (state IN ('active')), created_at INTEGER NOT NULL, activated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`).run()
190+
try { await database.prepare("ALTER TABLE wp_codebox_sites ADD COLUMN generation INTEGER NOT NULL DEFAULT 1").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error }
191+
await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_site_hostname ON wp_codebox_sites(hostname)`).run()
192+
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_site_aliases (hostname TEXT PRIMARY KEY, site_id TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run()
184193
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operations (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_key TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('queued','running','retryable','publication-pending','succeeded','failed')), stage TEXT NOT NULL, progress INTEGER NOT NULL CHECK (progress BETWEEN 0 AND 100), attempts INTEGER NOT NULL, retry_at INTEGER, claim_token TEXT, claim_expires_at INTEGER, prepared_version INTEGER, prepared_revision TEXT, prepared_manifest_key TEXT, prepared_persisted_at TEXT, prepared_result_json TEXT, prepared_publication_job TEXT, receipt_json TEXT, error_code TEXT, error_message TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, completed_at TEXT, PRIMARY KEY (site_id, operation_id), UNIQUE (site_id, idempotency_key), FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run()
185194
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operation_attempts (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, attempt_number INTEGER NOT NULL, claim_token TEXT NOT NULL, started_at TEXT NOT NULL, completed_at TEXT, state TEXT NOT NULL, stage TEXT NOT NULL, error_code TEXT, error_message TEXT, PRIMARY KEY (site_id, operation_id, attempt_number), FOREIGN KEY (site_id, operation_id) REFERENCES wp_codebox_operations(site_id, operation_id))`).run()
186195
await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_one_active_operation ON wp_codebox_operations(site_id) WHERE state IN ('queued','running','retryable')`).run()

packages/runtime-cloudflare/src/provisioning-api.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { D1OperationRepository, OperationConflict, type StaticArtifactOperation, type StaticArtifactOperationInput } from "./d1-operation-repository.js"
22
import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactImport, StaticArtifactImportError, validateStaticArtifact } from "./static-artifact-import.js"
3-
import { parseSiteContexts, siteStorageKeys, type SiteContext } from "./site-context.js"
3+
import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js"
44
import { deriveSiteCredential } from "./wordpress-auth.js"
55

66
export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1"
@@ -16,7 +16,7 @@ export interface ProvisioningAllocation {
1616
artifactSha256: string; artifactSize: number; options: StaticArtifactOperationInput["options"]
1717
}
1818
interface CreateInput { key: string; fingerprint: string; artifactSha256: string; artifactSize: number; options: StaticArtifactOperationInput["options"] }
19-
export interface ProvisioningEnv { WORDPRESS_STATE_DATABASE: D1Database; WORDPRESS_STATE_BUCKET: R2Bucket; WORDPRESS_SITE_CONTEXTS?: string; WORDPRESS_API_TOKENS?: string; WORDPRESS_ADMIN_CLAIM_SECRET?: string; WORDPRESS_ADMIN_PASSWORD?: string }
19+
export interface ProvisioningEnv { WORDPRESS_STATE_DATABASE: D1Database; WORDPRESS_STATE_BUCKET: R2Bucket; WORDPRESS_SITE_CONTEXTS?: string; WORDPRESS_PREVIEW_DOMAIN?: string; WORDPRESS_PREVIEW_HOST_SECRET?: string; WORDPRESS_API_TOKENS?: string; WORDPRESS_ADMIN_CLAIM_SECRET?: string; WORDPRESS_ADMIN_PASSWORD?: string }
2020

2121
export async function routeProvisioningApi(request: Request, env: ProvisioningEnv, operations: D1OperationRepository): Promise<Response> {
2222
const parts = new URL(request.url).pathname.split("/").filter(Boolean)
@@ -73,13 +73,14 @@ async function create(request: Request, env: ProvisioningEnv, operations: D1Oper
7373
const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE)
7474
let allocation: ProvisioningAllocation
7575
try {
76+
const domain = previewDomain(env.WORDPRESS_PREVIEW_DOMAIN, env.WORDPRESS_PREVIEW_HOST_SECRET)
7677
const active = await env.WORDPRESS_STATE_DATABASE.prepare("SELECT site_id FROM wp_codebox_sites WHERE state = 'active'").all<{ site_id: string }>()
7778
const occupied = new Set(active.results.map((row) => row.site_id))
78-
const candidates = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).filter((site) => allowed(token, site.id) && !occupied.has(site.id))
79-
allocation = await store.allocate(token, input, candidates)
79+
const configured = domain ? [] : parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).filter((site) => allowed(token, site.id) && !occupied.has(site.id))
80+
allocation = await store.allocate(token, input, domain, configured)
8081
} catch (error) { return error instanceof OperationConflict ? apiError(409, "idempotency_conflict", error.message) : allocationError(error) }
8182
if (!allowed(token, allocation.siteId)) return notFound()
82-
const site = context(env, allocation.siteId)
83+
const site = await store.context(allocation.siteId)
8384
if (!site) return notFound()
8485
try {
8586
const operation = await resumeProvisioningAllocation(env, site, operations)
@@ -91,7 +92,7 @@ async function create(request: Request, env: ProvisioningEnv, operations: D1Oper
9192
async function readSite(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string): Promise<Response> {
9293
const token = await authenticate(request, env, "sites:read"); if (token instanceof Response) return token
9394
const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId)
94-
const site = context(env, siteId)
95+
const site = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).context(siteId)
9596
if (!allocation || !site || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound()
9697
const claim = await new AdministratorClaimStore(env.WORDPRESS_STATE_DATABASE).metadata(siteId)
9798
return siteResource(site, allocation.operationId ? await operations.get(siteId, allocation.operationId) : null, 200, claim)
@@ -118,7 +119,7 @@ async function claimAdministrator(request: Request, env: ProvisioningEnv, operat
118119
async function importSite(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string): Promise<Response> {
119120
const token = await authenticate(request, env, "sites:import"); if (token instanceof Response) return token
120121
const key = idempotencyKey(request); if (key instanceof Response) return key
121-
const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId); const site = context(env, siteId)
122+
const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId); const site = await store.context(siteId)
122123
if (!allocation || !site || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound()
123124
const provision = allocation.operationId ? await operations.get(siteId, allocation.operationId) : null
124125
if (provision?.state !== "succeeded") return apiError(409, "site_not_ready", "The site is not ready for imports.")
@@ -134,7 +135,7 @@ async function importSite(request: Request, env: ProvisioningEnv, operations: D1
134135
async function readOperation(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string, operationId: string): Promise<Response> {
135136
const token = await authenticate(request, env, "operations:read"); if (token instanceof Response) return token
136137
const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId)
137-
if (!allocation || !context(env, siteId) || allocation.principal !== token.principal || !allowed(token, siteId) || !await store.ownsOperation(token.principal, siteId, operationId)) return notFound()
138+
if (!allocation || !await store.context(siteId) || allocation.principal !== token.principal || !allowed(token, siteId) || !await store.ownsOperation(token.principal, siteId, operationId)) return notFound()
138139
const operation = await operations.get(siteId, operationId)
139140
return operation ? operationResource(siteId, operation) : notFound()
140141
}
@@ -242,7 +243,6 @@ function parseTokens(value: string | undefined): Token[] {
242243
function optionsOf(value: Record<string, unknown>): StaticArtifactOperationInput["options"] { const slug = value.slug; const name = typeof value.name === "string" ? value.name.trim() : ""; const siteTitle = typeof value.siteTitle === "string" ? value.siteTitle.trim() : ""; if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 64 || !name || name.length > 120 || !siteTitle || siteTitle.length > 120) throw new StaticArtifactImportError("Provisioning import options are invalid.", 400); return { slug, name, siteTitle } }
243244
function stagedKey(sha256: string): string { return `sites/provisioning/import-artifacts/${sha256}.json` }
244245
function destinationKey(site: SiteContext, sha256: string): string { return `${siteStorageKeys(site).staticArtifactPrefix}/${sha256}.json` }
245-
function context(env: ProvisioningEnv, id: string): SiteContext | undefined { return parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).find((site) => site.id === id) }
246246
function allowed(token: Token, siteId: string): boolean { return !token.sites || token.sites.includes(siteId) }
247247
function validSiteId(value: string): boolean { return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value.length <= 63 }
248248
function record(value: unknown): value is Record<string, unknown> { return !!value && typeof value === "object" && !Array.isArray(value) }
@@ -254,10 +254,13 @@ function claimConfiguration(env: ProvisioningEnv): boolean { return typeof env.W
254254
class AllocationError extends Error { constructor(readonly code: "quota_exceeded" | "capacity_exhausted") { super(code) } }
255255
class AllocationStore {
256256
constructor(private readonly db: D1Database) {}
257-
async allocate(token: Token, input: CreateInput, sites: SiteContext[]): Promise<ProvisioningAllocation> {
257+
async allocate(token: Token, input: CreateInput, domain: ReturnType<typeof previewDomain>, configured: SiteContext[]): Promise<ProvisioningAllocation> {
258258
await this.schema(); const existing = await this.byRequest(token.principal, input.key)
259259
if (existing) { if (existing.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return existing }
260-
for (const site of sites) {
260+
if (domain && token.sites) throw new AllocationError("quota_exceeded")
261+
const attempts = domain ? 8 : configured.length
262+
for (let attempt = 0; attempt < attempts; attempt++) {
263+
const site = domain ? await allocatePreviewSiteContext(domain) : configured[attempt]
261264
const now = Date.now()
262265
try {
263266
const [allocation, reservation] = await this.db.batch([
@@ -274,6 +277,7 @@ class AllocationStore {
274277
const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites WHERE principal = ?").bind(token.principal).first<{ count: number }>()
275278
throw new AllocationError((count?.count ?? 0) >= token.maxSites ? "quota_exceeded" : "capacity_exhausted")
276279
}
280+
async context(siteId: string): Promise<SiteContext | null> { await this.schema(); const row = await this.db.prepare("SELECT site_id, hostname, origin FROM wp_codebox_sites WHERE site_id = ? AND state = 'active'").bind(siteId).first<{ site_id: string; hostname: string; origin: string }>(); return row ? { id: row.site_id, hostname: row.hostname, origin: row.origin } : null }
277281
async bySite(siteId: string): Promise<ProvisioningAllocation | null> { await this.schema(); const row = await this.db.prepare("SELECT site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id FROM wp_codebox_api_sites WHERE site_id = ?").bind(siteId).first<Record<string, unknown>>(); return row ? allocation(row) : null }
278282
async bindOperation(value: ProvisioningAllocation, operationId: string): Promise<void> {
279283
await this.schema()

0 commit comments

Comments
 (0)