Skip to content

Commit 9a7ce51

Browse files
committed
Add authenticated canonical state reset
1 parent b247295 commit 9a7ce51

4 files changed

Lines changed: 39 additions & 0 deletions

File tree

packages/runtime-cloudflare/src/request-routing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type WorkerRequestRoute =
44
| { kind: "r2-state" }
55
| { kind: "r2-mutate" }
66
| { kind: "canonical-auth" }
7+
| { kind: "operator-reset" }
78
| { kind: "probe"; phase: string }
89

910
export function routeWorkerRequest(request: Request): WorkerRequestRoute {
@@ -13,5 +14,6 @@ export function routeWorkerRequest(request: Request): WorkerRequestRoute {
1314
if (phase === "r2-state") return { kind: "r2-state" }
1415
if (phase === "r2-mutate") return { kind: "r2-mutate" }
1516
if (phase === "canonical-auth") return { kind: "canonical-auth" }
17+
if (phase === "operator-reset") return { kind: "operator-reset" }
1618
return { kind: "probe", phase }
1719
}

packages/runtime-cloudflare/src/state-coordinator.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class WordPressStateCoordinator implements DurableObject {
5959
if (action === "release") return Response.json(await this.release(body))
6060
if (action === "abort") return Response.json(await this.abort(body))
6161
if (action === "commit") return Response.json(await this.commit(body))
62+
if (action === "reset") return Response.json(await this.reset())
6263
return new Response("Unknown coordinator action.", { status: 404 })
6364
}
6465

@@ -120,6 +121,12 @@ export class WordPressStateCoordinator implements DurableObject {
120121
return lease
121122
}
122123

124+
private async reset(): Promise<{ reset: true }> {
125+
await this.env.WORDPRESS_STATE_BUCKET.delete(POINTER_KEY)
126+
await this.state.storage.delete(STORAGE_KEY)
127+
return { reset: true }
128+
}
129+
123130
private async record(): Promise<CoordinatorRecord> {
124131
const stored = await this.state.storage.get<CoordinatorRecord>(STORAGE_KEY)
125132
if (stored?.initialized) return stored

packages/runtime-cloudflare/src/worker.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ interface Env {
7474
WORDPRESS_STATE_BUCKET: R2Bucket
7575
WORDPRESS_ADMIN_PASSWORD?: string
7676
WORDPRESS_AUTH_SECRET?: string
77+
WORDPRESS_OPERATOR_TOKEN?: string
7778
}
7879

7980
export default {
@@ -82,6 +83,7 @@ export default {
8283
if (staticResponse) return staticResponse
8384
const route = routeWorkerRequest(request)
8485
const coordinator = env.WORDPRESS_STATE.getByName("default")
86+
if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator)
8587
if (route.kind === "probe") {
8688
if (route.phase === "canonical-session") return canonicalSessionProbe(env.WORDPRESS_STATE_BUCKET, await coordinatorCall<{ pointer: MarkdownPointer | null }>(coordinator, request.url, "state"))
8789
return runBootProbe(route.phase, env.WORDPRESS_STATE_BUCKET)
@@ -135,6 +137,27 @@ interface Runtime {
135137
let cachedRuntime: { baseRevision: string; promise: Promise<Runtime> } | undefined
136138
const LEASE_ACQUISITION_TIMEOUT_MS = 100_000
137139

140+
async function resetCanonicalWordPress(request: Request, env: Env, coordinator: DurableObjectStub): Promise<Response> {
141+
if (request.method !== "POST") return new Response("Canonical reset requires POST.", { status: 405 })
142+
const authorization = request.headers.get("authorization")
143+
if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) {
144+
return new Response("Canonical reset authorization failed.", { status: 401 })
145+
}
146+
const response = await coordinator.fetch(new Request(coordinatorUrl(request.url, "reset"), { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }))
147+
if (response.ok) await discardCachedRuntime()
148+
return response
149+
}
150+
151+
async function secretsMatch(left: string, right: string): Promise<boolean> {
152+
const encoder = new TextEncoder()
153+
const [leftHash, rightHash] = await Promise.all([crypto.subtle.digest("SHA-256", encoder.encode(left)), crypto.subtle.digest("SHA-256", encoder.encode(right))])
154+
const leftBytes = new Uint8Array(leftHash)
155+
const rightBytes = new Uint8Array(rightHash)
156+
let difference = 0
157+
for (let index = 0; index < leftBytes.length; index++) difference |= leftBytes[index] ^ rightBytes[index]
158+
return difference === 0
159+
}
160+
138161
async function runCoordinatedWordPressRequest(request: Request, env: Env, coordinator: DurableObjectStub, route: "wordpress" | "health" | "r2-mutate" | "canonical-auth"): Promise<Response> {
139162
if (route === "r2-mutate" && request.method !== "POST") return new Response("WordPress state mutation requires POST.", { status: 405 })
140163
let lease = await acquireLease(coordinator, request.url)

tests/cloudflare-runtime.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ test("Cloudflare routing reserves phases while the phase-less route serves WordP
4141
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=r2-state")), { kind: "r2-state" })
4242
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=r2-mutate")), { kind: "r2-mutate" })
4343
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=canonical-auth")), { kind: "canonical-auth" })
44+
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-reset")), { kind: "operator-reset" })
4445
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=seeded-wordpress")), { kind: "probe", phase: "seeded-wordpress" })
4546
})
4647

@@ -543,11 +544,13 @@ test("Cloudflare coordinator serializes leases, promotes with CAS, and recovers
543544
storage: {
544545
get: async <T>(key: string) => values.get(key) as T | undefined,
545546
put: async (key: string, value: unknown) => { values.set(key, value) },
547+
delete: async (key: string) => { values.delete(key) },
546548
},
547549
}
548550
const bucket = {
549551
get: async (key: string) => objects.has(key) ? { json: async <T>() => JSON.parse(objects.get(key)!) as T } : null,
550552
put: async (key: string, value: string) => { objects.set(key, value) },
553+
delete: async (key: string) => { objects.delete(key) },
551554
}
552555
const coordinator = new WordPressStateCoordinator(state as never, { WORDPRESS_STATE_BUCKET: bucket as never, COORDINATOR_LEASE_MS: 50 })
553556
const call = async (action: string, body: Record<string, unknown> = {}) => coordinator.fetch(new Request(`https://worker.example/?__wp_codebox_coordinator=${action}`, { method: action === "state" ? "GET" : "POST", headers: { "content-type": "application/json" }, body: action === "state" ? undefined : JSON.stringify(body) }))
@@ -566,6 +569,10 @@ test("Cloudflare coordinator serializes leases, promotes with CAS, and recovers
566569
const recovered = await (await call("begin")).json() as { token: string }
567570
assert.notEqual(recovered.token, stale.token)
568571
assert.equal((await call("release", { token: recovered.token })).status, 200)
572+
assert.equal((await call("reset")).status, 200)
573+
const resetState = await (await call("state")).json() as { pointer: null; version: number }
574+
assert.equal(resetState.pointer, null)
575+
assert.equal(resetState.version, 0)
569576
})
570577

571578
test("serialized Cloudflare mutations use MDI flush paths and complete canonical state", async () => {

0 commit comments

Comments
 (0)