Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6ac3d0d
Add the project proxy token generation
Brooooooklyn Oct 6, 2026
0fc881c
Add Cloudflare helpers for proxy token rotation
Brooooooklyn Oct 6, 2026
d9a329a
Rotate a project's proxy token across its Workers
Brooooooklyn Oct 6, 2026
f03433b
Rotate proxy tokens when a member loses deploy access or their code i…
Brooooooklyn Oct 6, 2026
e111e75
Add the rotate-proxy-tokens operator command
Brooooooklyn Oct 6, 2026
a897cd2
Warn project owners when a credential rotation is stuck
Brooooooklyn Oct 6, 2026
94eef37
Document proxy token rotation
Brooooooklyn Oct 6, 2026
1d05679
Explain recovery after a rollback below generation-aware releases
Brooooooklyn Oct 6, 2026
c83da05
Rotate when replacing code from an admin-suspended collaborator
Brooooooklyn Oct 6, 2026
2a8ccc6
Initialize the proxy token generation on deploy
Brooooooklyn Oct 7, 2026
81fd953
Require a project or --all to rotate proxy tokens
Brooooooklyn Oct 7, 2026
30f991e
Use only the proxy key on proxy-to-API metadata calls
Brooooooklyn Oct 6, 2026
b456689
Update the proxy token runbook and rollback notes
Brooooooklyn Oct 7, 2026
cc7971e
Show rotation progress without the Cloudflare API token
Brooooooklyn Oct 7, 2026
b91c12c
Rotate proxy tokens when an administrator suspends a member
Brooooooklyn Oct 7, 2026
4256dc0
Keep the drain wait after a PATCH from a failed rotation attempt
Brooooooklyn Oct 7, 2026
d539aad
Close the proxy token floor when a project is deleted
Brooooooklyn Oct 7, 2026
ac75285
Keep new proxy tokens away from retained code of former members
Brooooooklyn Oct 7, 2026
f45ec4e
Attribute deployments from before project teams to their owner
Brooooooklyn Oct 7, 2026
140ca31
Queue rotations when an administrator's suspension is first written
Brooooooklyn Oct 7, 2026
aeb0fe3
Send each Worker the token it holds
Brooooooklyn Oct 7, 2026
c1d0eaf
Leave Workers without a proxy token binding on the token they hold
Brooooooklyn Oct 7, 2026
6644134
Keep Cloudflare calls within the rotation run's time limit
Brooooooklyn Oct 7, 2026
b8b8522
Queue an admin suspension's token rotations in one statement
Brooooooklyn Oct 8, 2026
0c3ef7f
Clear the warn spy between runs in the platform warnings test
Brooooooklyn Oct 8, 2026
09a9493
Rotate after upgrading back from any platform rollback
Brooooooklyn Oct 8, 2026
bdd90d7
Correct the rotation docs for old Workers, rollbacks and remote bindings
Brooooooklyn Oct 8, 2026
b4a0522
Log rotations that Cloudflare rate-limits
Brooooooklyn Oct 8, 2026
836bb81
Retry a rotation step soon when it failed after its PATCH
Brooooooklyn Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/platform/administration/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ void platform project show <project-id>

Project details include resources, domains, and recent builds and deployments. The [command reference](../../../reference/cli/platform.md#operator-commands) also covers suspending and restoring users, deleting projects, and removing accounts.

In the admin dashboard, open a project to view its team. Search for a platform user and choose a role to add them immediately; an email address is not required. You can also change or remove existing members. Pending invitations created through other project workflows remain visible until accepted or revoked. If email is unavailable for a pending invitation, share its ID so the user can accept it with `void project team accept <invitation-id>`. The owner cannot be removed from the team; transfer ownership first.
In the admin dashboard, open a project to view its team. Search for a platform user and choose a role to add them immediately; an email address is not required. You can also change or remove existing members. Pending invitations created through other project workflows remain visible until accepted or revoked. If email is unavailable for a pending invitation, share its ID so the user can accept it with `void project team accept <invitation-id>`. The owner cannot be removed from the team; transfer ownership first. Removing a collaborator or admin, changing one to reader, or suspending or deleting their account [replaces the project's platform credentials](../../project-collaboration.md#change-or-remove-access), the same as the CLI.

### Transferring Project Ownership

Expand Down
4 changes: 4 additions & 0 deletions docs/guide/platform/installation/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ void platform rollback [id] --runtime /path/to/earlier/runtime

Keep the earlier runtime files if you need rollback. If the installed version is a custom build, also pass its files with `--from-runtime /path/to/current/runtime`. Void checks that the earlier version can run against your current database and refuses incompatible rollbacks. A later `upgrade` can move forward again. See [Platform Development](/guide/platform/development/runtime#deploying-your-runtime) for working with custom builds.

Do not roll back to 0.26.0 or earlier after upgrading to a later release. Void does not refuse this rollback, but those releases reject the credentials that later releases give your apps. Every project deployed or rotated since the upgrade loses AI, email, ISR revalidation, remote D1, KV, and R2, and prerendering until you upgrade again. If you have rolled back that far, upgrade again, then run `void platform system rotate-proxy-tokens --all --yes` and check with `--plan` until nothing is pending or failed.

Let running rotations finish before you roll back. If an older proxy runs next to a newer API, prerendering and remote D1, KV, and R2 fail until both run the same release. After a rollback to the release just before credential rotation, projects deployed since the upgrade lose prerendering and remote D1, KV, and R2 until they are deployed again or you upgrade again. A rotated project deployed during any rollback stays broken after you upgrade again, and 0.26.0 and earlier also drop rotations that have not finished. After upgrading again from any rollback, run the same `rotate-proxy-tokens` commands.

:::

### Follow upgrade and rollback progress
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/project-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,7 @@ void project team revoke <invitation-id>
```

A non-owner member can leave with `void project team leave`. The owner cannot leave; an installation administrator must [transfer ownership](./platform/administration/projects.md#transferring-project-ownership) first.

When a collaborator or admin is removed, leaves, or is changed to reader, the platform replaces the credentials the project's Workers use to call platform services such as AI, email, and remote bindings. The old credentials stop working once every Worker of the project has the new ones, usually within about 10 minutes, later while a deploy is running. If the replacement cannot finish, the old credentials keep working until it does, and owners and admins see a warning in `void project status` and `void project team list`. WebSocket connections reconnect once. While a Worker is switched to the new credentials, its calls to platform services can fail for a moment, a scheduled run can be skipped, and queue messages are delivered again a couple of minutes later.

Code the removed person deployed keeps running until you replace it. If you no longer trust it, deploy code you trust; the platform replaces the credentials again after that deploy. Earlier deployments of theirs kept for rollback keep the old credentials, so after a rollback to one of them, AI, email, and ISR revalidation fail until you deploy again.
3 changes: 3 additions & 0 deletions docs/reference/cli/platform-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ void platform system asset-migrations [--page <n>] [--limit <n>]
void platform system events [--page <n>] [--limit <n>]
void platform system backfill-queue-tokens
void platform system sandbox-drain [--cursor <opaque-cursor>]
void platform system rotate-proxy-tokens [--project <id> | --all]
```

`overview` shows platform totals and recent activity. `health` checks the configured services and database, and exits with a nonzero status if a check fails. `cli-versions` reports the CLI versions used by deployments.
Expand All @@ -64,6 +65,8 @@ void platform system sandbox-drain [--cursor <opaque-cursor>]

`sandbox-drain` removes legacy tenant-owned Sandbox resources. Use `--plan` to preview and `--yes` to apply, then pass `nextCursor` as `--cursor` to continue. Application traffic stays paused during cleanup.

`rotate-proxy-tokens` gives projects new platform credentials on their live Worker and the Workers kept for rollback, without a redeploy. Workers kept for rollback whose author can no longer deploy keep the old credentials. Use `--project <id>` for one project or `--all` for every project, with `--plan` to preview and `--yes` to apply. `--plan` alone shows every rotation that is still running. Each run does a bounded batch and the platform finishes the rest in the background; a rotation takes about 6 minutes per project, longer while a deploy is running. Use `--plan` to check progress until nothing is pending or failed. Running `--yes` again with `--project` or `--all` while a rotation is still running starts one more rotation for that project. When a rotation is finished, the previous credentials no longer work. Each rotation starts a new version of the project's live Worker, so WebSocket connections reconnect once.

Use the [platform lifecycle commands](./platform-installation.md#lifecycle-commands) to maintain your installation's Workers.

## Hosted Workers {#operator-workers}
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/cli/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Show deployments for the configured target.

- Void targets show recent hosted deployments; `[name]` looks up a project by slug and otherwise the linked project is used.
- Cloudflare targets list Worker Versions, identify the active version, and show the recorded migration count. A project name is not accepted because the Worker name comes from `cloudflare.name` in `void.config.ts`.
- Owners and admins also see a warning when the platform cannot finish [replacing the project's credentials](../../guide/project-collaboration.md#change-or-remove-access).

## `void project link [name]` {#void-project-link-name}

Expand All @@ -36,7 +37,7 @@ void project team accept <invitation-id>
void project team decline <invitation-id>
```

Project-scoped commands use `--project`, then `VOID_PROJECT`, then the linked project. Invitations can target only an email address already registered to a user on that platform; they do not create accounts or grant signup access. Only the invited account can accept or decline its invitation.
Project-scoped commands use `--project`, then `VOID_PROJECT`, then the linked project. Invitations can target only an email address already registered to a user on that platform; they do not create accounts or grant signup access. Only the invited account can accept or decline its invitation. `list` shows the same credential warning as `void project status`.

Readers can view the project but cannot deploy. Collaborators can deploy and manage deploy prerequisites. Project administrators can additionally manage domains, email destinations, GitHub configuration, and the project team. The owner alone can delete the project. See [Project Collaboration](../../guide/project-collaboration.md) for the full role boundaries.

Expand Down
12 changes: 9 additions & 3 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ export type ProjectMemberResponse = {
};
};

export type ProjectMemberListResponse = {
members: Array<ProjectMemberResponse>;
warnings?: Array<string>;
};

export type ProjectInvitationResponse = {
id: string;
target_email?: string;
Expand Down Expand Up @@ -337,6 +342,7 @@ export type DeploymentResponse = {
export type DeploymentListResponse = {
deployments: Array<DeploymentResponse>;
activeDeploymentId: string | null;
warnings?: Array<string>;
};

export type SecretResponse = {
Expand Down Expand Up @@ -963,15 +969,15 @@ export class PlatformClient {
return res.json();
}

async listProjectMembers(projectId: string): Promise<Array<ProjectMemberResponse>> {
async listProjectMembers(projectId: string): Promise<ProjectMemberListResponse> {
const res = await this.request(`${this.baseUrl}/projects/${projectId}/members`, {
headers: { ...this.authHeaders },
});
if (!res.ok) {
await throwPlatformApiError('Failed to list project members', res, 'read');
}
const body = (await res.json()) as { members: Array<ProjectMemberResponse> };
return body.members;
const body = (await res.json()) as ProjectMemberListResponse;
return { members: body.members, ...(body.warnings ? { warnings: body.warnings } : {}) };
}

async listProjectInvitations(projectId: string): Promise<Array<ProjectInvitationResponse>> {
Expand Down
4 changes: 3 additions & 1 deletion packages/void/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ function operatorHelpTree(): HelpTree {
? entry.action === 'domain-remove'
? 'Read a replacement Cloudflare token for blocked project cleanup from stdin'
: 'Read a scoped Cloudflare API token from stdin'
: 'Read a full API session from stdin for explicit operator session exchange',
: flag === 'all'
? 'Rotate every project'
: 'Read a full API session from stdin for explicit operator session exchange',
})),
],
},
Expand Down
7 changes: 5 additions & 2 deletions packages/void/src/cli/list.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { outro, log } from '@voidzero-dev/vite-plus-prompts';
import { getToken } from './auth.ts';
import { PlatformClient, type DeploymentResponse } from './client.ts';
import { dim, ensureUtc, formatCount, pc, tablePrefix } from './output.ts';
import { dim, ensureUtc, formatCount, pc, sanitizeTerminalText, tablePrefix } from './output.ts';
import { readDeploymentPlatform, readProjectConfig } from './project.ts';
import { resolveProjectBySlug } from './resolve-project.ts';

Expand Down Expand Up @@ -90,7 +90,10 @@ async function showDeployments(
projectId: string,
slug: string,
): Promise<void> {
const { deployments, activeDeploymentId } = await client.listDeployments(projectId);
const { deployments, activeDeploymentId, warnings } = await client.listDeployments(projectId);
for (const warning of warnings ?? []) {
log.warn(sanitizeTerminalText(warning));
}

if (deployments.length === 0) {
outro(`No deployments for ${slug}.`);
Expand Down
9 changes: 9 additions & 0 deletions packages/void/src/cli/operator-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ export const operatorCommands: Array<OperatorCommand> = [
values: { cursor: 'Opaque cursor returned by --plan' },
method: 'POST',
},
{
group: 'system',
action: 'rotate-proxy-tokens',
description: 'Rotate project proxy tokens on tenant Workers',
path: '/system/rotate-proxy-tokens',
values: { project: 'Project ID' },
booleans: ['all'],
method: 'POST',
},
{
group: 'email',
action: 'policy',
Expand Down
7 changes: 5 additions & 2 deletions packages/void/src/cli/project-team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type ProjectInvitationResponse,
type ProjectMemberResponse,
} from './client.ts';
import { printSuccess, formatCount, note } from './output.ts';
import { printSuccess, formatCount, note, sanitizeTerminalText } from './output.ts';
import { parsePlatformErrorBody, platformErrorMessage } from './platform-errors.ts';
import type { ProjectArgs } from './project-args.ts';
import { readProjectConfig } from './project.ts';
Expand Down Expand Up @@ -149,7 +149,10 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs)
'projectSlug' in args ? args.projectSlug : '',
);
if (args.action === 'list') {
const members = await client.listProjectMembers(projectId);
const { members, warnings } = await client.listProjectMembers(projectId);
for (const warning of warnings ?? []) {
log.warn(sanitizeTerminalText(warning));
}
note(memberLines(members), 'Project Team');
outro(formatCount(members.length, 'member'));
return;
Expand Down
9 changes: 9 additions & 0 deletions packages/void/test/unit/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ describe('rendered help advertises the flags the parser honours', () => {
expect(page).toContain('--project <name>');
});

it('lists proxy token rotation under platform system with its scope flags', () => {
expect(render(['platform', 'system'])).toContain('rotate-proxy-tokens');
const page = render(['platform', 'system', 'rotate-proxy-tokens']);
expect(page).toContain('--project <value>');
expect(page).toContain('--all');
expect(page).toContain('Rotate every project');
expect(page).toContain('--plan');
});

it('still shows --project on the email subcommands that take only that', () => {
for (const sub of ['usage', 'destinations', 'allow', 'disallow']) {
const page = render(['email', sub]);
Expand Down
59 changes: 59 additions & 0 deletions packages/void/test/unit/list-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,63 @@ describe('runListCommand', () => {
expect(rowFor('dep-global')).toMatch(/\b120s\b/);
expect(rowFor('dep-none')).not.toMatch(/rule|\d+s\b/);
});

it('prints platform warnings before the deployment table', async () => {
setIsolatedHome();
mockGetToken.mockReturnValue('test-token');
const root = mkdtempSync(join(tmpdir(), 'void-list-'));
tempHomes.push(root);
mkdirSync(join(root, '.void'), { recursive: true });
writeFileSync(
join(root, '.void', 'project.json'),
JSON.stringify({ projectId: 'proj-1', slug: 'demo' }),
);
const body = (warnings?: Array<string>) => ({
deployments: [
{
id: 'dep-live',
status: 'live',
source: 'cli',
commit: null,
manifest: JSON.stringify({ type: 'ssr' }),
error_message: null,
retained: true,
applied_migrations: null,
created_at: '2026-01-01T00:00:00Z',
deployed_at: '2026-01-01T00:00:00Z',
},
],
activeDeploymentId: 'dep-live',
...(warnings ? { warnings } : {}),
});

const run = async (warnings?: Array<string>) => {
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json(body(warnings))),
);
const capture = captureOutput();
const runListCommand = await importRunListCommand();
const { log } = await import('@voidzero-dev/vite-plus-prompts');
const warnedAt: Array<number> = [];
const warn = vi.spyOn(log, 'warn').mockImplementation(() => {
warnedAt.push(capture.output().length);
});
// The prompts module survives vi.resetModules(), so a second spyOn
// returns the first spy with its earlier calls.
warn.mockClear();
await runListCommand(root);
const out = capture.output();
capture.restore();
return { warn, warnedAt, tableAt: out.indexOf('dep-live') };
};

const warned = await run(['stuck \u001b[31mred']);
expect(warned.warn).toHaveBeenCalledTimes(1);
expect(warned.warn).toHaveBeenCalledWith('stuck [31mred');
expect(warned.tableAt).toBeGreaterThan(warned.warnedAt[0]!);

const quiet = await run();
expect(quiet.warn).not.toHaveBeenCalled();
});
});
27 changes: 27 additions & 0 deletions packages/void/test/unit/operator-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,33 @@ describe('operator argument and help coverage', () => {
parseOperatorArgs(['deployment', 'migrate-assets', 'id', '--plan', '--yes']),
).toThrow('--plan and --yes cannot be combined');
});
it('routes proxy token rotation with its project scope in the body', () => {
expect(
parsePlatformArgs([
'system',
'rotate-proxy-tokens',
'--project',
'proj_000000000001',
'--plan',
]),
).toMatchObject({
method: 'POST',
path: '/system/rotate-proxy-tokens',
body: { project: 'proj_000000000001' },
plan: true,
});
const all = parseOperatorArgs(['system', 'rotate-proxy-tokens', '--all', '--yes']);
expect(all).toMatchObject({ method: 'POST', yes: true });
expect(all.body.all).toBe(true);
expect(parseOperatorArgs(['system', 'rotate-proxy-tokens', '--yes']).body).toEqual({});
expect(() => parseOperatorArgs(['system', 'rotate-proxy-tokens', '--dry-run'])).toThrow(
'--dry-run requires a value',
);
expect(() => parseOperatorArgs(['system', 'sandbox-drain', '--all'])).toThrow(
"Unknown flag '--all'",
);
expect(hasHelpPage(['platform', 'system', 'rotate-proxy-tokens'])).toBe(true);
});
it('routes asset migration inventory with bounded pagination and nested help', () => {
expect(
parsePlatformArgs(['system', 'asset-migrations', '--page', '2', '--limit', '100']),
Expand Down
43 changes: 41 additions & 2 deletions packages/void/test/unit/platform-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2909,6 +2909,7 @@ describe('platform installer', () => {
expect(database.migrations.slice(baseline.length).map((migration) => migration.tag)).toEqual([
'0056_email_shared_recipients',
'0057_github_authority_constraints',
'0058_project_proxy_token_gen',
]);
const rows = baseline.map((migration, index) => ({
id: index + 1,
Expand All @@ -2934,7 +2935,7 @@ describe('platform installer', () => {
'0.22.0',
true,
);
expect(result.appliedMigrations).toBe(2);
expect(result.appliedMigrations).toBe(3);
expect(
accepted.queries.some((query) =>
query.includes('CREATE TRIGGER "void_shared_email_recipient_teardown_guard"'),
Expand Down Expand Up @@ -2973,14 +2974,52 @@ describe('platform installer', () => {
sourceVersion,
true,
);
expect(result.appliedMigrations).toBe(1);
expect(result.appliedMigrations).toBe(2);
expect(
accepted.queries.some((query) => query.includes('"authorized_by_user_id" SET NOT NULL')),
).toBe(true);
expect(accepted.rows).toHaveLength(database.migrations.length);
},
);

it.each(['0.25.0', '0.26.0'])(
'upgrades a %s installation through the project proxy token generation',
async (sourceVersion) => {
const database = readPlatformRuntimeManifest().database;
const baseline = database.migrations.filter((migration) => migration.index <= 57);
expect(baseline.at(-1)?.tag).toBe('0057_github_authority_constraints');
const rows = baseline.map((migration, index) => ({
id: index + 1,
hash: migration.sha256,
created_at: migration.timestampMillis,
}));
const rejected = migrationConnection(rows);
await expect(
preparePlatformDatabaseConnection(
rejected,
getPlatformRuntimeDirectory(),
database,
'0.24.0',
true,
),
).rejects.toThrow('cannot safely roll back');
expect(rejected.queries).not.toContain('BEGIN');
const accepted = migrationConnection(rows);
const result = await preparePlatformDatabaseConnection(
accepted,
getPlatformRuntimeDirectory(),
database,
sourceVersion,
true,
);
expect(result.appliedMigrations).toBe(1);
expect(accepted.queries.some((query) => query.includes('ADD COLUMN "proxy_token_gen"'))).toBe(
true,
);
expect(accepted.rows).toHaveLength(database.migrations.length);
},
);

it('rejects the isolated legacy email0054 fork without rewriting its history or applying canonical migrations', async () => {
const database = readPlatformRuntimeManifest().database;
const commonPrefix = database.migrations.filter((migration) => migration.index <= 53);
Expand Down
Loading
Loading