diff --git a/package.json b/package.json index 78bfd12e6..071b893e7 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "test:redaction": "tsx tests/redaction.test.ts", "test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts", "test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts", - "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", + "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-allocation-lifecycle.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", "test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts", "test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts", "test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts", diff --git a/packages/runtime-cloudflare/src/allocation-lifecycle.ts b/packages/runtime-cloudflare/src/allocation-lifecycle.ts new file mode 100644 index 000000000..47d82af8f --- /dev/null +++ b/packages/runtime-cloudflare/src/allocation-lifecycle.ts @@ -0,0 +1,147 @@ +import type { SiteContext } from "./site-context.js" + +export type AllocationLifecycleState = "active" | "deleting" | "tombstoned" +export interface AllocationIdentity { siteId: string; generation: number } +export interface AllocationRetentionPolicy { ttlMs: number; retainMs: number } +export interface AllocationDeletionReceipt { schema: "wp-codebox/cloudflare-allocation-deletion-receipt/v1"; siteId: string; generation: number; deletedObjects: number; deletedBytes: number; deletedRecords: number; retainedObjects: number; retainedBytes: number; retainedRecords: number; unresolvedObjects: number; unresolvedRecords: number; startedAt: string; completedAt: string; terminalState: "tombstoned" } +export interface AllocationLifecycle { identity: AllocationIdentity; principal: string; state: AllocationLifecycleState; expiresAt: number; retainUntil: number; mutationFence: number; operationFence: number; cleanupCursor: string | null; deletedObjects: number; deletedBytes: number; deletedRecords: number; receipt: AllocationDeletionReceipt | null } + +const RECEIPT_SCHEMA = "wp-codebox/cloudflare-allocation-deletion-receipt/v1" as const +const schemaReady = new WeakMap>() + +/** Durable lifecycle policy for elastic Cloudflare site allocations. */ +export class CloudflareAllocationLifecycle { + constructor(private readonly database: D1Database, private readonly policy: AllocationRetentionPolicy = { ttlMs: 24 * 60 * 60 * 1000, retainMs: 60 * 60 * 1000 }) { + if (!Number.isSafeInteger(policy.ttlMs) || !Number.isSafeInteger(policy.retainMs) || policy.ttlMs < 1 || policy.retainMs < 0) throw new Error("Allocation lifecycle policy is invalid.") + } + + async initialize(): Promise { await ensureSchema(this.database) } + async create(site: SiteContext, principal: string, now = Date.now()): Promise { + await this.initialize() + const identity = allocationIdentity(site.id) + await this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_lifecycles (site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, 1, 1, NULL, 0, 0, ?, ?)").bind(site.id, identity.generation, principal, now + this.policy.ttlMs, now + this.policy.ttlMs + this.policy.retainMs, now, now).run() + const lifecycle = await this.get(identity) + if (!lifecycle || lifecycle.principal !== principal) throw new AllocationLifecycleConflict("Allocation identity conflicts with an existing owner.") + return lifecycle + } + async get(identity: AllocationIdentity): Promise { + await this.initialize() + const row = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, deleted_records, receipt_json FROM wp_codebox_site_lifecycles WHERE site_id = ? AND generation = ?").bind(identity.siteId, identity.generation).first() + return row ? hydrate(row) : null + } + async renew(identity: AllocationIdentity, principal: string, now = Date.now()): Promise { + await this.initialize() + const result = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET expires_at = ?, retain_until = ?, updated_at = ? WHERE site_id = ? AND generation = ? AND principal = ? AND state = 'active' AND expires_at > ?").bind(now + this.policy.ttlMs, now + this.policy.ttlMs + this.policy.retainMs, now, identity.siteId, identity.generation, principal, now).run() + if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not renewable by this owner.") + return (await this.get(identity))! + } + async beginMutation(identity: AllocationIdentity, principal: string, now = Date.now()): Promise { + await this.initialize() + const result = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET mutation_fence = mutation_fence + 1, updated_at = ? WHERE site_id = ? AND generation = ? AND principal = ? AND state = 'active' AND expires_at > ?").bind(now, identity.siteId, identity.generation, principal, now).run() + if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not mutable by this owner.") + return (await this.get(identity))!.mutationFence + } + async assertMutation(identity: AllocationIdentity, fence: number, now = Date.now()): Promise { + const lifecycle = await this.get(identity) + if (!lifecycle || lifecycle.state !== "active" || lifecycle.expiresAt <= now || lifecycle.mutationFence !== fence) throw new AllocationLifecycleConflict("Allocation mutation fence changed.") + } + async assertActive(identity: AllocationIdentity, now = Date.now()): Promise { + const lifecycle = await this.get(identity) + if (lifecycle && (lifecycle.state !== "active" || lifecycle.expiresAt <= now)) throw new AllocationLifecycleConflict("Allocation is no longer active.") + } + async beginDeletion(identity: AllocationIdentity, principal: string | null, now = Date.now()): Promise { + await this.initialize() + const owner = principal === null ? "" : " AND principal = ?" + const values: unknown[] = [now + this.policy.retainMs, now, identity.siteId, identity.generation] + if (principal !== null) values.push(principal) + const result = await this.database.prepare(`UPDATE wp_codebox_site_lifecycles SET state = 'deleting', mutation_fence = mutation_fence + 1, operation_fence = operation_fence + 1, retain_until = MIN(retain_until, ?), cleanup_cursor = NULL, updated_at = ? WHERE site_id = ? AND generation = ?${owner} AND state = 'active'`).bind(...values).run() + if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not deletable by this owner.") + const lifecycle = (await this.get(identity))! + await this.cancelWork(identity, now) + return lifecycle.operationFence + } + async expire(now = Date.now(), limit = 8): Promise { + await this.initialize() + const rows = await this.database.prepare("SELECT site_id, generation FROM wp_codebox_site_lifecycles WHERE state = 'active' AND expires_at <= ? ORDER BY expires_at LIMIT ?").bind(now, limit).all>() + const expired: AllocationIdentity[] = [] + for (const row of rows.results) { + try { await this.beginDeletion({ siteId: row.site_id, generation: row.generation }, null, now); expired.push({ siteId: row.site_id, generation: row.generation }) } catch { /* A competing owner or sweeper already fenced it. */ } + } + return expired + } + async pendingDeletions(limit = 8, now = Date.now()): Promise> { + await this.initialize() + const rows = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, deleted_records, receipt_json FROM wp_codebox_site_lifecycles WHERE state = 'deleting' AND retain_until <= ? ORDER BY updated_at LIMIT ?").bind(now, limit).all() + return rows.results.map(hydrate) + } + async reclaim(bucket: Pick, identity: AllocationIdentity, operationFence: number, limit = 100, now = Date.now()): Promise { + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("Allocation reclamation limit is invalid.") + const lifecycle = await this.get(identity) + if (!lifecycle || lifecycle.state !== "deleting" || lifecycle.operationFence !== operationFence) throw new AllocationLifecycleConflict("Allocation deletion fence changed.") + if (lifecycle.retainUntil > now) throw new AllocationLifecycleConflict("Allocation retention period is still active.") + const page = await bucket.list({ prefix: `sites/${identity.siteId}/`, cursor: lifecycle.cleanupCursor ?? undefined, limit }) + if (page.objects.length) await bucket.delete(page.objects.map((object) => object.key)) + const cursor = page.truncated ? page.cursor : null + const deletedBytes = page.objects.reduce((total, object) => total + (object.size ?? 0), 0) + const cleaned = cursor ? { deleted: 0, unresolved: 0 } : await this.cleanupWork(identity.siteId, limit) + const updated = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET cleanup_cursor = ?, deleted_objects = deleted_objects + ?, deleted_bytes = deleted_bytes + ?, deleted_records = deleted_records + ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(cursor, page.objects.length, deletedBytes, cleaned.deleted, now, identity.siteId, identity.generation, operationFence).run() + if (updated.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation deletion fence changed.") + if (cursor || cleaned.unresolved) return (await this.get(identity))! + const receipt: AllocationDeletionReceipt = { schema: RECEIPT_SCHEMA, siteId: identity.siteId, generation: identity.generation, deletedObjects: lifecycle.deletedObjects + page.objects.length, deletedBytes: lifecycle.deletedBytes + deletedBytes, deletedRecords: lifecycle.deletedRecords + cleaned.deleted, retainedObjects: 0, retainedBytes: 0, retainedRecords: 0, unresolvedObjects: 0, unresolvedRecords: 0, startedAt: new Date(lifecycle.expiresAt).toISOString(), completedAt: new Date(now).toISOString(), terminalState: "tombstoned" } + await this.database.batch([ + this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_deletion_receipts (site_id, generation, receipt_json, created_at) VALUES (?, ?, ?, ?)").bind(identity.siteId, identity.generation, JSON.stringify(receipt), now), + this.database.prepare("UPDATE wp_codebox_site_lifecycles SET state = 'tombstoned', cleanup_cursor = NULL, receipt_json = ?, terminal_at = ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(JSON.stringify(receipt), now, now, identity.siteId, identity.generation, operationFence), + ]) + return (await this.get(identity))! + } + private async cancelWork(identity: AllocationIdentity, now: number): Promise { + if (await tableExists(this.database, "wp_codebox_operations") && await tableExists(this.database, "wp_codebox_operation_attempts")) { + await this.database.batch([ + this.database.prepare("UPDATE wp_codebox_operation_attempts SET completed_at = ?, state = 'failed', stage = 'allocation-deleted', error_code = 'allocation_deleted', error_message = 'Allocation deletion fenced this attempt.' WHERE site_id = ? AND completed_at IS NULL").bind(new Date(now).toISOString(), identity.siteId), + this.database.prepare("UPDATE wp_codebox_operations SET state = 'failed', stage = 'allocation-deleted', progress = 100, claim_token = NULL, claim_expires_at = NULL, retry_at = NULL, error_code = 'allocation_deleted', error_message = 'Allocation deletion fenced this operation.', completed_at = ?, updated_at = ? WHERE site_id = ? AND state IN ('queued','running','retryable','publication-pending')").bind(new Date(now).toISOString(), now, identity.siteId), + ]) + } + if (await tableExists(this.database, "wp_codebox_api_admin_claims")) await this.database.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'expired', updated_at = ? WHERE site_id = ? AND state = 'pending'").bind(now, identity.siteId).run() + } + private async cleanupWork(siteId: string, limit: number): Promise<{ deleted: number; unresolved: number }> { + let deleted = 0 + if (await tableExists(this.database, "wp_codebox_operation_attempts")) { + const result = await this.database.prepare("DELETE FROM wp_codebox_operation_attempts WHERE rowid IN (SELECT rowid FROM wp_codebox_operation_attempts WHERE site_id = ? LIMIT ?)").bind(siteId, limit).run(); deleted += result.meta.changes + } + if (await tableExists(this.database, "wp_codebox_operations")) { + const result = await this.database.prepare("DELETE FROM wp_codebox_operations WHERE rowid IN (SELECT rowid FROM wp_codebox_operations WHERE site_id = ? LIMIT ?)").bind(siteId, limit).run(); deleted += result.meta.changes + } + if (await tableExists(this.database, "wp_codebox_api_admin_claims")) { + const result = await this.database.prepare("DELETE FROM wp_codebox_api_admin_claims WHERE site_id = ?").bind(siteId).run(); deleted += result.meta.changes + } + const tables = ["wp_codebox_operation_attempts", "wp_codebox_operations", "wp_codebox_api_admin_claims"] + let unresolved = 0 + for (const table of tables) if (await tableExists(this.database, table)) unresolved += (await this.database.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE site_id = ?`).bind(siteId).first<{ count: number }>())?.count ?? 0 + return { deleted, unresolved } + } +} + +export class AllocationLifecycleConflict extends Error {} +export function allocationIdentity(siteId: string): AllocationIdentity { + const match = /-g([1-9][0-9]*)-[a-f0-9]{16}$/.exec(siteId) + return { siteId, generation: match ? Number(match[1]) : 1 } +} + +interface LifecycleRow { site_id: string; generation: number; principal: string; state: AllocationLifecycleState; expires_at: number; retain_until: number; mutation_fence: number; operation_fence: number; cleanup_cursor: string | null; deleted_objects: number; deleted_bytes: number; deleted_records: number; receipt_json: string | null } +function hydrate(row: LifecycleRow): AllocationLifecycle { return { identity: { siteId: row.site_id, generation: row.generation }, principal: row.principal, state: row.state, expiresAt: row.expires_at, retainUntil: row.retain_until, mutationFence: row.mutation_fence, operationFence: row.operation_fence, cleanupCursor: row.cleanup_cursor, deletedObjects: row.deleted_objects, deletedBytes: row.deleted_bytes, deletedRecords: row.deleted_records, receipt: row.receipt_json ? JSON.parse(row.receipt_json) as AllocationDeletionReceipt : null } } +async function tableExists(database: D1Database, name: string): Promise { return !!await database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").bind(name).first() } +async function ensureSchema(database: D1Database): Promise { + let pending = schemaReady.get(database as object) + if (!pending) { + pending = (async () => { + await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active','deleting','tombstoned')), expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, deleted_bytes INTEGER NOT NULL DEFAULT 0, deleted_records INTEGER NOT NULL DEFAULT 0, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() + try { await database.prepare("ALTER TABLE wp_codebox_site_lifecycles ADD COLUMN deleted_bytes INTEGER NOT NULL DEFAULT 0").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error } + try { await database.prepare("ALTER TABLE wp_codebox_site_lifecycles ADD COLUMN deleted_records INTEGER NOT NULL DEFAULT 0").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error } + await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_deletion_receipts (site_id TEXT NOT NULL, generation INTEGER NOT NULL, receipt_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() + await database.prepare("CREATE INDEX IF NOT EXISTS wp_codebox_site_lifecycle_expiry ON wp_codebox_site_lifecycles(state, expires_at)").run() + })() + schemaReady.set(database as object, pending) + pending.catch(() => schemaReady.delete(database as object)) + } + await pending +} diff --git a/packages/runtime-cloudflare/src/d1-operation-repository.ts b/packages/runtime-cloudflare/src/d1-operation-repository.ts index feba88936..f1d415875 100644 --- a/packages/runtime-cloudflare/src/d1-operation-repository.ts +++ b/packages/runtime-cloudflare/src/d1-operation-repository.ts @@ -49,8 +49,9 @@ export class D1OperationRepository { return { operation: existing, created: false } } try { - await this.database.prepare(`INSERT INTO wp_codebox_operations (site_id, operation_id, idempotency_key, fingerprint, artifact_key, artifact_sha256, artifact_size, slug, name, site_title, state, stage, progress, attempts, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 'created', 0, 0, ?, ?)`) - .bind(site.id, crypto.randomUUID(), input.idempotencyKey, input.fingerprint, input.artifact.r2Key, input.artifact.sha256, input.artifact.size, input.options.slug, input.options.name, input.options.siteTitle, now, now).run() + const created = await this.database.prepare(`INSERT INTO wp_codebox_operations (site_id, operation_id, idempotency_key, fingerprint, artifact_key, artifact_sha256, artifact_size, slug, name, site_title, allocation_fence, state, stage, progress, attempts, created_at, updated_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, l.operation_fence, 'queued', 'created', 0, 0, ?, ? FROM (SELECT 1) LEFT JOIN wp_codebox_sites s ON s.site_id = ? LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE l.site_id IS NULL OR (l.state = 'active' AND l.expires_at > ?)`) + .bind(site.id, crypto.randomUUID(), input.idempotencyKey, input.fingerprint, input.artifact.r2Key, input.artifact.sha256, input.artifact.size, input.options.slug, input.options.name, input.options.siteTitle, now, now, site.id, now).run() + if (created.meta.changes !== 1) throw new OperationConflict("The allocation is no longer active.") } catch (error) { const raced = await this.byKey(site.id, input.idempotencyKey) if (raced?.input.fingerprint === input.fingerprint) return { operation: raced, created: false } @@ -72,18 +73,20 @@ export class D1OperationRepository { async activeSites(): Promise { await ensureSchema(this.database) - 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 }>() + const rows = await this.database.prepare("SELECT s.site_id, s.hostname, s.origin FROM wp_codebox_sites s LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE s.state = 'active' AND (l.site_id IS NULL OR l.state = 'active') ORDER BY s.site_id").all<{ site_id: string; hostname: string; origin: string }>() return rows.results.map((row) => ({ id: row.site_id, hostname: row.hostname, origin: row.origin })) } async claimNext(siteId: string, now = Date.now()): Promise<(StaticArtifactOperation & { claimToken: string }) | null> { await ensureSchema(this.database) - 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 }>() + // A dynamic allocation may be fenced between queue deliveries. Configured + // static sites have no lifecycle row and retain their existing behavior. + const candidate = await this.database.prepare(`SELECT o.operation_id FROM wp_codebox_operations o JOIN wp_codebox_sites s ON s.site_id = o.site_id LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = o.site_id AND l.generation = s.generation WHERE o.site_id = ? AND (l.site_id IS NULL OR l.state = 'active') AND (o.state = 'queued' OR (o.state = 'retryable' AND o.retry_at <= ?) OR (o.state = 'running' AND o.claim_expires_at <= ?)) ORDER BY o.created_at LIMIT 1`).bind(siteId, now, now).first<{ operation_id: string }>() if (!candidate) return null const token = crypto.randomUUID() const expiresAt = now + this.claimMs const [update, attempt] = await this.database.batch([ - this.database.prepare(`UPDATE wp_codebox_operations SET state = 'running', stage = 'claimed', claim_token = ?, claim_expires_at = ?, attempts = attempts + 1, retry_at = NULL, error_code = NULL, error_message = NULL, updated_at = ? WHERE site_id = ? AND operation_id = ? AND (state = 'queued' OR (state = 'retryable' AND retry_at <= ?) OR (state = 'running' AND claim_expires_at <= ?))`).bind(token, expiresAt, now, siteId, candidate.operation_id, now, now), + this.database.prepare(`UPDATE wp_codebox_operations SET state = 'running', stage = 'claimed', claim_token = ?, claim_expires_at = ?, attempts = attempts + 1, retry_at = NULL, error_code = NULL, error_message = NULL, updated_at = ? WHERE site_id = ? AND operation_id = ? AND (state = 'queued' OR (state = 'retryable' AND retry_at <= ?) OR (state = 'running' AND claim_expires_at <= ?)) AND EXISTS (SELECT 1 FROM wp_codebox_sites s LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE s.site_id = wp_codebox_operations.site_id AND (l.site_id IS NULL OR (l.state = 'active' AND l.expires_at > ? AND l.operation_fence = wp_codebox_operations.allocation_fence)))`).bind(token, expiresAt, now, siteId, candidate.operation_id, now, now, now), this.database.prepare(`INSERT INTO wp_codebox_operation_attempts (site_id, operation_id, attempt_number, claim_token, started_at, state, stage) SELECT site_id, operation_id, attempts, ?, ?, 'running', 'claimed' FROM wp_codebox_operations WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at = ?`).bind(token, new Date(now).toISOString(), siteId, candidate.operation_id, token, expiresAt), ]) if (update.meta.changes !== 1) return null @@ -131,15 +134,16 @@ export class D1OperationRepository { } private async ownedUpdate(siteId: string, operationId: string, token: string, update: string, values: unknown[]): Promise { - const result = await this.database.prepare(`${update} WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ?`).bind(...values, siteId, operationId, token, Date.now()).run() + const now = Date.now() + const result = await this.database.prepare(`${update} WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ? AND ${activeLifecycle}`).bind(...values, siteId, operationId, token, now, now).run() if (result.meta.changes !== 1) throw new OperationConflict("Operation claim expired or changed.") } private async terminalBatch(siteId: string, operationId: string, token: string, update: string, values: unknown[], state: string, stage: string, error: { code: string; message: string } | null): Promise { const now = Date.now() const [attempt, operation] = await this.database.batch([ - this.database.prepare(`UPDATE wp_codebox_operation_attempts SET completed_at = ?, state = ?, stage = ?, error_code = ?, error_message = ? WHERE site_id = ? AND operation_id = ? AND claim_token = ? AND completed_at IS NULL AND EXISTS (SELECT 1 FROM wp_codebox_operations WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ?)`).bind(new Date(now).toISOString(), state, stage, error?.code ?? null, error?.message ?? null, siteId, operationId, token, siteId, operationId, token, now), - this.database.prepare(`${update} WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ?`).bind(...values, siteId, operationId, token, now), + this.database.prepare(`UPDATE wp_codebox_operation_attempts SET completed_at = ?, state = ?, stage = ?, error_code = ?, error_message = ? WHERE site_id = ? AND operation_id = ? AND claim_token = ? AND completed_at IS NULL AND EXISTS (SELECT 1 FROM wp_codebox_operations WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ? AND ${activeLifecycle})`).bind(new Date(now).toISOString(), state, stage, error?.code ?? null, error?.message ?? null, siteId, operationId, token, siteId, operationId, token, now, now), + this.database.prepare(`${update} WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ? AND ${activeLifecycle}`).bind(...values, siteId, operationId, token, now, now), ]) if (attempt.meta.changes !== 1 || operation.meta.changes !== 1) throw new OperationConflict("Operation claim expired or changed.") } @@ -153,7 +157,8 @@ export class D1OperationRepository { const completedAt = new Date().toISOString() const receipt: OperationReceipt = { ...operation.receipt, publication: outcome === "promoted" ? { status: "promoted", jobKey, revision: revision! } : { status: outcome, jobKey }, terminalCompletedAt: completedAt } const failed = outcome !== "promoted" - await this.database.prepare(`UPDATE wp_codebox_operations SET state = ?, stage = ?, progress = 100, receipt_json = ?, error_code = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE site_id = ? AND operation_id = ? AND state = 'publication-pending' AND prepared_publication_job = ?`).bind(failed ? "failed" : "succeeded", failed ? `publication-${outcome}` : "published", JSON.stringify(receipt), failed ? `publication_${outcome}` : null, failed ? `Publication ${outcome}.` : null, completedAt, Date.now(), siteId, operation.operationId, jobKey).run() + const now = Date.now() + await this.database.prepare(`UPDATE wp_codebox_operations SET state = ?, stage = ?, progress = 100, receipt_json = ?, error_code = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE site_id = ? AND operation_id = ? AND state = 'publication-pending' AND prepared_publication_job = ? AND ${activeLifecycle}`).bind(failed ? "failed" : "succeeded", failed ? `publication-${outcome}` : "published", JSON.stringify(receipt), failed ? `publication_${outcome}` : null, failed ? `Publication ${outcome}.` : null, completedAt, now, siteId, operation.operationId, jobKey, now).run() } async pendingPublicationJobs(siteId: string, limit = 16): Promise { @@ -173,6 +178,7 @@ export class D1OperationRepository { } } +const activeLifecycle = `EXISTS (SELECT 1 FROM wp_codebox_sites s LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE s.site_id = wp_codebox_operations.site_id AND (l.site_id IS NULL OR (l.state = 'active' AND l.expires_at > ${"?"} AND l.operation_fence = wp_codebox_operations.allocation_fence)))` const operationSelect = `SELECT o.*, s.hostname, s.origin FROM wp_codebox_operations o JOIN wp_codebox_sites s ON s.site_id = o.site_id` interface Row { site_id: string; hostname: string; origin: string; operation_id: string; idempotency_key: string; fingerprint: string; artifact_key: string; artifact_sha256: string; artifact_size: number; slug: string; name: string; site_title: string; state: StaticArtifactOperationState; stage: string; progress: number; attempts: number; retry_at: number | null; claim_expires_at: number | null; prepared_version: number | null; prepared_revision: string | null; prepared_manifest_key: string | null; prepared_persisted_at: string | null; prepared_result_json: string | null; prepared_publication_job: string | null; error_code: string | null; error_message: string | null; receipt_json: string | null } interface AttemptRow { attempt_number: number; claim_token: string; started_at: string; completed_at: string | null; state: string; stage: string; error_code: string | null; error_message: string | null } @@ -188,9 +194,13 @@ async function ensureSchema(database: D1Database): Promise { const pending = (async () => { 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() 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 } + // Lifecycle owns this schema; the minimal compatible creation keeps queue + // fencing available when the operation repository initializes first. + await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL, expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_site_hostname ON wp_codebox_sites(hostname)`).run() 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() - 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() + 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, allocation_fence INTEGER, 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() + try { await database.prepare("ALTER TABLE wp_codebox_operations ADD COLUMN allocation_fence INTEGER").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error } 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() 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() await database.prepare(`CREATE INDEX IF NOT EXISTS wp_codebox_operation_ready ON wp_codebox_operations(site_id, state, retry_at, claim_expires_at, created_at)`).run() diff --git a/packages/runtime-cloudflare/src/provisioning-api.ts b/packages/runtime-cloudflare/src/provisioning-api.ts index 80e54b72c..36134a19c 100644 --- a/packages/runtime-cloudflare/src/provisioning-api.ts +++ b/packages/runtime-cloudflare/src/provisioning-api.ts @@ -2,6 +2,7 @@ import { D1OperationRepository, OperationConflict, type StaticArtifactOperation, import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactImport, StaticArtifactImportError, validateStaticArtifact } from "./static-artifact-import.js" import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js" import { deriveSiteCredential } from "./wordpress-auth.js" +import { allocationIdentity, CloudflareAllocationLifecycle, type AllocationLifecycle } from "./allocation-lifecycle.js" export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1" export const PROVISIONING_CREATE_REQUEST_SCHEMA = "wp-codebox/provisioning-create-request/v1" @@ -29,11 +30,35 @@ export async function routeProvisioningApi(request: Request, env: ProvisioningEn // This capability endpoint must never fall through to API bearer authentication or WordPress. if (parts.length === 4 && parts[3] === "administrator-claim") return method === "POST" ? claimAdministrator(request, env, operations, siteId) : methodNotAllowed("POST") if (parts.length === 3) return method === "GET" ? readSite(request, env, operations, siteId) : methodNotAllowed("GET") + if (parts.length === 4 && parts[3] === "lifecycle" && method === "GET") return lifecycleStatus(request, env, siteId) + if (parts.length === 5 && parts[3] === "lifecycle" && parts[4] === "renew") return method === "POST" ? renewLifecycle(request, env, siteId) : methodNotAllowed("POST") + if (parts.length === 4 && parts[3] === "lifecycle") return method === "DELETE" ? deleteLifecycle(request, env, siteId) : methodNotAllowed("GET, DELETE") if (parts.length === 4 && parts[3] === "imports") return method === "POST" ? importSite(request, env, operations, siteId) : methodNotAllowed("POST") if (parts.length === 5 && parts[3] === "operations" && /^[0-9a-f-]{36}$/.test(parts[4])) return method === "GET" ? readOperation(request, env, operations, siteId, parts[4]) : methodNotAllowed("GET") return notFound() } +async function lifecycleStatus(request: Request, env: ProvisioningEnv, siteId: string): Promise { + const token = await authenticate(request, env, "sites:read"); if (token instanceof Response) return token + const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId) + if (!allocation || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + const lifecycle = await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).get(allocationIdentity(siteId)) + return lifecycle ? Response.json({ schema: PROVISIONING_API_SCHEMA, lifecycle: lifecycleResource(lifecycle) }) : notFound() +} +async function renewLifecycle(request: Request, env: ProvisioningEnv, siteId: string): Promise { + const token = await authenticate(request, env, "sites:import"); if (token instanceof Response) return token + const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId) + if (!allocation || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + try { const lifecycle = await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).renew(allocationIdentity(siteId), token.principal); return Response.json({ schema: PROVISIONING_API_SCHEMA, lifecycle: lifecycleResource(lifecycle) }) } catch { return apiError(409, "lifecycle_conflict", "The allocation lifecycle cannot be renewed.") } +} +async function deleteLifecycle(request: Request, env: ProvisioningEnv, siteId: string): Promise { + const token = await authenticate(request, env, "sites:import"); if (token instanceof Response) return token + const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId) + if (!allocation || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + try { await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).beginDeletion(allocationIdentity(siteId), token.principal); return lifecycleStatus(new Request(request.url, { headers: request.headers }), env, siteId) } catch { return apiError(409, "lifecycle_conflict", "The allocation lifecycle cannot be deleted.") } +} +function lifecycleResource(lifecycle: AllocationLifecycle) { return { state: lifecycle.state, expiresAt: new Date(lifecycle.expiresAt).toISOString(), retainUntil: new Date(lifecycle.retainUntil).toISOString(), generation: lifecycle.identity.generation, receipt: lifecycle.receipt } } + async function stageArtifact(request: Request, env: ProvisioningEnv, expectedSha256: string): Promise { const token = await authenticate(request, env, "sites:create"); if (token instanceof Response) return token const declaredLength = request.headers.get("content-length") @@ -121,6 +146,7 @@ async function importSite(request: Request, env: ProvisioningEnv, operations: D1 const key = idempotencyKey(request); if (key instanceof Response) return key const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId); const site = await store.context(siteId) if (!allocation || !site || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + if (!await allocationActive(env.WORDPRESS_STATE_DATABASE, siteId)) return apiError(409, "allocation_inactive", "The allocation is no longer active.") const provision = allocation.operationId ? await operations.get(siteId, allocation.operationId) : null if (provision?.state !== "succeeded") return apiError(409, "site_not_ready", "The site is not ready for imports.") try { @@ -145,6 +171,7 @@ export async function resumeProvisioningAllocation(env: ProvisioningEnv, site: S const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE) const allocation = await store.bySite(site.id) if (!allocation) return null + if (!await allocationActive(env.WORDPRESS_STATE_DATABASE, site.id)) throw new OperationConflict("The allocation is no longer active.") if (!claimConfiguration(env)) throw new AdministratorClaimError() const administratorClaim = await new AdministratorClaimStore(env.WORDPRESS_STATE_DATABASE).issue(allocation, env) if (allocation.operationId) { @@ -255,7 +282,7 @@ class AllocationError extends Error { constructor(readonly code: "quota_exceeded class AllocationStore { constructor(private readonly db: D1Database) {} async allocate(token: Token, input: CreateInput, domain: ReturnType, configured: SiteContext[]): Promise { - await this.schema(); const existing = await this.byRequest(token.principal, input.key) + await this.schema(); const lifecycle = new CloudflareAllocationLifecycle(this.db); await lifecycle.initialize(); const existing = await this.byRequest(token.principal, input.key) if (existing) { if (existing.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return existing } if (domain && token.sites) throw new AllocationError("quota_exceeded") const attempts = domain ? 8 : configured.length @@ -264,17 +291,20 @@ class AllocationStore { const now = Date.now() try { const [allocation, reservation] = await this.db.batch([ - this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_sites (site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ? WHERE (SELECT COUNT(*) FROM wp_codebox_api_sites WHERE principal = ?) < ? AND NOT EXISTS (SELECT 1 FROM wp_codebox_sites WHERE site_id = ?)").bind(site.id, token.principal, input.key, input.fingerprint, input.artifactSha256, input.artifactSize, input.options.slug, input.options.name, input.options.siteTitle, now, token.principal, token.maxSites, site.id), - this.db.prepare("INSERT INTO wp_codebox_sites (site_id, hostname, origin, state, created_at, activated_at, updated_at) SELECT ?, ?, ?, 'active', ?, ?, ? WHERE EXISTS (SELECT 1 FROM wp_codebox_api_sites WHERE site_id = ? AND principal = ? AND idempotency_key = ? AND fingerprint = ?)").bind(site.id, site.hostname, site.origin, now, now, now, site.id, token.principal, input.key, input.fingerprint), + this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_sites (site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ? WHERE (SELECT COUNT(*) FROM wp_codebox_api_sites a LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = a.site_id WHERE a.principal = ? AND (l.site_id IS NULL OR l.state != 'tombstoned')) < ? AND NOT EXISTS (SELECT 1 FROM wp_codebox_sites WHERE site_id = ?)").bind(site.id, token.principal, input.key, input.fingerprint, input.artifactSha256, input.artifactSize, input.options.slug, input.options.name, input.options.siteTitle, now, token.principal, token.maxSites, site.id), + this.db.prepare("INSERT INTO wp_codebox_sites (site_id, hostname, origin, generation, state, created_at, activated_at, updated_at) SELECT ?, ?, ?, ?, 'active', ?, ?, ? WHERE EXISTS (SELECT 1 FROM wp_codebox_api_sites WHERE site_id = ? AND principal = ? AND idempotency_key = ? AND fingerprint = ?)").bind(site.id, site.hostname, site.origin, allocationIdentity(site.id).generation, now, now, now, site.id, token.principal, input.key, input.fingerprint), ]) - if (allocation.meta.changes === 1 && reservation.meta.changes === 1) return { siteId: site.id, principal: token.principal, key: input.key, fingerprint: input.fingerprint, operationId: null, artifactSha256: input.artifactSha256, artifactSize: input.artifactSize, options: input.options } + if (allocation.meta.changes === 1 && reservation.meta.changes === 1) { + await lifecycle.create(site, token.principal, now) + return { siteId: site.id, principal: token.principal, key: input.key, fingerprint: input.fingerprint, operationId: null, artifactSha256: input.artifactSha256, artifactSize: input.artifactSize, options: input.options } + } } catch (error) { if (!(error instanceof Error) || !/unique|constraint/i.test(error.message)) throw error } const raced = await this.byRequest(token.principal, input.key) if (raced) { if (raced.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return raced } } - const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites WHERE principal = ?").bind(token.principal).first<{ count: number }>() + const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites a LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = a.site_id WHERE a.principal = ? AND (l.site_id IS NULL OR l.state != 'tombstoned')").bind(token.principal).first<{ count: number }>() throw new AllocationError((count?.count ?? 0) >= token.maxSites ? "quota_exceeded" : "capacity_exhausted") } async context(siteId: string): Promise { 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 } @@ -308,6 +338,7 @@ class AdministratorClaimStore { constructor(private readonly db: D1Database) {} async issue(allocation: ProvisioningAllocation, env: ProvisioningEnv): Promise { await this.schema() + if (!await allocationActive(this.db, allocation.siteId)) throw new AdministratorClaimError() const token = await claimCapability(env.WORDPRESS_ADMIN_CLAIM_SECRET!, allocation) const digest = await shaText(token) const credential = await deriveSiteCredential(env.WORDPRESS_ADMIN_PASSWORD!, allocation.siteId, "admin-password") @@ -326,9 +357,14 @@ class AdministratorClaimStore { async metadata(siteId: string): Promise { const claim = await this.bySite(siteId); if (claim?.state === "pending" && claim.expiresAt <= Date.now()) { await this.expire(siteId); return { state: "expired", expiresAt: claim.expiresAt } } return claim && { state: claim.state, expiresAt: claim.expiresAt } } async bySite(siteId: string): Promise { await this.schema(); const row = await this.db.prepare("SELECT capability_digest, credential_digest, expires_at, state FROM wp_codebox_api_admin_claims WHERE site_id = ?").bind(siteId).first<{ capability_digest: string; credential_digest: string; expires_at: number; state: AdministratorClaimMetadata["state"] }>(); return row ? { capabilityDigest: row.capability_digest, credentialDigest: row.credential_digest, expiresAt: row.expires_at, state: row.state } : null } async expire(siteId: string): Promise { await this.db.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'expired', updated_at = ? WHERE site_id = ? AND state = 'pending' AND expires_at <= ?").bind(Date.now(), siteId, Date.now()).run() } - async consume(siteId: string, digest: string): Promise { const result = await this.db.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'consumed', updated_at = ? WHERE site_id = ? AND state = 'pending' AND capability_digest = ? AND expires_at > ?").bind(Date.now(), siteId, digest, Date.now()).run(); return result.meta.changes === 1 } + async consume(siteId: string, digest: string): Promise { const now = Date.now(); const result = await this.db.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'consumed', updated_at = ? WHERE site_id = ? AND state = 'pending' AND capability_digest = ? AND expires_at > ? AND EXISTS (SELECT 1 FROM wp_codebox_sites s LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE s.site_id = wp_codebox_api_admin_claims.site_id AND (l.site_id IS NULL OR (l.state = 'active' AND l.expires_at > ?)))").bind(now, siteId, digest, now, now).run(); return result.meta.changes === 1 } private async schema(): Promise { await this.db.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_api_admin_claims (site_id TEXT PRIMARY KEY, capability_digest TEXT NOT NULL, credential_digest TEXT NOT NULL, expires_at INTEGER NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending','consumed','expired')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)").run() } } +async function allocationActive(db: D1Database, siteId: string): Promise { + const lifecycle = new CloudflareAllocationLifecycle(db) + const current = await lifecycle.get(allocationIdentity(siteId)) + return !current || (current.state === "active" && current.expiresAt > Date.now()) +} async function claimCapability(root: string, allocation: ProvisioningAllocation): Promise { const encoder = new TextEncoder() const key = await crypto.subtle.importKey("raw", encoder.encode(root), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]) diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index 0ddb686b5..fb4dbba39 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -15,6 +15,7 @@ import { routeWorkerRequest } from "./request-routing.js" import { readStaticArtifactImport, STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA, StaticArtifactImportError, type StaticArtifactImport } from "./static-artifact-import.js" import { D1OperationRepository, OperationConflict, STATIC_ARTIFACT_OPERATION_SCHEMA, shouldRecoverPreparedCommit } from "./d1-operation-repository.js" import { resumeProvisioningAllocation, routeProvisioningApi } from "./provisioning-api.js" +import { allocationIdentity, CloudflareAllocationLifecycle } from "./allocation-lifecycle.js" import { toFetchResponse, toPHPRequest } from "./request-translation.js" import { DEFAULT_SITE_CONTEXT, parseSiteContexts, previewDomain, resolvePreviewSiteContextFromRequest, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js" import { validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js" @@ -257,6 +258,13 @@ export function createCloudflareRuntime( if (staticResponse) return staticResponse const uploadResponse = await serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator, site) if (uploadResponse) return uploadResponse + if (env.WORDPRESS_STATE_DATABASE && lifecycleProtectedRoute(route.kind)) { + try { + await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).assertActive(allocationIdentity(site.id)) + } catch { + return new Response("The allocation is no longer active.", { status: 410 }) + } + } if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator, site) if (route.kind === "operator-restore") return restoreCanonicalWordPress(request, env, coordinator, site) if (route.kind === "operator-adopt") return adoptCanonicalWordPress(request, env, coordinator, site, selection.selected) @@ -279,9 +287,16 @@ export function createCloudflareRuntime( } }, async scheduled(controller: ScheduledController, env: Env): Promise { - const configured = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS) - const registered = env.WORDPRESS_STATE_DATABASE && resolveOperations?.(env) ? await resolveOperations(env)!.activeSites() : [] - const sites = [...new Map([...configured, ...registered].map((site) => [site.id, site])).values()].sort((left, right) => left.id.localeCompare(right.id)) + if (env.WORDPRESS_STATE_DATABASE) { + const lifecycle = new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE) + await lifecycle.expire(controller.scheduledTime, 1) + const deleting = (await lifecycle.pendingDeletions(1, controller.scheduledTime))[0] + if (deleting) await lifecycle.reclaim(env.WORDPRESS_STATE_BUCKET, deleting.identity, deleting.operationFence, 100, controller.scheduledTime) + } + const configured = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS) + const registered = env.WORDPRESS_STATE_DATABASE && resolveOperations?.(env) ? await resolveOperations(env)!.activeSites() : [] + const sites = [...new Map([...configured, ...registered].map((site) => [site.id, site])).values()].sort((left, right) => left.id.localeCompare(right.id)) + if (!sites.length) return const site = sites[Math.floor(controller.scheduledTime / 60_000) % sites.length] const coordinator = resolveCoordinator(env, site) const operations = resolveOperations?.(env) @@ -935,6 +950,7 @@ async function reconcilePublicationReceipts(bucket: R2Bucket, site: SiteContext, } async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext, renewLease: () => Promise, operations?: D1OperationRepository): Promise { + await assertActiveAllocation(env, site) const listed = await env.WORDPRESS_STATE_BUCKET.list({ prefix: `${siteStorageKeys(site).publicationJobPrefix}/`, limit: 16 }) if (!listed.objects.length) return null const state = await coordinator.state() @@ -1017,6 +1033,7 @@ async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: await renewLease() await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publishedRevisionObjectKey(publication.revision, site), serialized) await renewLease() + await assertActiveAllocation(env, site) if (!await promoteIncrementalPublication(env.WORDPRESS_STATE_BUCKET, current, { serialized, invalidatedRoutes: [...new Set([...plan.upsert, ...plan.remove])].sort() }, site)) { return { schema: "wp-codebox/cloudflare-publication/v1", status: "stale", jobKey: job.key } } @@ -1042,6 +1059,14 @@ async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: } } +function lifecycleProtectedRoute(kind: string): boolean { + return ["wordpress", "r2-mutate", "operator-reset", "operator-restore", "operator-adopt", "operator-fence", "operator-static-artifact-import", "operator-publish"].includes(kind) +} + +async function assertActiveAllocation(env: RuntimeEnv, site: SiteContext): Promise { + if (env.WORDPRESS_STATE_DATABASE) await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).assertActive(allocationIdentity(site.id)) +} + async function compilePublicationRoutes(runtime: Runtime, routes: string[], origin: string): Promise { const compiled: CompiledPublicationRoute[] = [] for (const route of routes) { diff --git a/tests/cloudflare-allocation-lifecycle.test.ts b/tests/cloudflare-allocation-lifecycle.test.ts new file mode 100644 index 000000000..2750fcfa2 --- /dev/null +++ b/tests/cloudflare-allocation-lifecycle.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { DatabaseSync } from "node:sqlite" +import test from "node:test" +import { AllocationLifecycleConflict, CloudflareAllocationLifecycle } from "../packages/runtime-cloudflare/src/allocation-lifecycle.js" + +function database(): D1Database { + const sqlite = new DatabaseSync(":memory:") + let queued = Promise.resolve() + return { prepare(query: string) { const statement = sqlite.prepare(query); let values: unknown[] = []; return { bind(...next: unknown[]) { values = next; return this }, async run() { return { meta: { changes: statement.run(...values).changes } } }, async first() { return statement.get(...values) as T | null }, async all() { return { results: statement.all(...values) as T[] } } } }, async batch(statements: Array<{ run: () => Promise<{ meta: { changes: number } }> }>) { let release!: () => void; const previous = queued; queued = new Promise((resolve) => { release = resolve }); await previous; sqlite.exec("BEGIN"); try { const results = []; for (const statement of statements) results.push(await statement.run()); sqlite.exec("COMMIT"); return results } catch (error) { sqlite.exec("ROLLBACK"); throw error } finally { release() } } } as unknown as D1Database +} +class Bucket { + objects = new Map() + failOnce = false + async list(options: { prefix?: string; cursor?: string; limit?: number }) { + const keys = [...this.objects.keys()].filter((key) => key.startsWith(options.prefix ?? "")).sort() + const available = options.cursor ? keys.filter((key) => key > options.cursor!) : keys; const page = available.slice(0, options.limit ?? 100) + return { objects: page.map((key) => ({ key })), truncated: page.length < available.length, cursor: page.at(-1) ?? "" } + } + async delete(keys: string[]) { if (this.failOnce) { this.failOnce = false; throw new Error("transient R2 failure") } for (const key of keys) this.objects.delete(key) } +} +const site = (id = "saaaaaaaaaaaaaaaaaaaaaaaa-g2-0123456789abcdef") => ({ id, hostname: `${id}.preview.example`, origin: `https://${id}.preview.example` }) + +test("expiration fences mutations, retains quota until terminal reclamation, and then releases it", async () => { + const lifecycle = new CloudflareAllocationLifecycle(database(), { ttlMs: 10, retainMs: 20 }); const bucket = new Bucket(); const now = 1_000 + const active = await lifecycle.create(site(), "owner", now); assert.equal(active.retainUntil, now + 30); const mutation = await lifecycle.beginMutation(active.identity, "owner", now + 1) + assert.deepEqual(await lifecycle.expire(now + 10), [active.identity]) + await assert.rejects(() => lifecycle.assertMutation(active.identity, mutation, now + 10), AllocationLifecycleConflict) + assert.equal((await lifecycle.get(active.identity))!.state, "deleting") + bucket.objects.set(`sites/${site().id}/one`, new Uint8Array([1])) + assert.deepEqual(await lifecycle.pendingDeletions(1, now + 29), [], "reads remain available during the declared grace period") + const deleting = (await lifecycle.get(active.identity))!; const tombstone = await lifecycle.reclaim(bucket as unknown as Pick, active.identity, deleting.operationFence, 10, now + 30) + assert.equal(tombstone.state, "tombstoned"); assert.equal(tombstone.receipt?.deletedObjects, 1); assert.equal(tombstone.receipt?.completedAt, new Date(now + 30).toISOString()) +}) + +test("owner authorization, concurrent deletion, and stale generations fail closed", async () => { + const lifecycle = new CloudflareAllocationLifecycle(database()); const created = await lifecycle.create(site(), "owner", 1_000) + await assert.rejects(() => lifecycle.renew(created.identity, "other", 1_001), AllocationLifecycleConflict) + await assert.rejects(() => lifecycle.beginDeletion(created.identity, "other", 1_001), AllocationLifecycleConflict) + const [one, two] = await Promise.allSettled([lifecycle.beginDeletion(created.identity, "owner", 1_001), lifecycle.beginDeletion(created.identity, "owner", 1_001)]) + assert.equal([one, two].filter((result) => result.status === "fulfilled").length, 1) + assert.equal(await lifecycle.get({ ...created.identity, generation: 1 }), null, "a prior generation is never an authorized identity") +}) + +test("cleanup checkpoints resume after partial R2 failure and tombstone receipts are immutable", async () => { + const lifecycle = new CloudflareAllocationLifecycle(database(), { ttlMs: 10, retainMs: 0 }); const bucket = new Bucket(); const created = await lifecycle.create(site(), "owner", 1_000) + for (const key of ["a", "b", "c"]) bucket.objects.set(`sites/${site().id}/${key}`, new Uint8Array([1])) + const fence = await lifecycle.beginDeletion(created.identity, "owner", 1_001) + const first = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_002) + assert.ok(first.cleanupCursor?.endsWith("/a")); assert.equal(first.deletedObjects, 1) + bucket.failOnce = true + await assert.rejects(() => lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_003)) + let current = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_004) + current = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_005) + assert.equal(current.state, "tombstoned"); assert.equal(bucket.objects.size, 0) + const receipt = JSON.stringify(current.receipt) + await assert.rejects(() => lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_006), AllocationLifecycleConflict) + assert.equal(JSON.stringify((await lifecycle.get(created.identity))!.receipt), receipt) +}) + +test("reclamation removes bounded D1 work before issuing a zero-unresolved receipt", async () => { + const db = database(); const lifecycle = new CloudflareAllocationLifecycle(db, { ttlMs: 10, retainMs: 0 }); const created = await lifecycle.create(site(), "owner", 1_000) + await db.prepare("CREATE TABLE wp_codebox_operations (site_id TEXT, operation_id TEXT, state TEXT, stage TEXT, progress INTEGER, claim_token TEXT, claim_expires_at INTEGER, retry_at INTEGER, error_code TEXT, error_message TEXT, completed_at TEXT, updated_at INTEGER)").run() + await db.prepare("CREATE TABLE wp_codebox_operation_attempts (site_id TEXT, operation_id TEXT, completed_at TEXT, state TEXT, stage TEXT, error_code TEXT, error_message TEXT)").run() + await db.prepare("CREATE TABLE wp_codebox_api_admin_claims (site_id TEXT, state TEXT, updated_at INTEGER)").run() + await db.prepare("INSERT INTO wp_codebox_operations (site_id, operation_id, state) VALUES (?, ?, 'queued')").bind(site().id, "one").run() + await db.prepare("INSERT INTO wp_codebox_operations (site_id, operation_id, state) VALUES (?, ?, 'queued')").bind(site().id, "two").run() + await db.prepare("INSERT INTO wp_codebox_operation_attempts (site_id, operation_id) VALUES (?, ?)").bind(site().id, "one").run() + await db.prepare("INSERT INTO wp_codebox_api_admin_claims (site_id, state) VALUES (?, 'pending')").bind(site().id).run() + const fence = await lifecycle.beginDeletion(created.identity, "owner", 1_001) + const bucket = new Bucket(); const first = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_002) + assert.equal(first.state, "deleting", "a bounded cleanup pass cannot tombstone unresolved D1 work") + const final = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 10, 1_003) + assert.equal(final.state, "tombstoned") + assert.equal(final.receipt?.unresolvedRecords, 0) + assert.equal(final.receipt?.deletedRecords, 4) +}) diff --git a/tests/cloudflare-d1-operation-repository.test.ts b/tests/cloudflare-d1-operation-repository.test.ts index 01d613bdf..00c92610e 100644 --- a/tests/cloudflare-d1-operation-repository.test.ts +++ b/tests/cloudflare-d1-operation-repository.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict" import { DatabaseSync } from "node:sqlite" import test from "node:test" import { D1OperationRepository, OperationConflict, shouldRecoverPreparedCommit } from "../packages/runtime-cloudflare/src/d1-operation-repository.js" +import { CloudflareAllocationLifecycle } from "../packages/runtime-cloudflare/src/allocation-lifecycle.js" function database(): D1Database { const sqlite = new DatabaseSync(":memory:") @@ -91,3 +92,19 @@ test("exact prepared-pointer recovery only skips SSI for the matching coordinato assert.equal(shouldRecoverPreparedCommit(prepared, { ...pointer, revision: "unrelated", manifestKey: "sites/alpha/markdown/revisions/unrelated.json" }), false) assert.equal(shouldRecoverPreparedCommit(null, pointer), false) }) + +test("deletion fences an in-flight claim and a publication callback", async () => { + const db = database(); const dynamic = { id: "race-g2-0123456789abcdef", hostname: "race.preview.example", origin: "https://race.preview.example" } + const lifecycle = new CloudflareAllocationLifecycle(db, { ttlMs: 60_000, retainMs: 0 }); const active = await lifecycle.create(dynamic, "owner") + const repository = new D1OperationRepository(db); const created = await repository.createOrConverge(dynamic, input("race")); const claim = await repository.claimNext(dynamic.id) + assert.ok(claim) + const fence = await lifecycle.beginDeletion(active.identity, "owner") + await assert.rejects(() => repository.renew(dynamic.id, claim.operationId, claim.claimToken), OperationConflict) + await assert.rejects(() => repository.prepareCommit(dynamic.id, claim.operationId, claim.claimToken, 1, { ...pointer, manifestKey: `sites/${dynamic.id}/markdown/revisions/revision.json` }, { imported: true }, "job-race"), OperationConflict) + await assert.rejects(() => repository.complete(dynamic.id, claim.operationId, claim.claimToken, { imported: true }, "job-race", dynamic.origin), OperationConflict) + assert.equal(await repository.claimNext(dynamic.id), null) + assert.equal((await repository.get(dynamic.id, created.operation.operationId))?.stage, "allocation-deleted") + await repository.reconcilePublication(dynamic.id, "job-race", "promoted", "revision") + assert.equal((await repository.get(dynamic.id, created.operation.operationId))?.state, "failed") + assert.ok(fence > active.operationFence) +}) diff --git a/tests/cloudflare-provisioning-api.test.ts b/tests/cloudflare-provisioning-api.test.ts index 2b2306396..359a9329f 100644 --- a/tests/cloudflare-provisioning-api.test.ts +++ b/tests/cloudflare-provisioning-api.test.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto" import { DatabaseSync } from "node:sqlite" import test from "node:test" import { D1OperationRepository } from "../packages/runtime-cloudflare/src/d1-operation-repository.js" +import { allocationIdentity, CloudflareAllocationLifecycle } from "../packages/runtime-cloudflare/src/allocation-lifecycle.js" import { PROVISIONING_ARTIFACT_RESOURCE_SCHEMA, PROVISIONING_CREATE_REQUEST_SCHEMA, resumeProvisioningAllocation, routeProvisioningApi } from "../packages/runtime-cloudflare/src/provisioning-api.js" import { STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA } from "../packages/runtime-cloudflare/src/static-artifact-import.js" @@ -18,6 +19,8 @@ class Bucket { objects = new Map(); gets = 0; puts = 0; race?: Uint8Array; dropSuccessfulPut = false async get(key: string) { this.gets++; const value = this.objects.get(key); return value ? { size: value.byteLength, etag: hash(value), arrayBuffer: async () => value.slice().buffer } : null } async put(key: string, value: string | Uint8Array, options?: { onlyIf?: { etagDoesNotMatch?: string } }) { this.puts++; const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value; if (options?.onlyIf?.etagDoesNotMatch === "*" && this.objects.has(key)) return null; if (this.race && options?.onlyIf) { this.objects.set(key, this.race); this.race = undefined; return null } if (!this.dropSuccessfulPut) this.objects.set(key, bytes.slice()); return { key } } + async list(options: { prefix?: string; cursor?: string; limit?: number }) { const keys = [...this.objects.keys()].filter((key) => key.startsWith(options.prefix ?? "")).sort(); const available = options.cursor ? keys.filter((key) => key > options.cursor!) : keys; const page = available.slice(0, options.limit ?? 100); return { objects: page.map((key) => ({ key, size: this.objects.get(key)!.byteLength })), truncated: page.length < available.length, cursor: page.at(-1) ?? "" } } + async delete(keys: string[]) { for (const key of keys) this.objects.delete(key) } } function runtime(db = database(), bucket = new Bucket(), tokens?: unknown, maxSites = 2) { bucket.objects.set(`sites/provisioning/import-artifacts/${digest}.json`, artifact) @@ -79,6 +82,15 @@ test("changed fingerprints conflict without another allocation", async () => { c test("create replay honors current site restrictions", async () => { const r = runtime(); await create(r); const records = JSON.parse(r.env.WORDPRESS_API_TOKENS) as Array>; records[0].sites = ["beta"]; r.env.WORDPRESS_API_TOKENS = JSON.stringify(records); assert.equal((await create(r)).status, 404); assert.equal(count(r.db, "wp_codebox_api_sites"), 1) }) test("concurrent distinct keys allocate unique contexts", async () => { const r = runtime(); await Promise.all([create(r, "one"), create(r, "two")]); const ids = r.db.sqlite.prepare("SELECT site_id FROM wp_codebox_api_sites ORDER BY site_id").all() as Array<{ site_id: string }>; assert.deepEqual(ids.map((row) => row.site_id), ["alpha", "beta"]) }) test("maxSites concurrency distinguishes quota from pool exhaustion", async () => { const quota = runtime(database(), new Bucket(), undefined, 1); const responses = await Promise.all([create(quota, "one"), create(quota, "two")]); assert.equal(responses.filter((response) => response.status === 202).length, 1); const rejected = responses.find((response) => response.status !== 202)!; assert.equal((await rejected.json() as { error: { code: string } }).error.code, "quota_exceeded"); assert.equal(count(quota.db, "wp_codebox_api_sites"), 1); const pool = runtime(database(), new Bucket(), undefined, 3); await Promise.all([create(pool, "one"), create(pool, "two")]); const pr = await create(pool, "three"); assert.equal((await pr.json() as { error: { code: string } }).error.code, "capacity_exhausted") }) +test("quota is released only after the lifecycle reaches its tombstone", async () => { const r = dynamicRuntime(database(), new Bucket(), 1); const first = await (await create(r, "one")).json() as { site: { id: string } }; assert.equal((await create(r, "two")).status, 429); const lifecycle = new CloudflareAllocationLifecycle(r.db, { ttlMs: 1, retainMs: 0 }); const identity = allocationIdentity(first.site.id); const fence = await lifecycle.beginDeletion(identity, "a"); await lifecycle.reclaim(r.bucket as unknown as Pick, identity, fence, 100); assert.equal((await create(r, "two")).status, 202) }) +test("owners can inspect, renew, and delete their allocation lifecycle", async () => { + const r = dynamicRuntime(); const created = await (await create(r)).json() as { site: { id: string } }; const url = `https://control.invalid/v1/sites/${created.site.id}/lifecycle` + const read = await routeProvisioningApi(new Request(url, { headers: { authorization: "Bearer good" } }), r.env, r.operations) + assert.equal(read.status, 200); assert.equal((await read.json() as { lifecycle: { state: string } }).lifecycle.state, "active") + assert.equal((await routeProvisioningApi(new Request(`${url}/renew`, { method: "POST", headers: { authorization: "Bearer good" } }), r.env, r.operations)).status, 200) + const deleted = await routeProvisioningApi(new Request(url, { method: "DELETE", headers: { authorization: "Bearer good" } }), r.env, r.operations) + assert.equal(deleted.status, 200); assert.equal((await deleted.json() as { lifecycle: { state: string } }).lifecycle.state, "deleting") +}) test("dynamic preview allocation is unique, idempotent, and not limited by configured contexts", async () => { const r = dynamicRuntime() const responses = await Promise.all(Array.from({ length: 100 }, (_, index) => create(r, `dynamic-${index}`))) @@ -133,3 +145,13 @@ test("consumed claims do not block allocation recovery after credential rotation test("administrator claim credential configuration failures do not consume a ready claim", async () => { const r = runtime(); const created = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } }; r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state = 'succeeded' WHERE site_id = ?").run(created.site.id); delete r.env.WORDPRESS_ADMIN_PASSWORD; const response = await routeProvisioningApi(new Request(`https://control.invalid/v1/sites/${created.site.id}/administrator-claim`, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }), r.env, r.operations); assert.equal(response.status, 401); assert.equal((r.db.sqlite.prepare("SELECT state FROM wp_codebox_api_admin_claims").get() as { state: string }).state, "pending") }) + +test("deletion prevents administrator credential consumption and reclaims its claim record", async () => { + const r = runtime(); const created = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } } + const lifecycle = new CloudflareAllocationLifecycle(r.db, { ttlMs: 60_000, retainMs: 0 }); const identity = allocationIdentity(created.site.id); const fence = await lifecycle.beginDeletion(identity, "a") + const claim = new Request(`https://control.invalid/v1/sites/${created.site.id}/administrator-claim`, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }) + assert.equal((await routeProvisioningApi(claim, r.env, r.operations)).status, 401) + const bucket = new Bucket(); const final = await lifecycle.reclaim(bucket as unknown as Pick, identity, fence, 100) + assert.equal(final.state, "tombstoned"); assert.ok((final.receipt?.deletedRecords ?? 0) >= 2) + assert.equal(count(r.db, "wp_codebox_api_admin_claims"), 0) +})