Skip to content

Commit 51c20df

Browse files
Brooooooklynclaude
andcommitted
Leave Workers without a proxy token binding on the token they hold
A rotation recorded the new generation on the row of a Worker that has no __VOID_PROXY_TOKEN binding, although nothing was patched. Senders then presented a token that Worker does not hold. Such a Worker is now skipped like one whose author cannot deploy: its row, queue entries and cron scheduler keep the generation it holds, the step logs that it needs a redeploy, and REVOKE raises the floor without waiting for it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 2080b8f commit 51c20df

3 files changed

Lines changed: 95 additions & 15 deletions

File tree

‎platform/meta/operations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ These commands are for Void Cloud's Workers. Self-hosted installations use the [
7272

7373
A deploy gives a project still on generation 0 a generation 1 token. Its older Workers keep the legacy token, which stays accepted until the project rotates.
7474

75-
Workers deployed before the binding rename hold the legacy token in `__AI_TOKEN`, which rotation cannot replace, so they lose AI, email and ISR revalidation when their project rotates, including after a rollback to such a deployment. Get them redeployed. To list the active ones, set `rename_live_at` (psql `\set`) to the first production deploy that contains commit `54375ae1` (2026-03-03), or a later time if unsure; static deployments in the list need nothing.
75+
Workers deployed before the binding rename hold the legacy token in `__AI_TOKEN`, which rotation cannot replace. A rotation skips them (`[proxy-token-rotation] skipped <project> worker=<name> has no __VOID_PROXY_TOKEN binding`) and leaves their deployment rows, queue entries and cron scheduler on the legacy token, so queues, crons and proxy calls into them keep working. Their own calls to the proxy (AI, email, ISR revalidation) fail once the floor rises, including after a rollback to such a deployment. Get them redeployed. To list the active ones, set `rename_live_at` (psql `\set`) to the first production deploy that contains commit `54375ae1` (2026-03-03), or a later time if unsure; static deployments in the list need nothing.
7676

7777
```sql
7878
SELECT p.id, p.slug, d.id AS deployment_id, d.created_at

‎platform/packages/api/src/proxy-token-rotation.ts‎

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -324,14 +324,32 @@ async function selectInventory(
324324
* every retained one (rollback leases excluded), oldest first. A retained
325325
* Worker whose author can no longer deploy is left out: it keeps its old
326326
* token, which the raised floor rejects, so rolling back to it needs a new
327-
* deploy before platform services work again. Rows of a static deployment
328-
* stay in; their GET finds no script.
327+
* deploy before platform services work again. A Worker the running rotation
328+
* found without a proxy token binding is left out the same way; one the walk
329+
* has not reached yet looks like any other until its GET. Rows of a static
330+
* deployment stay in; their GET finds no script.
329331
*/
330332
export async function listProxyTokenInventory(
331333
db: DbOrTx,
332334
project: Pick<Project, 'id' | 'slug' | 'active_deployment_id'>,
333335
): Promise<ReadonlyArray<InventoryRow>> {
334-
return (await selectInventory(db, project)).filter((row) => !row.skipped);
336+
const [job] = await db
337+
.select({ payload: projectCleanupJobs.payload })
338+
.from(projectCleanupJobs)
339+
.where(and(jobTarget(project.id), eq(projectCleanupJobs.kind, PROXY_TOKEN_ROTATION_JOB_KIND)));
340+
const unbound = unboundDeployments(job?.payload);
341+
return (await selectInventory(db, project)).filter(
342+
(row) => !row.skipped && !unbound.has(row.deploymentId),
343+
);
344+
}
345+
346+
/**
347+
* Deployments whose Worker this rotation found without a proxy token binding
348+
* (`unbound` in the job payload, comma-separated). Their rows keep the
349+
* generation they hold.
350+
*/
351+
function unboundDeployments(payload: JobPayload | undefined): ReadonlySet<string> {
352+
return new Set(payload?.unbound ? payload.unbound.split(',') : []);
335353
}
336354

337355
function parseCursor(cursor: string | undefined): { createdAt: string; id: string } | undefined {
@@ -547,17 +565,34 @@ async function patchNext(tx: DbOrTx, ctx: StepContext, locked: LockedStep): Prom
547565
const gen = project.proxy_token_gen;
548566
const token = await deriveProxyToken(ctx.env.JWT_SECRET, ctx.projectId, gen);
549567
const bindings = await ctx.cf.getScriptBindings(row.workerName);
568+
const current = bindings ? tokenBinding(bindings) : undefined;
569+
if (bindings && !current) {
570+
// Deployed before the binding rename: there is no binding to PATCH. Its
571+
// row and copies keep the generation it holds, so senders still reach it;
572+
// its own proxy calls fail once the floor rises, until a redeploy.
573+
rotationLog(
574+
'skipped',
575+
ctx.projectId,
576+
`worker=${row.workerName} has no ${PROXY_TOKEN_BINDING} binding; redeploy the project to give it one`,
577+
);
578+
const unbound = [...new Set(unboundDeployments(payload)).add(row.deploymentId)].join(',');
579+
await finishStep(
580+
tx,
581+
ctx.projectId,
582+
sql`jsonb_build_object('cursor', ${cursor}::text, 'unbound', ${unbound}::text)`,
583+
);
584+
return { kind: 'continue' };
585+
}
550586
let patched = false;
551587
if (bindings === null) {
552588
if (row.active && !isStaticProjectType(project.type)) {
553589
throw new Error(`active Worker ${row.workerName} not found`);
554590
}
555-
} else {
556-
const current = tokenBinding(bindings);
557-
if (current && current.type !== 'plain_text') {
591+
} else if (current) {
592+
if (current.type !== 'plain_text') {
558593
throw new Error(`Worker ${row.workerName} has a ${current.type} proxy token binding`);
559594
}
560-
if (current && current.text !== token) {
595+
if (current.text !== token) {
561596
const result =
562597
(await ctx.cf.patchScriptBindings(
563598
row.workerName,
@@ -568,15 +603,15 @@ async function patchNext(tx: DbOrTx, ctx: StepContext, locked: LockedStep): Prom
568603
}
569604
patched = true;
570605
rotationLog('patched', ctx.projectId, `worker=${row.workerName} gen=${gen}`);
571-
} else if (current && (locked.attempts > 0 || row.proxyTokenGen !== gen)) {
606+
} else if (locked.attempts > 0 || row.proxyTokenGen !== gen) {
572607
// An earlier attempt patched this Worker and then lost the response,
573608
// failed or rolled back before its step committed. Its old version can
574609
// still serve, so it drains as if patched now.
575610
patched = true;
576611
}
577612
}
578-
// The Worker now holds tok(gen), or holds no proxy token at all. Only now do
579-
// the row and the copies name it, so senders never run ahead of the Worker.
613+
// The Worker now holds tok(gen), or has no script. Only now do the row and
614+
// the copies name it, so senders never run ahead of the Worker.
580615
if (patched && row.active) {
581616
const active = await activeWorker(tx, project);
582617
if (hasTokenCopies(project, active)) {
@@ -611,8 +646,9 @@ async function revoke(tx: DbOrTx, ctx: StepContext, locked: LockedStep): Promise
611646
return { kind: 'continue' };
612647
}
613648
const gen = project.proxy_token_gen;
649+
const unbound = unboundDeployments(payload);
614650
const stale = (await selectInventory(tx, project)).find(
615-
(row) => !row.skipped && row.proxyTokenGen !== gen,
651+
(row) => !row.skipped && !unbound.has(row.deploymentId) && row.proxyTokenGen !== gen,
616652
);
617653
if (stale) {
618654
// The floor would reject the token a sender still presents to this Worker.
@@ -633,11 +669,12 @@ async function revoke(tx: DbOrTx, ctx: StepContext, locked: LockedStep): Promise
633669
await finishStep(tx, ctx.projectId, sql`'{"phase":"patch","cursor":""}'::jsonb`);
634670
return { kind: 'continue' };
635671
}
636-
if (hasTokenCopies(project, active)) {
672+
// An active Worker without the binding keeps the copies of the token it holds.
673+
if (current && hasTokenCopies(project, active)) {
637674
await writeCopies(ctx.env, ctx.projectId, active, token);
638675
}
639676
}
640-
// Written before COMMIT: KV may only run ahead, and every Worker holds T.
677+
// Written before COMMIT: KV may only run ahead, and every patched Worker holds T.
641678
await ctx.env.ROUTING.put(proxyTokenStateKey(ctx.projectId), serializeProxyTokenState({ gen }));
642679
await finishStep(
643680
tx,

‎platform/packages/api/test/integration/proxy-token-rotation.test.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { reconcileProjectCleanupJobs } from '../../src/provisioning/cleanup.ts';
2323
import {
2424
advanceProxyTokenRotation,
2525
issueProjectProxyToken,
26+
listProxyTokenInventory,
2627
PROXY_TOKEN_ROTATION_STUCK_ATTEMPTS,
2728
PROXY_TOKEN_ROTATION_WAIT_MS,
2829
proxyTokenRotationStuckWarning,
@@ -1028,9 +1029,51 @@ describe('proxy token rotation', () => {
10281029
cf.state.hostScripts.get(fx.worker(fx.retained[0]!))!.bindings = [
10291030
{ type: 'd1', name: 'DB', id: 'd1-database-id' },
10301031
];
1032+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
10311033
await request(fx.projectId);
1032-
expect(await advance(fx.projectId)).toEqual({ status: 'done', gen: 1 });
1034+
await advance(fx.projectId, { waitMs: 60_000 });
1035+
expect((await job(fx.projectId))!.payload.phase).toBe('revoke');
1036+
const [project] = await env.db.select().from(projects).where(eq(projects.id, fx.projectId));
1037+
expect(
1038+
(await listProxyTokenInventory(env.db, project!)).map((row) => row.workerName),
1039+
).toEqual([fx.retained[1]!, fx.active].map(fx.worker));
1040+
1041+
expect(await step(fx.projectId)).toMatchObject({ status: 'pending', phase: 'settle' });
1042+
expect(await step(fx.projectId)).toEqual({ status: 'done', gen: 1 });
10331043
expect(patchedScripts()).toEqual([fx.worker(fx.retained[1]!), fx.worker(fx.active)]);
1044+
expect(log).toHaveBeenCalledWith(
1045+
`[proxy-token-rotation] skipped ${fx.projectId} worker=${fx.worker(fx.retained[0]!)} has no __VOID_PROXY_TOKEN binding; redeploy the project to give it one`,
1046+
);
1047+
// Its row still names the token it holds; the floor rises past it.
1048+
expect(await held(fx.retained[0]!)).toBe(0);
1049+
expect(await held(fx.retained[1]!)).toBe(1);
1050+
expect(await held(fx.active)).toBe(1);
1051+
expect(await floor(fx.projectId)).toEqual({ gen: 1 });
1052+
});
1053+
1054+
it('leaves the row and copies of an active Worker without a proxy token binding', async () => {
1055+
const cf = cfApi();
1056+
const fx = await setupProject(cf);
1057+
cf.state.hostScripts.get(fx.worker(fx.active))!.bindings = [
1058+
{ type: 'd1', name: 'DB', id: 'd1-database-id' },
1059+
];
1060+
vi.spyOn(console, 'log').mockImplementation(() => {});
1061+
await request(fx.projectId);
1062+
expect(await advance(fx.projectId)).toEqual({ status: 'done', gen: 1 });
1063+
1064+
expect(patchedScripts()).toEqual(fx.retained.map(fx.worker));
1065+
// One GET in the walk and one at REVOKE: no re-walk for its row.
1066+
expect(events.filter((event) => event === `GET ${fx.worker(fx.active)}`)).toHaveLength(2);
1067+
expect(await held(fx.active)).toBe(0);
1068+
expect(await resolvedGen(fx.projectId)).toBe(0);
1069+
expect((await routing.get(fx.queueKey, 'json')) as Record<string, unknown>).toMatchObject({
1070+
proxyToken: fx.legacy,
1071+
});
1072+
expect(cron.states.get(fx.projectId)).toMatchObject({ proxyToken: fx.legacy });
1073+
for (const id of fx.retained) {
1074+
expect(await held(id)).toBe(1);
1075+
}
1076+
expect(await floor(fx.projectId)).toEqual({ gen: 1 });
10341077
});
10351078

10361079
it('fails when the active Worker of a Worker project has no script', async () => {

0 commit comments

Comments
 (0)