Skip to content

Commit 7641bcf

Browse files
authored
feat(platform): add project teams (#328)
## Summary - add project-scoped reader, collaborator, admin, and implicit owner roles for Void Platform projects - require invitation targets to already have a registered Void account - enforce live capability checks across deploys, configuration, teams, and project lifecycle operations - bill usage and enforce quotas against the current owner, including exact ownership-transfer accounting - add installation-admin ownership transfer through the operator API, CLI, and admin dashboard - harden concurrent ownership, membership, deletion, email, revalidation, deployment cancellation, and usage reconciliation paths - derive the current owner plan under lock for deploy, build, OIDC, and scheduler admission - preserve routing suspension state during first deploy and OAuth denial cleanup - reject team mutations after project deletion starts - remove dead helpers, unused queries, redundant non-production tests, and stale comments - keep direct Cloudflare deployments isolated from platform team functionality - document project collaboration and administration workflows - merge origin/main at a5a010b, preserving its custom email-domain work and moving the project-team migration to 0048 ## Verification - vp run check — all 26 workspaces passed - vp lint — passed - vp fmt --check — passed - git diff --check — passed - latest affected-scope batch — 448 tests passed - dedicated routing and operator suites — 60 tests passed - custom email-domain suite — 91 tests passed - full workspace suite — 10,338 tests passed - Svelte suite passed twice in isolation after a single full-parallel startup timeout ## Known unrelated test failure The unchanged Vue pages client form-identity assertions fail in both dev and preview. Both failures reproduce independently, and this branch changes none of the related Vue source, fixture, dependency, or configuration paths.
1 parent 7e39315 commit 7641bcf

117 files changed

Lines changed: 19183 additions & 2034 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/.vitepress/config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export default extendConfig(
247247
items: [
248248
{ text: 'Remote Development', link: '/guide/remote-dev' },
249249
{ text: 'Deploying Your App', link: '/guide/deployment' },
250+
{ text: 'Project Collaboration', link: '/guide/project-collaboration' },
250251
],
251252
},
252253
{

‎docs/guide/deployment.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ Already have a Cloudflare Worker and a root `wrangler.jsonc` or `wrangler.json`?
6060

6161
To connect to your team's platform and sign in, run `void connect <url>` using the URL from your administrator. Use `void connect --platform cloudflare` to set up your own Cloudflare account. Connecting preserves existing project links. New Void projects need a platform connection.
6262

63+
Owners can [share a platform project](./project-collaboration.md) with readers, collaborators, and project administrators. This does not apply to direct Cloudflare deployments.
64+
6365
### Migrations
6466

6567
If your app uses Drizzle, `void deploy` runs migrations as part of the deploy flow:

‎docs/guide/platform-administration.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,19 @@ void platform project show <project-id>
185185

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

188+
### Transferring Project Ownership
189+
190+
Only an installation administrator can change a project's owner. The new owner must already have an account on this platform. Preview the transfer before applying it:
191+
192+
```sh
193+
void platform project owner <project-id> <new-owner-user-id> --plan
194+
void platform project owner <project-id> <new-owner-user-id> --yes
195+
```
196+
197+
The preview shows both owners, their plans and suspension state, active work that blocks the transfer, and the changes to routing and usage counters. Wait for or cancel any active deploy, rollback, or build before retrying.
198+
199+
After the transfer, the former owner becomes a project administrator. Existing project-scoped CI deploy credentials are revoked; create replacements as the new owner. The new owner's plan and limits apply immediately. Usage before the transfer remains charged to the former owner; later usage is charged to the new owner. If an apply reports partial convergence, inspect the project and operator event log before repeating it.
200+
188201
New users on a self-hosted installation start with the `custom` profile, which
189202
does not cap application requests, AI usage, deployment frequency, or retained
190203
Worker deployments. Named profiles such as `pro` apply the platform's quota and
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Project Collaboration
6+
7+
A project hosted on a Void platform can have one owner and additional readers, collaborators, and project administrators. Team access is a platform feature; it is not available for projects deployed directly to Cloudflare.
8+
9+
## Roles
10+
11+
| Role | Access |
12+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------- |
13+
| Reader | View the project, usage, resources, deployments, builds, logs, and member roster. Cannot deploy. |
14+
| Collaborator | Reader access plus deploy, rollback, cancellation, migrations, secrets, database configuration, and deploy prerequisites. |
15+
| Project administrator | Collaborator access plus domains, email destinations, GitHub configuration, invitations, roles, and member removal. |
16+
| Owner | Project administrator access plus project deletion. |
17+
18+
Only the owner can create, list, renew, or revoke the project's CI deploy
19+
credentials. Those credentials run unattended deployments as the billing owner
20+
and remain independent of a member's login session, so collaborators and
21+
project administrators deploy with their own account instead of minting a
22+
durable owner credential.
23+
24+
The owner's plan, limits, and suspension state govern the project. A member's
25+
own plan does not change the project's available usage, and acting through a
26+
collaborator does not bypass an owner suspension. A project administrator is
27+
not an installation administrator and receives no platform-wide access.
28+
29+
## Invite a Registered User
30+
31+
An owner or project administrator can invite someone by the email address registered to their existing account on the same platform:
32+
33+
```sh
34+
void project team invite teammate@example.com --role collaborator
35+
```
36+
37+
Pass `--project <slug>` to manage a project other than the linked project. An unknown email is rejected. A project invitation neither creates an account nor adds the address to a restricted-signup allowlist.
38+
39+
Invitations expire after seven days. If the platform cannot send invitation
40+
email, the invitation still appears when the invited user runs
41+
`void project team pending`.
42+
43+
List the roster and sent invitations with:
44+
45+
```sh
46+
void project team list
47+
void project team invitations
48+
```
49+
50+
## Respond to an Invitation
51+
52+
The invited user can see invitations addressed to their account and respond using the invitation ID:
53+
54+
```sh
55+
void project team pending
56+
void project team accept <invitation-id>
57+
# or
58+
void project team decline <invitation-id>
59+
```
60+
61+
An invitation is bound to the registered account. Forwarding its ID does not let another user accept it.
62+
63+
These three invitation-response commands are account-scoped and do not depend
64+
on the current project's deploy target. They can run from a direct Cloudflare
65+
project directory when it resolves your connected Void platform account.
66+
67+
## Change or Remove Access
68+
69+
Owners and project administrators can change any non-owner member or revoke a pending invitation:
70+
71+
```sh
72+
void project team role <user-id> reader
73+
void project team remove <user-id>
74+
void project team revoke <invitation-id>
75+
```
76+
77+
A non-owner member can leave with `void project team leave`. The owner cannot leave; an installation administrator must [transfer ownership](./platform-administration.md#transferring-project-ownership) first.

‎docs/reference/cli.md‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,33 @@ Link current directory to an existing hosted Void project by slug, or select int
228228

229229
### `void project list`
230230

231-
List all hosted projects (slug, mode, URL). For a saved Cloudflare target, this displays the current Worker's versions instead because there is no Void project registry.
231+
List all accessible hosted projects (slug, role, type, URL). Shared projects are included and the role column distinguishes them from projects you own. For a saved Cloudflare target, this displays the current Worker's versions instead because there is no Void project registry.
232+
233+
### `void project team`
234+
235+
Manage access to a project hosted on a Void platform:
236+
237+
```sh
238+
void project team list [--project <slug>]
239+
void project team invite <email> --role <reader|collaborator|admin> [--project <slug>]
240+
void project team invitations [--project <slug>]
241+
void project team role <user-id> <reader|collaborator|admin> [--project <slug>]
242+
void project team remove <user-id> [--project <slug>]
243+
void project team revoke <invitation-id> [--project <slug>]
244+
void project team leave [--project <slug>]
245+
void project team pending
246+
void project team accept <invitation-id>
247+
void project team decline <invitation-id>
248+
```
249+
250+
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.
251+
252+
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.
253+
254+
Project-scoped team management commands are not available for projects deployed
255+
directly to Cloudflare. The account-scoped `pending`, `accept`, and `decline`
256+
commands do not depend on the project deploy target and can be run from a direct
257+
Cloudflare project directory that resolves the connected Void platform account.
232258

233259
### `void project token <create|list|renew|revoke>`
234260

@@ -526,10 +552,13 @@ List projects across the platform, filter them by owner, or inspect one project'
526552
void platform project list [--user <user-id>] [--search <text>] [--page <n>] [--limit <n>]
527553
void platform project show <id>
528554
void platform project delete <id>
555+
void platform project owner <project-id> <user-id>
529556
```
530557

531558
Search matches a project's slug, ID, or owner's login. `show` includes resources, domains, the latest 10 deployments, and the latest 20 builds. `delete` removes the project and its resources.
532559

560+
`owner` transfers a project to another registered user. Preview it with `--plan`; apply it interactively or with `--yes`. The preview reports active-work blockers and the account plan that will apply. The former owner becomes a project administrator, existing project-scoped CI deploy credentials are revoked, and usage already incurred remains with the former owner.
561+
533562
#### Deployments {#operator-deployments}
534563

535564
Find a deployment, inspect its manifest, or request cancellation:
@@ -786,7 +815,7 @@ Platform migrations only move forward. Void checks compatibility before updating
786815

787816
An upgrade completes after the new Workers pass health checks. If rollout fails, Void attempts to restore the previous Workers. Retrying does not repeat completed migrations.
788817

789-
`platform rollback` restores a compatible earlier runtime without reversing database migrations. Pass its files with `--runtime`. If the installed version is a custom build, also supply that version with `--from-runtime`. Void refuses rollbacks that are incompatible with the current database. A later `upgrade` can move forward again.
818+
`platform rollback` restores a compatible earlier runtime without reversing database migrations. Pass its files with `--runtime`. If the installed version is a custom build, also supply that version with `--from-runtime`. Void refuses targets that are incompatible with the current database or predate installed authentication, sandbox-drain, or ownership-aware usage protocols. A later `upgrade` can move forward again.
790819

791820
Uninstall verifies remote ownership before removing anything. Data resources are retained unless you pass `--purge-data`. Workers, R2, AI Gateway, DNS records, routes, custom domains, adopted resources, external PostgreSQL, and zones are always retained for manual review.
792821

‎packages/platform/src/discovery.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export type PlatformDiscoveryDocument = Readonly<{
2626
platformVersion: string;
2727
proxyUrl: string;
2828
schemaVersion: typeof PLATFORM_DISCOVERY_SCHEMA_VERSION | 2 | 3;
29-
usageProtocolVersion: 0 | 1;
29+
usageProtocolVersion: 0 | 1 | 2;
3030
}>;
3131

3232
function normalizeOrigin(value: string, name: 'apiUrl' | 'proxyUrl'): string {
@@ -50,7 +50,7 @@ function normalizeOrigin(value: string, name: 'apiUrl' | 'proxyUrl'): string {
5050

5151
export function createPlatformDiscoveryDocument(
5252
input: Omit<PlatformDiscoveryDocument, 'schemaVersion' | 'usageProtocolVersion'> & {
53-
usageProtocolVersion?: 0 | 1;
53+
usageProtocolVersion?: 0 | 1 | 2;
5454
},
5555
): PlatformDiscoveryDocument {
5656
if (!input.displayName.trim()) {
@@ -76,9 +76,10 @@ export function createPlatformDiscoveryDocument(
7676
if (
7777
input.usageProtocolVersion !== undefined &&
7878
input.usageProtocolVersion !== 0 &&
79-
input.usageProtocolVersion !== 1
79+
input.usageProtocolVersion !== 1 &&
80+
input.usageProtocolVersion !== 2
8081
) {
81-
throw new Error('usageProtocolVersion must be 0 or 1.');
82+
throw new Error('usageProtocolVersion must be 0, 1, or 2.');
8283
}
8384
const authentication = input.authentication
8485
? parseAuthentication(input.authentication)
@@ -251,12 +252,15 @@ function parseAuthentication(value: unknown): PlatformAuthenticationDiscovery {
251252
};
252253
}
253254

254-
function parseUsageProtocolVersion(value: unknown): 0 | 1 {
255+
function parseUsageProtocolVersion(value: unknown): 0 | 1 | 2 {
255256
if (value === undefined || value === 0) {
256257
return 0;
257258
}
258259
if (value === 1) {
259260
return 1;
260261
}
261-
throw new Error('usageProtocolVersion must be 0 or 1.');
262+
if (value === 2) {
263+
return 2;
264+
}
265+
throw new Error('usageProtocolVersion must be 0, 1, or 2.');
262266
}

‎packages/platform/src/installation.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY = '__void/platform/instal
1111
export const PLATFORM_SANDBOX_DRAIN_KEY = '__void/platform/sandbox-drain/v1';
1212
export const PLATFORM_R2_OWNERSHIP_MARKER_KEY = '__void/platform/ownership/v1';
1313
export const PLATFORM_RESOURCE_OWNERSHIP_SCHEMA_VERSION = 1 as const;
14-
export const PLATFORM_USAGE_PROTOCOL_VERSION = 1 as const;
14+
export const PLATFORM_USAGE_PROTOCOL_VERSION = 2 as const;
1515

1616
export type InstallationStatus =
1717
| 'draft'
@@ -106,7 +106,7 @@ export type PlatformInstallationManifest = Readonly<{
106106
steps: ReadonlyArray<InstallationStep>;
107107
updatedAt: string;
108108
/** Last usage protocol proven live across the installed Worker set. */
109-
usageProtocolVersion: 0 | 1;
109+
usageProtocolVersion: 0 | 1 | 2;
110110
}>;
111111

112112
export type PlatformResourceOwnershipMarker = Readonly<{
@@ -251,7 +251,7 @@ export function createPlatformInstallationManifest(input: {
251251
ownershipNonce: string;
252252
platformVersion: string;
253253
safetyProtocols?: Readonly<{ sandboxDrain: 0 | 1 | 2 }>;
254-
usageProtocolVersion?: 0 | 1;
254+
usageProtocolVersion?: 0 | 1 | 2;
255255
}): PlatformInstallationManifest {
256256
const createdAt = assertTimestamp(input.createdAt, 'createdAt');
257257
if (!/^[a-z0-9][a-z0-9-]{7,79}$/.test(input.id)) {
@@ -537,14 +537,17 @@ function parseAuthenticationResources(
537537
return resources;
538538
}
539539

540-
function parseUsageProtocolVersion(value: unknown): 0 | 1 {
540+
function parseUsageProtocolVersion(value: unknown): 0 | 1 | 2 {
541541
if (value === undefined || value === 0) {
542542
return 0;
543543
}
544544
if (value === 1) {
545545
return 1;
546546
}
547-
throw new Error('usageProtocolVersion must be 0 or 1.');
547+
if (value === 2) {
548+
return 2;
549+
}
550+
throw new Error('usageProtocolVersion must be 0, 1, or 2.');
548551
}
549552

550553
function parseSafetyProtocols(value: unknown): PlatformInstallationManifest['safetyProtocols'] {

‎packages/platform/src/installer.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export type PlatformRuntimeManifest = Readonly<{
4444
schemaVersion: 2;
4545
sourceRevision: string | null;
4646
/** Queue payload contract shared by the API consumer and trusted producers. */
47-
usageProtocolVersion: 0 | 1;
47+
usageProtocolVersion: 0 | 1 | 2;
4848
}>;
4949

5050
export type PlatformRuntime = Readonly<{
@@ -219,14 +219,17 @@ function parseSafetyProtocols(value: unknown): PlatformRuntimeManifest['safetyPr
219219
return { sandboxDrain: protocols.sandboxDrain };
220220
}
221221

222-
function usageProtocolVersion(value: unknown): 0 | 1 {
222+
function usageProtocolVersion(value: unknown): 0 | 1 | 2 {
223223
if (value === undefined || value === 0) {
224224
return 0;
225225
}
226226
if (value === 1) {
227227
return 1;
228228
}
229-
throw new Error('runtime manifest usageProtocolVersion must be 0 or 1');
229+
if (value === 2) {
230+
return 2;
231+
}
232+
throw new Error('runtime manifest usageProtocolVersion must be 0, 1, or 2');
230233
}
231234

232235
export function getPlatformRuntimeDirectory(): string {

‎packages/platform/src/worker-config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export function createPlatformWorkerConfigs(input: {
9494

9595
const api: Record<string, unknown> = {
9696
...common,
97+
version_metadata: { binding: 'VERSION_METADATA' },
9798
workers_dev:
9899
publicEndpoints &&
99100
(!deployment.authentication.accessProtection ||

0 commit comments

Comments
 (0)