From 5214dc4319e27d4089b6848287bb5508cda19f1d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 17 Sep 2026 22:06:49 +0800 Subject: [PATCH 01/18] feat(platform): add project teams --- docs/.vitepress/config.ts | 1 + docs/guide/deployment.md | 2 + docs/guide/platform-administration.md | 13 + docs/guide/project-collaboration.md | 60 + docs/reference/cli.md | 28 +- packages/void/skills/void/SKILL.md | 2 + packages/void/src/cli/cli.ts | 4 +- packages/void/src/cli/client.ts | 186 + packages/void/src/cli/help.ts | 60 + packages/void/src/cli/operator-args.ts | 8 + packages/void/src/cli/project-args.ts | 163 +- packages/void/src/cli/project-cmd.ts | 14 + packages/void/src/cli/project-team.ts | 157 + packages/void/src/cli/projects.ts | 6 +- .../void/test/integration/cli-help.test.ts | 13 + .../cloudflare-deploy-build.test.ts | 30 + .../test/integration/operator-cli.test.ts | 19 + .../test/integration/projects-command.test.ts | 4 + .../void/test/unit/cloudflare-deploy.test.ts | 26 + packages/void/test/unit/operator-cli.test.ts | 8 + packages/void/test/unit/project-args.test.ts | 77 + .../test/unit/project-team-client.test.ts | 117 + .../test/unit/project-team-command.test.ts | 68 + .../meta/design-docs/0080-project-teams.md | 520 ++ platform/meta/design-docs/README.md | 2 +- .../api/drizzle/0048_project_teams.sql | 90 + .../api/drizzle/meta/0048_snapshot.json | 6153 +++++++++++++++++ .../packages/api/drizzle/meta/_journal.json | 7 + .../api/src/admin/routes/projects.tsx | 171 +- platform/packages/api/src/db.ts | 838 ++- platform/packages/api/src/email.ts | 30 + platform/packages/api/src/index.ts | 28 +- platform/packages/api/src/operator/audit.ts | 9 +- .../api/src/operator/tenant-routes.ts | 12 + .../api/src/operator/tenant-service.ts | 486 +- platform/packages/api/src/project-access.ts | 105 + .../packages/api/src/provisioning/cleanup.ts | 18 +- platform/packages/api/src/routes/auth-oidc.ts | 18 +- platform/packages/api/src/routes/delete.ts | 21 +- platform/packages/api/src/routes/deploy.ts | 153 +- platform/packages/api/src/routes/domains.ts | 203 +- platform/packages/api/src/routes/email.ts | 50 +- platform/packages/api/src/routes/github.ts | 111 +- .../api/src/routes/internal-verify-owner.ts | 19 +- .../packages/api/src/routes/project-access.ts | 27 + .../packages/api/src/routes/project-team.ts | 465 ++ platform/packages/api/src/routes/projects.ts | 660 +- .../packages/api/src/routes/revalidate.ts | 14 +- platform/packages/api/src/routes/secrets.ts | 45 +- platform/packages/api/src/routes/usage.ts | 220 +- platform/packages/api/src/routing.ts | 27 +- platform/packages/api/src/schema.ts | 141 +- platform/packages/api/src/types.ts | 21 + platform/packages/api/src/usage-counter.ts | 420 +- platform/packages/api/src/usage-queue.ts | 52 +- .../test/integration/admin-dashboard.test.ts | 90 + .../api/test/integration/api-delete.test.ts | 49 + .../api/test/integration/api-deploy.test.ts | 123 + .../api/test/integration/api-domains.test.ts | 61 +- .../api/test/integration/api-github.test.ts | 199 + .../test/integration/api-migrations.test.ts | 11 +- .../api-project-access-routes.test.ts | 202 + .../test/integration/api-project-team.test.ts | 551 ++ .../api/test/integration/api-projects.test.ts | 75 + .../api/test/integration/db-builds.test.ts | 108 + .../test/integration/deploy-direct-r2.test.ts | 17 +- .../test/integration/operator-audit.test.ts | 25 + .../test/integration/operator-tenants.test.ts | 387 ++ .../test/integration/project-access.test.ts | 266 + .../project-team-lifecycle.test.ts | 174 + .../api/test/unit/project-access.test.ts | 30 + .../packages/api/test/unit/routing.test.ts | 60 +- .../packages/api/test/unit/suspension.test.ts | 10 + .../api/test/unit/usage-counter.test.ts | 283 +- .../api/test/unit/usage-queue.test.ts | 94 +- platform/packages/proxy/src/index.ts | 4 +- platform/packages/proxy/src/resolve.ts | 6 +- .../packages/proxy/test/unit/proxy.test.ts | 35 +- 78 files changed, 14386 insertions(+), 676 deletions(-) create mode 100644 docs/guide/project-collaboration.md create mode 100644 packages/void/src/cli/project-team.ts create mode 100644 packages/void/test/unit/project-team-client.test.ts create mode 100644 packages/void/test/unit/project-team-command.test.ts create mode 100644 platform/meta/design-docs/0080-project-teams.md create mode 100644 platform/packages/api/drizzle/0048_project_teams.sql create mode 100644 platform/packages/api/drizzle/meta/0048_snapshot.json create mode 100644 platform/packages/api/src/project-access.ts create mode 100644 platform/packages/api/src/routes/project-access.ts create mode 100644 platform/packages/api/src/routes/project-team.ts create mode 100644 platform/packages/api/test/integration/api-project-access-routes.test.ts create mode 100644 platform/packages/api/test/integration/api-project-team.test.ts create mode 100644 platform/packages/api/test/integration/project-access.test.ts create mode 100644 platform/packages/api/test/integration/project-team-lifecycle.test.ts create mode 100644 platform/packages/api/test/unit/project-access.test.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index cd32aef26..066f49641 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -247,6 +247,7 @@ export default extendConfig( items: [ { text: 'Remote Development', link: '/guide/remote-dev' }, { text: 'Deploying Your App', link: '/guide/deployment' }, + { text: 'Project Collaboration', link: '/guide/project-collaboration' }, ], }, { diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 8628a9712..230076f88 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -60,6 +60,8 @@ Already have a Cloudflare Worker and a root `wrangler.jsonc` or `wrangler.json`? To connect to your team's platform and sign in, run `void connect ` 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. +Owners can [share a platform project](./project-collaboration.md) with readers, collaborators, and project administrators. This does not apply to direct Cloudflare deployments. + ### Migrations If your app uses Drizzle, `void deploy` runs migrations as part of the deploy flow: diff --git a/docs/guide/platform-administration.md b/docs/guide/platform-administration.md index fdbc3aba5..6846cb920 100644 --- a/docs/guide/platform-administration.md +++ b/docs/guide/platform-administration.md @@ -185,6 +185,19 @@ void platform project show 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. +### Transferring Project Ownership + +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: + +```sh +void platform project owner --plan +void platform project owner --yes +``` + +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, build, or Sandbox lifecycle operation before retrying. + +After the transfer, the former owner becomes a project administrator. 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. + New users on a self-hosted installation start with the `custom` profile, which does not cap application requests, AI usage, deployment frequency, or retained Worker deployments. Named profiles such as `pro` apply the platform's quota and diff --git a/docs/guide/project-collaboration.md b/docs/guide/project-collaboration.md new file mode 100644 index 000000000..fbf4a8cc5 --- /dev/null +++ b/docs/guide/project-collaboration.md @@ -0,0 +1,60 @@ +--- +outline: deep +--- + +# Project Collaboration + +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. + +## Roles + +| Role | Access | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Reader | View the project, usage, resources, deployments, builds, logs, and member roster. Cannot deploy. | +| Collaborator | Reader access plus deploy, rollback, cancellation, migrations, secrets, database configuration, and deploy prerequisites. | +| Project administrator | Collaborator access plus domains, email destinations, GitHub configuration, invitations, roles, and member removal. | +| Owner | Project administrator access plus project deletion. | + +A collaborator's deploys use the owner's plan and limits. Changing who performs the deploy does not change the project's available usage. A project administrator is not an installation administrator and receives no platform-wide access. + +## Invite a Registered User + +An owner or project administrator can invite someone by the email address registered to their existing account on the same platform: + +```sh +void project team invite teammate@example.com --role collaborator +``` + +Pass `--project ` 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. + +List the roster and sent invitations with: + +```sh +void project team list +void project team invitations +``` + +## Respond to an Invitation + +The invited user can see invitations addressed to their account and respond using the invitation ID: + +```sh +void project team pending +void project team accept +# or +void project team decline +``` + +An invitation is bound to the registered account. Forwarding its ID does not let another user accept it. + +## Change or Remove Access + +Owners and project administrators can change any non-owner member or revoke a pending invitation: + +```sh +void project team role reader +void project team remove +void project team revoke +``` + +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. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c5646b181..bc053b89b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -228,7 +228,30 @@ Link current directory to an existing hosted Void project by slug, or select int ### `void project list` -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. +List all accessible hosted projects (slug, role, mode, 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. + +### `void project team` + +Manage access to a project hosted on a Void platform: + +```sh +void project team list [--project ] +void project team invite --role [--project ] +void project team invitations [--project ] +void project team role [--project ] +void project team remove [--project ] +void project team revoke [--project ] +void project team leave [--project ] +void project team pending +void project team accept +void project team decline +``` + +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. + +Readers can view the project but cannot deploy. Collaborators can deploy and manage deploy prerequisites. Project administrators can additionally configure the project and manage its team. The owner alone can delete the project. See [Project Collaboration](../guide/project-collaboration.md) for the full role boundaries. + +Team commands are not available for projects deployed directly to Cloudflare. ### `void project token ` @@ -526,10 +549,13 @@ List projects across the platform, filter them by owner, or inspect one project' void platform project list [--user ] [--search ] [--page ] [--limit ] void platform project show void platform project delete +void platform project owner ``` 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. +`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 limits that will apply. The former owner becomes a project administrator, and usage already incurred remains with the former owner. + #### Deployments {#operator-deployments} Find a deployment, inspect its manifest, or request cancellation: diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 401c5770f..47bd34ae7 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -75,6 +75,8 @@ mutation failure before retrying. Use `void connect` for deployment onboarding: no arguments offers Cloudflare or a Void platform, `--platform cloudflare` signs in and selects an account, and a platform URL verifies discovery and signs in with a supported provider. It preserves existing project links when connecting elsewhere. `--no-login` saves only verified Void connection metadata; authenticated headless connection requires a valid origin-scoped keychain session or `VOID_TOKEN` with matching `VOID_API_URL`. Platform installation and administration use `void platform`. +For Void platform project access, use `void project team`: invite only an email already registered on that platform, assign `reader`, `collaborator`, or `admin`, and use the pending/accept/decline commands for the invited account. These commands do not apply to direct Cloudflare deployments. Installation administrators transfer ownership with `void platform project owner --plan` and apply the reviewed transfer with `--yes`. + Use Void commands for every user-facing workflow. Never ask the user to install, authenticate, or run Wrangler directly. Say Cloudflare or Void instead, except when naming literal `wrangler.jsonc` / `wrangler.json` files or `WRANGLER_*` environment variables the user must inspect. Use `void` in examples and commands in this skill. For first-time setup, prefer `void init` followed by `void deploy`; in an empty directory, install `void` first and let `void init` add the matching Pages adapter and starter dependencies with Vite+ as the default scaffold toolchain. In an existing app, `void init` configures Void in place by adding missing Vite scripts and creating or patching `vite.config.*` with `voidPlugin()`. For Cloudflare deployment, Void uses bundled tooling and secure browser OAuth; init or the first deploy saves the selected `account_id`, and `void cloudflare login|status|logout` manages the session; Void-managed deployment requires an explicitly connected platform. Use `void connect ` for an existing platform or `void platform install --plan` to preview a company control plane in the user's Cloudflare account. The core self-hosted platform excludes the dashboard, GitHub App, and build Containers; lifecycle commands are resumable and verify remote, Worker, and R2 ownership before mutations. `void platform disable` gates traffic through installation-owned routing storage and remains disabled through repair or upgrade; `void platform enable` explicitly restores traffic. Safe uninstall retains name-addressed Workers, R2, AI Gateway, external PostgreSQL, and zones for explicit manual cleanup. Direct Cloudflare deploys support native Void apps, static/SPA/SSG output, and Cloudflare builds from TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog, and Astro. They provision inferred resources, validate and apply migrations, require schema-declared server values in encrypted remote secret storage, upload and probe an immutable Worker Version, then activate and synchronize triggers. Native Void features are Workers Free-compatible by default; `void/sandbox` is an explicit exception because it uses Cloudflare Containers and therefore requires Workers Paid. A Sandbox deploy checks Containers access before provisioning/building, while apps without Sandbox perform no entitlement probe. Versions without preview URLs are staged at 0% and probed through workers.dev using a version override; the same safe fallback applies when Access blocks a generated preview alias but admits the stable Worker hostname. Set `CLOUDFLARE_WORKERS_SUBDOMAIN` in a fresh CI checkout, while local deploys cache it automatically. When Cloudflare Access protects `workers.dev`, use an admitted `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` service-token pair for CI, or a short-lived `CF_ACCESS_TOKEN` from `cloudflared` for an interactive local readiness probe. With Cloudflare saved in `.void/project.json`, secret, domain, project status/log/rollback, and remote database commands operate directly on the pinned Worker/account. Logs are a live tail. Rollback restores the selected version's saved schedules, queues, workflows, routes, and domains but never reverses database migrations; versions without complete trigger snapshots use code-only rollback and keep the current routes and schedules. On either deployment platform, auth-enabled `void deploy` preserves an existing `BETTER_AUTH_SECRET` or creates a persistent encrypted secret when missing; always use Void's deployment flow so it can manage that secret safely. diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index f942725a0..21f4eba5c 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -264,8 +264,8 @@ if (helpPath) { }, { name: 'project', - hint: 'status | link | list | token | logs | requests | rollback | cancel | delete | purge-cache', - description: 'Manage projects, deployments, and scoped CI credentials', + hint: 'status | link | list | team | token | logs | requests | rollback | cancel | delete | purge-cache', + description: 'Manage projects, teams, deployments, and scoped CI credentials', }, { name: 'secret', hint: 'list | put | delete | sync', description: 'Manage project secrets' }, { diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index f429ef22f..eb2af471c 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -115,6 +115,63 @@ export type ProjectResponse = { type: string; created_at: string; custom_domain: string | null; + role?: ProjectRole; + owner?: { + id: string; + login?: string; + }; +}; + +export type ProjectRole = 'reader' | 'collaborator' | 'admin' | 'owner'; + +export type ProjectMemberResponse = { + user_id?: string; + role: ProjectRole; + login?: string; + email?: string; + created_at?: string; + user?: { + id: string; + login?: string; + email?: string; + }; +}; + +export type ProjectInvitationResponse = { + id: string; + project_id?: string; + target_user_id?: string; + target_email_snapshot?: string; + target_email?: string; + role: Exclude; + status?: 'pending' | 'accepted' | 'declined' | 'revoked' | 'expired'; + created_at: string; + expires_at: string; + accepted_at?: string | null; + revoked_at?: string | null; + project?: { + id: string; + slug: string; + owner?: { + id: string; + login?: string; + email?: string; + }; + }; + target?: { + id: string; + login?: string; + email?: string; + }; + invited_by?: { + id: string; + login?: string; + email?: string; + }; +}; + +export type ProjectInvitationDelivery = { + status: 'sent' | 'not_configured' | 'failed'; }; export type ProjectDeployCredentialResponse = { @@ -593,6 +650,52 @@ export class PlatformClient { return res.json(); } + async listProjectMembers(projectId: string): Promise> { + const res = await this.request(`${this.baseUrl}/projects/${projectId}/members`, { + headers: { ...this.authHeaders }, + }); + if (!res.ok) { + await throwPlatformApiError('Failed to list project members', res); + } + const body = (await res.json()) as + | Array + | { members: Array }; + return Array.isArray(body) ? body : body.members; + } + + async listProjectInvitations(projectId: string): Promise> { + const res = await this.request(`${this.baseUrl}/projects/${projectId}/invitations`, { + headers: { ...this.authHeaders }, + }); + if (!res.ok) { + await throwPlatformApiError('Failed to list project invitations', res); + } + const body = (await res.json()) as + | Array + | { invitations: Array }; + return Array.isArray(body) ? body : body.invitations; + } + + async inviteProjectMember( + projectId: string, + email: string, + role: Exclude, + ): Promise<{ + invitation: ProjectInvitationResponse; + refreshed?: boolean; + delivery: ProjectInvitationDelivery; + }> { + const res = await this.request(`${this.baseUrl}/projects/${projectId}/invitations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this.authHeaders }, + body: JSON.stringify({ email, role }), + }); + if (!res.ok) { + await throwPlatformApiError('Failed to invite project member', res); + } + return res.json(); + } + async renewProjectToken( projectId: string, credentialId: string, @@ -612,6 +715,25 @@ export class PlatformClient { return res.json(); } + async updateProjectMemberRole( + projectId: string, + userId: string, + role: Exclude, + ): Promise<{ member: ProjectMemberResponse }> { + const res = await this.request( + `${this.baseUrl}/projects/${projectId}/members/${encodeURIComponent(userId)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...this.authHeaders }, + body: JSON.stringify({ role }), + }, + ); + if (!res.ok) { + await throwPlatformApiError('Failed to change project member role', res); + } + return res.json(); + } + async revokeProjectToken(projectId: string, credentialId: string): Promise<{ ok: boolean }> { const res = await this.request( `${this.baseUrl}/projects/${projectId}/tokens/${encodeURIComponent(credentialId)}`, @@ -623,6 +745,70 @@ export class PlatformClient { return res.json(); } + async removeProjectMember(projectId: string, userId: string): Promise<{ ok: boolean }> { + const res = await this.request( + `${this.baseUrl}/projects/${projectId}/members/${encodeURIComponent(userId)}`, + { method: 'DELETE', headers: { ...this.authHeaders } }, + ); + if (!res.ok) { + await throwPlatformApiError('Failed to remove project member', res); + } + return { ok: true }; + } + + async revokeProjectInvitation(projectId: string, invitationId: string): Promise<{ ok: boolean }> { + const res = await this.request( + `${this.baseUrl}/projects/${projectId}/invitations/${encodeURIComponent(invitationId)}`, + { method: 'DELETE', headers: { ...this.authHeaders } }, + ); + if (!res.ok) { + await throwPlatformApiError('Failed to revoke project invitation', res); + } + return { ok: true }; + } + + async leaveProject(projectId: string): Promise<{ ok: boolean }> { + const res = await this.request(`${this.baseUrl}/projects/${projectId}/leave`, { + method: 'POST', + headers: { ...this.authHeaders }, + }); + if (!res.ok) { + await throwPlatformApiError('Failed to leave project', res); + } + return { ok: true }; + } + + async listPendingProjectInvitations(): Promise> { + const res = await this.request(`${this.baseUrl}/me/project-invitations`, { + headers: { ...this.authHeaders }, + }); + if (!res.ok) { + await throwPlatformApiError('Failed to list pending project invitations', res); + } + const body = (await res.json()) as + | Array + | { invitations: Array }; + return Array.isArray(body) ? body : body.invitations; + } + + async respondToProjectInvitation( + invitationId: string, + action: 'accept' | 'decline', + ): Promise<{ ok: true; member?: ProjectMemberResponse }> { + const res = await this.request( + `${this.baseUrl}/project-invitations/${encodeURIComponent(invitationId)}/${action}`, + { method: 'POST', headers: { ...this.authHeaders } }, + ); + if (!res.ok) { + await throwPlatformApiError(`Failed to ${action} project invitation`, res); + } + if (action === 'decline' || res.status === 204) { + return { ok: true }; + } + const body = (await res.json()) as { member: ProjectMemberResponse }; + return { ok: true, member: body.member }; + } + async listDeployments(projectId: string): Promise { const res = await this.request(`${this.baseUrl}/projects/${projectId}/deployments`, { headers: { diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 67099637b..a177b584e 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -458,6 +458,7 @@ const HELP_TREE: HelpTree = { { command: 'link [name]', description: 'Link this directory to a project' }, { command: 'list', description: 'List your projects' }, { command: 'token ', description: 'Manage project-scoped CI deploy credentials' }, + { command: 'team ', description: 'Manage members and project invitations' }, { command: 'logs', description: 'Show runtime logs from the deployed project' }, { command: 'requests', description: 'Show request-level traffic (status, method, timing)' }, { command: 'rollback [deployId]', description: 'Roll back to a previous deployment' }, @@ -494,6 +495,65 @@ const HELP_TREE: HelpTree = { { command: 'void project token revoke ', description: 'Revoke a credential now' }, ], }, + team: { + summary: 'Manage members and invitations for Void platform projects.', + usage: ['void project team '], + commands: [ + { command: 'list', description: 'List project members and roles' }, + { command: 'invite ', description: 'Invite a registered user by email' }, + { command: 'invitations', description: 'List invitations sent for a project' }, + { command: 'role ', description: "Change a member's role" }, + { command: 'remove ', description: 'Remove a project member' }, + { command: 'revoke ', description: 'Revoke a project invitation' }, + { command: 'leave', description: 'Leave a shared project' }, + { command: 'pending', description: 'List invitations awaiting your response' }, + { command: 'accept ', description: 'Accept your invitation' }, + { command: 'decline ', description: 'Decline your invitation' }, + ], + children: { + list: withProjectOption( + 'List project members and roles.', + 'void project team list [options]', + ), + invite: { + summary: 'Invite an existing Void platform user by registered email address.', + usage: ['void project team invite --role [options]'], + options: [ + { command: '--role ', description: 'reader, collaborator, or admin' }, + { command: '--project ', description: 'Project name override' }, + ], + }, + invitations: withProjectOption( + 'List invitations sent for a project.', + 'void project team invitations [options]', + ), + role: withProjectOption( + "Change a member's role to reader, collaborator, or admin.", + 'void project team role [options]', + ), + remove: withProjectOption( + 'Remove a member from the project.', + 'void project team remove [options]', + ), + revoke: withProjectOption( + 'Revoke a pending project invitation.', + 'void project team revoke [options]', + ), + leave: withProjectOption('Leave a shared project.', 'void project team leave [options]'), + pending: basicSubcommand( + 'List project invitations awaiting your response.', + 'void project team pending', + ), + accept: basicSubcommand( + 'Accept an invitation addressed to your account.', + 'void project team accept ', + ), + decline: basicSubcommand( + 'Decline an invitation addressed to your account.', + 'void project team decline ', + ), + }, + }, logs: { summary: 'Show runtime logs from the deployed project.', usage: ['void project logs [options]'], diff --git a/packages/void/src/cli/operator-args.ts b/packages/void/src/cli/operator-args.ts index 543fd3f1d..eb95620a3 100644 --- a/packages/void/src/cli/operator-args.ts +++ b/packages/void/src/cli/operator-args.ts @@ -111,6 +111,14 @@ export const operatorCommands: Array = [ positional: ['id'], method: 'POST', }, + { + group: 'project', + action: 'owner', + description: 'Transfer project ownership to another registered user', + path: '/projects/:id/owner', + positional: ['id', 'userId'], + method: 'POST', + }, { group: 'deployment', action: 'list', diff --git a/packages/void/src/cli/project-args.ts b/packages/void/src/cli/project-args.ts index 194242f80..38d8a5a08 100644 --- a/packages/void/src/cli/project-args.ts +++ b/packages/void/src/cli/project-args.ts @@ -5,6 +5,9 @@ const LOG_LEVELS = new Set(['error', 'warn', 'info', 'log', 'debug', 'all']); // `all`. Exact codes are range-bounded so a typo like `600` errors instead of // silently returning an empty result. const STATUS_FILTER = /^([1-5]xx|[1-5]\d{2}|all)$/; +const TEAM_ROLES = new Set(['reader', 'collaborator', 'admin']); + +export type TeamRole = 'reader' | 'collaborator' | 'admin'; export const projectSubcommands: Array = [ { name: 'status', arg: '[name]', description: 'Show deployments (omit name for linked project)' }, @@ -15,6 +18,11 @@ export const projectSubcommands: Array = [ arg: '', description: 'Manage project-scoped CI deploy credentials', }, + { + name: 'team', + hint: 'list | invite | invitations | role | remove | revoke | leave | pending | accept | decline', + description: 'Manage project members and invitations', + }, { name: 'logs', description: 'Show runtime logs from the deployed project' }, { name: 'requests', description: 'Show request-level traffic (status, method, timing)' }, { name: 'rollback', arg: '[deployId]', description: 'Roll back to a previous deployment' }, @@ -31,6 +39,47 @@ export type ProjectArgs = | { subcommand: 'status'; name: string } | { subcommand: 'link'; name: string } | { subcommand: 'list' } + | { subcommand: 'team'; action: '' } + | { + subcommand: 'team'; + action: 'list' | 'invitations' | 'leave'; + projectSlug: string; + } + | { + subcommand: 'team'; + action: 'invite'; + email: string; + role: TeamRole; + projectSlug: string; + } + | { + subcommand: 'team'; + action: 'role'; + userId: string; + role: TeamRole; + projectSlug: string; + } + | { + subcommand: 'team'; + action: 'remove'; + userId: string; + projectSlug: string; + } + | { + subcommand: 'team'; + action: 'revoke'; + invitationId: string; + projectSlug: string; + } + | { + subcommand: 'team'; + action: 'pending'; + } + | { + subcommand: 'team'; + action: 'accept' | 'decline'; + invitationId: string; + } | { subcommand: 'token'; action: 'create' | 'list' | 'renew' | 'revoke'; @@ -137,6 +186,10 @@ export function parseProjectArgs(args: Array): ProjectArgs { }; } + if (subcommand === 'team') { + return parseTeamArgs(rest); + } + if (subcommand === 'logs') { let filter = ''; let range = '1h'; @@ -237,6 +290,114 @@ export function parseProjectArgs(args: Array): ProjectArgs { } throw new Error( - 'project: Usage: void project .', + 'project: Usage: void project .', ); } + +function parseTeamArgs(rest: Array): ProjectArgs { + const [action, ...args] = rest; + const usage = + 'project: Usage: void project team .'; + if (!action) { + return { subcommand: 'team', action: '' }; + } + + const positionals: Array = []; + let projectSlug = ''; + let role = ''; + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (arg === '--project' || arg === '--role') { + const value = args[++index]; + if (!value || value.startsWith('-')) { + throw new Error(`project: ${arg} requires a value.`); + } + if (arg === '--project') { + if (projectSlug) { + throw new Error("project: Duplicate flag '--project'."); + } + projectSlug = value; + } else { + if (role) { + throw new Error("project: Duplicate flag '--role'."); + } + role = value; + } + } else if (arg.startsWith('-')) { + throw new Error(`project: Unknown flag '${arg}'.`); + } else { + positionals.push(arg); + } + } + + if (role && !TEAM_ROLES.has(role)) { + throw new Error(`project: Invalid role '${role}'. Use one of reader|collaborator|admin.`); + } + if (['pending', 'accept', 'decline'].includes(action) && projectSlug) { + throw new Error(`project: --project is not used with team ${action}.`); + } + + if (action === 'list' || action === 'invitations' || action === 'leave') { + if (positionals.length || role) { + throw new Error(`project: Usage: void project team ${action} [--project ].`); + } + return { subcommand: 'team', action, projectSlug }; + } + if (action === 'invite') { + if (positionals.length !== 1 || !role) { + throw new Error( + 'project: Usage: void project team invite --role [--project ].', + ); + } + const email = positionals[0]!; + if (!email.includes('@') || /\s/.test(email)) { + throw new Error('project: Invitee must be a registered email address.'); + } + return { subcommand: 'team', action, email, role: role as TeamRole, projectSlug }; + } + if (action === 'role') { + if (positionals.length !== 2 || role) { + throw new Error( + 'project: Usage: void project team role [--project ].', + ); + } + const nextRole = positionals[1]!; + if (!TEAM_ROLES.has(nextRole)) { + throw new Error(`project: Invalid role '${nextRole}'. Use one of reader|collaborator|admin.`); + } + return { + subcommand: 'team', + action, + userId: positionals[0]!, + role: nextRole as TeamRole, + projectSlug, + }; + } + if (action === 'remove') { + if (positionals.length !== 1 || role) { + throw new Error('project: Usage: void project team remove [--project ].'); + } + return { subcommand: 'team', action, userId: positionals[0]!, projectSlug }; + } + if (action === 'revoke') { + if (positionals.length !== 1 || role) { + throw new Error( + 'project: Usage: void project team revoke [--project ].', + ); + } + return { subcommand: 'team', action, invitationId: positionals[0]!, projectSlug }; + } + if (action === 'pending') { + if (positionals.length || role) { + throw new Error('project: Usage: void project team pending.'); + } + return { subcommand: 'team', action }; + } + if (action === 'accept' || action === 'decline') { + if (positionals.length !== 1 || role) { + throw new Error(`project: Usage: void project team ${action} .`); + } + return { subcommand: 'team', action, invitationId: positionals[0]! }; + } + throw new Error(usage); +} diff --git a/packages/void/src/cli/project-cmd.ts b/packages/void/src/cli/project-cmd.ts index 6e38dd55c..21cadde07 100644 --- a/packages/void/src/cli/project-cmd.ts +++ b/packages/void/src/cli/project-cmd.ts @@ -33,6 +33,20 @@ export async function runProjectCommand(root: string, args: ProjectArgs): Promis const { runProjectTokenCommand } = await import('./project-token.ts'); return runProjectTokenCommand(root, args); } + if (args.subcommand === 'team') { + if (args.action === '') { + const { printHelpPage } = await import('./help.ts'); + printHelpPage(['project', 'team']); + return; + } + if (cloudflare) { + throw new Error( + 'project: Teams are available only for projects hosted on a Void platform, not direct Cloudflare deployments.', + ); + } + const { runProjectTeamCommand } = await import('./project-team.ts'); + return runProjectTeamCommand(root, args); + } if (args.subcommand === 'logs') { const { runLogsCommand } = await import('./logs.ts'); return runLogsCommand( diff --git a/packages/void/src/cli/project-team.ts b/packages/void/src/cli/project-team.ts new file mode 100644 index 000000000..18a5f25c9 --- /dev/null +++ b/packages/void/src/cli/project-team.ts @@ -0,0 +1,157 @@ +import { intro, log, note, outro } from '@clack/prompts'; +import { getToken } from './auth.ts'; +import { + PlatformClient, + type ProjectInvitationResponse, + type ProjectMemberResponse, +} from './client.ts'; +import { cliTitle } from './output.ts'; +import type { ProjectArgs } from './project-args.ts'; +import { readProjectConfig } from './project.ts'; +import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; + +type ProjectTeamArgs = Exclude, { action: '' }>; + +function clean(value: string | undefined): string { + // Tenant text must not execute terminal control sequences. + // eslint-disable-next-line no-control-regex + return (value ?? '—').replace(/[\u0000-\u001f\u007f]/g, ' '); +} + +function memberLines(members: Array): string { + const rows = members.map((member) => ({ + id: member.user?.id ?? member.user_id, + login: member.user?.login ?? member.login ?? '—', + email: member.user?.email ?? member.email ?? '—', + role: member.role, + })); + const widths = { + id: Math.max(2, ...rows.map((row) => clean(row.id).length)), + login: Math.max(5, ...rows.map((row) => clean(row.login).length)), + email: Math.max(5, ...rows.map((row) => clean(row.email).length)), + }; + return [ + `${'ID'.padEnd(widths.id)} ${'LOGIN'.padEnd(widths.login)} ${'EMAIL'.padEnd(widths.email)} ROLE`, + ...rows.map( + (row) => + `${clean(row.id).padEnd(widths.id)} ${clean(row.login).padEnd(widths.login)} ${clean(row.email).padEnd(widths.email)} ${row.role}`, + ), + ].join('\n'); +} + +function invitationEmail(invitation: ProjectInvitationResponse): string { + return ( + invitation.target?.email ?? invitation.target_email ?? invitation.target_email_snapshot ?? '—' + ); +} + +function invitationLines(invitations: Array, pending: boolean): string { + return invitations + .map((invitation) => { + const project = pending ? ` ${clean(invitation.project?.slug)}` : ''; + const target = pending ? '' : ` ${clean(invitationEmail(invitation))}`; + const status = pending ? '' : ` ${invitation.status ?? 'pending'}`; + return `${clean(invitation.id)}${project}${target} ${invitation.role}${status} expires ${clean(invitation.expires_at)}`; + }) + .join('\n'); +} + +async function resolveProjectId( + root: string, + client: PlatformClient, + projectSlug: string, +): Promise { + const requestedSlug = getRequestedProjectSlug({ projectSlug }); + if (requestedSlug) { + const project = resolveProjectBySlug(await client.listProjects(), requestedSlug); + if (!project) { + throw new Error(`project: No project found with slug '${requestedSlug}'.`); + } + return project.projectId; + } + const linked = readProjectConfig(root); + if (!linked) { + throw new Error('No linked project. Run `void project link` or pass --project .'); + } + return linked.projectId; +} + +export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs): Promise { + console.log(); + intro(cliTitle(`project team ${args.action}`)); + + const token = getToken(root); + if (!token) { + log.error('Not logged in. Run `void auth login` first.'); + process.exit(1); + } + const client = new PlatformClient(token, { root }); + + if (args.action === 'pending') { + const invitations = await client.listPendingProjectInvitations(); + if (invitations.length === 0) { + outro('No pending project invitations.'); + return; + } + note(invitationLines(invitations, true), 'Pending Project Invitations'); + outro(`${invitations.length} invitation(s)`); + return; + } + if (args.action === 'accept' || args.action === 'decline') { + await client.respondToProjectInvitation(args.invitationId, args.action); + outro(`Project invitation ${args.action === 'accept' ? 'accepted' : 'declined'}.`); + return; + } + + const projectId = await resolveProjectId( + root, + client, + 'projectSlug' in args ? args.projectSlug : '', + ); + if (args.action === 'list') { + const members = await client.listProjectMembers(projectId); + if (members.length === 0) { + outro('No project members found.'); + return; + } + note(memberLines(members), 'Project Team'); + outro(`${members.length} member(s)`); + return; + } + if (args.action === 'invitations') { + const invitations = await client.listProjectInvitations(projectId); + if (invitations.length === 0) { + outro('No project invitations found.'); + return; + } + note(invitationLines(invitations, false), 'Project Invitations'); + outro(`${invitations.length} invitation(s)`); + return; + } + if (args.action === 'invite') { + const result = await client.inviteProjectMember(projectId, args.email, args.role); + const delivery = + result.delivery.status === 'sent' + ? '' + : ` (email delivery: ${result.delivery.status.replace('_', ' ')})`; + outro(`Invited ${invitationEmail(result.invitation)} as ${args.role}${delivery}.`); + return; + } + if (args.action === 'role') { + await client.updateProjectMemberRole(projectId, args.userId, args.role); + outro(`Changed ${args.userId} to ${args.role}.`); + return; + } + if (args.action === 'remove') { + await client.removeProjectMember(projectId, args.userId); + outro(`Removed ${args.userId} from the project.`); + return; + } + if (args.action === 'revoke') { + await client.revokeProjectInvitation(projectId, args.invitationId); + outro(`Revoked invitation ${args.invitationId}.`); + return; + } + await client.leaveProject(projectId); + outro('Left the project.'); +} diff --git a/packages/void/src/cli/projects.ts b/packages/void/src/cli/projects.ts index da66fcec7..dd48f8766 100644 --- a/packages/void/src/cli/projects.ts +++ b/packages/void/src/cli/projects.ts @@ -23,6 +23,7 @@ export async function runProjectsCommand(root: string): Promise { const rows = projects.map((p) => ({ slug: p.slug, + role: p.role ?? 'owner', appType: p.type ?? 'worker', url: p.custom_domain ? `https://${p.custom_domain}` @@ -30,13 +31,14 @@ export async function runProjectsCommand(root: string): Promise { })); const slugWidth = Math.max('slug'.length, ...rows.map((r) => r.slug.length)); + const roleWidth = Math.max('role'.length, ...rows.map((r) => r.role.length)); const typeWidth = Math.max('type'.length, ...rows.map((r) => r.appType.length)); const prefix = tablePrefix(); - const header = `${prefix}${dim(`${'slug'.padEnd(slugWidth)} ${'type'.padEnd(typeWidth)} url`)}`; + const header = `${prefix}${dim(`${'slug'.padEnd(slugWidth)} ${'role'.padEnd(roleWidth)} ${'type'.padEnd(typeWidth)} url`)}`; const lines = rows.map( (row) => - `${prefix}${pc.green(row.slug)}${''.padEnd(slugWidth - row.slug.length)} ${row.appType.padEnd(typeWidth)} ${row.url}`, + `${prefix}${pc.green(row.slug)}${''.padEnd(slugWidth - row.slug.length)} ${row.role.padEnd(roleWidth)} ${row.appType.padEnd(typeWidth)} ${row.url}`, ); console.log(prefix); console.log(header); diff --git a/packages/void/test/integration/cli-help.test.ts b/packages/void/test/integration/cli-help.test.ts index e17625f02..d17ba1b0a 100644 --- a/packages/void/test/integration/cli-help.test.ts +++ b/packages/void/test/integration/cli-help.test.ts @@ -89,6 +89,19 @@ describe('void help (subprocess)', () => { expect(output).toContain('void db execute --file '); }); + it('prints project team and ownership-transfer help', () => { + const root = createProject(); + const team = runVoid(root, ['project', 'team', 'invite', '--help']); + expect(team.status).toBe(0); + expect(team.output).toContain('registered email'); + expect(team.output).toContain('--role '); + + const owner = runVoid(root, ['platform', 'project', 'owner', '--help']); + expect(owner.status).toBe(0); + expect(owner.output).toContain('void platform project owner '); + expect(owner.output).toContain('--plan'); + }); + it('fails unknown help topics', () => { const root = createProject(); const { output, status } = runVoid(root, ['help', 'missing']); diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts index 61a3173ea..fe36499ec 100644 --- a/packages/void/test/integration/cloudflare-deploy-build.test.ts +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -219,6 +219,36 @@ describe('playground/kitchen-sink --backend cloudflare real-build deploy guard', expect(queues.consumers).toContainEqual(expect.objectContaining({ queue: 'demo-queue' })); }); + it('emits no Void Platform project-team binding or variable', () => { + const teamTerms = /(?:project[_-]?)?(?:team|members?|invitations?)/i; + const matches: Array = []; + const visit = (value: unknown, path: string): void => { + if (typeof value === 'string') { + if (teamTerms.test(value)) { + matches.push(`${path}=${value}`); + } + return; + } + if (Array.isArray(value)) { + value.forEach((entry, index) => visit(entry, `${path}[${index}]`)); + return; + } + if (!value || typeof value !== 'object') { + return; + } + for (const [key, entry] of Object.entries(value)) { + const entryPath = path ? `${path}.${key}` : key; + if (teamTerms.test(key)) { + matches.push(entryPath); + } + visit(entry, entryPath); + } + }; + + visit(pristineEmitted, ''); + expect(matches).toEqual([]); + }); + it('reconstructs a container set byte-identical to the emitted sandbox container', () => { // The highest-value FP-safety lock: `reconstructExpectedContainers` (a pure pre-build function) // must produce EXACTLY what the CF plugin emitted, or every sandbox deploy would false-positive. diff --git a/packages/void/test/integration/operator-cli.test.ts b/packages/void/test/integration/operator-cli.test.ts index 891afc99e..c386936b9 100644 --- a/packages/void/test/integration/operator-cli.test.ts +++ b/packages/void/test/integration/operator-cli.test.ts @@ -191,6 +191,25 @@ describe('operator CLI (real subprocess and HTTP)', () => { expect(applied.stderr).toBe(''); expect(requests.map(({ body }) => body)).toEqual([{ dryRun: true }, {}]); }); + it('previews and applies project ownership transfer with the target user ID', async () => { + reply = (_path, body) => + body.dryRun + ? { body: { dryRun: true, action: 'project.owner', target: 'project-1' } } + : { body: { ok: true, owner: { id: 'user-2' } } }; + const result = await run(['project', 'owner', 'project-1', 'user-2', '--yes', '--json']); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ owner: { id: 'user-2' } }); + expect(requests).toEqual([ + expect.objectContaining({ + path: '/operator/v1/projects/project-1/owner', + body: { userId: 'user-2', dryRun: true }, + }), + expect.objectContaining({ + path: '/operator/v1/projects/project-1/owner', + body: { userId: 'user-2' }, + }), + ]); + }); it('prints structured argument failures without network calls', async () => { const result = await run(['user', 'list', '--limit', '10000', '--json']); expect(result.code).toBe(1); diff --git a/packages/void/test/integration/projects-command.test.ts b/packages/void/test/integration/projects-command.test.ts index 7a46c5b60..2dfcb6b33 100644 --- a/packages/void/test/integration/projects-command.test.ts +++ b/packages/void/test/integration/projects-command.test.ts @@ -90,12 +90,14 @@ describe('runProjectsCommand', () => { id: 'proj_1', user_id: 'user_1', slug: 'star-tracker', + role: 'owner', created_at: '2026-02-15T00:00:00.000Z', }, { id: 'proj_2', user_id: 'user_1', slug: 'todo-app', + role: 'collaborator', created_at: '2026-02-15T00:00:00.000Z', }, ]), @@ -116,8 +118,10 @@ describe('runProjectsCommand', () => { redirect: 'manual', }); expect(out).toContain('star-tracker'); + expect(out).toContain('owner'); expect(out).toContain('https://star-tracker.staging.void.app'); expect(out).toContain('todo-app'); + expect(out).toContain('collaborator'); expect(out).toContain('https://todo-app.staging.void.app'); }); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index c3792283c..b54d8c70d 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -513,6 +513,32 @@ function harness(overrides: Partial = {}): { return { deps, trace, wranglerArgs, exitCalls }; } +describe('direct Cloudflare deploy isolation', () => { + it('does not contact Void Platform project-team endpoints', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'site', dependencies: { void: '*' } }), + 'void.json': '{}', + 'routes/index.ts': 'export const GET = () => "site";', + 'wrangler.jsonc': JSON.stringify({ + name: 'site', + account_id: 'personal', + compatibility_date: '2026-02-24', + }), + }); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('direct Cloudflare deploy attempted an unexpected HTTP call')); + const { deps } = harness(); + + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); +}); + describe('existing Cloudflare site handoff', () => { const existing = { versionId: 'original', diff --git a/packages/void/test/unit/operator-cli.test.ts b/packages/void/test/unit/operator-cli.test.ts index 7dcb0c98d..39f75c585 100644 --- a/packages/void/test/unit/operator-cli.test.ts +++ b/packages/void/test/unit/operator-cli.test.ts @@ -124,6 +124,14 @@ describe('operator argument and help coverage', () => { plan: true, body: { cursor: '{"phase":"resources"}' }, }); + expect( + parsePlatformArgs(['project', 'owner', 'project / 1', 'user-2', '--plan']), + ).toMatchObject({ + path: '/projects/project%20%2F%201/owner', + method: 'POST', + body: { userId: 'user-2' }, + plan: true, + }); }); it('routes the email group to the operator email endpoints', () => { expect(parseOperatorArgs(['email', 'policy'])).toMatchObject({ diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index dda3b2982..9f37d5c65 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -41,6 +41,83 @@ describe('parseProjectArgs', () => { }); }); + describe('team', () => { + it('opens the team command group without requiring an action', () => { + expect(parseProjectArgs(['team'])).toEqual({ subcommand: 'team', action: '' }); + }); + + it('parses project-scoped team commands', () => { + expect(parseProjectArgs(['team', 'list', '--project', 'my-app'])).toEqual({ + subcommand: 'team', + action: 'list', + projectSlug: 'my-app', + }); + expect( + parseProjectArgs(['team', 'invite', 'member@example.com', '--role', 'collaborator']), + ).toEqual({ + subcommand: 'team', + action: 'invite', + email: 'member@example.com', + role: 'collaborator', + projectSlug: '', + }); + expect(parseProjectArgs(['team', 'role', 'user_1', 'admin'])).toEqual({ + subcommand: 'team', + action: 'role', + userId: 'user_1', + role: 'admin', + projectSlug: '', + }); + expect(parseProjectArgs(['team', 'remove', 'user_1'])).toEqual({ + subcommand: 'team', + action: 'remove', + userId: 'user_1', + projectSlug: '', + }); + expect(parseProjectArgs(['team', 'revoke', 'invite_1'])).toEqual({ + subcommand: 'team', + action: 'revoke', + invitationId: 'invite_1', + projectSlug: '', + }); + }); + + it('parses invitation inbox commands without a project', () => { + expect(parseProjectArgs(['team', 'pending'])).toEqual({ + subcommand: 'team', + action: 'pending', + }); + expect(parseProjectArgs(['team', 'accept', 'invite_1'])).toEqual({ + subcommand: 'team', + action: 'accept', + invitationId: 'invite_1', + }); + expect(parseProjectArgs(['team', 'decline', 'invite_1'])).toEqual({ + subcommand: 'team', + action: 'decline', + invitationId: 'invite_1', + }); + }); + + it('requires a registered email input and valid roles', () => { + expect(() => parseProjectArgs(['team', 'invite', 'member', '--role', 'reader'])).toThrow( + 'Invitee must be a registered email address', + ); + expect(() => + parseProjectArgs(['team', 'invite', 'member@example.com', '--role', 'owner']), + ).toThrow("Invalid role 'owner'"); + expect(() => parseProjectArgs(['team', 'role', 'user_1', 'owner'])).toThrow( + "Invalid role 'owner'", + ); + }); + + it('rejects project selection for invitation inbox commands', () => { + expect(() => parseProjectArgs(['team', 'pending', '--project', 'my-app'])).toThrow( + '--project is not used with team pending', + ); + }); + }); + describe('cancel', () => { it('parses cancel without a deployment id', () => { expect(parseProjectArgs(['cancel'])).toEqual({ diff --git a/packages/void/test/unit/project-team-client.test.ts b/packages/void/test/unit/project-team-client.test.ts new file mode 100644 index 000000000..43a8015bf --- /dev/null +++ b/packages/void/test/unit/project-team-client.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { PlatformClient } from '../../src/cli/client.ts'; + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; + +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); +}); + +afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } +}); + +const apiUrl = 'https://api.test'; +const projectId = 'project_1'; + +function client(): PlatformClient { + return new PlatformClient('test-token', { apiUrl }); +} + +function json(body: unknown, status = 200): Response { + return Response.json(body, { status }); +} + +function request(index = 0): [string, RequestInit] { + return fetchMock.mock.calls[index] as [string, RequestInit]; +} + +describe('PlatformClient project teams', () => { + it('lists members and project invitations from response envelopes', async () => { + const member = { user: { id: 'user_1', login: 'alex' }, role: 'admin' }; + const invitation = { id: 'invite_1', role: 'reader' }; + fetchMock.mockResolvedValueOnce(json({ members: [member] })); + fetchMock.mockResolvedValueOnce(json({ invitations: [invitation] })); + + await expect(client().listProjectMembers(projectId)).resolves.toEqual([member]); + await expect(client().listProjectInvitations(projectId)).resolves.toEqual([invitation]); + expect(request(0)[0]).toBe(`${apiUrl}/projects/${projectId}/members`); + expect(request(1)[0]).toBe(`${apiUrl}/projects/${projectId}/invitations`); + }); + + it('invites only by email and role', async () => { + const result = { + invitation: { id: 'invite_1', role: 'collaborator' }, + delivery: { status: 'not_configured' }, + }; + fetchMock.mockResolvedValueOnce(json(result, 201)); + + await expect( + client().inviteProjectMember(projectId, 'alex@example.com', 'collaborator'), + ).resolves.toEqual(result); + expect(request()[0]).toBe(`${apiUrl}/projects/${projectId}/invitations`); + expect(request()[1]).toMatchObject({ + method: 'POST', + body: JSON.stringify({ email: 'alex@example.com', role: 'collaborator' }), + }); + }); + + it('changes and removes members with encoded user IDs', async () => { + fetchMock.mockResolvedValueOnce(json({ member: { user: { id: 'user / 1' }, role: 'admin' } })); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await client().updateProjectMemberRole(projectId, 'user / 1', 'admin'); + await client().removeProjectMember(projectId, 'user / 1'); + expect(request(0)[0]).toBe(`${apiUrl}/projects/${projectId}/members/user%20%2F%201`); + expect(request(0)[1]).toMatchObject({ + method: 'PATCH', + body: JSON.stringify({ role: 'admin' }), + }); + expect(request(1)[1]).toMatchObject({ method: 'DELETE' }); + }); + + it('revokes and leaves without parsing 204 responses', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect(client().revokeProjectInvitation(projectId, 'invite / 1')).resolves.toEqual({ + ok: true, + }); + await expect(client().leaveProject(projectId)).resolves.toEqual({ ok: true }); + expect(request(0)[0]).toBe(`${apiUrl}/projects/${projectId}/invitations/invite%20%2F%201`); + expect(request(1)[0]).toBe(`${apiUrl}/projects/${projectId}/leave`); + }); + + it('lists, accepts, and declines account-bound invitations', async () => { + const invitation = { id: 'invite_1', project: { id: projectId, slug: 'app' }, role: 'reader' }; + fetchMock.mockResolvedValueOnce(json({ invitations: [invitation] })); + fetchMock.mockResolvedValueOnce(json({ member: { user: { id: 'user_1' }, role: 'reader' } })); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect(client().listPendingProjectInvitations()).resolves.toEqual([invitation]); + await expect( + client().respondToProjectInvitation('invite / 1', 'accept'), + ).resolves.toMatchObject({ + ok: true, + member: { role: 'reader' }, + }); + await expect(client().respondToProjectInvitation('invite / 1', 'decline')).resolves.toEqual({ + ok: true, + }); + expect(request(0)[0]).toBe(`${apiUrl}/me/project-invitations`); + expect(request(1)[0]).toBe(`${apiUrl}/project-invitations/invite%20%2F%201/accept`); + expect(request(2)[0]).toBe(`${apiUrl}/project-invitations/invite%20%2F%201/decline`); + }); + + it('surfaces an unknown registered email response', async () => { + fetchMock.mockResolvedValueOnce(json({ error: 'user_not_found' }, 404)); + await expect( + client().inviteProjectMember(projectId, 'unknown@example.com', 'reader'), + ).rejects.toMatchObject({ name: 'PlatformApiError', status: 404 }); + }); +}); diff --git a/packages/void/test/unit/project-team-command.test.ts b/packages/void/test/unit/project-team-command.test.ts new file mode 100644 index 000000000..1ea5b1f6f --- /dev/null +++ b/packages/void/test/unit/project-team-command.test.ts @@ -0,0 +1,68 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathslash'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { runProjectCommand } from '../../src/cli/project-cmd.ts'; + +const roots: Array = []; + +function rootWithPlatform(platform: 'cloudflare' | 'void'): string { + const root = mkdtempSync(join(tmpdir(), 'void-project-team-')); + roots.push(root); + mkdirSync(join(root, '.void')); + writeFileSync( + join(root, '.void', 'project.json'), + JSON.stringify({ platform, projectId: 'project_1', slug: 'my-app' }), + ); + return root; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('project team target selection', () => { + it('fails clearly before contacting a direct Cloudflare target', async () => { + await expect( + runProjectCommand(rootWithPlatform('cloudflare'), { + subcommand: 'team', + action: 'list', + projectSlug: '', + }), + ).rejects.toThrow( + 'Teams are available only for projects hosted on a Void platform, not direct Cloudflare deployments', + ); + }); + + it('uses the linked Void platform project for member listing', async () => { + const root = rootWithPlatform('void'); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + const fetchMock = vi.fn(async () => + Response.json({ + members: [{ user: { id: 'user_1', login: 'alex' }, role: 'owner' }], + }), + ); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await runProjectCommand(root, { + subcommand: 'team', + action: 'list', + projectSlug: '', + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.test/projects/project_1/members', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer test-token' }), + }), + ); + }); +}); diff --git a/platform/meta/design-docs/0080-project-teams.md b/platform/meta/design-docs/0080-project-teams.md new file mode 100644 index 000000000..b99542c92 --- /dev/null +++ b/platform/meta/design-docs/0080-project-teams.md @@ -0,0 +1,520 @@ +--- +title: Project teams and ownership transfer +status: implemented +created: 2026-09-17 +--- + +# Project teams and ownership transfer + +## Summary + +Add project-scoped membership to the Void Platform. A project keeps one owner, +and can additionally have readers, collaborators, and project administrators. +The owner remains the billing and quota subject regardless of which member +performs an action. + +This is a Void Platform control-plane feature. It does not add an organization +object, change application manifests, or affect projects deployed directly to +Cloudflare. + +## Goals + +- Let an owner or project administrator invite people to a project. +- Let authorized members view or operate the project according to their role. +- Let the owner and project administrators change or remove non-owner members. +- Let an installation administrator transfer a project to another existing + Platform user. +- Apply the owner's plan, billing period, suspension, and quota state to the + project even when another member deploys or configures it. +- Make membership removal and role downgrade revoke new work immediately and + prevent an already-issued deploy credential from publishing afterward. +- Keep existing single-owner projects working without a data migration that + manufactures membership rows. + +## Non-goals + +- Reusable teams, organizations, organization-wide roles, or shared billing + accounts. +- Membership on direct Cloudflare deployments. +- Multiple owners or project ownership by a group. +- Project members who do not have their own Void Platform account. +- Sharing the installation administrator role through a project role. +- Reading stored secret values. The existing API exposes secret names and + write/delete operations, not plaintext reads; teams do not change that. + +## Terminology + +- **Installation administrator**: a user whose account has `users.role = +'admin'`. They use the browser admin UI or `/operator/v1` API. +- **Project administrator**: a project member with role `admin`. This role has + no installation-wide authority. +- **Owner**: the user referenced by `projects.user_id`. The column retains its + current name for an additive, backward-compatible rollout, but means + `owner_user_id` throughout this design. +- **Actor**: the authenticated user who requested an action. +- **Billing owner**: the project owner whose plan and counters authorize and + account for the action. + +## Product decisions + +### Project roles + +The owner is implicit and is not duplicated in `project_members`. Permissions +are capability lists rather than string comparisons so a later role can be +added without relying on role ordering. + +| Capability | Reader | Collaborator | Project admin | Owner | +| ----------------------------------------------------------------- | :----: | :----------: | :-----------: | :---: | +| View project, usage, resources, domains, builds, deploys and logs | ✓ | ✓ | ✓ | ✓ | +| View member roster | ✓ | ✓ | ✓ | ✓ | +| Deploy, cancel, roll back, revalidate and run migrations | | ✓ | ✓ | ✓ | +| Perform deploy prerequisites and manage secrets/database config | | ✓ | ✓ | ✓ | +| Manage domains, email destinations and GitHub connection | | | ✓ | ✓ | +| Invite, change roles, revoke invitations and remove members | | | ✓ | ✓ | +| Delete the project | | | | ✓ | +| Transfer ownership | | | | | + +Only an installation administrator can transfer ownership. A project +administrator may invite or promote another project administrator, but cannot +change, remove, or impersonate the owner. A non-owner member may leave the +project. The owner cannot leave until an installation administrator transfers +the project. + +Deploy permission intentionally includes production code execution, secret +updates, migrations, and automatic resource provisioning. Treating those as +lower-trust operations would be misleading: code that can be deployed can +already act with the application's runtime bindings. Domains, email recipient +policy, GitHub automation, membership, and project deletion remain separate +administrative capabilities. + +These role boundaries are confirmed product policy. Project administrators can +manage any non-owner member, including another project administrator. + +### Owner-based limits + +For every project operation, authorization has two independent inputs: + +1. the actor's project capability determines whether the action is allowed; +2. the current owner's account determines the applicable plan and quota. + +The actor's personal plan never raises or lowers the project's limits. This +applies to request and AI usage, Sandbox runtime and concurrency, deploys per +day, build concurrency and timeout, build instance size, CPU and subrequest +limits, deployment retention, and future account-level storage enforcement. + +A manually suspended actor cannot authenticate, even if they are a member. An +actor automatically suspended for usage on projects they own may still work on +someone else's project. A manually suspended owner suspends their owned +projects. An owner's usage suspension continues to apply to their projects and +cannot be bypassed by a collaborator deploying them. + +### Ownership-transfer accounting + +Ownership changes are effective at one database timestamp and are not +retroactive: + +- usage before the timestamp remains charged to the former owner; +- usage at or after the timestamp is charged to the new owner; +- the new owner's plan, runtime limits, and suspension state apply immediately + after the transfer commits. + +This prevents account dashboards and enforcement counters from silently +rewriting a completed billing period. Only an installation administrator can +transfer ownership, and every transfer is previewed and audited, which prevents +this rule from becoming a self-service quota-avoidance mechanism. + +The former owner becomes a project administrator after transfer. If the new +owner was already a member, that membership row is removed because ownership +is implicit. This preserves access for both people and avoids choosing a role +for the former owner in every operator invocation. The new owner may remove the +former owner afterward. + +The target must be an existing Platform user. The preview names the target's +plan and suspension state. A transfer is rejected while the project is deleting +or has a nonterminal deploy, rollback, managed build, or Sandbox lifecycle +operation. The administrator can cancel or wait for that work and retry. This +keeps a single operation from beginning under one owner's plan and publishing +under another's. + +## Data model + +### `project_members` + +```text +project_id text FK projects(id) ON DELETE CASCADE +user_id text FK users(id) ON DELETE CASCADE +role text CHECK IN ('reader', 'collaborator', 'admin') +added_by_user_id text nullable, durable actor id +created_at timestamp +updated_at timestamp +PRIMARY KEY (project_id, user_id) +INDEX (user_id, project_id) +``` + +The service rejects a row whose `user_id` equals `projects.user_id`. Keeping the +owner out of this table makes ownership authoritative in one place and lets all +legacy projects acquire owner access automatically. + +### `project_invitations` + +This is separate from the existing installation-level `invitations` table, +which grants signup access but no project access. + +```text +id text PRIMARY KEY +project_id text FK projects(id) ON DELETE CASCADE +target_user_id text FK users(id) ON DELETE CASCADE +target_email_snapshot text normalized lowercase address used for the invitation +role text CHECK IN ('reader', 'collaborator', 'admin') +status text CHECK IN ('pending', 'accepted', 'declined', 'revoked', 'expired') +invited_by_user_id text durable actor id +accepted_by_user_id text nullable durable actor id +created_at timestamp +expires_at timestamp +accepted_at timestamp nullable +revoked_at timestamp nullable +revoked_by_user_id text nullable durable actor id +``` + +A partial unique index permits only one pending invitation per project and +target user. Creating an invitation first resolves the normalized email to +exactly one existing Platform user; no match is rejected. Reinviting updates +the intended role and extends expiry. Actor IDs deliberately do not use +deleting foreign keys because they are audit history. + +### `project_ownership_periods` + +```text +id text PRIMARY KEY +project_id text FK projects(id) ON DELETE CASCADE +owner_user_id text durable billing identity +starts_at timestamp +ends_at timestamp nullable +changed_by_user_id text nullable installation-admin actor id +``` + +There is one open period per project. Existing projects receive an initial +period beginning at `projects.created_at`. A transfer closes that period and +opens the next one in the same transaction that updates `projects.user_id`. +These intervals resolve delayed usage events against the owner at the time the +event occurred. + +Account usage queries intersect these ownership periods with each user's +billing window. Project usage remains a complete project history, while an +owner's aggregate contains only the portions billed to that owner. Request +events use their queue enqueue time, Sandbox intervals are split at an +ownership boundary, and AI queries start at the later of the billing-period or +ownership-period boundary. + +### Actor and billing attribution + +Add `deployments.billing_user_id` and `deployments.initiated_by_user_id`. +Backfill `billing_user_id` from the current owner before transfers are enabled. +Daily deploy counts use `billing_user_id` instead of joining to the project's +current owner. + +`builds.user_id` remains the billing owner for compatibility with the build +scheduler and its per-user locks. Add `builds.triggered_by_user_id` for a human +trigger where one exists. Automated GitHub builds use the current project owner +for `builds.user_id`; their GitHub authorization provenance remains on the +connection. + +### Audit events + +Add a small `project_access_events` table for invite, acceptance, decline, role +change, removal, and leave events. Store the project, durable actor ID, durable +subject ID, invitation email snapshot, previous/new role, action, and +timestamp. Ownership transfers additionally use the existing installation +operator event log so browser and CLI administrator actions retain the same +audit contract. + +## Authorization architecture + +Introduce one project-access service used by every authenticated project route: + +```ts +type ProjectRole = 'reader' | 'collaborator' | 'admin' | 'owner' +type ProjectCapability = + | 'read' + | 'deploy' + | 'configure' + | 'settings' + | 'members' + | 'delete' + +requireProjectCapability(db, projectId, actorUserId, capability) + -> { project, role, owner } +``` + +The service first checks `projects.user_id`, then `project_members`. It returns +the owner account separately so callers cannot accidentally use the actor's +plan. Missing projects remain 404 and existing-but-inaccessible projects remain +403, matching the current API. + +Replace all direct `project.user_id === context.get('user').id` checks in +projects, deploy, delete, secrets, domains, email, GitHub, usage, revalidation, +logs, builds, migrations, database, and binding-proxy verification. Replace +`getProjectsByUserId` with distinct helpers for owned projects and accessible +projects; billing, suspension, operator user views, and user deletion must keep +using the owned-project helper. + +`GET /projects` returns owned and shared projects with `role` and a small owner +summary. A project-scoped deploy token still returns exactly its one scoped +project. The server, not the CLI, filters this list. + +### Locks and credential revocation + +Roles are never embedded as trusted JWT state. Full API, deploy, and prerender +credentials carry the actor ID and project scope, and every request resolves +current access from PostgreSQL. + +Membership writes take the project row `FOR UPDATE` before changing a member. +Long or externally visible project mutations recheck the capability while +holding the same project lock immediately before commit or publish. In +particular, the final deploy transaction checks that the token subject still +has `deploy` capability as well as checking the bound build. Therefore: + +- removal or downgrade that gets the lock first prevents publication; +- publication that gets the lock first completes before the membership change; +- a leaked unexpired deploy token cannot start new work after removal. + +The binding proxy's internal `verify-owner` route becomes a capability check +for `deploy` and is renamed or versioned so old and new proxies can coexist +during rollout. Build-bound prerender credentials retain their build-state +check. + +## Invitation flow + +1. An owner or project administrator submits an existing user's registered + email and the intended role. +2. The API normalizes the email and resolves exactly one registered Platform + account. It rejects an unknown address, the owner, and an existing member. +3. Under the project lock, the API validates the role, creates or refreshes a + pending invitation bound to the target user ID, and returns delivery status. +4. If platform email is configured, the API sends a notification link + containing the invitation ID. The ID is a locator, not a bearer credential; + only the authenticated target user can accept it. Without email, the + invitation still appears in that user's pending-invitation list. +5. After login, one transaction locks the project and invitation, checks that + the actor is still `target_user_id`, inserts `project_members`, marks the + invitation accepted, and records the access event. Decline follows the same + target-account check without creating membership. + +The role is always read from the database invitation. Acceptance is single-use +and idempotent for its target user. Forwarding or guessing an invitation ID +does not grant access to another account. A project invitation never creates an +account, bypasses restricted signup, or adds a platform allowlist entry. + +An invitation expires after seven days by default. Revoking it prevents later +acceptance. Resend uses the target account's current registered email, keeps the +same account binding, and follows the platform email rate limiter and a bounded +pending-invitation limit. + +## API and user experience + +### Project API + +```text +GET /projects/:id/members +GET /projects/:id/invitations +POST /projects/:id/invitations +POST /projects/:id/invitations/:invitationId/resend +DELETE /projects/:id/invitations/:invitationId +PATCH /projects/:id/members/:userId +DELETE /projects/:id/members/:userId +POST /projects/:id/leave +GET /me/project-invitations +POST /project-invitations/:invitationId/accept +POST /project-invitations/:invitationId/decline +``` + +Member listings are visible to all project roles. Invitation addresses and +mutation endpoints require `members`. `PATCH` accepts only a role. The owner is +returned in the member list with role `owner`, but owner mutations return 409. + +### CLI + +Add a `void project team` group: + +```text +void project team list [--project ] +void project team invite --role [--project ] +void project team invitations [--project ] +void project team role [--project ] +void project team remove [--project ] +void project team revoke [--project ] +void project team leave [--project ] +void project team pending +void project team accept +void project team decline +``` + +The existing project selector and repository link identify the project when +`--project` is absent. Commands require a connected Void Platform and give a +clear error for a direct Cloudflare target. Project list output gains a role +column so shared projects are distinguishable. + +The optional user dashboard adds a Team tab to project details and a pending +invitation page. The core acceptance landing route belongs to the API so a +self-hosted installation does not require the optional dashboard. + +### Installation administrator API + +Add the shared operator service and expose it through both administrator +interfaces: + +```text +POST /operator/v1/projects/:id/owner +{ "userId": "user_...", "dryRun": true | false } + +void platform project owner [--plan | --yes] +``` + +The dry run reports current and new owner, both plans and suspension states, +the former owner's resulting project-admin role, active-work blockers, GitHub +connection provenance, and routing/counter updates. Apply follows the existing +operator audit and partial-failure contract and never retries an uncertain +mutation automatically. + +## Ownership transfer and quota convergence + +PostgreSQL is authoritative. The usage queue must resolve each event through +`project_ownership_periods` using the queue message timestamp, rather than +trusting the producer's `userId` hint or the project's current owner. This +correctly attributes an event that waited in the queue across a transfer. + +UsageCounter state distinguishes already-billed totals from the set of projects +the user currently owns. Its initialization replaces that current-project set +from PostgreSQL instead of only merging slugs. Transfer control calls detach +the project from the former owner's counter and attach it to the new owner's +counter without moving already-billed totals. Delayed pre-transfer events may +still increment the former owner's total but may not reattach the project. + +Counter writes that change project routing or AI blocks recheck current +ownership under the project lock. This prevents a stale former-owner Durable +Object from suspending, unsuspending, or deleting an AI block belonging to the +new ownership period. + +The operator transfer performs these steps: + +1. Write a retryable transfer fence that prevents new Sandbox acquisitions for + the project while ordinary application requests continue. +2. In one database transaction, lock the project then both users in stable ID + order; reject active lifecycle work; close/open the ownership periods; + update `projects.user_id`; remove the target's membership; add the former + owner as project admin; and record the event. +3. Detach/attach the UsageCounter project mapping. +4. Republish the project-account and active routing entries with the new owner + ID, plan limits, and suspension state. Usage ingestion already uses the + database period, so stale producer hints cannot misbill requests. +5. Clear the transfer fence. + +If a post-commit external update fails, the database transfer remains committed +and the fence stays closed for Sandboxes. The operator response identifies the +partial state and a repeat apply resumes the idempotent convergence steps. +Application requests continue to be metered to the database-authoritative +owner during recovery. + +GitHub connection authorization provenance may name any member who had +`configure` capability when the connection was created. Removing or +downgrading that authorizer below project administrator invalidates the +connection and cancels its active builds unless another project administrator +reauthorizes it first. A transfer normally preserves it because the former +owner becomes a project administrator. Webhook and Actions builds always +acquire capacity and select a build size from the current billing owner. + +## User and project lifecycle + +- Deleting a non-owner user cascades their memberships. It also revokes their + pending project invitations and invalidates GitHub connections whose proof + depended on them. +- Deleting an owner keeps the existing behavior: owned projects are torn down + before the account is removed. Being a member of other projects does not + delete those projects. +- Deleting a project cascades members, invitations, ownership periods, and + access events together with its other project data. +- Plan changes, billing rollovers, suspension, and restoration enumerate only + owned projects, never projects on which the user is merely a member. +- Invitations remain bound to the resolved target user ID if that account's + registered email changes. A resend uses the account's current email. + +## Rollout + +1. **Schema and attribution.** Add the tables and attribution columns, backfill + one ownership period and deployment billing owner per existing project, and + deploy readers that tolerate absent optional data. +2. **Central authorization.** Introduce the capability service and move every + project route to it while owner remains the only possible role. Move all + plan lookups to the returned owner. Version the proxy verification endpoint. +3. **Counter correctness.** Resolve usage by ownership period, split billed + totals from current project mappings, and add the Sandbox transfer fence and + stale-counter ownership checks. +4. **Membership and invitations.** Enable member rows, registered-user lookup, + acceptance/decline, access audit, email delivery, API routes, CLI commands, + and dashboard surfaces. +5. **Operator transfer.** Enable dry-run/apply in the shared operator service, + browser administrator UI, and CLI after quota convergence is deployed. +6. **Cleanup.** Remove the old owner-only proxy endpoint and temporary fallback + paths after all packaged runtime revisions require the new capability. + +No phase changes direct Cloudflare deployment configuration, bindings, upload +logic, or manifests. + +## Verification plan + +During each implementation batch: + +- run `vp test run` only for the affected API, proxy, dispatch, dashboard, or + CLI test files; +- run `vp run check` after the batch; +- investigate and fix any newly slow or hanging focused test immediately. + +Coverage must include: + +- every role/capability pair and accessible-project listing; +- owner-implicit access for a pre-feature project; +- reader deploy denial and collaborator deploy success under the owner's plan; +- actor suspension versus owner usage/manual suspension; +- secret, migration, domain, email, database, GitHub, logs, and usage routes; +- rejection of unregistered invitees, invitation expiry, refresh, revocation, + target-only acceptance/decline, forwarded IDs, concurrent acceptance, email + changes, and audit history; +- member removal/role downgrade racing deploy start and final publish; +- deploy and prerender tokens minted before access revocation; +- GitHub connection authorizer removal and builds charged to the owner; +- transfer dry run, former-owner role, existing-member target, active-work + rejection, delayed events on both sides of the timestamp, billing rollover, + stale DO writes, routing partial failure, and idempotent recovery; +- plan changes, suspension, user deletion, and project deletion ignoring shared + membership where ownership is required; +- `void deploy --platform cloudflare` emitting no team binding/configuration, + making no team API request, and retaining its existing Free-compatible path. + +When all batches are complete, run `vp lint`, `vp fmt`, `vp run check`, and the +full `vp test run` suite. + +After verification, document only the public behavior and workflows: + +- add a project collaboration guide and link it from deployment guidance; +- update CLI help, the no-argument project-group hint, and + `docs/reference/cli.md` for every new command; +- update `docs/guide/platform-administration.md` for ownership transfer; +- update relevant package README files only where their supported interfaces + changed; +- update `packages/void/skills/void` when the CLI guidance changes; +- leave `packages/void/AGENTS_TEMPLATE.md` unchanged unless a brief generated + app instruction is genuinely necessary. + +## Confirmed product decisions + +Confirmed on 2026-09-17: + +1. Project administrators can change roles and remove any non-owner member, + including another project administrator. +2. Collaborators receive all deploy-equivalent production authority described + in the role table; project settings and team management remain admin-only. +3. The former owner becomes a project administrator on transfer. +4. Ownership transfer is non-retroactive for usage already incurred. +5. Invitations resolve a registered email to an existing Void Platform user. + They cannot create an account or admit an unregistered user through + restricted signup. diff --git a/platform/meta/design-docs/README.md b/platform/meta/design-docs/README.md index 4b1b935b3..38fc16ace 100644 --- a/platform/meta/design-docs/README.md +++ b/platform/meta/design-docs/README.md @@ -33,7 +33,7 @@ as an instruction to modify a live installation. | Builds and GitHub | [Build design](./0010-builds.md), [implementation decisions](./0011-builds-implementation-plan.md), [identity linking](./0074-github-deploy-identity-linking.md), [build logs](./0076-build-logs.md) | | Serving and routing | [Asset fallback](./0066-wfp-asset-r2-fallback.md), [direct R2 uploads](./0068-direct-r2-asset-upload.md), [route-aware ISR](./0077-route-aware-isr-ttl.md) | | Custom domains | [Record presentation](./0072-custom-domain-verification-ux-improvements.md), [HTTP certificate validation](./0073-custom-domain-http-dcv-migration.md) | -| Administration | [Operator CLI](./0078-platform-operator-cli.md) | +| Administration | [Operator CLI](./0078-platform-operator-cli.md), [project teams and ownership transfer](./0080-project-teams.md) | | Email | [Routing, recovery and delivery design](./0078-email-cf-native.md) | | Legacy Sandboxes | [Container ownership and teardown](./0067-cross-script-do-host-architecture.md) | diff --git a/platform/packages/api/drizzle/0048_project_teams.sql b/platform/packages/api/drizzle/0048_project_teams.sql new file mode 100644 index 000000000..9e26b14b2 --- /dev/null +++ b/platform/packages/api/drizzle/0048_project_teams.sql @@ -0,0 +1,90 @@ +CREATE TABLE "project_access_events" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "actor_user_id" text NOT NULL, + "subject_user_id" text, + "invitation_email_snapshot" text, + "previous_role" text, + "new_role" text, + "action" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "project_access_events_previous_role_check" CHECK ("project_access_events"."previous_role" IS NULL OR "project_access_events"."previous_role" IN ('reader', 'collaborator', 'admin')), + CONSTRAINT "project_access_events_new_role_check" CHECK ("project_access_events"."new_role" IS NULL OR "project_access_events"."new_role" IN ('reader', 'collaborator', 'admin')), + CONSTRAINT "project_access_events_action_check" CHECK ("project_access_events"."action" IN ('invited', 'invitation_resent', 'accepted', 'declined', 'invitation_revoked', 'role_changed', 'member_removed', 'left')) +); +--> statement-breakpoint +CREATE TABLE "project_invitations" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "target_user_id" text NOT NULL, + "target_email_snapshot" text NOT NULL, + "role" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "invited_by_user_id" text NOT NULL, + "accepted_by_user_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp NOT NULL, + "accepted_at" timestamp, + "revoked_at" timestamp, + "revoked_by_user_id" text, + CONSTRAINT "project_invitations_role_check" CHECK ("project_invitations"."role" IN ('reader', 'collaborator', 'admin')), + CONSTRAINT "project_invitations_status_check" CHECK ("project_invitations"."status" IN ('pending', 'accepted', 'declined', 'revoked', 'expired')) +); +--> statement-breakpoint +CREATE TABLE "project_members" ( + "project_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text NOT NULL, + "added_by_user_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "project_members_project_id_user_id_pk" PRIMARY KEY("project_id","user_id"), + CONSTRAINT "project_members_role_check" CHECK ("project_members"."role" IN ('reader', 'collaborator', 'admin')) +); +--> statement-breakpoint +CREATE TABLE "project_ownership_periods" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "owner_user_id" text NOT NULL, + "starts_at" timestamp NOT NULL, + "ends_at" timestamp, + "changed_by_user_id" text, + CONSTRAINT "project_ownership_periods_time_check" CHECK ("project_ownership_periods"."ends_at" IS NULL OR "project_ownership_periods"."ends_at" >= "project_ownership_periods"."starts_at") +); +--> statement-breakpoint +ALTER TABLE "builds" ADD COLUMN "triggered_by_user_id" text;--> statement-breakpoint +ALTER TABLE "deployments" ADD COLUMN "billing_user_id" text;--> statement-breakpoint +ALTER TABLE "deployments" ADD COLUMN "initiated_by_user_id" text;--> statement-breakpoint +UPDATE "deployments" AS "deployment" +SET "billing_user_id" = "project"."user_id" +FROM "projects" AS "project" +WHERE "deployment"."project_id" = "project"."id" + AND "deployment"."billing_user_id" IS NULL;--> statement-breakpoint +INSERT INTO "project_ownership_periods" ( + "id", + "project_id", + "owner_user_id", + "starts_at" +) +SELECT + 'pown_' || "project"."id", + "project"."id", + "project"."user_id", + "project"."created_at" +FROM "projects" AS "project" +ON CONFLICT DO NOTHING;--> statement-breakpoint +ALTER TABLE "project_access_events" ADD CONSTRAINT "project_access_events_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project_invitations" ADD CONSTRAINT "project_invitations_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project_invitations" ADD CONSTRAINT "project_invitations_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project_members" ADD CONSTRAINT "project_members_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project_members" ADD CONSTRAINT "project_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project_ownership_periods" ADD CONSTRAINT "project_ownership_periods_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_project_access_events_project_created" ON "project_access_events" USING btree ("project_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_project_access_events_subject_created" ON "project_access_events" USING btree ("subject_user_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_project_invitations_project_status" ON "project_invitations" USING btree ("project_id","status");--> statement-breakpoint +CREATE INDEX "idx_project_invitations_target_status" ON "project_invitations" USING btree ("target_user_id","status");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_project_invitations_pending_target" ON "project_invitations" USING btree ("project_id","target_user_id") WHERE "project_invitations"."status" = 'pending';--> statement-breakpoint +CREATE INDEX "idx_project_members_user_project" ON "project_members" USING btree ("user_id","project_id");--> statement-breakpoint +CREATE INDEX "idx_project_ownership_periods_owner_time" ON "project_ownership_periods" USING btree ("owner_user_id","starts_at","ends_at");--> statement-breakpoint +CREATE INDEX "idx_project_ownership_periods_project_time" ON "project_ownership_periods" USING btree ("project_id","starts_at");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_project_ownership_periods_open" ON "project_ownership_periods" USING btree ("project_id") WHERE "project_ownership_periods"."ends_at" IS NULL; diff --git a/platform/packages/api/drizzle/meta/0048_snapshot.json b/platform/packages/api/drizzle/meta/0048_snapshot.json new file mode 100644 index 000000000..b3f0d5e91 --- /dev/null +++ b/platform/packages/api/drizzle/meta/0048_snapshot.json @@ -0,0 +1,6153 @@ +{ + "id": "25c4bc80-5939-4dd5-a155-d8bb5e636f33", + "prevId": "7a60f798-644e-40c9-9cc8-d042c56c9603", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_codes": { + "name": "auth_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "jwt": { + "name": "jwt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cli_state": { + "name": "cli_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.builds": { + "name": "builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "executor": { + "name": "executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_id": { + "name": "github_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_attempt": { + "name": "github_run_attempt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs_url": { + "name": "logs_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_run_id": { + "name": "check_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_builds_project_created": { + "name": "idx_builds_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_status": { + "name": "idx_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_user_status": { + "name": "idx_builds_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_project_github_run_id": { + "name": "idx_builds_project_github_run_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"builds\".\"github_run_id\" IS NOT NULL AND \"builds\".\"github_run_attempt\" IS NOT NULL AND \"builds\".\"status\" IN ('starting', 'building', 'deploying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "builds_project_id_projects_id_fk": { + "name": "builds_project_id_projects_id_fk", + "tableFrom": "builds", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_user_id_users_id_fk": { + "name": "builds_user_id_users_id_fk", + "tableFrom": "builds", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_deployment_id_deployments_id_fk": { + "name": "builds_deployment_id_deployments_id_fk", + "tableFrom": "builds", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit": { + "name": "commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_url": { + "name": "commit_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_ref": { + "name": "git_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_hashes": { + "name": "asset_hashes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retained": { + "name": "retained", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "applied_migrations": { + "name": "applied_migrations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bundle_stored": { + "name": "bundle_stored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_user_id": { + "name": "billing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_deployments_project_id": { + "name": "idx_deployments_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_project_status": { + "name": "idx_deployments_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_created_id": { + "name": "idx_deployments_created_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_project_id_projects_id_fk": { + "name": "deployments_project_id_projects_id_fk", + "tableFrom": "deployments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_data": { + "name": "verification_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_domains_project_id": { + "name": "idx_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domains_project_id_projects_id_fk": { + "name": "domains_project_id_projects_id_fk", + "tableFrom": "domains", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_hostname_unique": { + "name": "domains_hostname_unique", + "nullsNotDistinct": false, + "columns": [ + "hostname" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_destination_cleanups": { + "name": "email_destination_cleanups", + "schema": "", + "columns": { + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_started_at": { + "name": "lease_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destination_cleanups_due": { + "name": "idx_email_destination_cleanups_due", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_destination_cleanups_status_check": { + "name": "email_destination_cleanups_status_check", + "value": "\"email_destination_cleanups\".\"status\" IN ('pending', 'deleting', 'completed')" + }, + "email_destination_cleanups_attempts_check": { + "name": "email_destination_cleanups_attempts_check", + "value": "\"email_destination_cleanups\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_destinations": { + "name": "email_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_source": { + "name": "verification_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cf'" + }, + "verified_by_user_id": { + "name": "verified_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by_email_snapshot": { + "name": "verified_by_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destinations_project_id": { + "name": "idx_email_destinations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_cf_destination_id": { + "name": "idx_email_destinations_cf_destination_id", + "columns": [ + { + "expression": "cf_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_verified_by_user_id": { + "name": "idx_email_destinations_verified_by_user_id", + "columns": [ + { + "expression": "verified_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verified_by_email_snapshot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_destinations_project_id_projects_id_fk": { + "name": "email_destinations_project_id_projects_id_fk", + "tableFrom": "email_destinations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_destinations_verified_by_user_id_users_id_fk": { + "name": "email_destinations_verified_by_user_id_users_id_fk", + "tableFrom": "email_destinations", + "tableTo": "users", + "columnsFrom": [ + "verified_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_destinations_project_address_uq": { + "name": "email_destinations_project_address_uq", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "address" + ] + } + }, + "policies": {}, + "checkConstraints": { + "email_destinations_status_check": { + "name": "email_destinations_status_check", + "value": "\"email_destinations\".\"status\" IN ('pending', 'verified')" + }, + "email_destinations_verification_source_check": { + "name": "email_destinations_verification_source_check", + "value": "\"email_destinations\".\"verification_source\" IN ('cf', 'platform')" + } + }, + "isRLSEnabled": false + }, + "public.email_domains": { + "name": "email_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credential_kind": { + "name": "credential_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_key_version": { + "name": "token_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "relay_ciphertext": { + "name": "relay_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_iv": { + "name": "relay_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_key_version": { + "name": "relay_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_worker_name": { + "name": "relay_worker_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "relay_version": { + "name": "relay_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_on": { + "name": "token_expires_on", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "setup_state": { + "name": "setup_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_domains_project_id": { + "name": "idx_email_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_zone_uq": { + "name": "email_domains_zone_uq", + "columns": [ + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"zone_id\" IS NOT NULL AND \"email_domains\".\"status\" IN ('active', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_relay_uq": { + "name": "email_domains_relay_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relay_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"relay_worker_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domains_project_id_projects_id_fk": { + "name": "email_domains_project_id_projects_id_fk", + "tableFrom": "email_domains", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_domains_domain_uq": { + "name": "email_domains_domain_uq", + "nullsNotDistinct": false, + "columns": [ + "domain" + ] + } + }, + "policies": {}, + "checkConstraints": { + "email_domains_mode_check": { + "name": "email_domains_mode_check", + "value": "\"email_domains\".\"mode\" IN ('apex', 'subdomain')" + }, + "email_domains_status_check": { + "name": "email_domains_status_check", + "value": "\"email_domains\".\"status\" IN ('pending', 'active', 'token_revoked', 'token_expired', 'failed')" + }, + "email_domains_credential_kind_check": { + "name": "email_domains_credential_kind_check", + "value": "\"email_domains\".\"credential_kind\" IN ('oauth_grant', 'api_token', 'platform')" + }, + "email_domains_platform_token_check": { + "name": "email_domains_platform_token_check", + "value": "(\"email_domains\".\"credential_kind\" = 'platform' AND \"email_domains\".\"token_ciphertext\" IS NULL AND \"email_domains\".\"token_iv\" IS NULL AND \"email_domains\".\"token_key_version\" IS NULL) OR (\"email_domains\".\"credential_kind\" <> 'platform' AND \"email_domains\".\"token_ciphertext\" IS NOT NULL AND \"email_domains\".\"token_iv\" IS NOT NULL AND \"email_domains\".\"token_key_version\" IS NOT NULL)" + }, + "email_domains_managed_by_check": { + "name": "email_domains_managed_by_check", + "value": "\"email_domains\".\"managed_by\" IN ('owner', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.github_connections": { + "name": "github_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "build_executor": { + "name": "build_executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'.github/workflows/void-deploy.yml'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_connections_installation_id": { + "name": "idx_github_connections_installation_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_repo_full_name": { + "name": "idx_github_connections_repo_full_name", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_installation_repo_id": { + "name": "idx_github_connections_installation_repo_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_connections_project_id_projects_id_fk": { + "name": "github_connections_project_id_projects_id_fk", + "tableFrom": "github_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_connections_authorized_by_user_id_users_id_fk": { + "name": "github_connections_authorized_by_user_id_users_id_fk", + "tableFrom": "github_connections", + "tableTo": "users", + "columnsFrom": [ + "authorized_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_connections_project_id_unique": { + "name": "github_connections_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_members": { + "name": "github_installation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_members_install_user": { + "name": "idx_github_installation_members_install_user", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installation_members_user_id": { + "name": "idx_github_installation_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installation_members_user_id_users_id_fk": { + "name": "github_installation_members_user_id_users_id_fk", + "tableFrom": "github_installation_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_revocations": { + "name": "github_installation_revocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_revocations_install_account": { + "name": "idx_github_installation_revocations_install_account", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'selected'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installations_user_id": { + "name": "idx_github_installations_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installations_account_id": { + "name": "idx_github_installations_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_webhook_redeliveries": { + "name": "github_webhook_redeliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redelivered_at": { + "name": "redelivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_webhook_redeliveries_guid": { + "name": "idx_github_webhook_redeliveries_guid", + "columns": [ + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invitations_email": { + "name": "idx_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invitations_status": { + "name": "idx_invitations_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_renames": { + "name": "migration_renames", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_migration_renames_project_id": { + "name": "idx_migration_renames_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_renames_project_id_projects_id_fk": { + "name": "migration_renames_project_id_projects_id_fk", + "tableFrom": "migration_renames", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_renames_project_id_from_name_to_name_pk": { + "name": "migration_renames_project_id_from_name_to_name_pk", + "columns": [ + "project_id", + "from_name", + "to_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_oauth_accounts_provider_id": { + "name": "idx_oauth_accounts_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_oauth_accounts_user_id": { + "name": "idx_oauth_accounts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_nonces": { + "name": "oauth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_events": { + "name": "operator_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_events_created": { + "name": "idx_operator_events_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_sessions": { + "name": "operator_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_session_id": { + "name": "human_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_sessions_expiry": { + "name": "idx_operator_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operator_sessions_user_id_users_id_fk": { + "name": "operator_sessions_user_id_users_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operator_sessions_human_session_id_platform_human_sessions_id_fk": { + "name": "operator_sessions_human_session_id_platform_human_sessions_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "platform_human_sessions", + "columnsFrom": [ + "human_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_attempts": { + "name": "platform_auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_auth_attempts_expiry": { + "name": "platform_auth_attempts_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_auth_attempts_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_attempts_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_attempts", + "tableTo": "platform_login_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_bootstrap": { + "name": "platform_auth_bootstrap", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "claim_hash": { + "name": "claim_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_identity": { + "name": "pending_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_bootstrap", + "tableTo": "platform_login_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_bootstrap_singleton": { + "name": "platform_auth_bootstrap_singleton", + "value": "\"platform_auth_bootstrap\".\"singleton\" = true" + } + }, + "isRLSEnabled": false + }, + "public.platform_auth_settings": { + "name": "platform_auth_settings", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "admission": { + "name": "admission", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "protection": { + "name": "protection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_seconds": { + "name": "session_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2592000 + }, + "sessions_required": { + "name": "sessions_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_settings_singleton": { + "name": "platform_auth_settings_singleton", + "value": "\"platform_auth_settings\".\"singleton\" = true" + }, + "platform_auth_settings_revision": { + "name": "platform_auth_settings_revision", + "value": "\"platform_auth_settings\".\"revision\" > 0" + }, + "platform_auth_settings_session_seconds": { + "name": "platform_auth_settings_session_seconds", + "value": "\"platform_auth_settings\".\"session_seconds\" BETWEEN 300 AND 2592000" + } + }, + "isRLSEnabled": false + }, + "public.platform_company_identities": { + "name": "platform_company_identities", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_company_identity_user": { + "name": "platform_company_identity_user", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_company_identities_user_id_users_id_fk": { + "name": "platform_company_identities_user_id_users_id_fk", + "tableFrom": "platform_company_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_company_identities_issuer_subject_pk": { + "name": "platform_company_identities_issuer_subject_pk", + "columns": [ + "issuer", + "subject" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_events": { + "name": "platform_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_name": { + "name": "worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "script_name": { + "name": "script_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_login": { + "name": "admin_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_platform_events_batch_id": { + "name": "idx_platform_events_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_platform_events_env_created": { + "name": "idx_platform_events_env_created", + "columns": [ + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_human_sessions": { + "name": "platform_human_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_issuer": { + "name": "access_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_subject": { + "name": "access_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_human_sessions_connection": { + "name": "platform_human_sessions_connection", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_user": { + "name": "platform_human_sessions_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_expiry": { + "name": "platform_human_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_human_sessions_user_id_users_id_fk": { + "name": "platform_human_sessions_user_id_users_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_human_sessions_connection_id_platform_login_connections_id_fk": { + "name": "platform_human_sessions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "platform_login_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_human_sessions_audience": { + "name": "platform_human_sessions_audience", + "value": "\"platform_human_sessions\".\"audience\" IN ('api', 'admin')" + }, + "platform_human_sessions_access_identity": { + "name": "platform_human_sessions_access_identity", + "value": "(\"platform_human_sessions\".\"access_issuer\" IS NULL) = (\"platform_human_sessions\".\"access_subject\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_connections": { + "name": "platform_login_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active_revision": { + "name": "active_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_login_connections_kind": { + "name": "platform_login_connections_kind", + "value": "\"platform_login_connections\".\"kind\" IN ('github', 'google', 'oidc', 'cloudflare-access')" + }, + "platform_login_connections_revision": { + "name": "platform_login_connections_revision", + "value": "\"platform_login_connections\".\"revision\" > 0 AND \"platform_login_connections\".\"generation\" > 0" + }, + "platform_login_connections_active_revision": { + "name": "platform_login_connections_active_revision", + "value": "\"platform_login_connections\".\"active_revision\" IS NULL OR \"platform_login_connections\".\"active_revision\" BETWEEN 1 AND \"platform_login_connections\".\"revision\"" + }, + "platform_login_connections_enabled": { + "name": "platform_login_connections_enabled", + "value": "NOT \"platform_login_connections\".\"enabled\" OR \"platform_login_connections\".\"active_revision\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_identities": { + "name": "platform_login_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_login_identity_subject": { + "name": "platform_login_identity_subject", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_login_identity_user": { + "name": "platform_login_identity_user", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_identities_user_id_users_id_fk": { + "name": "platform_login_identities_user_id_users_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_identities_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_identities_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "platform_login_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_revisions": { + "name": "platform_login_revisions", + "schema": "", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "platform_login_revisions_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_revisions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_revisions", + "tableTo": "platform_login_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_login_revisions_connection_id_revision_pk": { + "name": "platform_login_revisions_connection_id_revision_pk", + "columns": [ + "connection_id", + "revision" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_tests": { + "name": "platform_login_tests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_login_tests_expiry": { + "name": "platform_login_tests_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_tests_user_id_users_id_fk": { + "name": "platform_login_tests_user_id_users_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_tests_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_tests_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "platform_login_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_settings": { + "name": "platform_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_access_events": { + "name": "project_access_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitation_email_snapshot": { + "name": "invitation_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_role": { + "name": "new_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_access_events_project_created": { + "name": "idx_project_access_events_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_access_events_subject_created": { + "name": "idx_project_access_events_subject_created", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_access_events_project_id_projects_id_fk": { + "name": "project_access_events_project_id_projects_id_fk", + "tableFrom": "project_access_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_access_events_previous_role_check": { + "name": "project_access_events_previous_role_check", + "value": "\"project_access_events\".\"previous_role\" IS NULL OR \"project_access_events\".\"previous_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_new_role_check": { + "name": "project_access_events_new_role_check", + "value": "\"project_access_events\".\"new_role\" IS NULL OR \"project_access_events\".\"new_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_action_check": { + "name": "project_access_events_action_check", + "value": "\"project_access_events\".\"action\" IN ('invited', 'invitation_resent', 'accepted', 'declined', 'invitation_revoked', 'role_changed', 'member_removed', 'left')" + } + }, + "isRLSEnabled": false + }, + "public.project_database_credentials": { + "name": "project_database_credentials", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_database_credentials_project_id_projects_id_fk": { + "name": "project_database_credentials_project_id_projects_id_fk", + "tableFrom": "project_database_credentials", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_deploy_credentials": { + "name": "project_deploy_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_by": { + "name": "issued_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_deploy_credentials_project": { + "name": "project_deploy_credentials_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_deploy_credentials_expiry": { + "name": "project_deploy_credentials_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_deploy_credentials_project_id_projects_id_fk": { + "name": "project_deploy_credentials_project_id_projects_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_deploy_credentials_issued_by_users_id_fk": { + "name": "project_deploy_credentials_issued_by_users_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "users", + "columnsFrom": [ + "issued_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_deploy_credentials_name": { + "name": "project_deploy_credentials_name", + "value": "length(\"project_deploy_credentials\".\"name\") BETWEEN 1 AND 80" + } + }, + "isRLSEnabled": false + }, + "public.project_invitations": { + "name": "project_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_email_snapshot": { + "name": "target_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_invitations_project_status": { + "name": "idx_project_invitations_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_target_status": { + "name": "idx_project_invitations_target_status", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_pending_target": { + "name": "idx_project_invitations_pending_target", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_invitations\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_invitations_project_id_projects_id_fk": { + "name": "project_invitations_project_id_projects_id_fk", + "tableFrom": "project_invitations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_target_user_id_users_id_fk": { + "name": "project_invitations_target_user_id_users_id_fk", + "tableFrom": "project_invitations", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_invitations_role_check": { + "name": "project_invitations_role_check", + "value": "\"project_invitations\".\"role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_invitations_status_check": { + "name": "project_invitations_status_check", + "value": "\"project_invitations\".\"status\" IN ('pending', 'accepted', 'declined', 'revoked', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_members_user_project": { + "name": "idx_project_members_user_project", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_members_project_id_user_id_pk": { + "name": "project_members_project_id_user_id_pk", + "columns": [ + "project_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_members_role_check": { + "name": "project_members_role_check", + "value": "\"project_members\".\"role\" IN ('reader', 'collaborator', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.project_migrations": { + "name": "project_migrations", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_deployment_id": { + "name": "source_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_migrations_project_id": { + "name": "idx_project_migrations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "applied_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_migrations_project_id_projects_id_fk": { + "name": "project_migrations_project_id_projects_id_fk", + "tableFrom": "project_migrations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_migrations_source_deployment_id_deployments_id_fk": { + "name": "project_migrations_source_deployment_id_deployments_id_fk", + "tableFrom": "project_migrations", + "tableTo": "deployments", + "columnsFrom": [ + "source_deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_migrations_project_id_name_pk": { + "name": "project_migrations_project_id_name_pk", + "columns": [ + "project_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_ownership_periods": { + "name": "project_ownership_periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "changed_by_user_id": { + "name": "changed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_ownership_periods_owner_time": { + "name": "idx_project_ownership_periods_owner_time", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_project_time": { + "name": "idx_project_ownership_periods_project_time", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_open": { + "name": "idx_project_ownership_periods_open", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_ownership_periods\".\"ends_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_ownership_periods_project_id_projects_id_fk": { + "name": "project_ownership_periods_project_id_projects_id_fk", + "tableFrom": "project_ownership_periods", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_ownership_periods_time_check": { + "name": "project_ownership_periods_time_check", + "value": "\"project_ownership_periods\".\"ends_at\" IS NULL OR \"project_ownership_periods\".\"ends_at\" >= \"project_ownership_periods\".\"starts_at\"" + } + }, + "isRLSEnabled": false + }, + "public.project_secrets": { + "name": "project_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_reason": { + "name": "managed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_secrets_project_id": { + "name": "idx_project_secrets_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_secrets_project_id_projects_id_fk": { + "name": "project_secrets_project_id_projects_id_fk", + "tableFrom": "project_secrets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_secrets_project_id_name_unique": { + "name": "project_secrets_project_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'worker'" + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websocket_version": { + "name": "websocket_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_projects_slug": { + "name": "idx_projects_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_projects_user_id": { + "name": "idx_projects_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_user_id_slug_unique": { + "name": "projects_user_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_id": { + "name": "cf_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_name": { + "name": "binding_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resources_project_id_projects_id_fk": { + "name": "resources_project_id_projects_id_fk", + "tableFrom": "resources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resources_project_id_type_binding_name_unique": { + "name": "resources_project_id_type_binding_name_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "type", + "binding_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_drain_checkpoints": { + "name": "sandbox_drain_checkpoints", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "billing_cycle_start": { + "name": "billing_cycle_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_provider_id": { + "name": "idx_users_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_cache_outbox": { + "name": "email_cache_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_cache_outbox_version_uq": { + "name": "email_cache_outbox_version_uq", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_cache_outbox_pending_idx": { + "name": "email_cache_outbox_pending_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_credentials": { + "name": "email_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_on": { + "name": "expires_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'healthy'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_credentials_scope_idx": { + "name": "email_credentials_scope_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_credentials_material_check": { + "name": "email_credentials_material_check", + "value": "(\"email_credentials\".\"kind\" = 'platform' AND \"email_credentials\".\"ciphertext\" IS NULL AND \"email_credentials\".\"iv\" IS NULL AND \"email_credentials\".\"key_version\" IS NULL) OR (\"email_credentials\".\"kind\" = 'api_token' AND \"email_credentials\".\"ciphertext\" IS NOT NULL AND \"email_credentials\".\"iv\" IS NOT NULL AND \"email_credentials\".\"key_version\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_domain_routes": { + "name": "email_domain_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addressing": { + "name": "addressing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_user_id": { + "name": "assigned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "desired_state": { + "name": "desired_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outbound_status": { + "name": "outbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outbound_reason": { + "name": "outbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbound_observed_at": { + "name": "outbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_domain_routes_domain_uq": { + "name": "email_domain_routes_domain_uq", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_connection_idx": { + "name": "email_domain_routes_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_project_idx": { + "name": "email_domain_routes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domain_routes_connection_id_email_zone_connections_id_fk": { + "name": "email_domain_routes_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_domain_routes", + "tableTo": "email_zone_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_domain_routes_assignment_check": { + "name": "email_domain_routes_assignment_check", + "value": "(\"email_domain_routes\".\"addressing\" = 'custom' AND \"email_domain_routes\".\"project_id\" IS NOT NULL) OR (\"email_domain_routes\".\"addressing\" = 'shared' AND \"email_domain_routes\".\"project_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_operation_steps": { + "name": "email_operation_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_identity": { + "name": "resource_identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_operation_steps_order_uq": { + "name": "email_operation_steps_order_uq", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operation_steps_operation_id_email_operations_id_fk": { + "name": "email_operation_steps_operation_id_email_operations_id_fk", + "tableFrom": "email_operation_steps", + "tableTo": "email_operations", + "columnsFrom": [ + "operation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_operations": { + "name": "email_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_generation": { + "name": "route_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_scope": { + "name": "actor_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_operations_key_uq": { + "name": "email_operations_key_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_work_idx": { + "name": "email_operations_work_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_connection_idx": { + "name": "email_operations_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operations_connection_id_email_zone_connections_id_fk": { + "name": "email_operations_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_zone_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_operations_route_id_email_domain_routes_id_fk": { + "name": "email_operations_route_id_email_domain_routes_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_domain_routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_zone_connections": { + "name": "email_zone_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingress_worker_name": { + "name": "ingress_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingress_version": { + "name": "ingress_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "active_secret_generation": { + "name": "active_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "staged_secret_generation": { + "name": "staged_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staged_secret_ciphertext": { + "name": "staged_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_iv": { + "name": "staged_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_key_version": { + "name": "staged_secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_hash": { + "name": "previous_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_until": { + "name": "previous_secret_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "management_status": { + "name": "management_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'probe_failed'" + }, + "management_reason": { + "name": "management_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "management_observed_at": { + "name": "management_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_zone_connections_zone_uq": { + "name": "email_zone_connections_zone_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_ingress_uq": { + "name": "email_zone_connections_ingress_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ingress_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_manager_idx": { + "name": "email_zone_connections_manager_idx", + "columns": [ + { + "expression": "manager_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_zone_connections_manager_check": { + "name": "email_zone_connections_manager_check", + "value": "(\"email_zone_connections\".\"managed_by\" = 'owner' AND \"email_zone_connections\".\"manager_user_id\" IS NOT NULL) OR (\"email_zone_connections\".\"managed_by\" = 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.email_action_recipients": { + "name": "email_action_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_email_action_recipients_project_reserved": { + "name": "idx_email_action_recipients_project_reserved", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_action_recipients_expiring": { + "name": "idx_email_action_recipients_expiring", + "columns": [ + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"email_action_recipients\".\"state\" = 'reserved'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_action_recipients_action_id_email_actions_id_fk": { + "name": "email_action_recipients_action_id_email_actions_id_fk", + "tableFrom": "email_action_recipients", + "tableTo": "email_actions", + "columnsFrom": [ + "action_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_action_recipients_action_recipient_uq": { + "name": "email_action_recipients_action_recipient_uq", + "nullsNotDistinct": false, + "columns": [ + "action_id", + "recipient" + ] + } + }, + "policies": {}, + "checkConstraints": { + "email_action_recipients_state_check": { + "name": "email_action_recipients_state_check", + "value": "\"email_action_recipients\".\"state\" IN ('rejected', 'reserved', 'attempt_started', 'provider_accepted', 'failed', 'outcome_unknown', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.email_actions": { + "name": "email_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_actions_project_created": { + "name": "idx_email_actions_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_actions_receipt_id_email_receipts_id_fk": { + "name": "email_actions_receipt_id_email_receipts_id_fk", + "tableFrom": "email_actions", + "tableTo": "email_receipts", + "columnsFrom": [ + "receipt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_actions_project_key_uq": { + "name": "email_actions_project_key_uq", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "email_actions_kind_check": { + "name": "email_actions_kind_check", + "value": "\"email_actions\".\"kind\" IN ('direct', 'forward', 'reply')" + } + }, + "isRLSEnabled": false + }, + "public.email_monthly_usage": { + "name": "email_monthly_usage", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_attempts": { + "name": "recipient_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inbound_receipts": { + "name": "inbound_receipts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_monthly_usage_project_month_uq": { + "name": "email_monthly_usage_project_month_uq", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "month" + ] + } + }, + "policies": {}, + "checkConstraints": { + "email_monthly_usage_nonnegative_check": { + "name": "email_monthly_usage_nonnegative_check", + "value": "\"email_monthly_usage\".\"recipient_attempts\" >= 0" + }, + "email_monthly_usage_inbound_nonnegative_check": { + "name": "email_monthly_usage_inbound_nonnegative_check", + "value": "\"email_monthly_usage\".\"inbound_receipts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_project_controls": { + "name": "email_project_controls", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 200 + }, + "burst_limit": { + "name": "burst_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "email_project_controls_project_id_projects_id_fk": { + "name": "email_project_controls_project_id_projects_id_fk", + "tableFrom": "email_project_controls", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_receipts": { + "name": "email_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_reject": { + "name": "final_reject", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_receipts_project_created": { + "name": "idx_email_receipts_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_unresolved_attempts": { + "name": "email_unresolved_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_email_unresolved_attempts_project": { + "name": "idx_email_unresolved_attempts_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_unresolved_attempts_domain": { + "name": "idx_email_unresolved_attempts_domain", + "columns": [ + { + "expression": "sender_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/platform/packages/api/drizzle/meta/_journal.json b/platform/packages/api/drizzle/meta/_journal.json index ebe0e9d36..ac0a58df8 100644 --- a/platform/packages/api/drizzle/meta/_journal.json +++ b/platform/packages/api/drizzle/meta/_journal.json @@ -288,6 +288,13 @@ "when": 1789749117331, "tag": "0047_email_gateway", "breakpoints": true + }, + { + "idx": 48, + "version": "7", + "when": 1789920262096, + "tag": "0048_project_teams", + "breakpoints": true } ] } diff --git a/platform/packages/api/src/admin/routes/projects.tsx b/platform/packages/api/src/admin/routes/projects.tsx index 999ba3215..db85558f9 100644 --- a/platform/packages/api/src/admin/routes/projects.tsx +++ b/platform/packages/api/src/admin/routes/projects.tsx @@ -3,7 +3,12 @@ import { SITE_DOMAIN } from '../../constants.ts'; import { getAllProjectsWithOwner, countProjects, searchProjects } from '../../db.ts'; import type { ApiEnv } from '../../env.ts'; import { executeAuditedMutation, operatorAuditIntent } from '../../operator/audit.ts'; -import { deleteOperatorProject, getOperatorProject } from '../../operator/tenant-service.ts'; +import { OperatorError } from '../../operator/common.ts'; +import { + deleteOperatorProject, + getOperatorProject, + transferOperatorProjectOwner, +} from '../../operator/tenant-service.ts'; import { projectUrl, projectWithUrl } from '../../project-url.ts'; import { renderOperationError } from '../operation-error.tsx'; import { BuildTable } from '../views/build-table.tsx'; @@ -15,6 +20,45 @@ const PAGE_SIZE = 20; const app = new Hono(); app.onError(renderOperationError); +type OwnerSummary = Readonly<{ + id: string; + login: string; + email: string | null; + plan: string; + suspended: boolean; + suspendedReason: string | null; +}>; + +type OwnerTransferPreview = Readonly<{ + target: { id: string; slug: string; userId: string }; + previousOwner: OwnerSummary; + newOwner: OwnerSummary; + formerOwnerRole: 'admin'; + targetPreviousRole: string | null; + blockers: ReadonlyArray<{ kind: string; id: string; status: string }>; + github: { + installationId: string; + repository: string; + authorizedByUserId: string | null; + executor: string; + } | null; + routingUpdates: ReadonlyArray; + counterUpdates: { detachUserId: string; attachUserId: string }; + usageTransfer: string; +}>; + +function transferTarget(body: Record) { + const value = body.userId; + if (typeof value !== 'string' || !value.trim() || value.length > 512) { + throw new OperatorError(400, 'New owner user ID is required.'); + } + return value.trim(); +} + +function suspensionLabel(owner: OwnerSummary) { + return owner.suspended ? `Suspended (${owner.suspendedReason ?? 'unknown reason'})` : 'Active'; +} + // GET /projects app.get('/', async (context) => { const search = context.req.query('search'); @@ -175,6 +219,19 @@ app.get('/:id', async (context) => {

Builds ({builds.length})

+

Transfer Ownership

+

+ Transfer this project to an existing Void Platform user. You will review the ownership, + plan, suspension, active-work, routing, and usage-counter changes before applying them. +

+
+ {' '} + +
+

Danger Zone

{ ); }); +// POST /projects/:id/owner/preview +app.post('/:id/owner/preview', async (context) => { + const projectId = context.req.param('id'); + const body = await context.req.parseBody(); + const userId = transferTarget(body); + const result = (await transferOperatorProjectOwner( + context, + projectId, + userId, + true, + )) as unknown as OwnerTransferPreview; + + return context.html( + +

Transfer Ownership: {result.target.slug}

+ + + + + + + + + + + + + + + + + + + + + + + +
AccountUserPlanStatus
Current owner + {result.previousOwner.login} ({result.previousOwner.id}) + {result.previousOwner.plan}{suspensionLabel(result.previousOwner)}
New owner + {result.newOwner.login} ({result.newOwner.id}) + {result.newOwner.plan}{suspensionLabel(result.newOwner)}
+ +

+ The former owner will become a {result.formerOwnerRole}. The target's + current project role is {result.targetPreviousRole ?? 'none'}. Usage + transfer is {result.usageTransfer}. +

+ +

Active-work blockers

+ {result.blockers.length > 0 ? ( +
    + {result.blockers.map((blocker) => ( +
  • + {blocker.kind}: {blocker.id} ({blocker.status}) +
  • + ))} +
+ ) : ( +

None.

+ )} + +

Convergence

+

+ Usage counters: detach from {result.counterUpdates.detachUserId}, attach to{' '} + {result.counterUpdates.attachUserId}. +

+

Routing and account entries: {result.routingUpdates.join(', ')}

+ +

GitHub authorization provenance

+ {result.github ? ( +

+ {result.github.repository} installation {result.github.installationId}, authorized by{' '} + {result.github.authorizedByUserId ?? 'unknown'} ({result.github.executor}). +

+ ) : ( +

No GitHub connection.

+ )} + + {result.blockers.length === 0 ? ( + + + + + ) : ( +

Resolve or cancel all active work before applying this transfer.

+ )} +

+ Back to project +

+
, + ); +}); + +// POST /projects/:id/owner +app.post('/:id/owner', async (context) => { + const projectId = context.req.param('id'); + const body = await context.req.parseBody(); + const userId = transferTarget(body); + return executeAuditedMutation( + context, + operatorAuditIntent(`projects/${projectId}/owner`, { userId }), + async () => { + await transferOperatorProjectOwner(context, projectId, userId); + return { response: context.redirect(`/admin/projects/${projectId}`), succeeded: true }; + }, + ); +}); + // POST /projects/:id/delete app.post('/:id/delete', async (context) => { const projectId = context.req.param('id'); diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 27b4bef03..5b357d865 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -7,6 +7,8 @@ import { eq, ne, lt, + lte, + gt, gte, inArray, notInArray, @@ -27,6 +29,10 @@ import { users, oauthAccounts, projects, + projectMembers, + projectInvitations, + projectOwnershipPeriods, + projectAccessEvents, deployments, projectSecrets, resources, @@ -55,7 +61,7 @@ import { REASON_REQUESTS_EXCEEDED, REASON_SANDBOX_RUNTIME_EXCEEDED, } from './suspension.ts'; -import type { PlanId } from './tiers.ts'; +import { getBuildInstanceType, getPlanLimits, type PlanId } from './tiers.ts'; import type { Build, Deployment, @@ -68,7 +74,14 @@ import type { Invitation, OAuthAccount, PlatformEvent, + ProjectAccessEvent, + ProjectAccessEventAction, ProjectDatabaseCredentials, + ProjectInvitation, + ProjectMember, + ProjectMemberRole, + ProjectOwnershipPeriod, + ProjectRole, ProjectSecret, Project, Resource, @@ -399,7 +412,10 @@ export async function countUserDeploymentsToday(db: Db, userId: string): Promise .innerJoin(projects, eq(deployments.project_id, projects.id)) .where( and( - eq(projects.user_id, userId), + // billing_user_id is authoritative after the team migration. Keep the + // owner fallback during the additive rollout for a placeholder created + // by an older API revision before the backfill converges. + sql`COALESCE(${deployments.billing_user_id}, ${projects.user_id}) = ${userId}`, sql`${deployments.created_at} >= CURRENT_DATE`, isNotNull(deployments.manifest), ), @@ -411,7 +427,16 @@ export async function countUserDeploymentsToday(db: Db, userId: string): Promise export async function createProject(db: Db, userId: string, slug: string): Promise { const id = generateId('proj'); - const [row] = await db.insert(projects).values({ id, user_id: userId, slug }).returning(); + const row = await db.transaction(async (tx) => { + const [created] = await tx.insert(projects).values({ id, user_id: userId, slug }).returning(); + await tx.insert(projectOwnershipPeriods).values({ + id: generateId('pown'), + project_id: created.id, + owner_user_id: userId, + starts_at: created.created_at, + }); + return created; + }); return row; } @@ -426,6 +451,14 @@ export async function getProjectBySlug(db: Db, slug: string): Promise> { + return getOwnedProjectsByUserId(db, userId); +} + +/** Projects for which userId is the billing owner. */ +export async function getOwnedProjectsByUserId( + db: Db, + userId: string, +): Promise> { return db .select() .from(projects) @@ -433,6 +466,684 @@ export async function getProjectsByUserId(db: Db, userId: string): Promise; + +const accessibleProjectOwner = alias(users, 'accessible_project_owner'); + +/** Owned and shared projects visible to an actor, with explicit billing owner. */ +export async function getAccessibleProjectsByUserId( + db: Db, + userId: string, +): Promise> { + const rows = await db + .select({ project: projects, memberRole: projectMembers.role, owner: accessibleProjectOwner }) + .from(projects) + .innerJoin(accessibleProjectOwner, eq(projects.user_id, accessibleProjectOwner.id)) + .leftJoin( + projectMembers, + and(eq(projectMembers.project_id, projects.id), eq(projectMembers.user_id, userId)), + ) + .where(or(eq(projects.user_id, userId), eq(projectMembers.user_id, userId))) + .orderBy(desc(projects.created_at)); + + return rows.map((row) => ({ + project: row.project, + role: row.project.user_id === userId ? 'owner' : row.memberRole!, + owner: row.owner, + })); +} + +// --- Project team membership and invitations --- + +export type ProjectMemberWithUser = Readonly<{ member: ProjectMember; user: User }>; + +export async function getProjectMember( + db: Db, + projectId: string, + userId: string, +): Promise { + const [row] = await db + .select() + .from(projectMembers) + .where(and(eq(projectMembers.project_id, projectId), eq(projectMembers.user_id, userId))) + .limit(1); + return row ?? null; +} + +export async function getProjectMembers( + db: Db, + projectId: string, +): Promise> { + return db + .select({ member: projectMembers, user: users }) + .from(projectMembers) + .innerJoin(users, eq(projectMembers.user_id, users.id)) + .where(eq(projectMembers.project_id, projectId)) + .orderBy(asc(projectMembers.created_at), asc(projectMembers.user_id)); +} + +export type ProjectTeamMutationErrorReason = + | 'project-not-found' + | 'target-user-not-found' + | 'owner-conflict' + | 'member-conflict' + | 'member-not-found' + | 'invitation-not-found' + | 'invitation-not-pending' + | 'invitation-not-target' + | 'invitation-expired' + | 'pending-invitation-limit' + | 'forbidden'; + +export class ProjectTeamMutationError extends Error { + readonly status: 403 | 404 | 409; + + constructor(readonly reason: ProjectTeamMutationErrorReason) { + super(reason); + this.name = 'ProjectTeamMutationError'; + this.status = + reason === 'invitation-not-target' || reason === 'forbidden' + ? 403 + : reason.endsWith('not-found') + ? 404 + : 409; + } +} + +async function lockProject(db: Db, projectId: string): Promise { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + if (!project) { + throw new ProjectTeamMutationError('project-not-found'); + } + return project; +} + +/** Re-check team-management access while the project lock is held. */ +async function requireLockedMembersCapability( + db: Db, + project: Project, + actorUserId: string, +): Promise { + if (project.user_id === actorUserId) { + return; + } + const actorMember = await getProjectMember(db, project.id, actorUserId); + if (actorMember?.role !== 'admin') { + throw new ProjectTeamMutationError('forbidden'); + } +} + +export async function createProjectAccessEvent( + db: Db, + args: { + projectId: string; + actorUserId: string; + action: ProjectAccessEventAction; + subjectUserId?: string | null; + invitationEmailSnapshot?: string | null; + previousRole?: ProjectMemberRole | null; + newRole?: ProjectMemberRole | null; + }, +): Promise { + const [event] = await db + .insert(projectAccessEvents) + .values({ + id: generateId('pae'), + project_id: args.projectId, + actor_user_id: args.actorUserId, + subject_user_id: args.subjectUserId ?? null, + invitation_email_snapshot: args.invitationEmailSnapshot ?? null, + previous_role: args.previousRole ?? null, + new_role: args.newRole ?? null, + action: args.action, + }) + .returning(); + return event; +} + +export async function getProjectAccessEvents( + db: Db, + projectId: string, + limit = 100, +): Promise> { + return db + .select() + .from(projectAccessEvents) + .where(eq(projectAccessEvents.project_id, projectId)) + .orderBy(desc(projectAccessEvents.created_at)) + .limit(limit); +} + +export async function setProjectMemberRole( + db: Db, + args: { + projectId: string; + subjectUserId: string; + role: ProjectMemberRole; + actorUserId: string; + }, +): Promise { + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const project = await lockProject(txDb, args.projectId); + await requireLockedMembersCapability(txDb, project, args.actorUserId); + if (project.user_id === args.subjectUserId) { + throw new ProjectTeamMutationError('owner-conflict'); + } + const current = await getProjectMember(txDb, args.projectId, args.subjectUserId); + if (!current) { + throw new ProjectTeamMutationError('member-not-found'); + } + if (current.role === args.role) { + return current; + } + const [member] = await txDb + .update(projectMembers) + .set({ role: args.role, updated_at: sql`now()` }) + .where( + and( + eq(projectMembers.project_id, args.projectId), + eq(projectMembers.user_id, args.subjectUserId), + ), + ) + .returning(); + await createProjectAccessEvent(txDb, { + projectId: args.projectId, + actorUserId: args.actorUserId, + subjectUserId: args.subjectUserId, + action: 'role_changed', + previousRole: current.role, + newRole: args.role, + }); + if (args.role !== 'admin') { + await invalidateGitHubConnectionAuthorizedBy( + txDb, + args.projectId, + args.subjectUserId, + 'github connection authorizer lost admin access', + ); + } + return member; + }); +} + +export async function removeProjectMember( + db: Db, + args: { projectId: string; subjectUserId: string; actorUserId: string }, +): Promise { + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const project = await lockProject(txDb, args.projectId); + if (project.user_id === args.subjectUserId) { + throw new ProjectTeamMutationError('owner-conflict'); + } + const current = await getProjectMember(txDb, args.projectId, args.subjectUserId); + if (!current) { + throw new ProjectTeamMutationError('member-not-found'); + } + if (args.actorUserId !== args.subjectUserId) { + await requireLockedMembersCapability(txDb, project, args.actorUserId); + } + const [member] = await txDb + .delete(projectMembers) + .where( + and( + eq(projectMembers.project_id, args.projectId), + eq(projectMembers.user_id, args.subjectUserId), + ), + ) + .returning(); + if (!member) { + throw new ProjectTeamMutationError('member-not-found'); + } + await createProjectAccessEvent(txDb, { + projectId: args.projectId, + actorUserId: args.actorUserId, + subjectUserId: args.subjectUserId, + action: args.actorUserId === args.subjectUserId ? 'left' : 'member_removed', + previousRole: member.role, + }); + await invalidateGitHubConnectionAuthorizedBy( + txDb, + args.projectId, + args.subjectUserId, + 'github connection authorizer removed from project', + ); + return member; + }); +} + +export async function getProjectInvitation( + db: Db, + invitationId: string, +): Promise { + const [row] = await db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.id, invitationId)) + .limit(1); + return row ?? null; +} + +export async function getProjectInvitations( + db: Db, + projectId: string, +): Promise> { + return db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.project_id, projectId)) + .orderBy(desc(projectInvitations.created_at)); +} + +export async function getPendingProjectInvitationsByUserId( + db: Db, + userId: string, +): Promise> { + return db + .select() + .from(projectInvitations) + .where( + and( + eq(projectInvitations.target_user_id, userId), + eq(projectInvitations.status, 'pending'), + gt(projectInvitations.expires_at, sql`now()`), + ), + ) + .orderBy(desc(projectInvitations.created_at)); +} + +export async function createOrRefreshProjectInvitation( + db: Db, + args: { + projectId: string; + email: string; + role: ProjectMemberRole; + actorUserId: string; + expiresAt?: string; + }, +): Promise> { + const normalizedEmail = args.email.trim().toLowerCase(); + const targets = await db + .select() + .from(users) + .where(sql`lower(${users.email}) = ${normalizedEmail}`) + .limit(2); + if (targets.length !== 1) { + throw new ProjectTeamMutationError('target-user-not-found'); + } + const target = targets[0]!; + const expiresAt = args.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const project = await lockProject(txDb, args.projectId); + await requireLockedMembersCapability(txDb, project, args.actorUserId); + const [lockedTarget] = await txDb + .select() + .from(users) + .where(and(eq(users.id, target.id), sql`lower(${users.email}) = ${normalizedEmail}`)) + // The invitation is bound to the account resolved from this registered + // email. Prevent a concurrent profile update from changing the address + // between resolution and the invitation commit. + .for('update') + .limit(1); + if (!lockedTarget) { + throw new ProjectTeamMutationError('target-user-not-found'); + } + if (project.user_id === target.id) { + throw new ProjectTeamMutationError('owner-conflict'); + } + if (await getProjectMember(txDb, args.projectId, target.id)) { + throw new ProjectTeamMutationError('member-conflict'); + } + const [pending] = await txDb + .select() + .from(projectInvitations) + .where( + and( + eq(projectInvitations.project_id, args.projectId), + eq(projectInvitations.target_user_id, target.id), + eq(projectInvitations.status, 'pending'), + ), + ) + .for('update') + .limit(1); + + if (!pending) { + const [pendingCount] = await txDb + .select({ count: count() }) + .from(projectInvitations) + .where( + and( + eq(projectInvitations.project_id, args.projectId), + eq(projectInvitations.status, 'pending'), + gt(projectInvitations.expires_at, sql`now()`), + ), + ); + if ((pendingCount?.count ?? 0) >= 100) { + throw new ProjectTeamMutationError('pending-invitation-limit'); + } + } + + let invitation: ProjectInvitation; + if (pending) { + [invitation] = await txDb + .update(projectInvitations) + .set({ + role: args.role, + target_email_snapshot: normalizedEmail, + invited_by_user_id: args.actorUserId, + expires_at: expiresAt, + }) + .where(eq(projectInvitations.id, pending.id)) + .returning(); + } else { + [invitation] = await txDb + .insert(projectInvitations) + .values({ + id: generateId('pinv'), + project_id: args.projectId, + target_user_id: target.id, + target_email_snapshot: normalizedEmail, + role: args.role, + invited_by_user_id: args.actorUserId, + expires_at: expiresAt, + }) + .returning(); + } + await createProjectAccessEvent(txDb, { + projectId: args.projectId, + actorUserId: args.actorUserId, + subjectUserId: target.id, + invitationEmailSnapshot: normalizedEmail, + action: 'invited', + previousRole: pending?.role, + newRole: args.role, + }); + return { invitation, target: lockedTarget, refreshed: !!pending }; + }); +} + +export async function resendProjectInvitation( + db: Db, + args: { projectId: string; invitationId: string; actorUserId: string; expiresAt?: string }, +): Promise> { + const expiresAt = args.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const project = await lockProject(txDb, args.projectId); + await requireLockedMembersCapability(txDb, project, args.actorUserId); + const [invitation] = await txDb + .select() + .from(projectInvitations) + .where( + and( + eq(projectInvitations.id, args.invitationId), + eq(projectInvitations.project_id, args.projectId), + ), + ) + .for('update') + .limit(1); + if (!invitation) { + throw new ProjectTeamMutationError('invitation-not-found'); + } + if (invitation.status !== 'pending') { + throw new ProjectTeamMutationError('invitation-not-pending'); + } + const [target] = await txDb + .select() + .from(users) + .where(eq(users.id, invitation.target_user_id)) + .limit(1); + if (!target?.email) { + throw new ProjectTeamMutationError('target-user-not-found'); + } + const normalizedEmail = target.email.trim().toLowerCase(); + const [updated] = await txDb + .update(projectInvitations) + .set({ target_email_snapshot: normalizedEmail, expires_at: expiresAt }) + .where(eq(projectInvitations.id, invitation.id)) + .returning(); + await createProjectAccessEvent(txDb, { + projectId: args.projectId, + actorUserId: args.actorUserId, + subjectUserId: target.id, + invitationEmailSnapshot: normalizedEmail, + action: 'invitation_resent', + newRole: invitation.role, + }); + return { invitation: updated, target }; + }); +} + +export async function revokeProjectInvitation( + db: Db, + args: { projectId: string; invitationId: string; actorUserId: string }, +): Promise { + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const project = await lockProject(txDb, args.projectId); + await requireLockedMembersCapability(txDb, project, args.actorUserId); + const [invitation] = await txDb + .select() + .from(projectInvitations) + .where( + and( + eq(projectInvitations.id, args.invitationId), + eq(projectInvitations.project_id, args.projectId), + ), + ) + .for('update') + .limit(1); + if (!invitation) { + throw new ProjectTeamMutationError('invitation-not-found'); + } + if (invitation.status !== 'pending') { + throw new ProjectTeamMutationError('invitation-not-pending'); + } + const [updated] = await txDb + .update(projectInvitations) + .set({ + status: 'revoked', + revoked_at: sql`now()`, + revoked_by_user_id: args.actorUserId, + }) + .where(eq(projectInvitations.id, invitation.id)) + .returning(); + await createProjectAccessEvent(txDb, { + projectId: args.projectId, + actorUserId: args.actorUserId, + subjectUserId: invitation.target_user_id, + invitationEmailSnapshot: invitation.target_email_snapshot, + action: 'invitation_revoked', + previousRole: invitation.role, + }); + return updated; + }); +} + +export async function respondToProjectInvitation( + db: Db, + args: { invitationId: string; actorUserId: string; response: 'accept' | 'decline' }, +): Promise> { + const locator = await getProjectInvitation(db, args.invitationId); + if (!locator) { + throw new ProjectTeamMutationError('invitation-not-found'); + } + + const result = await db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const project = await lockProject(txDb, locator.project_id); + const [locked] = await txDb + .select({ + invitation: projectInvitations, + expired: sql`${projectInvitations.expires_at} <= now()`, + }) + .from(projectInvitations) + .where(eq(projectInvitations.id, args.invitationId)) + .for('update') + .limit(1); + if (!locked) { + throw new ProjectTeamMutationError('invitation-not-found'); + } + const invitation = locked.invitation; + if (invitation.target_user_id !== args.actorUserId) { + throw new ProjectTeamMutationError('invitation-not-target'); + } + const resultingStatus = args.response === 'accept' ? 'accepted' : 'declined'; + if (invitation.status === resultingStatus) { + if (args.response === 'accept' && project.user_id === args.actorUserId) { + throw new ProjectTeamMutationError('owner-conflict'); + } + const member = + args.response === 'accept' + ? await getProjectMember(txDb, project.id, args.actorUserId) + : null; + // Acceptance is idempotent only while the access it created still + // exists. Once an administrator removes that member, replaying the old + // invitation must not produce a 500 or recreate access; a fresh invite + // is required. + if (args.response === 'accept' && !member) { + throw new ProjectTeamMutationError('invitation-not-pending'); + } + return { + invitation, + member, + }; + } + if (invitation.status !== 'pending') { + throw new ProjectTeamMutationError('invitation-not-pending'); + } + if (locked.expired) { + await txDb + .update(projectInvitations) + .set({ status: 'expired' }) + .where(eq(projectInvitations.id, invitation.id)); + return { error: 'invitation-expired' as const }; + } + if (project.user_id === args.actorUserId) { + throw new ProjectTeamMutationError('owner-conflict'); + } + if (await getProjectMember(txDb, project.id, args.actorUserId)) { + throw new ProjectTeamMutationError('member-conflict'); + } + + let member: ProjectMember | null = null; + if (args.response === 'accept') { + [member] = await txDb + .insert(projectMembers) + .values({ + project_id: project.id, + user_id: args.actorUserId, + role: invitation.role, + added_by_user_id: invitation.invited_by_user_id, + }) + .returning(); + } + const [updated] = await txDb + .update(projectInvitations) + .set({ + status: resultingStatus, + accepted_by_user_id: args.response === 'accept' ? args.actorUserId : null, + accepted_at: args.response === 'accept' ? sql`now()` : null, + }) + .where(eq(projectInvitations.id, invitation.id)) + .returning(); + await createProjectAccessEvent(txDb, { + projectId: project.id, + actorUserId: args.actorUserId, + subjectUserId: args.actorUserId, + invitationEmailSnapshot: invitation.target_email_snapshot, + action: args.response === 'accept' ? 'accepted' : 'declined', + newRole: args.response === 'accept' ? invitation.role : null, + }); + return { invitation: updated, member }; + }); + + if ('error' in result) { + throw new ProjectTeamMutationError(result.error!); + } + return result; +} + +// --- Project ownership history --- + +export async function getProjectOwnershipPeriods( + db: Db, + projectId: string, +): Promise> { + return db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, projectId)) + .orderBy(asc(projectOwnershipPeriods.starts_at)); +} + +export async function getProjectOwnershipPeriodsByProjectIds( + db: Db, + projectIds: ReadonlyArray, +): Promise> { + if (projectIds.length === 0) { + return []; + } + return db + .select() + .from(projectOwnershipPeriods) + .where(inArray(projectOwnershipPeriods.project_id, projectIds)) + .orderBy(asc(projectOwnershipPeriods.project_id), asc(projectOwnershipPeriods.starts_at)); +} + +export async function getOpenProjectOwnershipPeriod( + db: Db, + projectId: string, +): Promise { + const [row] = await db + .select() + .from(projectOwnershipPeriods) + .where( + and( + eq(projectOwnershipPeriods.project_id, projectId), + isNull(projectOwnershipPeriods.ends_at), + ), + ) + .limit(1); + return row ?? null; +} + +/** Ownership intervals use [starts_at, ends_at), so a boundary bills new owner. */ +export async function getProjectOwnerAt( + db: Db, + projectId: string, + occurredAt: string, +): Promise { + const [row] = await db + .select({ ownerUserId: projectOwnershipPeriods.owner_user_id }) + .from(projectOwnershipPeriods) + .where( + and( + eq(projectOwnershipPeriods.project_id, projectId), + lte(projectOwnershipPeriods.starts_at, occurredAt), + or( + isNull(projectOwnershipPeriods.ends_at), + gt(projectOwnershipPeriods.ends_at, occurredAt), + ), + ), + ) + .orderBy(desc(projectOwnershipPeriods.starts_at)) + .limit(1); + return row?.ownerUserId ?? null; +} + export async function getLatestDeploymentsByProjectIds( db: Db, projectIds: ReadonlyArray, @@ -533,9 +1244,13 @@ export async function createDeployment( gitRef?: string | null; repositoryUrl?: string | null; assetHashes?: string; + billingUserId?: string | null; + initiatedByUserId?: string | null; }, ): Promise { const id = generateId('dpl'); + const billingUserId = + metadata?.billingUserId ?? (await getProject(db, projectId))?.user_id ?? null; const [row] = await db .insert(deployments) .values({ @@ -549,6 +1264,8 @@ export async function createDeployment( git_ref: metadata?.gitRef ?? null, cli_version: metadata?.cliVersion ?? null, asset_hashes: metadata?.assetHashes ?? null, + billing_user_id: billingUserId, + initiated_by_user_id: metadata?.initiatedByUserId ?? null, }) .returning(); return row; @@ -1651,7 +2368,7 @@ export async function tryCreateEmailDestination( const outcome = await db.transaction(async (tx) => { await lockEmailPolicy(tx); const [project] = await tx - .select({ status: projects.status }) + .select({ status: projects.status, ownerUserId: projects.user_id }) .from(projects) .where(eq(projects.id, projectId)) .for('update') @@ -1659,6 +2376,9 @@ export async function tryCreateEmailDestination( if (!project || project.status === 'deleting') { return 'project-unavailable' as const; } + if (project.ownerUserId !== ownerAudit.userId) { + return 'owner-email-changed' as const; + } const [owner] = await tx .select({ email: users.email }) .from(users) @@ -2022,12 +2742,15 @@ export async function promoteEmailDestinationWithOwnerAudit( ): Promise { return db.transaction(async (tx) => { await lockEmailPolicy(tx); - await tx - .select({ id: projects.id }) + const [project] = await tx + .select({ ownerUserId: projects.user_id }) .from(projects) .where(eq(projects.id, projectId)) .for('update') .limit(1); + if (project?.ownerUserId !== ownerAudit.userId) { + return false; + } const [retired] = await tx .select({ id: emailDestinationCleanups.cf_destination_id }) .from(emailDestinationCleanups) @@ -2478,7 +3201,12 @@ export async function getUsersWithProjectsBatch(db: Db, limit: number, offset: n const userIds = userRows.map((u) => u.id); const projectRows = await db - .select({ id: projects.id, slug: projects.slug, user_id: projects.user_id }) + .select({ + id: projects.id, + slug: projects.slug, + user_id: projects.user_id, + created_at: projects.created_at, + }) .from(projects) .where(inArray(projects.user_id, userIds)); @@ -2907,6 +3635,7 @@ export async function createBuild( logsUrl?: string | null; instanceType?: string | null; status?: Build['status']; + triggeredByUserId?: string | null; }, ): Promise { const id = generateId('bld'); @@ -2916,6 +3645,7 @@ export async function createBuild( id, project_id: args.projectId, user_id: args.userId, + triggered_by_user_id: args.triggeredByUserId ?? null, executor: args.executor ?? 'container', status: args.status ?? 'queued', commit_sha: args.commitSha ?? null, @@ -3305,27 +4035,65 @@ export async function hasQueuedContainerBuilds(db: Db): Promise { * and the plain read avoids any lock-order concern on `github_installations`. * * FAILS CLOSED unless the exact connection still belongs to the project, has - * durable authority, and its active installation still covers the repo. Personal - * authority is the installation owner matching the project owner. Organization - * authority is `authorized_by_user_id` matching the project owner, written only - * after per-repo OAuth proof. Legacy organization rows are null and stop here. + * durable authority, and its active installation still covers the repo. New + * connections record the project owner/admin who completed the repository proof; + * that authorizer must still be the owner or an admin. Legacy personal-install + * rows without provenance retain their former owner-match behavior; legacy + * organization rows are null and stop here. * An `all` install covers every repo; a `selected` install requires the immutable * repo id in its cache. This is the shared predicate used by webhook and OIDC * resolvers as well as enqueue and final startup checks. */ function githubConnectionAuthorityClause() { return or( + eq(githubConnections.authorized_by_user_id, projects.user_id), + sql`exists ( + select 1 + from ${projectMembers} + where ${projectMembers.project_id} = ${projects.id} + and ${projectMembers.user_id} = ${githubConnections.authorized_by_user_id} + and ${projectMembers.role} = 'admin' + )`, and( + isNull(githubConnections.authorized_by_user_id), eq(githubInstallations.account_type, 'User'), eq(githubInstallations.user_id, projects.user_id), ), - and( - eq(githubInstallations.account_type, 'Organization'), - eq(githubConnections.authorized_by_user_id, projects.user_id), - ), ); } +/** + * Invalidate provenance that no longer names a project owner/admin. Membership + * mutations call this while holding the project row lock, so they serialize with + * connection CRUD/build creation (which use the same project-first order). + * Reauthorization by another admin changes `authorized_by_user_id`, making this + * a no-op. Active build rows are terminalized before the connection is removed, + * which immediately revokes their build-bound deploy tokens. + */ +async function invalidateGitHubConnectionAuthorizedBy( + db: Db, + projectId: string, + userId: string, + reason: string, +): Promise { + const [connection] = await db + .select({ id: githubConnections.id }) + .from(githubConnections) + .where( + and( + eq(githubConnections.project_id, projectId), + eq(githubConnections.authorized_by_user_id, userId), + ), + ) + .for('update') + .limit(1); + if (!connection) { + return; + } + await terminateBuildsForProject(db, projectId, { reason }); + await db.delete(githubConnections).where(eq(githubConnections.id, connection.id)); +} + async function installationCoversRepoInTx( tx: Db, connectionId: string, @@ -3466,7 +4234,7 @@ export async function supersedeAndCreateContainerBuild( // Lock order is unchanged (projects-first), so the round-4 40P01 class stays // closed. const [proj] = await tx - .select({ status: projects.status }) + .select({ status: projects.status, user_id: projects.user_id }) .from(projects) .where(eq(projects.id, args.projectId)) .for('update') @@ -3474,6 +4242,16 @@ export async function supersedeAndCreateContainerBuild( if (!proj || proj.status === 'deleting') { return { build: null, reused: false, cancelled: [], skipped: 'project-deleting' }; } + const ownerChanged = proj.user_id !== args.userId; + let instanceType = args.instanceType ?? null; + if (ownerChanged) { + const [owner] = await tx + .select({ plan: users.plan }) + .from(users) + .where(eq(users.id, proj.user_id)) + .limit(1); + instanceType = owner ? getBuildInstanceType(owner.plan) : null; + } // Serialize against PATCH /projects/:id/github: lock + re-read the single // connection (project_id is UNIQUE). If a concurrent config change committed @@ -3629,12 +4407,12 @@ export async function supersedeAndCreateContainerBuild( .values({ id, project_id: args.projectId, - user_id: args.userId, + user_id: proj.user_id, executor: 'container', status: 'queued', commit_sha: args.commitSha, branch: args.branch, - instance_type: args.instanceType ?? null, + instance_type: instanceType, }) .returning(); return { build: row, reused: false, cancelled }; @@ -3907,7 +4685,7 @@ export async function createGithubActionsBuildWithinLimit( // token for a deleting project would create an orphan build the cascade then // has to cancel. const [proj] = await tx - .select({ status: projects.status }) + .select({ status: projects.status, user_id: projects.user_id }) .from(projects) .where(eq(projects.id, args.projectId)) .for('update') @@ -3919,7 +4697,17 @@ export async function createGithubActionsBuildWithinLimit( // Serialize per-user slot acquisition. Concurrent exchanges for the same // owner block here until the holder commits, so the count below always // observes prior inserts. - await tx.execute(sql`SELECT id FROM ${users} WHERE id = ${args.userId} FOR UPDATE`); + await tx.execute(sql`SELECT id FROM ${users} WHERE id = ${proj.user_id} FOR UPDATE`); + + let limit = args.limit; + if (proj.user_id !== args.userId) { + const [owner] = await tx + .select({ plan: users.plan }) + .from(users) + .where(eq(users.id, proj.user_id)) + .limit(1); + limit = owner ? getPlanLimits(owner.plan).concurrentBuilds : 0; + } // Serialize against DELETE /projects/:id/github: lock + re-read the connection // inside this tx. If a concurrent disconnect committed first, the row is gone @@ -4057,12 +4845,14 @@ export async function createGithubActionsBuildWithinLimit( ); } - if (args.limit > 0) { + if (limit > 0) { const [result] = await tx .select({ count: count() }) .from(builds) - .where(and(eq(builds.user_id, args.userId), inArray(builds.status, ACTIVE_BUILD_STATUSES))); - if ((result?.count ?? 0) >= args.limit) { + .where( + and(eq(builds.user_id, proj.user_id), inArray(builds.status, ACTIVE_BUILD_STATUSES)), + ); + if ((result?.count ?? 0) >= limit) { return { outcome: 'limit-exceeded' }; } } @@ -4073,7 +4863,7 @@ export async function createGithubActionsBuildWithinLimit( .values({ id, project_id: args.projectId, - user_id: args.userId, + user_id: proj.user_id, executor: 'github_actions', status: 'building', commit_sha: args.commitSha ?? null, diff --git a/platform/packages/api/src/email.ts b/platform/packages/api/src/email.ts index 3b8acfa98..7e4fe91e4 100644 --- a/platform/packages/api/src/email.ts +++ b/platform/packages/api/src/email.ts @@ -93,6 +93,36 @@ export async function sendInvitationEmail(env: PlatformEmailEnv, to: string): Pr }); } +export async function sendProjectInvitationEmail( + env: PlatformEmailEnv, + params: { + to: string; + invitationId: string; + projectSlug: string; + inviterLogin: string; + role: 'reader' | 'collaborator' | 'admin'; + }, +): Promise { + const { apiUrl, name } = emailConfig(env); + const inviteUrl = new URL('/invite', apiUrl); + inviteUrl.searchParams.set('project-invitation', params.invitationId); + const command = platformConnectCommand(apiUrl); + await sendEmail(env, { + to: params.to, + subject: `You're invited to ${params.projectSlug}`, + html: emailHtml( + `

You're invited to ${escapeHtml(params.projectSlug)}

+

${escapeHtml(params.inviterLogin)} invited you to join ${escapeHtml(params.projectSlug)} as ${escapeHtml(params.role)} on ${escapeHtml(name)}.

+

Sign in with this email address, then accept the invitation from the dashboard or CLI.

+
${escapeHtml(command)}
+void project team accept ${escapeHtml(params.invitationId)}
+

${button(inviteUrl.href, 'View Invitation')}

`, + "If you didn't expect this invitation, you can decline it or ignore this email.", + ), + text: `You're invited to ${params.projectSlug}\n\n${params.inviterLogin} invited you to join ${params.projectSlug} as ${params.role} on ${name}.\n\nSign in with this email address, then accept the invitation from the dashboard or CLI:\n\n${command}\nvoid project team accept ${params.invitationId}\n\nView invitation: ${inviteUrl.href}\n\nIf you didn't expect this invitation, you can decline it or ignore this email.`, + }); +} + export async function sendWelcomeEmail( env: PlatformEmailEnv, to: string, diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index 6edddc48a..f7566ab12 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -42,6 +42,7 @@ import { failStaleActiveGithubActionsBuilds, hasQueuedContainerBuilds, getProject, + getOpenProjectOwnershipPeriod, getProjectsByIds, getUsersByIds, type Db, @@ -96,6 +97,11 @@ import internalVerifyPrerenderBuild from './routes/internal-verify-prerender-bui import me from './routes/me.ts'; import platformBootstrapRoutes from './routes/platform-bootstrap.ts'; import projectTokens from './routes/project-tokens.ts'; +import { + myProjectInvitationRoutes, + projectInvitationResponseRoutes, + projectTeamRoutes, +} from './routes/project-team.ts'; import projects from './routes/projects.ts'; import revalidate from './routes/revalidate.ts'; import secrets from './routes/secrets.ts'; @@ -439,10 +445,15 @@ const app = new Hono() .use('/email/*', capabilityMiddleware('email')) .route('/email', emailConfirm) .use('/me', authMiddleware) + .use('/me/*', authMiddleware) .route('/me', me) + .route('/me', myProjectInvitationRoutes) + .use('/project-invitations/*', authMiddleware) + .route('/project-invitations', projectInvitationResponseRoutes) .use('/projects/*', authMiddleware) .route('/projects', projects) .route('/projects', projectTokens) + .route('/projects', projectTeamRoutes) .route('/projects', deploy) .route('/projects', deleteRoute) .route('/projects', revalidate) @@ -928,7 +939,7 @@ async function processUserRollover( env: ApiEnv['Bindings'], db: Db, user: { id: string; plan: string; billing_cycle_start: string; suspended_reason: string | null }, - userProjects: ReadonlyArray<{ id: string; slug: string }>, + userProjects: ReadonlyArray<{ id: string; slug: string; created_at: string }>, ): Promise { // Backfill userId into old KV routing entries (all plans, before early return). // Wrapped in try/catch so a malformed KV entry doesn't block rollover/unsuspension. @@ -986,8 +997,13 @@ async function processUserRollover( const billingPeriodStart = getCurrentBillingPeriodStart(user.billing_cycle_start).toISOString(); const projectSlugs: Record = {}; - for (const p of userProjects) { + const projectOwnershipStarts: Record = {}; + const ownershipPeriods = await Promise.all( + userProjects.map((project) => getOpenProjectOwnershipPeriod(db, project.id)), + ); + for (const [index, p] of userProjects.entries()) { projectSlugs[p.id] = p.slug; + projectOwnershipStarts[p.id] = ownershipPeriods[index]?.starts_at ?? p.created_at; } const doId = env.USAGE_COUNTER.idFromName(user.id); @@ -1044,7 +1060,13 @@ async function processUserRollover( new Request('https://do/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plan: user.plan, billingPeriodStart, projectSlugs }), + body: JSON.stringify({ + plan: user.plan, + billingPeriodStart, + projectSlugs, + projectOwnershipStarts, + userId: user.id, + }), }), ); } diff --git a/platform/packages/api/src/operator/audit.ts b/platform/packages/api/src/operator/audit.ts index e32cd22bd..33b35574a 100644 --- a/platform/packages/api/src/operator/audit.ts +++ b/platform/packages/api/src/operator/audit.ts @@ -87,8 +87,13 @@ function auditTarget( return JSON.stringify({ userId: id }); } } - if (segments[0] === 'projects' && id && segments[2] === 'delete') { - return JSON.stringify({ projectId: id }); + if (segments[0] === 'projects' && id) { + if (segments[2] === 'owner') { + return JSON.stringify({ projectId: id, userId: boundedString(body.userId, 512) }); + } + if (segments[2] === 'delete') { + return JSON.stringify({ projectId: id }); + } } if (segments[0] === 'email' && segments[1] === 'operations' && segments[3] === 'resolve') { return JSON.stringify({ diff --git a/platform/packages/api/src/operator/tenant-routes.ts b/platform/packages/api/src/operator/tenant-routes.ts index a8ecd86e2..0c44a411e 100644 --- a/platform/packages/api/src/operator/tenant-routes.ts +++ b/platform/packages/api/src/operator/tenant-routes.ts @@ -20,6 +20,7 @@ import { listOperatorUsers, restoreOperatorUser, suspendOperatorUser, + transferOperatorProjectOwner, } from './tenant-service.ts'; export const tenantRoutes = new Hono(); @@ -92,6 +93,17 @@ tenantRoutes.get('/projects', async (context) => tenantRoutes.get('/projects/:id', async (context) => context.json(await getOperatorProject(context.get('db'), context.req.param('id'))), ); +tenantRoutes.post('/projects/:id/owner', async (context) => { + const body = await readBody(context); + return context.json( + await transferOperatorProjectOwner( + context, + context.req.param('id'), + stringField(body, 'userId'), + dryRun(body), + ), + ); +}); tenantRoutes.post('/projects/:id/delete', async (context) => { const body = await readBody(context); return context.json(await deleteOperatorProject(context, context.req.param('id'), dryRun(body))); diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index e99ddb3d0..03dcf4b76 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -1,7 +1,22 @@ -import { and, count, desc, eq, getTableColumns, ilike, or } from 'drizzle-orm'; +import { + and, + asc, + count, + desc, + eq, + getTableColumns, + ilike, + inArray, + isNotNull, + isNull, + notInArray, + or, + sql, +} from 'drizzle-orm'; import type { Context } from 'hono'; import { type Db, + ACTIVE_BUILD_STATUSES, countUserDeploymentsToday, deleteUser, getDomainsByProjectId, @@ -11,6 +26,7 @@ import { getProjectResources, getProjectsByUserId, getUser, + TERMINAL_DEPLOYMENT_STATUSES, LastAdminError, prepareUserRemoval, suspendUser, @@ -19,20 +35,34 @@ import { updateUserPlan, } from '../db.ts'; import type { ApiEnv } from '../env.ts'; +import { generateId } from '../id.ts'; import { teardownProject } from '../provisioning/cleanup.ts'; import { getRequestsDataset, queryBatchProjectUsage, ZERO_USAGE } from '../routes/usage.ts'; import { + buildProjectAccountJson, + computeDispatchLimits, + projectAccountKey, refreshProjectKvForUser, sandboxBlockKey, setProjectsRoutingSuspension, } from '../routing.ts'; -import { deployments, projects, users } from '../schema.ts'; +import { + builds, + deployments, + githubConnections, + projectInvitations, + projectMembers, + projectOwnershipPeriods, + projects, + users, +} from '../schema.ts'; import { isAdminSuspended, REASON_REQUESTS_EXCEEDED, REASON_SANDBOX_RUNTIME_EXCEEDED, } from '../suspension.ts'; import { getCurrentBillingPeriodStart, PLAN_LIMITS, type PlanId } from '../tiers.ts'; +import type { DeployManifest, Project, User } from '../types.ts'; import { OperatorError, preview } from './common.ts'; type Page = { page: number; limit: number; offset: number }; @@ -189,6 +219,458 @@ export async function getOperatorProject(db: Db, id: string) { return { project, deployments, resources, owner, domains, builds }; } +type OwnershipTransferBlocker = Readonly<{ + kind: 'deletion' | 'deployment' | 'rollback' | 'build'; + id: string; + status: string; +}>; + +function accountSummary(user: User) { + return { + id: user.id, + login: user.login, + email: user.email, + plan: user.plan, + suspended: user.suspended_reason !== null, + suspendedReason: user.suspended_reason, + }; +} + +async function getOwnershipTransferBlockers( + db: Db, + projectId: string, +): Promise> { + const [activeDeployments, activeBuilds] = await Promise.all([ + db + .select({ id: deployments.id, status: deployments.status, source: deployments.source }) + .from(deployments) + .where( + and( + eq(deployments.project_id, projectId), + notInArray(deployments.status, TERMINAL_DEPLOYMENT_STATUSES), + ), + ) + .orderBy(asc(deployments.created_at), asc(deployments.id)), + db + .select({ id: builds.id, status: builds.status }) + .from(builds) + .where( + and( + eq(builds.project_id, projectId), + inArray(builds.status, ['queued', ...ACTIVE_BUILD_STATUSES]), + ), + ) + .orderBy(asc(builds.created_at), asc(builds.id)), + ]); + return [ + ...activeDeployments.map((deployment) => ({ + kind: + deployment.source === 'rollback-lease' ? ('rollback' as const) : ('deployment' as const), + id: deployment.id, + status: deployment.status, + })), + ...activeBuilds.map((build) => ({ + kind: 'build' as const, + id: build.id, + status: build.status, + })), + ]; +} + +async function inspectOwnershipTransfer(db: Db, projectId: string, targetUserId: string) { + const project = await requireProject(db, projectId); + const [currentOwner, newOwner, activeWork, targetMembership, github, domains] = await Promise.all( + [ + requireUser(db, project.user_id), + requireUser(db, targetUserId), + getOwnershipTransferBlockers(db, projectId), + db + .select({ role: projectMembers.role }) + .from(projectMembers) + .where( + and(eq(projectMembers.project_id, projectId), eq(projectMembers.user_id, targetUserId)), + ) + .limit(1) + .then((rows) => rows[0] ?? null), + db + .select({ + installationId: githubConnections.installation_id, + repository: githubConnections.repo_full_name, + authorizedByUserId: githubConnections.authorized_by_user_id, + executor: githubConnections.build_executor, + }) + .from(githubConnections) + .where(eq(githubConnections.project_id, projectId)) + .limit(1) + .then((rows) => rows[0] ?? null), + getDomainsByProjectId(db, projectId), + ], + ); + const blockers: Array = [ + ...(project.status === 'deleting' + ? [{ kind: 'deletion' as const, id: project.id, status: project.status }] + : []), + ...activeWork, + ]; + return { project, currentOwner, newOwner, blockers, targetMembership, github, domains }; +} + +type OwnershipTransferCommit = Readonly<{ + project: Project; + previousOwner: User; + newOwner: User; + previousOwnershipStartedAt: string; + transferredAt: string; + changed: boolean; +}>; + +async function commitOwnershipTransfer( + db: Db, + projectId: string, + targetUserId: string, + operatorUserId: string, +): Promise { + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const [lockedProject] = await txDb + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + if (!lockedProject) { + throw new OperatorError(404, 'Project not found'); + } + + const lockedUsers = await txDb + .select() + .from(users) + .where(inArray(users.id, [...new Set([lockedProject.user_id, targetUserId])].sort())) + .orderBy(asc(users.id)) + .for('update'); + const byId = new Map(lockedUsers.map((user) => [user.id, user])); + const currentOwner = byId.get(lockedProject.user_id); + const newOwner = byId.get(targetUserId); + if (!currentOwner) { + throw new OperatorError(409, 'The current project owner no longer exists.', 'owner_missing'); + } + if (!newOwner) { + throw new OperatorError(404, 'User not found'); + } + if (lockedProject.status === 'deleting') { + throw new OperatorError( + 409, + 'Project ownership cannot change while deletion is active.', + 'ownership_transfer_blocked', + { blockers: [{ kind: 'deletion', id: projectId, status: lockedProject.status }] }, + ); + } + const [openPeriod] = await txDb + .select() + .from(projectOwnershipPeriods) + .where( + and( + eq(projectOwnershipPeriods.project_id, projectId), + isNull(projectOwnershipPeriods.ends_at), + ), + ) + .for('update') + .limit(1); + + // Ownership is implicit. Keep these convergence writes before the + // idempotent branch so retry also repairs data written by an older API + // revision that committed the owner change without cleaning target access. + await txDb + .delete(projectMembers) + .where( + and(eq(projectMembers.project_id, projectId), eq(projectMembers.user_id, newOwner.id)), + ); + await txDb + .update(projectInvitations) + .set({ + status: 'revoked', + revoked_at: sql`now()`, + revoked_by_user_id: operatorUserId, + }) + .where( + and( + eq(projectInvitations.project_id, projectId), + eq(projectInvitations.target_user_id, newOwner.id), + eq(projectInvitations.status, 'pending'), + ), + ); + + if (lockedProject.user_id === targetUserId) { + const [previousPeriod] = await txDb + .select() + .from(projectOwnershipPeriods) + .where( + and( + eq(projectOwnershipPeriods.project_id, projectId), + isNotNull(projectOwnershipPeriods.ends_at), + ), + ) + .orderBy(desc(projectOwnershipPeriods.ends_at)) + .limit(1); + const previousOwner = previousPeriod + ? (( + await txDb + .select() + .from(users) + .where(eq(users.id, previousPeriod.owner_user_id)) + .limit(1) + )[0] ?? newOwner) + : newOwner; + return { + project: lockedProject, + previousOwner, + newOwner, + previousOwnershipStartedAt: previousPeriod?.starts_at ?? lockedProject.created_at, + transferredAt: openPeriod?.starts_at ?? lockedProject.created_at, + changed: false, + }; + } + + const blockers = await getOwnershipTransferBlockers(txDb, projectId); + if (blockers.length > 0) { + throw new OperatorError( + 409, + 'Project ownership cannot change while project work is active.', + 'ownership_transfer_blocked', + { blockers }, + ); + } + + if (openPeriod && openPeriod.owner_user_id !== lockedProject.user_id) { + throw new OperatorError( + 409, + 'Project ownership history does not match the current owner.', + 'ownership_state_conflict', + ); + } + if (openPeriod) { + await txDb + .update(projectOwnershipPeriods) + .set({ ends_at: sql`now()` }) + .where(eq(projectOwnershipPeriods.id, openPeriod.id)); + } else { + await txDb.insert(projectOwnershipPeriods).values({ + id: generateId('pown'), + project_id: projectId, + owner_user_id: currentOwner.id, + starts_at: lockedProject.created_at, + ends_at: sql`now()`, + }); + } + const [newPeriod] = await txDb + .insert(projectOwnershipPeriods) + .values({ + id: generateId('pown'), + project_id: projectId, + owner_user_id: newOwner.id, + starts_at: sql`now()`, + changed_by_user_id: operatorUserId, + }) + .returning(); + const [updatedProject] = await txDb + .update(projects) + .set({ user_id: newOwner.id }) + .where(eq(projects.id, projectId)) + .returning(); + await txDb + .insert(projectMembers) + .values({ + project_id: projectId, + user_id: currentOwner.id, + role: 'admin', + added_by_user_id: operatorUserId, + }) + .onConflictDoUpdate({ + target: [projectMembers.project_id, projectMembers.user_id], + set: { role: 'admin', updated_at: sql`now()` }, + }); + return { + project: updatedProject!, + previousOwner: currentOwner, + newOwner, + previousOwnershipStartedAt: openPeriod?.starts_at ?? lockedProject.created_at, + transferredAt: newPeriod!.starts_at, + changed: true, + }; + }); +} + +async function updateOwnershipCounters( + context: Context, + transfer: OwnershipTransferCommit, +): Promise { + const request = async (userId: string, path: string, body: Record) => { + const stub = context.env.USAGE_COUNTER.get(context.env.USAGE_COUNTER.idFromName(userId)); + const response = await stub.fetch( + new Request(`https://do${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ); + if (!response.ok) { + throw new Error(`UsageCounter ${path} returned HTTP ${response.status}`); + } + }; + if (transfer.previousOwner.id !== transfer.newOwner.id) { + await request(transfer.previousOwner.id, '/detach-project', { + projectId: transfer.project.id, + userId: transfer.previousOwner.id, + ownershipStartedAt: transfer.previousOwnershipStartedAt, + ownershipEndedAt: transfer.transferredAt, + }); + } + await request(transfer.newOwner.id, '/attach-project', { + projectId: transfer.project.id, + userId: transfer.newOwner.id, + slug: transfer.project.slug, + ownershipStartedAt: transfer.transferredAt, + plan: transfer.newOwner.plan, + }); +} + +async function republishTransferredProject( + context: Context, + transfer: OwnershipTransferCommit, +): Promise { + const db = context.get('db'); + await db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const [project] = await txDb + .select() + .from(projects) + .where(eq(projects.id, transfer.project.id)) + .for('update') + .limit(1); + if (!project || project.user_id !== transfer.newOwner.id || project.status === 'deleting') { + throw new Error('Project ownership changed again before routing convergence.'); + } + const [activeDeployment, domains] = await Promise.all([ + project.active_deployment_id + ? txDb + .select({ manifest: deployments.manifest }) + .from(deployments) + .where(eq(deployments.id, project.active_deployment_id)) + .limit(1) + .then((rows) => rows[0] ?? null) + : null, + getDomainsByProjectId(txDb, project.id), + ]); + let manifest: DeployManifest | null = null; + if (activeDeployment?.manifest) { + manifest = JSON.parse(activeDeployment.manifest) as DeployManifest; + } + const dispatchLimits = computeDispatchLimits( + PLAN_LIMITS[transfer.newOwner.plan], + manifest?.limits, + ); + for (const key of [project.slug, ...domains.map((domain) => `domain:${domain.hostname}`)]) { + const raw = await context.env.ROUTING.get(key); + if (!raw) { + continue; + } + const entry = JSON.parse(raw) as Record; + entry.userId = transfer.newOwner.id; + if ('workerName' in entry) { + if (dispatchLimits) { + entry.limits = dispatchLimits; + } else { + delete entry.limits; + } + } + if (transfer.newOwner.suspended_reason) { + entry.suspended = transfer.newOwner.suspended_reason; + } else { + delete entry.suspended; + } + await context.env.ROUTING.put(key, JSON.stringify(entry)); + } + await context.env.ROUTING.put( + projectAccountKey(project.id), + buildProjectAccountJson({ userId: transfer.newOwner.id, plan: transfer.newOwner.plan }), + ); + }); +} + +export async function transferOperatorProjectOwner( + context: Context, + projectId: string, + targetUserId: string, + dryRun = false, +) { + const db = context.get('db'); + if (dryRun) { + const inspection = await inspectOwnershipTransfer(db, projectId, targetUserId); + return preview( + 'project.owner', + { id: inspection.project.id, slug: inspection.project.slug, userId: targetUserId }, + { + previousOwner: accountSummary(inspection.currentOwner), + newOwner: accountSummary(inspection.newOwner), + formerOwnerRole: 'admin', + targetPreviousRole: inspection.targetMembership?.role ?? null, + blockers: inspection.blockers, + github: inspection.github, + routingUpdates: [ + inspection.project.slug, + ...inspection.domains.map((domain) => `domain:${domain.hostname}`), + projectAccountKey(inspection.project.id), + ], + counterUpdates: { + detachUserId: inspection.currentOwner.id, + attachUserId: inspection.newOwner.id, + }, + usageTransfer: 'non-retroactive', + }, + ); + } + + const transfer = await commitOwnershipTransfer( + db, + projectId, + targetUserId, + context.get('user').id, + ); + const convergence = { countersComplete: false, routingComplete: false }; + try { + await updateOwnershipCounters(context, transfer); + convergence.countersComplete = true; + await republishTransferredProject(context, transfer); + convergence.routingComplete = true; + } catch (error) { + console.error('[admin] project owner convergence failed', { projectId, targetUserId, error }); + throw new OperatorError( + 500, + 'Project ownership changed, but external accounting or routing convergence is incomplete. Inspect the project and retry this ownership transfer.', + 'partial_failure', + { + partial: true, + target: { id: transfer.project.id, slug: transfer.project.slug, userId: targetUserId }, + ownershipTransferred: true, + transferredAt: transfer.transferredAt, + previousOwner: accountSummary(transfer.previousOwner), + newOwner: accountSummary(transfer.newOwner), + convergence, + }, + ); + } + return { + action: 'project.owner', + target: { id: transfer.project.id, slug: transfer.project.slug, userId: targetUserId }, + previousOwner: accountSummary(transfer.previousOwner), + newOwner: accountSummary(transfer.newOwner), + transferredAt: transfer.transferredAt, + formerOwnerRole: transfer.previousOwner.id === transfer.newOwner.id ? null : 'admin', + changed: transfer.changed, + convergence, + }; +} + export async function changeOperatorUserPlan( context: Context, id: string, diff --git a/platform/packages/api/src/project-access.ts b/platform/packages/api/src/project-access.ts new file mode 100644 index 000000000..5358d7544 --- /dev/null +++ b/platform/packages/api/src/project-access.ts @@ -0,0 +1,105 @@ +import { and, eq } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import type { Db } from './db.ts'; +import { projectMembers, projects, users } from './schema.ts'; +import type { Project, ProjectCapability, ProjectRole, User } from './types.ts'; + +export type ProjectAccess = Readonly<{ + project: Project; + role: ProjectRole; + owner: User; +}>; + +export const PROJECT_ROLE_CAPABILITIES = { + reader: ['read'], + collaborator: ['read', 'deploy', 'configure'], + admin: ['read', 'deploy', 'configure', 'settings', 'members'], + owner: ['read', 'deploy', 'configure', 'settings', 'members', 'delete'], +} as const satisfies Record>; + +export function hasProjectCapability(role: ProjectRole, capability: ProjectCapability): boolean { + return (PROJECT_ROLE_CAPABILITIES[role] as ReadonlyArray).includes(capability); +} + +const projectOwner = alias(users, 'project_owner'); + +async function selectProjectAccess( + db: Db, + projectId: string, + actorUserId: string, +): Promise | null> { + const [row] = await db + .select({ + project: projects, + owner: projectOwner, + memberRole: projectMembers.role, + }) + .from(projects) + .innerJoin(projectOwner, eq(projects.user_id, projectOwner.id)) + .leftJoin( + projectMembers, + and(eq(projectMembers.project_id, projects.id), eq(projectMembers.user_id, actorUserId)), + ) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!row) { + return null; + } + return { + project: row.project, + owner: row.owner, + memberRole: row.project.user_id === actorUserId ? 'owner' : row.memberRole, + }; +} + +/** Resolve current access without asserting a capability. */ +export async function getProjectAccess( + db: Db, + projectId: string, + actorUserId: string, +): Promise { + const row = await selectProjectAccess(db, projectId, actorUserId); + if (!row?.memberRole) { + return null; + } + return { project: row.project, role: row.memberRole, owner: row.owner }; +} + +export class ProjectNotFoundError extends Error { + readonly status = 404 as const; + + constructor() { + super('project not found'); + this.name = 'ProjectNotFoundError'; + } +} + +export class ProjectAccessDeniedError extends Error { + readonly status = 403 as const; + + constructor() { + super('forbidden'); + this.name = 'ProjectAccessDeniedError'; + } +} + +/** + * Assert a live project role from PostgreSQL. The owner account is returned + * separately so plan/quota callers do not accidentally consult the actor. + */ +export async function requireProjectCapability( + db: Db, + projectId: string, + actorUserId: string, + capability: ProjectCapability, +): Promise { + const row = await selectProjectAccess(db, projectId, actorUserId); + if (!row) { + throw new ProjectNotFoundError(); + } + if (!row.memberRole || !hasProjectCapability(row.memberRole, capability)) { + throw new ProjectAccessDeniedError(); + } + return { project: row.project, role: row.memberRole, owner: row.owner }; +} diff --git a/platform/packages/api/src/provisioning/cleanup.ts b/platform/packages/api/src/provisioning/cleanup.ts index 2253821aa..a3fa6fdb6 100644 --- a/platform/packages/api/src/provisioning/cleanup.ts +++ b/platform/packages/api/src/provisioning/cleanup.ts @@ -25,6 +25,7 @@ import { getProjectDeploymentWorkerName, getProjectQueueName, } from '../naming.ts'; +import { requireProjectCapability } from '../project-access.ts'; import { deployments as deploymentsTable, projects as projectsTable } from '../schema.ts'; import type { Build, DeployManifest, Project, Resource } from '../types.ts'; import { @@ -351,6 +352,7 @@ async function cleanupBestEffortResources( async function lockAndMarkDeleting( db: Db, projectId: string, + actorUserId?: string, ): Promise< ReadonlyArray<{ id: string; @@ -366,6 +368,15 @@ async function lockAndMarkDeleting( if (!lockedProject) { throw new Error('project disappeared during deletion'); } + // API-initiated teardown starts with an unlocked route check so it can + // return the established 404/403 response quickly. Ownership can transfer + // before this transaction acquires the project lock, however. Re-resolve + // the actor under the same lock as the irreversible `deleting` status flip + // so a former owner cannot finish a stale delete request. Operator cleanup + // omits actorUserId and remains authorized by its separate admin guard. + if (actorUserId) { + await requireProjectCapability(txDb, projectId, actorUserId, 'delete'); + } const activeDeployment = await getActiveDeployment(txDb, projectId); if (activeDeployment?.source === 'rollback-lease') { throw new ProjectOperationInProgressError(); @@ -485,12 +496,17 @@ export async function teardownProject( context: Context, projectId: string, project: Pick, + options: Readonly<{ actorUserId?: string }> = {}, ): Promise<{ warnings: ReadonlyArray }> { // Always run lock + mark — idempotent for `status='deleting'` (the helper // skips the flip and just (re-)marks any non-terminal deployments + builds). // This covers retried teardown after partial failure where the previous // attempt crashed before the cancellations committed. - const cancelledOnLock = await lockAndMarkDeleting(context.get('db'), projectId); + const cancelledOnLock = await lockAndMarkDeleting( + context.get('db'), + projectId, + options.actorUserId, + ); // Stop the container DOs for the builds revoked above BEFORE any CF cleanup // begins (post-commit, never under the row lock — it's a DO round-trip), so // their tokens are dead and their containers stopped before we touch CF. diff --git a/platform/packages/api/src/routes/auth-oidc.ts b/platform/packages/api/src/routes/auth-oidc.ts index 1b4a865d9..29f0f12b3 100644 --- a/platform/packages/api/src/routes/auth-oidc.ts +++ b/platform/packages/api/src/routes/auth-oidc.ts @@ -317,6 +317,18 @@ app.post('/github-oidc', async (context) => { return context.json({ error: 'project is being deleted' }, 409); } const build = result.build; + // The build helper re-reads and locks the project, so `build.user_id` is the + // billing owner that was current at admission even if ownership changed after + // the earlier connection lookup. Use that authoritative owner for token + // identity and timeout sizing. + const billingOwner = await getUser(db, build.user_id); + if (!billingOwner) { + await markBuildTerminalIfActive(db, build.id, 'cancelled', { + error: 'project owner no longer exists', + completedAt: new Date().toISOString(), + }); + return context.json({ error: 'connection owner not found' }, 401); + } // Post-create freshness re-check, closing the compare→insert TOCTOU: a newer // push landing between the pre-check above and the build INSERT is invisible @@ -425,14 +437,14 @@ app.post('/github-oidc', async (context) => { const token = await signDeployToken( { projectId: connection.project_id, - userId: connection.user_id, - login: user.login, + userId: build.user_id, + login: billingOwner.login, buildId: build.id, }, context.env.JWT_SECRET, { ttlSeconds: deployTokenTtlSecondsForBuildMinutes( - getPlanLimits(user.plan).buildTimeoutMinutes, + getPlanLimits(billingOwner.plan).buildTimeoutMinutes, ), }, ); diff --git a/platform/packages/api/src/routes/delete.ts b/platform/packages/api/src/routes/delete.ts index 6cd543167..46268dedb 100644 --- a/platform/packages/api/src/routes/delete.ts +++ b/platform/packages/api/src/routes/delete.ts @@ -1,20 +1,18 @@ import { Hono } from 'hono'; -import { getProject } from '../db.ts'; import type { ApiEnv } from '../env.ts'; +import { ProjectAccessDeniedError, ProjectNotFoundError } from '../project-access.ts'; import { ProjectOperationInProgressError, teardownProject } from '../provisioning/cleanup.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; const app = new Hono(); app.delete('/:id', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'delete'); + if (access instanceof Response) { + return access; } + const project = access.project; // `teardownProject` handles the FOR UPDATE lock, status='deleting' flip, // bulk deployment cancellation, critical CF cleanup (container apps from @@ -24,8 +22,13 @@ app.delete('/:id', async (context) => { // cascade. Critical-cleanup failure throws and surfaces as a 5xx. let warnings: ReadonlyArray; try { - ({ warnings } = await teardownProject(context, projectId, project)); + ({ warnings } = await teardownProject(context, projectId, project, { + actorUserId: context.get('user').id, + })); } catch (error) { + if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { + return context.json({ error: error.message }, error.status); + } if (error instanceof ProjectOperationInProgressError) { return context.json({ error: error.message }, 409); } diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index 2e3315cb9..c3cc9d068 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -87,6 +87,7 @@ import { generateId } from '../id.ts'; import { checkCliVersionOrRespond } from '../middleware/cli-version.ts'; import { getProjectQueueName } from '../naming.ts'; import { createDeployLogger, type DeployLogger } from '../observability/logger.ts'; +import { ProjectAccessDeniedError, requireProjectCapability } from '../project-access.ts'; import { ensureProjectIngress } from '../project-ingress.ts'; import { BETTER_AUTH_SECRET_NAME, @@ -120,6 +121,7 @@ import { resolveRoutingUndo, } from '../routing.ts'; import { builds, deployments } from '../schema.ts'; +import { isAdminSuspended } from '../suspension.ts'; import { getPlanLimits, isWithinLimit, STATIC_RETENTION_LIMIT, type PlanId } from '../tiers.ts'; import { SUPPORTED_MANIFEST_VERSIONS, @@ -132,6 +134,7 @@ import { type SandboxMigrationMeta, type WebSocketMigrationMeta, } from '../types.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; export { MAX_REVALIDATE_QUERY_ALLOWLIST_ENTRIES, MAX_ROUTING_RULES, validateManifestShape }; @@ -144,6 +147,21 @@ const cutoverReadyKey = (deploymentId: string): string => `cutover-ready:${deplo const cutoverCompensatedKey = (deploymentId: string): string => `cutover-compensated:${deploymentId}`; +class ProjectOwnerSuspendedError extends Error { + constructor() { + super('project owner suspended'); + this.name = 'ProjectOwnerSuspendedError'; + } +} + +async function requireActiveDeployAccess(db: Db, projectId: string, actorUserId: string) { + const access = await requireProjectCapability(db, projectId, actorUserId, 'deploy'); + if (isAdminSuspended(access.owner.suspended_reason)) { + throw new ProjectOwnerSuspendedError(); + } + return access; +} + export function databaseTimestampMs(value: string): number | null { const withSeparator = value.includes('T') ? value : value.replace(' ', 'T'); const withZone = /[+-]\d{2}$/.test(withSeparator) @@ -1828,13 +1846,14 @@ const UPLOAD_URLS_PROJECT_DELETING = 'upload_urls:project_deleting'; app.post('/:id/deploy/upload-urls', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); + const access = await requireProjectRouteCapability(context, projectId, 'deploy'); + if (access instanceof Response) { + return access; } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + if (isAdminSuspended(access.owner.suspended_reason)) { + return context.json({ error: 'project owner suspended' }, 403); } + const project = access.project; // Fast 410 — mirror `handleManifestOnlyDeploy`. The unlocked read here // is a TOCTOU best-effort: it short-circuits the common case (project // already marked `deleting` before this request arrived). The @@ -1969,6 +1988,7 @@ app.post('/:id/deploy/upload-urls', async (context) => { // Derived signing keys are identical within this request. Keep the cache // request-local so credentials and derived keys are released with the batch. const signingKeyCache = new Map(); + await requireActiveDeployAccess(txDb, projectId, context.get('user').id); await uploadPool(assets, PRESIGN_BATCH_CONCURRENCY, async (asset) => { const result = await presignAssetPut( { @@ -1996,6 +2016,12 @@ app.post('/:id/deploy/upload-urls', async (context) => { }); }); } catch (err) { + if (err instanceof ProjectAccessDeniedError || err instanceof ProjectOwnerSuspendedError) { + return context.json( + { error: err instanceof ProjectOwnerSuspendedError ? err.message : 'forbidden' }, + 403, + ); + } if (err instanceof Error && err.message === UPLOAD_URLS_PROJECT_DELETING) { return context.json({ error: 'project is being deleted' }, 410); } @@ -2051,13 +2077,14 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): Promise 0 && requestedCpuMs > maxCpuMs) { - return context.json( - { - event: 'error', - message: `limits.cpuMs ${requestedCpuMs} exceeds your plan maximum of ${maxCpuMs}`, - }, - 400, - ); - } - } - // Validate the v2 asset manifest. Each entry MUST carry a 32-hex blake3 // (storage key), 32-hex md5 (R2 PUT integrity check), and a non-negative // integer size. We translate the v2 entries into the v1 shape @@ -2405,10 +2415,13 @@ async function handleManifestOnlyDeploy(context: Context): Promise>; currentLiveDeployment: Awaited>; + project: Project; + owner: typeof owner; reconciledMarkerKeys?: ReadonlyArray; } | { canceling: string } - | { recoveryFailed: string }; + | { recoveryFailed: string } + | { cpuLimit: Readonly<{ requested: number; maximum: number }> }; try { precheckResult = await context.get('db').transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); @@ -2417,6 +2430,12 @@ async function handleManifestOnlyDeploy(context: Context): Promise 0 && requestedCpuMs > maxCpuMs) { + return { cpuLimit: { requested: requestedCpuMs, maximum: maxCpuMs } } as const; + } let committedActiveDeploymentId = lockedProject.active_deployment_id; if (!committedActiveDeploymentId) { // active_deployment_id was introduced nullable and older projects were @@ -2569,7 +2588,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): Promise 0 && { reconciledMarkerKeys }), } as const; }); } catch (err) { + if (err instanceof ProjectAccessDeniedError || err instanceof ProjectOwnerSuspendedError) { + return context.json( + { error: err instanceof ProjectOwnerSuspendedError ? err.message : 'forbidden' }, + 403, + ); + } if (err instanceof ConcurrentDeployError) { return context.json({ error: 'deploy in progress', deployment: err.deploymentId }, 409); } @@ -2734,6 +2763,15 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): Promise): Promise 0) { - const todayCount = await countUserDeploymentsToday( - context.get('db'), - context.get('user').id, - ); + const todayCount = await countUserDeploymentsToday(context.get('db'), owner.id); if (!isWithinLimit(planLimits.deploysPerDay, todayCount)) { emit({ event: 'error', message: 'deploy limit exceeded', step: currentStep }); return; @@ -3567,7 +3605,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): Promise): Promise): Promise): Promise + sendDeployFailedEmail(context.env, owner.email, project.slug, msg).catch((err) => logger.error('catch', 'failed_email_send_failed', err, { projectSlug: project.slug, }), @@ -5548,14 +5588,14 @@ async function handleManifestOnlyDeploy(context: Context): Promise { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); + const access = await requireProjectRouteCapability(context, projectId, 'deploy'); + if (access instanceof Response) { + return access; } - - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + if (isAdminSuspended(access.owner.suspended_reason)) { + return context.json({ error: 'project owner suspended' }, 403); } + const project = access.project; // Mirror /upload-urls and handleManifestOnlyDeploy: short-circuit when // the project is being torn down so callers get the more specific 410 // before any body parsing or version-gate work. A stale CLI on a @@ -5619,8 +5659,16 @@ app.post('/:id/deploy/preflight', async (context) => { if (!lockedProject) { return { kind: 'missing' as const }; } - if (lockedProject.user_id !== context.get('user').id) { - return { kind: 'forbidden' as const }; + try { + await requireActiveDeployAccess(txDb, projectId, context.get('user').id); + } catch (error) { + if (error instanceof ProjectAccessDeniedError) { + return { kind: 'forbidden' as const }; + } + if (error instanceof ProjectOwnerSuspendedError) { + return { kind: 'owner-suspended' as const }; + } + throw error; } if (lockedProject.status === 'deleting') { return { kind: 'deleting' as const }; @@ -5641,6 +5689,9 @@ app.post('/:id/deploy/preflight', async (context) => { if (resolution.kind === 'forbidden') { return context.json({ error: 'forbidden' }, 403); } + if (resolution.kind === 'owner-suspended') { + return context.json({ error: 'project owner suspended' }, 403); + } if (resolution.kind === 'deleting') { return context.json({ error: 'project is being deleted' }, 410); } diff --git a/platform/packages/api/src/routes/domains.ts b/platform/packages/api/src/routes/domains.ts index d91e18628..5e5d148ea 100644 --- a/platform/packages/api/src/routes/domains.ts +++ b/platform/packages/api/src/routes/domains.ts @@ -3,7 +3,6 @@ import { Hono } from 'hono'; import { capabilityMiddleware } from '../capabilities.ts'; import { SITE_DOMAIN } from '../constants.ts'; import { - getProject, createDomain, getDomainsByProjectId, getDomain, @@ -22,6 +21,7 @@ import { type CustomHostnameInfo, } from '../provisioning/cloudflare-api.ts'; import type { DomainVerificationData } from '../schema.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; const CNAME_TARGET = `cname.${SITE_DOMAIN}`; const ORPHAN_ADOPTION_MIN_AGE_MS = 60_000; @@ -54,13 +54,9 @@ const app = new Hono() // Add a custom domain to a project .post('/:id/domains', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } const { hostname } = await context.req.json<{ hostname: string }>(); @@ -112,15 +108,20 @@ const app = new Hono() // earlier post-create DB failure. Only adopt still-pending CF objects with // an outstanding ownership challenge; a previously verified/active hostname // must not become claimable by another project without fresh ownership. + // Cloudflare creation is a reversible first phase. The durable second + // phase below takes the project lock and rechecks access before inserting + // the row. If a membership change or teardown wins that lock, hand the CF + // object back before returning the rejection; if this request wins, the + // domain commits before the membership write can proceed. let cfHostname: CustomHostnameInfo; - let createdCustomHostname = false; + let createdCfHostname: CustomHostnameInfo | undefined; try { cfHostname = await createCustomHostname( context.env.CF_ZONE_ID, context.env.CF_API_TOKEN, hostname, ); - createdCustomHostname = true; + createdCfHostname = cfHostname; } catch (err) { if (err instanceof CloudflareApiError && err.status === 409) { const existingAfterConflict = await getDomainByHostname(context.get('db'), hostname); @@ -154,71 +155,97 @@ const app = new Hono() } } - // Under HTTP-DCV the certificate is validated at CF's edge once the - // hostname proxies through it, so CF never emits `ssl.validation_records` - // (no `_acme-challenge` TXT). `ownership_verification` — the - // `_cf-custom-hostname` TXT that proves the user controls the hostname — - // IS returned synchronously in the create response, so persist it - // straight away and return. (`ssl_validation_records` will be - // empty/absent — expected.) - const verificationData: DomainVerificationData = { - ownership_verification: cfHostname.ownership_verification, - ssl_validation_records: cfHostname.ssl.validation_records, - }; - - // Insert domain record. If we just created the CF hostname and then fail - // to persist it, revoke that CF object so future adds do not hit a - // duplicate custom-hostname orphan that no Void project can delete. - let domain; - try { - domain = await createDomain( + const rollbackCreatedHostname = async (): Promise => { + if (!createdCfHostname) { + return; + } + const persistedDomain = await getDomainByHostnameOrCfHostnameId( context.get('db'), - projectId, hostname, - cfHostname.id, - verificationData, + createdCfHostname.id, ); - } catch (err) { - if (createdCustomHostname) { - const persistedDomain = await getDomainByHostnameOrCfHostnameId( - context.get('db'), + if (!persistedDomain) { + await deleteCustomHostname( + context.env.CF_ZONE_ID, + context.env.CF_API_TOKEN, + createdCfHostname.id, + ).catch((deleteErr) => { + console.error( + `Failed to delete orphaned custom hostname ${createdCfHostname?.id} after DB transaction failure:`, + deleteErr, + ); + }); + } + }; + + try { + const lockedResult = await context.get('db').transaction(async (tx) => { + await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + const txDb = tx as unknown as Db; + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'settings', + txDb, + ); + if (lockedAccess instanceof Response) { + return lockedAccess; + } + if (lockedAccess.project.status === 'deleting') { + return context.json({ error: 'Project is being deleted' }, 409); + } + + // Recheck after waiting for the project lock. A prior request for this + // project may have committed while this request was queued. + const lockedExisting = await getDomainByHostname(txDb, hostname); + if (lockedExisting) { + return context.json({ error: 'hostname already in use' }, 409); + } + + // Under HTTP-DCV the certificate is validated at CF's edge once the + // hostname proxies through it, so CF never emits + // `ssl.validation_records`. Persist the ownership challenge returned + // synchronously with the create response. + const verificationData: DomainVerificationData = { + ownership_verification: cfHostname.ownership_verification, + ssl_validation_records: cfHostname.ssl.validation_records, + }; + const domain = await createDomain( + txDb, + projectId, hostname, cfHostname.id, + verificationData, ); - if (!persistedDomain) { - await deleteCustomHostname( - context.env.CF_ZONE_ID, - context.env.CF_API_TOKEN, - cfHostname.id, - ).catch((deleteErr) => { - console.error( - `Failed to delete orphaned custom hostname ${cfHostname.id} after DB insert failure:`, - deleteErr, - ); - }); - } + + return context.json({ + ...domain, + cname_target: CNAME_TARGET, + ownership_verification: cfHostname.ownership_verification, + ssl_validation_records: cfHostname.ssl.validation_records ?? [], + }); + }); + if (!lockedResult.ok) { + await rollbackCreatedHostname(); } + return lockedResult; + } catch (err) { + // A CF create can succeed even when the DB transaction later fails to + // insert or commit. Hand back only the object this request created; an + // adopted pre-existing hostname belongs to the recovery path and must + // not be deleted. The read-back also protects a concurrent winning + // request that durably persisted this exact object. + await rollbackCreatedHostname(); throw err; } - - return context.json({ - ...domain, - cname_target: CNAME_TARGET, - ownership_verification: cfHostname.ownership_verification, - ssl_validation_records: cfHostname.ssl.validation_records ?? [], - }); }) // List domains for a project .get('/:id/domains', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } const domains = await getDomainsByProjectId(context.get('db'), projectId); @@ -230,14 +257,11 @@ const app = new Hono() const projectId = context.req.param('id'); const domainId = context.req.param('domainId'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } + const project = access.project; const domain = await getDomain(context.get('db'), domainId); if (!domain || domain.project_id !== projectId) { @@ -269,13 +293,9 @@ const app = new Hono() const projectId = context.req.param('id'); const domainId = context.req.param('domainId'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } const domain = await getDomain(context.get('db'), domainId); @@ -288,25 +308,38 @@ const app = new Hono() // CF cleanup is best-effort. KV + DB deletion serialize with deploy, // rollback, activation, and failed-cutover compensation through the // project row so none can resurrect this hostname from a stale snapshot. - await deleteCustomHostname( - context.env.CF_ZONE_ID, - context.env.CF_API_TOKEN, - domain.cf_hostname_id, - ).catch((err) => { - warnings.push( - `Failed to delete custom hostname: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - await context.get('db').transaction(async (tx) => { + const lockedResponse = await context.get('db').transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); const txDb = tx as unknown as Db; + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'settings', + txDb, + ); + if (lockedAccess instanceof Response) { + return lockedAccess; + } + await deleteCustomHostname( + context.env.CF_ZONE_ID, + context.env.CF_API_TOKEN, + domain.cf_hostname_id, + ).catch((err) => { + warnings.push( + `Failed to delete custom hostname: ${err instanceof Error ? err.message : String(err)}`, + ); + }); await context.env.ROUTING.delete(`domain:${domain.hostname}`).catch((err) => { warnings.push( `Failed to delete routing entry: ${err instanceof Error ? err.message : String(err)}`, ); }); await deleteDomain(txDb, domain.id); + return null; }); + if (lockedResponse) { + return lockedResponse; + } return context.json({ ok: true, ...(warnings.length > 0 && { warnings }) }); }); diff --git a/platform/packages/api/src/routes/email.ts b/platform/packages/api/src/routes/email.ts index df98005b1..74d1a2d04 100644 --- a/platform/packages/api/src/routes/email.ts +++ b/platform/packages/api/src/routes/email.ts @@ -7,7 +7,6 @@ import { deleteEmailDestination, getEmailDestinationByAddress, getEmailDestinationsByProjectId, - getProject, getUser, promoteEmailDestinationWithOwnerAudit, queueUnreferencedEmailDestinationCleanup, @@ -45,6 +44,7 @@ import { type CfDestinationAddress, } from '../provisioning/email-routing.ts'; import type { EmailDestination } from '../types.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; /** * Per-project rate-limit on POST /destinations to prevent abuse where a @@ -90,12 +90,9 @@ const app = new Hono() .use('/:id/email/*', capabilityMiddleware('email')) .get('/:id/email/usage', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } return context.json(await getProjectEmailUsage(context.get('db'), projectId)); @@ -103,12 +100,9 @@ const app = new Hono() .get('/:id/email/logs', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } const limit = Number(context.req.query('limit') ?? 50); @@ -127,13 +121,11 @@ const app = new Hono() // ------------------------------------------------------------------------- .get('/:id/email/destinations', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } + const project = access.project; const rows = await getEmailDestinationsByProjectId(context.get('db'), projectId); // Refresh status from CF for any rows still pending. CF's @@ -235,13 +227,11 @@ const app = new Hono() .post('/:id/email/destinations', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } + const project = access.project; // Same guard the other provisioning routes use (see `routes/projects.ts`). // Teardown snapshots the CF destination tags it must clean up ONCE; a // destination registered after that snapshot but before the cascade drops @@ -476,13 +466,11 @@ const app = new Hono() .delete('/:id/email/destinations/:destId', async (context) => { const projectId = context.req.param('id'); const destId = context.req.param('destId'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } + const project = access.project; if (project.status === 'deleting') { return context.json({ error: 'Project is being deleted' }, 409); } diff --git a/platform/packages/api/src/routes/github.ts b/platform/packages/api/src/routes/github.ts index a34678740..c47b7710b 100644 --- a/platform/packages/api/src/routes/github.ts +++ b/platform/packages/api/src/routes/github.ts @@ -27,7 +27,6 @@ import { hasActiveInstallationMembership, linkGitHubIdentity, linkGitHubInstallationToUser, - getProject, terminateBuildsForProject, updateGitHubConnection, upsertGitHubInstallation, @@ -46,9 +45,10 @@ import { } from '../github/user-installations.ts'; import { localhostRedirectResponse } from '../html-redirect.ts'; import { authMiddleware } from '../middleware/auth.ts'; -import { githubConnections, githubInstallations, oauthAccounts } from '../schema.ts'; +import { githubConnections, githubInstallations, oauthAccounts, projects } from '../schema.ts'; import type { Build, GitHubConnection, GitHubInstallation } from '../types.ts'; import { isLocalhostRedirect } from './auth.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; const GITHUB_API_BASE = 'https://api.github.com'; const GITHUB_API_VERSION = '2026-03-10'; @@ -1377,7 +1377,7 @@ function isValidWorkflowPath(path: string): boolean { // --------------------------------------------------------------------------- // /projects — per-project connection CRUD. Mounted UNDER the global // `app.use('/projects/*', authMiddleware)`, so auth is already applied; each -// route does the project-ownership check (mirrors routes/domains.ts). +// route checks the required live project capability. // --------------------------------------------------------------------------- export const githubProjectRoutes = new Hono() .use('*', capabilityMiddleware('managedBuilds')) @@ -1385,12 +1385,9 @@ export const githubProjectRoutes = new Hono() // Connect a project to a repo within one of the caller's installations. .post('/:id/github', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } const body = await context.req.json<{ @@ -1628,6 +1625,26 @@ export const githubProjectRoutes = new Hono() if (!lockedInstall) { return { ok: false as const, status: 404 as const, error: 'installation not found' }; } + // Serialize project-role changes with connection creation. The install row + // remains first (the established GitHub lifecycle lock order), followed by + // the project and then connection rows. A demoted/removed admin therefore + // cannot pass the preflight settings check and create a connection after + // losing access. + await tx + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'settings', + txDb, + ); + if (lockedAccess instanceof Response) { + return lockedAccess; + } // Re-derive ownership from the LOCKED row, not the stale pre-tx snapshot. If // member_removed cleared the owner pointer while we waited on the lock, the // pre-tx owner path is stale: we already resolved repositoryId via the @@ -1724,13 +1741,16 @@ export const githubProjectRoutes = new Hono() installationId, repoFullName, repositoryId: String(repositoryId), - authorizedByUserId: lockedInstall.account_type === 'Organization' ? caller : null, + authorizedByUserId: caller, branch, buildExecutor, workflowPath, }); return { ok: true as const, connection }; }); + if (result instanceof Response) { + return result; + } if (!result.ok) { return context.json({ error: result.error }, result.status); } @@ -1740,12 +1760,9 @@ export const githubProjectRoutes = new Hono() // Read the project's connection. .get('/:id/github', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } const connection = await getGitHubConnection(context.get('db'), projectId); @@ -1767,12 +1784,9 @@ export const githubProjectRoutes = new Hono() // Update branch / build executor. .patch('/:id/github', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } const connection = await getGitHubConnection(context.get('db'), projectId); @@ -1812,8 +1826,23 @@ export const githubProjectRoutes = new Hono() // in-flight OIDC exchange (which re-reads + locks the row) is serialized. // Only a REAL change invalidates builds — a no-op PATCH (value unchanged, or // field omitted) terminalizes nothing. - const { cancelled, updated, gone } = await context.get('db').transaction(async (tx) => { + const result = await context.get('db').transaction(async (tx) => { const txDb = tx as unknown as Db; + await tx + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'settings', + txDb, + ); + if (lockedAccess instanceof Response) { + return lockedAccess; + } await tx .select({ id: githubConnections.id }) .from(githubConnections) @@ -1878,6 +1907,11 @@ export const githubProjectRoutes = new Hono() return { cancelled: terminated, updated: next, gone: false }; }); + if (result instanceof Response) { + return result; + } + const { cancelled, updated, gone } = result; + if (gone || !updated) { return context.json({ error: 'connection not found' }, 404); } @@ -1892,12 +1926,9 @@ export const githubProjectRoutes = new Hono() // Disconnect the project. .delete('/:id/github', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } // Terminalize ALL in-flight builds (queued + active, both executors) for @@ -1909,8 +1940,23 @@ export const githubProjectRoutes = new Hono() // Actions builds also stop pinning a concurrency slot (their workflow_run // completion webhook resolves the CURRENT connection — once it's gone the // completion is ignored and the build would otherwise stay active forever). - const cancelled = await context.get('db').transaction(async (tx) => { + const result = await context.get('db').transaction(async (tx) => { const txDb = tx as unknown as Db; + await tx + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'settings', + txDb, + ); + if (lockedAccess instanceof Response) { + return lockedAccess; + } // Lock the connection row(s) so an in-flight OIDC exchange (which re-reads + // FOR UPDATE-locks the same row before inserting its build) is serialized: // whichever commits first wins, and a build created just before this commit @@ -1927,6 +1973,11 @@ export const githubProjectRoutes = new Hono() return terminated; }); + if (result instanceof Response) { + return result; + } + const cancelled = result; + // AFTER the tx commits (never while holding the connection lock), best-effort // stop the container DOs for any cancelled container builds. await cancelContainerBuildDos(context.env, cancelled, 'project disconnected'); diff --git a/platform/packages/api/src/routes/internal-verify-owner.ts b/platform/packages/api/src/routes/internal-verify-owner.ts index 4b5fcec74..2e80b00c5 100644 --- a/platform/packages/api/src/routes/internal-verify-owner.ts +++ b/platform/packages/api/src/routes/internal-verify-owner.ts @@ -4,16 +4,17 @@ import { readAuthSettings } from '../auth/configuration.ts'; import { hmacVerifyState } from '../auth/hmac.ts'; import { verifyJwt } from '../auth/jwt.ts'; import { validateHumanSession } from '../auth/sessions.ts'; -import { getProject, getUser } from '../db.ts'; +import { getUser } from '../db.ts'; import type { ApiEnv } from '../env.ts'; +import { requireProjectCapability } from '../project-access.ts'; import { isAdminSuspended } from '../suspension.ts'; const app = new Hono(); /** - * Internal ownership verification — called by the proxy via service binding. - * Used on the JWT auth path to confirm the authenticated user owns the project. - * Authenticated via HMAC token in X-Void-Internal header. + * Live deploy-capability verification called by the binding proxy. The legacy + * verify-owner path is intentionally retained so old and new proxy revisions + * can coexist during rollout; its semantics now include collaborators/admins. * * POST /internal/verify-owner/:projectId * Body: { "userId": "...", "proxyOrigin": "https://proxy.example.com", ... } @@ -92,8 +93,14 @@ app.post('/:projectId', async (context) => { return context.json({ error: 'forbidden' }, 403); } - const project = await getProject(context.get('db'), projectId); - if (!project || project.user_id !== userId) { + try { + const access = await requireProjectCapability(context.get('db'), projectId, userId, 'deploy'); + if (isAdminSuspended(access.owner.suspended_reason)) { + throw new Error('suspended'); + } + } catch { + // This internal authorization oracle deliberately does not disclose + // whether the project or membership exists. return context.json({ error: 'forbidden' }, 403); } diff --git a/platform/packages/api/src/routes/project-access.ts b/platform/packages/api/src/routes/project-access.ts new file mode 100644 index 000000000..0dcfe6ee0 --- /dev/null +++ b/platform/packages/api/src/routes/project-access.ts @@ -0,0 +1,27 @@ +import type { Context } from 'hono'; +import type { Db } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { + ProjectAccessDeniedError, + ProjectNotFoundError, + requireProjectCapability, + type ProjectAccess, +} from '../project-access.ts'; +import type { ProjectCapability } from '../types.ts'; + +/** Preserve the project routes' established 404/403 JSON contract. */ +export async function requireProjectRouteCapability( + context: Context, + projectId: string, + capability: ProjectCapability, + db: Db = context.get('db'), +): Promise { + try { + return await requireProjectCapability(db, projectId, context.get('user').id, capability); + } catch (error) { + if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { + return context.json({ error: error.message }, error.status); + } + throw error; + } +} diff --git a/platform/packages/api/src/routes/project-team.ts b/platform/packages/api/src/routes/project-team.ts new file mode 100644 index 000000000..0b064f6da --- /dev/null +++ b/platform/packages/api/src/routes/project-team.ts @@ -0,0 +1,465 @@ +import { Hono, type Context } from 'hono'; +import { + createOrRefreshProjectInvitation, + getPendingProjectInvitationsByUserId, + getProjectInvitations, + getProjectMembers, + getProjectsByIds, + getUser, + getUsersByIds, + ProjectTeamMutationError, + removeProjectMember, + resendProjectInvitation, + respondToProjectInvitation, + revokeProjectInvitation, + setProjectMemberRole, +} from '../db.ts'; +import { sendProjectInvitationEmail } from '../email.ts'; +import type { ApiEnv } from '../env.ts'; +import { + ProjectAccessDeniedError, + ProjectNotFoundError, + requireProjectCapability, + type ProjectAccess, +} from '../project-access.ts'; +import type { + ProjectInvitation, + ProjectInvitationStatus, + ProjectMember, + ProjectMemberRole, + User, +} from '../types.ts'; + +const MEMBER_ROLES = new Set(['reader', 'collaborator', 'admin']); + +type MemberJson = Readonly<{ + user: Readonly>; + role: ProjectMemberRole | 'owner'; + created_at: string; +}>; + +type InvitationJson = Readonly<{ + id: string; + target: Readonly>; + target_email: string; + role: ProjectMemberRole; + status: ProjectInvitationStatus; + created_at: string; + expires_at: string; + accepted_at: string | null; + revoked_at: string | null; +}>; + +function userJson(user: User): Pick { + return { id: user.id, login: user.login, email: user.email }; +} + +function memberJson(member: ProjectMember, user: User): MemberJson { + return { + user: userJson(user), + role: member.role, + created_at: member.created_at, + }; +} + +function effectiveInvitationStatus(invitation: ProjectInvitation): ProjectInvitationStatus { + if (invitation.status === 'pending' && Date.parse(invitation.expires_at) <= Date.now()) { + return 'expired'; + } + return invitation.status; +} + +function invitationJson(invitation: ProjectInvitation, target: User): InvitationJson { + return { + id: invitation.id, + target: userJson(target), + target_email: invitation.target_email_snapshot, + role: invitation.role, + status: effectiveInvitationStatus(invitation), + created_at: invitation.created_at, + expires_at: invitation.expires_at, + accepted_at: invitation.accepted_at, + revoked_at: invitation.revoked_at, + }; +} + +async function readJsonObject(context: Context): Promise | null> { + try { + const body: unknown = await context.req.json(); + return body !== null && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : null; + } catch { + return null; + } +} + +function parseRole(value: unknown): ProjectMemberRole | null { + return typeof value === 'string' && MEMBER_ROLES.has(value as ProjectMemberRole) + ? (value as ProjectMemberRole) + : null; +} + +function mutationErrorResponse(context: Context, error: unknown): Response | null { + if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { + return context.json({ error: error.message }, error.status); + } + if (!(error instanceof ProjectTeamMutationError)) { + return null; + } + const messages: Record = { + 'project-not-found': 'project not found', + 'target-user-not-found': 'registered user not found', + 'owner-conflict': 'the project owner cannot be invited or changed as a member', + 'member-conflict': 'user is already a project member', + 'member-not-found': 'project member not found', + 'invitation-not-found': 'project invitation not found', + 'invitation-not-pending': 'project invitation is no longer pending', + 'invitation-not-target': 'forbidden', + 'invitation-expired': 'project invitation has expired', + 'pending-invitation-limit': 'project has too many pending invitations', + forbidden: 'forbidden', + }; + return context.json({ error: messages[error.reason] }, error.status); +} + +async function requireAccess( + context: Context, + projectId: string, + capability: 'read' | 'members', +): Promise { + try { + return await requireProjectCapability( + context.get('db'), + projectId, + context.get('user').id, + capability, + ); + } catch (error) { + const response = mutationErrorResponse(context, error); + if (response) { + return response; + } + throw error; + } +} + +async function deliverInvitation( + context: Context, + invitation: ProjectInvitation, + target: User, + projectSlug: string, +): Promise<{ status: 'sent' | 'not_configured' | 'failed' }> { + if (!context.env.EMAIL || !target.email) { + return { status: 'not_configured' }; + } + try { + await sendProjectInvitationEmail(context.env, { + to: target.email, + invitationId: invitation.id, + projectSlug, + inviterLogin: context.get('user').login, + role: invitation.role, + }); + return { status: 'sent' }; + } catch (error) { + console.error('[api] project invitation email delivery failed', { + invitationId: invitation.id, + error, + }); + return { status: 'failed' }; + } +} + +export const projectTeamRoutes = new Hono() + .get('/:id/members', async (context) => { + const projectId = context.req.param('id'); + const access = await requireAccess(context, projectId, 'read'); + if (access instanceof Response) { + return access; + } + const members = await getProjectMembers(context.get('db'), projectId); + return context.json({ + members: [ + { + user: userJson(access.owner), + role: 'owner' as const, + created_at: access.project.created_at, + }, + ...members.map(({ member, user }) => memberJson(member, user)), + ], + }); + }) + + .get('/:id/invitations', async (context) => { + const projectId = context.req.param('id'); + const access = await requireAccess(context, projectId, 'members'); + if (access instanceof Response) { + return access; + } + const invitations = await getProjectInvitations(context.get('db'), projectId); + const targets = await getUsersByIds( + context.get('db'), + invitations.map((invitation) => invitation.target_user_id), + ); + const targetsById = new Map(targets.map((target) => [target.id, target])); + return context.json({ + invitations: invitations.flatMap((invitation) => { + const target = targetsById.get(invitation.target_user_id); + return target ? [invitationJson(invitation, target)] : []; + }), + }); + }) + + .post('/:id/invitations', async (context) => { + const projectId = context.req.param('id'); + const access = await requireAccess(context, projectId, 'members'); + if (access instanceof Response) { + return access; + } + const body = await readJsonObject(context); + const role = parseRole(body?.role); + const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : ''; + if (!email || email.length > 320 || !/^[^\s@]+@[^\s@]+$/.test(email)) { + return context.json({ error: 'a valid registered email is required' }, 400); + } + if (!role) { + return context.json({ error: 'role must be reader, collaborator, or admin' }, 400); + } + try { + const result = await createOrRefreshProjectInvitation(context.get('db'), { + projectId, + email, + role, + actorUserId: context.get('user').id, + }); + const delivery = await deliverInvitation( + context, + result.invitation, + result.target, + access.project.slug, + ); + return context.json( + { + invitation: invitationJson(result.invitation, result.target), + refreshed: result.refreshed, + delivery, + }, + result.refreshed ? 200 : 201, + ); + } catch (error) { + const response = mutationErrorResponse(context, error); + if (response) { + return response; + } + throw error; + } + }) + + .post('/:id/invitations/:invitationId/resend', async (context) => { + const projectId = context.req.param('id'); + const access = await requireAccess(context, projectId, 'members'); + if (access instanceof Response) { + return access; + } + try { + const result = await resendProjectInvitation(context.get('db'), { + projectId, + invitationId: context.req.param('invitationId'), + actorUserId: context.get('user').id, + }); + return context.json({ + invitation: invitationJson(result.invitation, result.target), + delivery: await deliverInvitation( + context, + result.invitation, + result.target, + access.project.slug, + ), + }); + } catch (error) { + const response = mutationErrorResponse(context, error); + if (response) { + return response; + } + throw error; + } + }) + + .delete('/:id/invitations/:invitationId', async (context) => { + const projectId = context.req.param('id'); + const access = await requireAccess(context, projectId, 'members'); + if (access instanceof Response) { + return access; + } + try { + await revokeProjectInvitation(context.get('db'), { + projectId, + invitationId: context.req.param('invitationId'), + actorUserId: context.get('user').id, + }); + return context.body(null, 204); + } catch (error) { + const response = mutationErrorResponse(context, error); + if (response) { + return response; + } + throw error; + } + }) + + .patch('/:id/members/:userId', async (context) => { + const projectId = context.req.param('id'); + const access = await requireAccess(context, projectId, 'members'); + if (access instanceof Response) { + return access; + } + const body = await readJsonObject(context); + const role = parseRole(body?.role); + if (!role) { + return context.json({ error: 'role must be reader, collaborator, or admin' }, 400); + } + try { + const member = await setProjectMemberRole(context.get('db'), { + projectId, + subjectUserId: context.req.param('userId'), + role, + actorUserId: context.get('user').id, + }); + const user = await getUser(context.get('db'), member.user_id); + if (!user) { + return context.json({ error: 'project member not found' }, 404); + } + return context.json({ member: memberJson(member, user) }); + } catch (error) { + const response = mutationErrorResponse(context, error); + if (response) { + return response; + } + throw error; + } + }) + + .delete('/:id/members/:userId', async (context) => { + const projectId = context.req.param('id'); + const access = await requireAccess(context, projectId, 'members'); + if (access instanceof Response) { + return access; + } + try { + await removeProjectMember(context.get('db'), { + projectId, + subjectUserId: context.req.param('userId'), + actorUserId: context.get('user').id, + }); + return context.body(null, 204); + } catch (error) { + const response = mutationErrorResponse(context, error); + if (response) { + return response; + } + throw error; + } + }) + + .post('/:id/leave', async (context) => { + const projectId = context.req.param('id'); + const access = await requireAccess(context, projectId, 'read'); + if (access instanceof Response) { + return access; + } + if (access.role === 'owner') { + return context.json({ error: 'the project owner cannot leave the project' }, 409); + } + try { + await removeProjectMember(context.get('db'), { + projectId, + subjectUserId: context.get('user').id, + actorUserId: context.get('user').id, + }); + return context.body(null, 204); + } catch (error) { + const response = mutationErrorResponse(context, error); + if (response) { + return response; + } + throw error; + } + }); + +export const myProjectInvitationRoutes = new Hono().get( + '/project-invitations', + async (context) => { + const invitations = await getPendingProjectInvitationsByUserId( + context.get('db'), + context.get('user').id, + ); + const projectIds = Array.from(new Set(invitations.map((invitation) => invitation.project_id))); + const projects = await getProjectsByIds(context.get('db'), projectIds); + const projectsById = new Map(projects.map((project) => [project.id, project])); + const users = await getUsersByIds( + context.get('db'), + Array.from( + new Set([ + ...projects.map((project) => project.user_id), + ...invitations.map((invitation) => invitation.invited_by_user_id), + ]), + ), + ); + const usersById = new Map(users.map((user) => [user.id, user])); + + return context.json({ + invitations: invitations.flatMap((invitation) => { + const project = projectsById.get(invitation.project_id); + if (!project) { + return []; + } + const owner = usersById.get(project.user_id); + const inviter = usersById.get(invitation.invited_by_user_id); + return [ + { + id: invitation.id, + project: { + id: project.id, + slug: project.slug, + owner: owner ? userJson(owner) : { id: project.user_id, login: null, email: null }, + }, + invited_by: inviter + ? userJson(inviter) + : { id: invitation.invited_by_user_id, login: null, email: null }, + role: invitation.role, + created_at: invitation.created_at, + expires_at: invitation.expires_at, + }, + ]; + }), + }); + }, +); + +async function respond(context: Context, response: 'accept' | 'decline') { + try { + const result = await respondToProjectInvitation(context.get('db'), { + invitationId: context.req.param('invitationId')!, + actorUserId: context.get('user').id, + response, + }); + if (response === 'decline') { + return context.body(null, 204); + } + if (!result.member) { + throw new Error('accepted project invitation has no membership'); + } + return context.json({ member: memberJson(result.member, context.get('user')) }); + } catch (error) { + const errorResponse = mutationErrorResponse(context, error); + if (errorResponse) { + return errorResponse; + } + throw error; + } +} + +export const projectInvitationResponseRoutes = new Hono() + .post('/:invitationId/accept', (context) => respond(context, 'accept')) + .post('/:invitationId/decline', (context) => respond(context, 'decline')); diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index e8242df04..a7c585992 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -16,10 +16,9 @@ import type { Db } from '../db.ts'; import { createProject, getBuild, - getProject, getProjectBuilds, getProjectBySlug, - getProjectsByUserId, + getAccessibleProjectsByUserId, getLatestDeploymentsByProjectIds, getProjectDeployments, getProjectResources, @@ -81,8 +80,9 @@ import { } from '../routing.ts'; import { tokenizeSql } from '../sql/tokenize.ts'; import { getPlanLimits } from '../tiers.ts'; -import type { Build, DeployManifest } from '../types.ts'; +import type { Build, DeployManifest, Project, ProjectRole, User } from '../types.ts'; import { rollbackCfDestination } from './email.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; import { getRequestsDataset, queryProjectRequestOverview } from './usage.ts'; /** @@ -186,7 +186,7 @@ const DESTRUCTIVE_PRAGMA = '-- void:allow-destructive'; */ class RollbackError extends Error { constructor( - public readonly status: 400 | 404 | 409 | 501, + public readonly status: 400 | 403 | 404 | 409 | 501, message: string, /** * For destructive-rollback rejections, we surface the server-computed @@ -204,7 +204,16 @@ class RollbackError extends Error { } } -type ProjectListItem = Awaited>[number] & { +class DatabaseUrlPersistenceError extends Error { + constructor(cause: unknown) { + super('failed to persist database configuration', { cause }); + this.name = 'DatabaseUrlPersistenceError'; + } +} + +type ProjectListItem = Project & { + role: ProjectRole; + owner: Pick; url?: string; custom_domain: string | null; latest_deployment: Awaited> extends Map< @@ -543,7 +552,7 @@ const app = new Hono() // List all projects for the current user .get('/', async (context) => { const userId = context.get('user').id; - const allProjects = await getProjectsByUserId(context.get('db'), userId); + const allProjects = await getAccessibleProjectsByUserId(context.get('db'), userId); // A project-scoped deploy token (aud:'deploy') is minted to deploy ONE // project and only needs the scoped project present here for the CLI's @@ -552,10 +561,12 @@ const app = new Hono() // deployments, traffic, and health. Full `api` tokens (deployScope unset) // keep the complete listing. const deployScope = context.get('deployScope'); - const projects = deployScope ? allProjects.filter((p) => p.id === deployScope) : allProjects; + const projects = deployScope + ? allProjects.filter(({ project }) => project.id === deployScope) + : allProjects; // Batch-fetch domains for all projects in a single query - const projectIds = projects.map((p) => p.id); + const projectIds = projects.map(({ project }) => project.id); const [domainMap, latestDeployments, requestOverview] = projectIds.length > 0 ? await Promise.all([ @@ -572,13 +583,15 @@ const app = new Hono() ]) : [new Map(), new Map(), new Map()]; - const projectsWithSummaries = projects.map((p): ProjectListItem => { + const projectsWithSummaries = projects.map(({ project: p, role, owner }): ProjectListItem => { const pDomains = domainMap.get(p.id); const domain = pDomains?.find((d: { status: string }) => d.status === 'active') ?? pDomains?.[0]; const overview = requestOverview.get(p.id); return { ...projectWithUrl(p, context.env), + role, + owner: { id: owner.id, login: owner.login, email: owner.email }, custom_domain: domain?.hostname ?? null, latest_deployment: latestDeployments.get(p.id) ?? null, traffic: { @@ -695,28 +708,32 @@ const app = new Hono() // Get project by ID .get('/:id', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, context.req.param('id'), 'read'); + if (access instanceof Response) { + return access; } - return context.json(projectWithUrl(project, context.env)); + return context.json({ + ...projectWithUrl(access.project, context.env), + role: access.role, + owner: { + id: access.owner.id, + login: access.owner.login, + email: access.owner.email, + }, + }); }) // Mint a narrowly scoped credential for deploy-time remote-binding access. // The built application receives this token while it runs under Miniflare, // so it must never inherit the caller's full API/deploy credential. .post('/:id/prerender-token', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); + const projectId = context.req.param('id'); + const access = await requireProjectRouteCapability(context, projectId, 'deploy'); + if (access instanceof Response) { + return access; } + const project = access.project; const user = context.get('user'); - if (project.user_id !== user.id) { - return context.json({ error: 'forbidden' }, 403); - } const deployBuild = context.get('deployBuild'); const deployCredential = context.get('deployCredential'); const signToken = (buildId?: string) => @@ -737,7 +754,7 @@ const app = new Hono() // remains a proxy-only, single-project credential and avoids // restoring the caller's broad platform token inside Miniflare. ttlSeconds: deployTokenTtlSecondsForBuildMinutes( - buildId ? getPlanLimits(user.plan).buildTimeoutMinutes : 0, + buildId ? getPlanLimits(access.owner.plan).buildTimeoutMinutes : 0, ), }, ); @@ -754,12 +771,16 @@ const app = new Hono() await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${project.id} FOR SHARE`); await tx.execute(drizzleSql`SELECT id FROM builds WHERE id = ${deployBuild} FOR UPDATE`); const txDb = tx as unknown as Db; - const lockedProject = await getProject(txDb, project.id); + const lockedAccess = await requireProjectRouteCapability( + context, + project.id, + 'deploy', + txDb, + ); const boundBuild = await getBuild(txDb, deployBuild); if ( - !lockedProject || - lockedProject.status === 'deleting' || - lockedProject.user_id !== user.id || + lockedAccess instanceof Response || + lockedAccess.project.status === 'deleting' || !boundBuild || boundBuild.project_id !== project.id || !ACTIVE_BUILD_STATUSES.includes(boundBuild.status) @@ -780,14 +801,11 @@ const app = new Hono() // List deployments for a project .get('/:id/deployments', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, context.req.param('id'), 'read'); + if (access instanceof Response) { + return access; } + const project = access.project; const deployments = await getProjectDeployments(context.get('db'), project.id, 20); // A project-scoped deploy token (aud:'deploy') runs in the UNTRUSTED build @@ -816,12 +834,9 @@ const app = new Hono() .post('/:id/deployments/:deploymentId/cancel', async (context) => { const projectId = context.req.param('id'); const deploymentId = context.req.param('deploymentId'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'deploy'); + if (access instanceof Response) { + return access; } const deployment = await getDeployment(context.get('db'), deploymentId); @@ -843,13 +858,11 @@ const app = new Hono() // Rollback to a previous deployment .post('/:id/rollback', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'deploy'); + if (access instanceof Response) { + return access; } + const project = access.project; const { deploymentId, allowDestructive } = await context.req.json<{ deploymentId: string; @@ -888,7 +901,7 @@ const app = new Hono() ); } - const planLimits = getPlanLimits(context.get('user').plan); + const planLimits = getPlanLimits(access.owner.plan); // A committed rollback lease blocks deploys and other rollbacks while // the cutover transaction commits or external compensation runs. Keeping @@ -931,10 +944,16 @@ const app = new Hono() const rollbackLease = await context.get('db').transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); const txDb = tx as unknown as Db; - const lockedProject = await getProject(txDb, projectId); - if (!lockedProject) { - throw new RollbackError(404, 'project not found'); + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'deploy', + txDb, + ); + if (lockedAccess instanceof Response) { + throw new RollbackError(lockedAccess.status as 403 | 404, 'forbidden'); } + const lockedProject = lockedAccess.project; if (lockedProject.status === 'deleting') { throw new RollbackError(409, 'project is being deleted'); } @@ -1026,6 +1045,15 @@ const app = new Hono() // established before any subsequent read or write. await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); const txDb = tx as unknown as Db; + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'deploy', + txDb, + ); + if (lockedAccess instanceof Response) { + throw new RollbackError(lockedAccess.status as 403 | 404, 'forbidden'); + } // Re-read target INSIDE the lock. applied_migrations can be // rewritten by a concurrent /rename; manifest and retained are @@ -1054,10 +1082,7 @@ const app = new Hono() // lock was acquired. `type` and `user_id` are effectively // immutable but re-reading keeps the computed routing JSON // consistent with the exact row the flip will reference. - const lockedProject = await getProject(txDb, projectId); - if (!lockedProject) { - throw new RollbackError(404, 'project not found'); - } + const lockedProject = lockedAccess.project; if (lockedProject.status === 'deleting') { throw new RollbackError(409, 'project is being deleted'); } @@ -1702,14 +1727,11 @@ const app = new Hono() // Get migration status for a project .get('/:id/migrations', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, context.req.param('id'), 'read'); + if (access instanceof Response) { + return access; } + const project = access.project; const resources = await getProjectResources(context.get('db'), project.id); const requestedDialect = context.req.query('dialect'); @@ -1808,13 +1830,11 @@ const app = new Hono() // Apply migrations to a project's remote D1 database .post('/:id/migrations/apply', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; } + const project = access.project; const body = await context.req.json<{ migrations: ReadonlyArray<{ name: string; sql: string }>; @@ -1863,6 +1883,20 @@ const app = new Hono() // subsequent reads/writes serialize against any concurrent // /apply, /rename, or /rollback holding the same row. await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'configure', + txDb, + ); + if (lockedAccess instanceof Response) { + return { + status: lockedAccess.status as 403 | 404, + body: { + error: lockedAccess.status === 404 ? 'project not found' : 'forbidden', + }, + }; + } const resources = await getProjectResources(txDb, projectId); const d1Resource = resources.find((r) => r.type === 'd1'); @@ -1900,7 +1934,7 @@ const app = new Hono() // Re-read the project inside the lock so active_deployment_id // reflects any concurrent rollback / setActive commit that landed // while we were blocked on the lock. - const locked = await getProject(txDb, projectId); + const locked = lockedAccess.project; // Append the UNION of applied + skipped, not just applied. // // If a previous /apply landed the migration in D1 but the pg @@ -1985,13 +2019,11 @@ const app = new Hono() // Rename migration records in a project's remote D1 database .post('/:id/migrations/rename', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; } + const project = access.project; const body = await context.req.json<{ renames: ReadonlyArray<{ from: string; to: string }> }>(); if (!body.renames || !Array.isArray(body.renames) || body.renames.length === 0) { @@ -2051,6 +2083,21 @@ const app = new Hono() // subsequent reads/writes serialize against any concurrent // /apply, /rename, or /rollback holding the same row. await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'configure', + txDb, + ); + if (lockedAccess instanceof Response) { + return { + status: lockedAccess.status as 403 | 404, + body: { + ok: false, + error: lockedAccess.status === 404 ? 'project not found' : 'forbidden', + }, + }; + } const resources = await getProjectResources(txDb, projectId); const d1Resource = resources.find((r) => r.type === 'd1'); @@ -2326,26 +2373,22 @@ const app = new Hono() // List resources for a project .get('/:id/resources', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, context.req.param('id'), 'read'); + if (access instanceof Response) { + return access; } + const project = access.project; const resources = await getProjectResources(context.get('db'), project.id); return context.json(resources); }) // Fetch logs for a project .get('/:id/logs', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, context.req.param('id'), 'read'); + if (access instanceof Response) { + return access; } + const project = access.project; const since = context.req.query('since') ?? '1h'; const sinceMs = parseDuration(since); @@ -2498,13 +2541,11 @@ const app = new Hono() // static-asset/SSR timeouts) and requests to static/SPA projects that never // invoke a worker. .get('/:id/requests', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, context.req.param('id'), 'read'); + if (access instanceof Response) { + return access; } + const project = access.project; const since = context.req.query('since') ?? '1h'; const sinceMs = parseDuration(since); @@ -2632,31 +2673,27 @@ const app = new Hono() return context.json({ data }); }) - // List recent builds for a project (owner-guarded). + // List recent builds for a project. .get('/:id/builds', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, context.req.param('id'), 'read'); + if (access instanceof Response) { + return access; } + const project = access.project; const rawLimit = Number(context.req.query('limit')); const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 100) : 20; const rows = await getProjectBuilds(context.get('db'), project.id, limit); return context.json({ builds: rows.map(toBuildSummary) }); }) - // Build logs for one of a project's builds (owner-guarded). Fronts the + // Build logs for one of a project's builds. Fronts the // BuildManager DO for container builds; github_actions builds return their // external GitHub Actions URL (no DO log store exists for them). .get('/:id/builds/:buildId/logs', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, context.req.param('id'), 'read'); + if (access instanceof Response) { + return access; } + const project = access.project; const build = await getBuild(context.get('db'), context.req.param('buildId')); if (!build || build.project_id !== project.id) { return context.json({ error: 'build not found' }, 404); @@ -2727,10 +2764,11 @@ const app = new Hono() // Provision Hyperdrive for an external database project .post('/:id/database', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project || project.user_id !== context.get('user').id) { - return context.json({ error: 'Not found' }, 404); + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; } + const project = access.project; // Refuse to provision once teardown has begun. Project teardown revokes // in-flight build deploy tokens up front, but a token captured just before // that (or a full `api` token used by the owner concurrently with their own @@ -2762,98 +2800,128 @@ const app = new Hono() } const resourceName = getHyperdriveResourceName(projectId); - let hyperdriveId: string; // Track whether WE created the CF Hyperdrive (vs. adopting a pre-existing one - // via the already-exists branch) — only a self-created object is safe to roll - // back if the resources-row INSERT below fails. + // via the already-exists branch) — only a self-created object is safe to + // roll back if the locked DB transaction later fails. + let hyperdriveId: string | undefined; let hyperdriveCreatedByUs = false; + const encrypted = await encryptProjectSecret( + context.env, + projectId, + 'database-url', + connectionString, + ); try { - const hd = await createHyperdrive( - context.env.CF_ACCOUNT_ID, - context.env.CF_API_TOKEN, - resourceName, - connectionString, - ); - hyperdriveId = hd.id; - hyperdriveCreatedByUs = true; - } catch (err: unknown) { - if (isAlreadyExistsError(err)) { - // CF already has a Hyperdrive for this name (orphaned from a prior partial failure). - // Look up its ID and update it with the submitted connectionString so CF reflects - // what we're about to store — never persist a URL that CF hasn't accepted. + const lockedResponse = await context.get('db').transaction(async (tx) => { + await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + const txDb = tx as unknown as Db; + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'configure', + txDb, + ); + if (lockedAccess instanceof Response) { + return lockedAccess; + } + if (lockedAccess.project.status === 'deleting') { + return context.json({ error: 'Project is being deleted' }, 409); + } + + // Re-read after acquiring the lock. Membership changes, teardown, and + // a competing database provision all serialize on this project row. + const lockedResources = await getProjectResources(txDb, projectId); + if (lockedResources.some((resource) => resource.type === 'hyperdrive')) { + return context.json( + { error: 'Hyperdrive already provisioned. Use PUT /database-url to update.' }, + 409, + ); + } + try { - const hd = await getHyperdriveByName( + const hd = await createHyperdrive( context.env.CF_ACCOUNT_ID, context.env.CF_API_TOKEN, resourceName, - ); - await updateHyperdrive( - context.env.CF_ACCOUNT_ID, - context.env.CF_API_TOKEN, - hd.id, connectionString, - resourceName, ); hyperdriveId = hd.id; - } catch (lookupErr: unknown) { - const response = toDatabaseProvisioningError(lookupErr); - if (response) { - return context.json(response.body, response.status); + hyperdriveCreatedByUs = true; + } catch (err: unknown) { + if (isAlreadyExistsError(err)) { + // CF already has a Hyperdrive for this name (orphaned from a prior + // partial failure). Update it while access is still protected by + // the project lock, then durably adopt its id below. + try { + const hd = await getHyperdriveByName( + context.env.CF_ACCOUNT_ID, + context.env.CF_API_TOKEN, + resourceName, + ); + await updateHyperdrive( + context.env.CF_ACCOUNT_ID, + context.env.CF_API_TOKEN, + hd.id, + connectionString, + resourceName, + ); + hyperdriveId = hd.id; + } catch (lookupErr: unknown) { + const response = toDatabaseProvisioningError(lookupErr); + if (response) { + return context.json(response.body, response.status); + } + throw lookupErr; + } + } else { + const response = toDatabaseProvisioningError(err); + if (response) { + return context.json(response.body, response.status); + } + throw err; } - throw lookupErr; } - } else { - const response = toDatabaseProvisioningError(err); - if (response) { - return context.json(response.body, response.status); + + if (!hyperdriveId) { + throw new Error('Cloudflare did not return a Hyperdrive ID'); } - throw err; + try { + // Persist both handles in the same transaction. If either write or + // COMMIT fails, the outer catch removes a Hyperdrive we created so + // no untracked CF object is left behind. + await upsertProjectDatabaseUrl( + txDb, + projectId, + encrypted.ciphertext, + encrypted.iv, + encrypted.keyVersion, + ); + await createResource(txDb, projectId, 'hyperdrive', hyperdriveId, 'HYPERDRIVE'); + } catch (error) { + throw new DatabaseUrlPersistenceError(error); + } + return null; + }); + if (lockedResponse) { + return lockedResponse; } - } - - // CF accepted the connection string — persist it as our cached copy. - // CF is the source of truth for production routing; this row is the URL - // that CF accepted and is now serving. If this upsert fails (rare), - // instruct the user to re-run `void db set-url` to re-sync. - try { - const encrypted = await encryptProjectSecret( - context.env, - projectId, - 'database-url', - connectionString, - ); - await upsertProjectDatabaseUrl( - context.get('db'), - projectId, - encrypted.ciphertext, - encrypted.iv, - encrypted.keyVersion, - ); - } catch { - return context.json( - { - error: - 'Failed to persist database URL after Cloudflare accepted it. Run `void db set-url` again to re-sync.', - }, - 500, - ); - } - - try { - await createResource(context.get('db'), projectId, 'hyperdrive', hyperdriveId, 'HYPERDRIVE'); - } catch (err) { - // The resources row didn't persist (most often a FK violation because - // teardown deleted the project concurrently). Without the row the cascade - // can't see the Hyperdrive, so it would orphan. Delete what we created - // before failing. Best-effort; pre-existing Hyperdrives we merely adopted - // are left alone. - if (hyperdriveCreatedByUs) { + } catch (err: unknown) { + if (hyperdriveCreatedByUs && hyperdriveId) { await deleteHyperdrive( context.env.CF_ACCOUNT_ID, context.env.CF_API_TOKEN, hyperdriveId, ).catch(() => {}); } + if (err instanceof DatabaseUrlPersistenceError) { + return context.json( + { + error: + 'Failed to persist database URL after Cloudflare accepted it. Run `void db set-url` again to re-sync.', + }, + 500, + ); + } throw err; } return context.json({ ok: true }); @@ -2862,10 +2930,11 @@ const app = new Hono() // Update external database connection string (updates Hyperdrive config) .put('/:id/database-url', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project || project.user_id !== context.get('user').id) { - return context.json({ error: 'Not found' }, 404); + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; } + const project = access.project; // Same teardown guard as POST /:id/database — its create-new-Hyperdrive // branch below can orphan a CF Hyperdrive the same way. This route is // `api`-token-only (not deploy-token-reachable), so the only racer is the @@ -2880,124 +2949,140 @@ const app = new Hono() await import('../provisioning/cloudflare-api.ts'); const { createResource, getProjectResources } = await import('../db.ts'); - const existing = await getProjectResources(context.get('db'), projectId); - const found = existing.find((r) => r.type === 'hyperdrive'); - - if (found) { - // Update existing Hyperdrive config - try { - const resourceName = getHyperdriveResourceName(projectId); - await updateHyperdrive( - context.env.CF_ACCOUNT_ID, - context.env.CF_API_TOKEN, - found.cf_id, - connectionString, - resourceName, + const encrypted = await encryptProjectSecret( + context.env, + projectId, + 'database-url', + connectionString, + ); + const resourceName = getHyperdriveResourceName(projectId); + let createdHyperdriveId: string | undefined; + try { + const lockedResponse = await context.get('db').transaction(async (tx) => { + await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + const txDb = tx as unknown as Db; + const lockedAccess = await requireProjectRouteCapability( + context, + projectId, + 'configure', + txDb, ); - } catch (err: unknown) { - const response = toDatabaseProvisioningError(err); - if (response) { - return context.json(response.body, response.status); + if (lockedAccess instanceof Response) { + return lockedAccess; } - throw err; - } - } else { - // Create new Hyperdrive config - const resourceName = getHyperdriveResourceName(projectId); - let hyperdriveId: string; - // See POST /:id/database — only roll back a Hyperdrive WE created. - let hyperdriveCreatedByUs = false; - try { - const hd = await createHyperdrive( - context.env.CF_ACCOUNT_ID, - context.env.CF_API_TOKEN, - resourceName, - connectionString, - ); - hyperdriveId = hd.id; - hyperdriveCreatedByUs = true; - } catch (err: unknown) { - if (isAlreadyExistsError(err)) { - // CF already has a Hyperdrive for this name (orphaned from a prior partial failure). - // Look up its ID and update it with the submitted connectionString so CF reflects - // what we're about to store — never persist a URL that CF hasn't accepted. + if (lockedAccess.project.status === 'deleting') { + return context.json({ error: 'Project is being deleted' }, 409); + } + + // Resolve the resource only after taking the lock. This prevents two + // creates and ensures a downgrade/removal is authoritative before any + // live Hyperdrive update is sent to Cloudflare. + const lockedResources = await getProjectResources(txDb, projectId); + const lockedHyperdrive = lockedResources.find((resource) => resource.type === 'hyperdrive'); + if (lockedHyperdrive) { try { - const hd = await getHyperdriveByName( - context.env.CF_ACCOUNT_ID, - context.env.CF_API_TOKEN, - resourceName, - ); await updateHyperdrive( context.env.CF_ACCOUNT_ID, context.env.CF_API_TOKEN, - hd.id, + lockedHyperdrive.cf_id, connectionString, resourceName, ); - hyperdriveId = hd.id; - } catch (lookupErr: unknown) { - const response = toDatabaseProvisioningError(lookupErr); + } catch (err: unknown) { + const response = toDatabaseProvisioningError(err); if (response) { return context.json(response.body, response.status); } - throw lookupErr; + throw err; } } else { - const response = toDatabaseProvisioningError(err); - if (response) { - return context.json(response.body, response.status); + let hyperdriveId: string; + try { + const hd = await createHyperdrive( + context.env.CF_ACCOUNT_ID, + context.env.CF_API_TOKEN, + resourceName, + connectionString, + ); + hyperdriveId = hd.id; + createdHyperdriveId = hd.id; + } catch (err: unknown) { + if (isAlreadyExistsError(err)) { + // An earlier partial attempt can leave an untracked object. + // Adopt it under the same project lock so no membership change + // can interleave its live update and the durable resource row. + try { + const hd = await getHyperdriveByName( + context.env.CF_ACCOUNT_ID, + context.env.CF_API_TOKEN, + resourceName, + ); + await updateHyperdrive( + context.env.CF_ACCOUNT_ID, + context.env.CF_API_TOKEN, + hd.id, + connectionString, + resourceName, + ); + hyperdriveId = hd.id; + } catch (lookupErr: unknown) { + const response = toDatabaseProvisioningError(lookupErr); + if (response) { + return context.json(response.body, response.status); + } + throw lookupErr; + } + } else { + const response = toDatabaseProvisioningError(err); + if (response) { + return context.json(response.body, response.status); + } + throw err; + } + } + try { + await createResource(txDb, projectId, 'hyperdrive', hyperdriveId, 'HYPERDRIVE'); + } catch (error) { + throw new DatabaseUrlPersistenceError(error); } - throw err; } + + try { + await upsertProjectDatabaseUrl( + txDb, + projectId, + encrypted.ciphertext, + encrypted.iv, + encrypted.keyVersion, + ); + } catch (error) { + throw new DatabaseUrlPersistenceError(error); + } + return null; + }); + if (lockedResponse) { + return lockedResponse; } - try { - await createResource( - context.get('db'), - projectId, - 'hyperdrive', - hyperdriveId, - 'HYPERDRIVE', + } catch (err: unknown) { + // Only a freshly-created object is safe to hand back. Adopted or + // pre-existing Hyperdrives may still be referenced by a prior attempt. + if (createdHyperdriveId) { + await deleteHyperdrive( + context.env.CF_ACCOUNT_ID, + context.env.CF_API_TOKEN, + createdHyperdriveId, + ).catch(() => {}); + } + if (err instanceof DatabaseUrlPersistenceError) { + return context.json( + { + error: + 'Failed to persist database URL after Cloudflare accepted it. Run `void db set-url` again to re-sync.', + }, + 500, ); - } catch (err) { - // Row didn't persist (e.g. concurrent teardown FK violation) → delete the - // Hyperdrive we just created so it doesn't orphan. Best-effort. - if (hyperdriveCreatedByUs) { - await deleteHyperdrive( - context.env.CF_ACCOUNT_ID, - context.env.CF_API_TOKEN, - hyperdriveId, - ).catch(() => {}); - } - throw err; } - } - - // CF accepted the connection string — persist it as our cached copy. - // CF is the source of truth for production routing; this row is the URL - // that CF accepted and is now serving. If this upsert fails (rare), - // instruct the user to re-run `void db set-url` to re-sync. - try { - const encrypted = await encryptProjectSecret( - context.env, - projectId, - 'database-url', - connectionString, - ); - await upsertProjectDatabaseUrl( - context.get('db'), - projectId, - encrypted.ciphertext, - encrypted.iv, - encrypted.keyVersion, - ); - } catch { - return context.json( - { - error: - 'Failed to persist database URL after Cloudflare accepted it. Run `void db set-url` again to re-sync.', - }, - 500, - ); + throw err; } return context.json({ ok: true }); @@ -3006,12 +3091,9 @@ const app = new Hono() // Retrieve the stored (encrypted-at-rest) external database connection string .get('/:id/database-url', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; } const row = await getProjectDatabaseUrl(context.get('db'), projectId); diff --git a/platform/packages/api/src/routes/revalidate.ts b/platform/packages/api/src/routes/revalidate.ts index 8f7fc1f1c..bade2a0e9 100644 --- a/platform/packages/api/src/routes/revalidate.ts +++ b/platform/packages/api/src/routes/revalidate.ts @@ -1,23 +1,19 @@ import { Hono } from 'hono'; -import { getProject } from '../db.ts'; import type { ApiEnv } from '../env.ts'; import { runRevalidate } from '../revalidate-core.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; const app = new Hono(); app.post('/:id/revalidate', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'deploy'); + if (access instanceof Response) { + return access; } const body = await context.req.json<{ paths?: ReadonlyArray; all?: boolean }>(); - return runRevalidate(context, projectId, project, body); + return runRevalidate(context, projectId, access.project, body); }); export default app; diff --git a/platform/packages/api/src/routes/secrets.ts b/platform/packages/api/src/routes/secrets.ts index d6e9f30c1..96d4865b5 100644 --- a/platform/packages/api/src/routes/secrets.ts +++ b/platform/packages/api/src/routes/secrets.ts @@ -8,7 +8,6 @@ import { import { deleteProjectSecret, getActiveDeployment, - getProject, listProjectSecretNames, upsertProjectSecret, type Db, @@ -21,6 +20,8 @@ import { putWorkerSecret, } from '../project-secrets.ts'; import { isReservedBindingName } from '../reserved-bindings.ts'; +import type { Project } from '../types.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; function isValidSecretName(name: string): boolean { return /^[A-Z][A-Z0-9_]*$/.test(name); @@ -28,10 +29,7 @@ function isValidSecretName(name: string): boolean { async function withProjectSecretUpdate( context: Context, - update: ( - db: Db, - project: NonNullable>>, - ) => Promise, + update: (db: Db, project: Project) => Promise, ): Promise { const projectId = context.req.param('id')!; return context.get('db').transaction(async (tx) => { @@ -40,13 +38,11 @@ async function withProjectSecretUpdate( // before changing storage: a candidate may already have captured secrets. await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); const db = tx as unknown as Db; - const project = await getProject(db, projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'configure', db); + if (access instanceof Response) { + return access; } + const project = access.project; if (project.status === 'deleting') { return context.json({ error: 'project is being deleted' }, 410); } @@ -68,12 +64,9 @@ const app = new Hono() .get('/:id/secrets', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; } return context.json(await listProjectSecretNames(context.get('db'), projectId)); @@ -96,12 +89,9 @@ const app = new Hono() return context.json({ error: 'reserved binding name' }, 400); } - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; } let body: { value?: string } = {}; @@ -158,12 +148,9 @@ const app = new Hono() return context.json({ error: 'invalid secret name' }, 400); } - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; } return withProjectSecretUpdate(context, async (db, project) => { diff --git a/platform/packages/api/src/routes/usage.ts b/platform/packages/api/src/routes/usage.ts index 40ec0d647..70d618f01 100644 --- a/platform/packages/api/src/routes/usage.ts +++ b/platform/packages/api/src/routes/usage.ts @@ -1,19 +1,20 @@ +import { and, eq, gt, inArray, isNull, lt, or } from 'drizzle-orm'; import { Hono } from 'hono'; -import { getProject, getProjectsByUserId } from '../db.ts'; +import { getOwnedProjectsByUserId, type Db } from '../db.ts'; import { type ApiEnv, isDevBypass } from '../env.ts'; +import { projectOwnershipPeriods, projects as projectsTable } from '../schema.ts'; import { getCurrentBillingPeriodStart } from '../tiers.ts'; +import type { Project } from '../types.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; const projectUsage = new Hono() // Project usage for current billing period .get('/:id/usage', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } // Dev bypass — skip slow CF Analytics Engine API calls @@ -27,7 +28,7 @@ const projectUsage = new Hono() }); } - const since = getCurrentBillingPeriodStart(context.get('user').billing_cycle_start); + const since = getCurrentBillingPeriodStart(access.owner.billing_cycle_start); const requestsDataset = getRequestsDataset(context.env); const [usage, aiUsage] = await Promise.all([ queryProjectUsage( @@ -58,21 +59,28 @@ const accountUsage = new Hono() // Aggregate usage across all user's projects .get('/', async (context) => { - const projects = await getProjectsByUserId(context.get('db'), context.get('user').id); - if (projects.length === 0) { + const since = getCurrentBillingPeriodStart(context.get('user').billing_cycle_start); + const until = new Date(); + const projectWindows = await getAccountUsageProjectWindows( + context.get('db'), + context.get('user').id, + since, + until, + ); + if (projectWindows.length === 0) { return context.json({ requests: 0, bandwidthBytes: 0, aiNeurons: 0, projects: [] }); } // Dev bypass — skip slow CF Analytics Engine API calls if (isDevBypass(context.env)) { return context.json({ - billingPeriodStart: new Date().toISOString(), + billingPeriodStart: since.toISOString(), requests: 0, bandwidthBytes: 0, aiNeurons: 0, - projects: projects.map((p) => ({ - projectId: p.id, - slug: p.slug, + projects: projectWindows.map(({ project }) => ({ + projectId: project.id, + slug: project.slug, requests: 0, bandwidthBytes: 0, aiNeurons: 0, @@ -80,31 +88,39 @@ const accountUsage = new Hono() }); } - const since = getCurrentBillingPeriodStart(context.get('user').billing_cycle_start); const requestsDataset = getRequestsDataset(context.env); const [usageMap, ...aiUsages] = await Promise.all([ - queryBatchProjectUsage( + queryBatchProjectUsageWindows( context.env.CF_ACCOUNT_ID, context.env.CF_API_TOKEN, requestsDataset, - projects.map((p) => p.id), - since, + projectWindows, ), - ...projects.map((p) => - queryProjectAiUsage( - context.env.CF_ACCOUNT_ID, - context.env.CF_API_TOKEN, - context.env.AI_GATEWAY_ID ?? 'void', - p.id, - since, + ...projectWindows.map(async ({ project, intervals }) => + intervals.reduce( + async (totalPromise, interval) => { + const [total, usage] = await Promise.all([ + totalPromise, + queryProjectAiUsage( + context.env.CF_ACCOUNT_ID, + context.env.CF_API_TOKEN, + context.env.AI_GATEWAY_ID ?? 'void', + project.id, + interval.start, + interval.end, + ), + ]); + return { neurons: total.neurons + usage.neurons }; + }, + Promise.resolve({ neurons: 0 }), ), ), ]); - const projectUsages = projects.map((p, i) => ({ - projectId: p.id, - slug: p.slug, - ...(usageMap.get(p.id) ?? ZERO_USAGE), + const projectUsages = projectWindows.map(({ project }, i) => ({ + projectId: project.id, + slug: project.slug, + ...(usageMap.get(project.id) ?? ZERO_USAGE), aiNeurons: aiUsages[i].neurons, })); @@ -214,6 +230,141 @@ async function queryProjectUsage( type UsageEntry = Readonly<{ requests: number; bandwidthBytes: number }>; const ZERO_USAGE: UsageEntry = { requests: 0, bandwidthBytes: 0 }; +type AccountUsageInterval = Readonly<{ start: Date; end: Date }>; +type AccountUsageProjectWindow = Readonly<{ + project: Project; + intervals: ReadonlyArray; +}>; + +function databaseTimestampMs(value: string): number { + const normalized = /(?:Z|[+-]\d{2}(?::?\d{2})?)$/i.test(value) + ? value + : `${value.replace(' ', 'T')}Z`; + return Date.parse(normalized); +} + +/** + * Resolve the portions of the actor's current billing window for which they + * owned each project. Projects without ownership-period rows are legacy/test + * data and retain the former current-owner behavior. + */ +async function getAccountUsageProjectWindows( + db: Db, + userId: string, + since: Date, + until: Date, +): Promise> { + const sinceIso = since.toISOString(); + const untilIso = until.toISOString(); + const rows = await db + .select({ + project: projectsTable, + startsAt: projectOwnershipPeriods.starts_at, + endsAt: projectOwnershipPeriods.ends_at, + }) + .from(projectOwnershipPeriods) + .innerJoin(projectsTable, eq(projectOwnershipPeriods.project_id, projectsTable.id)) + .where( + and( + eq(projectOwnershipPeriods.owner_user_id, userId), + lt(projectOwnershipPeriods.starts_at, untilIso), + or(isNull(projectOwnershipPeriods.ends_at), gt(projectOwnershipPeriods.ends_at, sinceIso)), + ), + ); + + const windows = new Map }>(); + for (const row of rows) { + const start = new Date(Math.max(since.getTime(), databaseTimestampMs(row.startsAt))); + const end = new Date( + Math.min(until.getTime(), row.endsAt ? databaseTimestampMs(row.endsAt) : until.getTime()), + ); + if (start < end) { + const entry = windows.get(row.project.id) ?? { project: row.project, intervals: [] }; + entry.intervals.push({ start, end }); + windows.set(row.project.id, entry); + } + } + + // Compatibility for projects created by an older runtime (and lightweight + // test fixtures) before ownership periods were written. + const currentlyOwned = await getOwnedProjectsByUserId(db, userId); + const currentIds = currentlyOwned.map((project) => project.id); + const projectsWithAnyPeriod = new Set(); + if (currentIds.length > 0) { + const periodRows = await db + .select({ projectId: projectOwnershipPeriods.project_id }) + .from(projectOwnershipPeriods) + .where(inArray(projectOwnershipPeriods.project_id, currentIds)); + for (const row of periodRows) { + projectsWithAnyPeriod.add(row.projectId); + } + } + for (const project of currentlyOwned) { + if (!windows.has(project.id) && !projectsWithAnyPeriod.has(project.id)) { + windows.set(project.id, { project, intervals: [{ start: since, end: until }] }); + } + } + + return [...windows.values()].sort((a, b) => + b.project.created_at.localeCompare(a.project.created_at), + ); +} + +function analyticsStringLiteral(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`; +} + +async function queryBatchProjectUsageWindows( + accountId: string, + apiToken: string, + requestsDataset: string, + windows: ReadonlyArray, +): Promise> { + if (windows.length === 0) { + return new Map(); + } + const predicates = windows.flatMap(({ project, intervals }) => + intervals.map( + ({ start, end }) => + `(blob1 = ${analyticsStringLiteral(project.id)} AND timestamp >= toDateTime(${analyticsStringLiteral(formatAnalyticsDate(start))}) AND timestamp < toDateTime(${analyticsStringLiteral(formatAnalyticsDate(end))}))`, + ), + ); + const query = ` + SELECT + blob1, + SUM(_sample_interval * double1) as requests, + SUM(_sample_interval * double2) as bandwidth_bytes + FROM ${requestsDataset} + WHERE ${predicates.join('\n OR ')} + GROUP BY blob1 + `; + + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'text/plain', + }, + body: query, + }, + ); + if (!response.ok) { + return new Map(); + } + const json = (await response.json()) as { data: ReadonlyArray }; + return new Map( + (json.data ?? []).map((row) => [ + row.blob1, + { + requests: Math.round(row.requests ?? 0), + bandwidthBytes: Math.round(row.bandwidth_bytes ?? 0), + }, + ]), + ); +} + async function queryBatchProjectUsage( accountId: string, apiToken: string, @@ -463,6 +614,8 @@ async function queryProjectAiUsage( gatewayId: string, projectId: string, since: Date, + until?: Date, + failOnError = false, ): Promise<{ neurons: number }> { const query = `query { viewer { @@ -472,6 +625,7 @@ async function queryProjectAiUsage( gatewayId: "${gatewayId}" metadata_projectId: "${projectId}" datetime_geq: "${since.toISOString()}" + ${until ? `datetime_lt: "${until.toISOString()}"` : ''} } ) { sum { cost } @@ -490,11 +644,17 @@ async function queryProjectAiUsage( }); if (!response.ok) { + if (failOnError) { + throw new Error(`AI usage query failed with HTTP ${response.status}`); + } return { neurons: 0 }; } const result = (await response.json()) as AiGatewayResponse; const requests = result.data?.viewer?.accounts?.[0]?.aiGatewayRequests; + if (!requests && failOnError) { + throw new Error('AI usage query returned no account data'); + } const cost = requests?.[0]?.sum?.cost ?? 0; return { neurons: Math.round(cost / COST_PER_NEURON) }; } @@ -503,9 +663,11 @@ export { getRequestsDataset, queryProjectUsage, queryBatchProjectUsage, + queryBatchProjectUsageWindows, queryTopProjectTraffic, queryProjectRequestOverview, queryProjectAiUsage, + getAccountUsageProjectWindows, projectUsage, accountUsage, ZERO_USAGE, diff --git a/platform/packages/api/src/routing.ts b/platform/packages/api/src/routing.ts index b0200b095..9ccc4d2ff 100644 --- a/platform/packages/api/src/routing.ts +++ b/platform/packages/api/src/routing.ts @@ -1,5 +1,5 @@ import { sql as drizzleSql } from 'drizzle-orm'; -import { getDomainsByProjectIds, getProjectsByUserId, getUser, type Db } from './db.ts'; +import { getDomainsByProjectIds, getProject, getProjectsByUserId, getUser, type Db } from './db.ts'; import type { PlanLimits } from './tiers.ts'; import type { AssetConfig, DeployManifest } from './types.ts'; @@ -51,7 +51,19 @@ export async function refreshProjectKvForUser( return; } const payload = buildProjectAccountJson({ userId, plan: user.plan }); - await Promise.all(projects.map((project) => routing.put(projectAccountKey(project.id), payload))); + for (const project of projects) { + await db.transaction(async (tx) => { + await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${project.id} FOR UPDATE`); + const current = await getProject(tx as unknown as Db, project.id); + // The owned-project list was read before this lock. An ownership transfer + // can commit in between; in that ordering its project-account entry is now + // authoritative and a stale plan refresh must not overwrite it. + if (current?.user_id !== userId) { + return; + } + await routing.put(projectAccountKey(project.id), payload); + }); + } } /** @@ -185,7 +197,7 @@ export async function applyRoutingSuspension( export async function setProjectsRoutingSuspension( routing: KVNamespace, db: Db, - projects: ReadonlyArray<{ id: string; slug: string }>, + projects: ReadonlyArray<{ id: string; slug: string; user_id?: string }>, update: RoutingSuspensionUpdate, ): Promise { if (projects.length === 0) { @@ -194,6 +206,15 @@ export async function setProjectsRoutingSuspension( for (const project of projects) { await db.transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${project.id} FOR UPDATE`); + if (project.user_id !== undefined) { + const current = await getProject(tx as unknown as Db, project.id); + // User-level suspension/restore callers pass Project snapshots from an + // owned-project query. If ownership changed before this lock, transfer + // convergence owns the route and this stale fan-out must stand down. + if (current?.user_id !== project.user_id) { + return; + } + } // Resolve domains only after the project lock. Domain activation takes // the same lock before publishing a route, so this read must happen // behind it or a domain activated between the eager read and this lock diff --git a/platform/packages/api/src/schema.ts b/platform/packages/api/src/schema.ts index eca031829..5fd6a4abc 100644 --- a/platform/packages/api/src/schema.ts +++ b/platform/packages/api/src/schema.ts @@ -21,7 +21,12 @@ import { import type { PlatformWorkerName, PlatformEnv } from './admin/platform-workers.ts'; import type { PlatformOidcIdentity, CompanyIdentity } from './auth/identity-types.ts'; import type { PlanId } from './tiers.ts'; -import type { InvitationStatus } from './types.ts'; +import type { + InvitationStatus, + ProjectAccessEventAction, + ProjectInvitationStatus, + ProjectMemberRole, +} from './types.ts'; // --------------------------------------------------------------------------- // users @@ -338,6 +343,134 @@ export const projectDeployCredentials = pgTable( ], ); +// --------------------------------------------------------------------------- +// Project access. Ownership remains authoritative on projects.user_id; owners +// are deliberately not duplicated in project_members. +// --------------------------------------------------------------------------- +export const projectMembers = pgTable( + 'project_members', + { + project_id: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + user_id: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + role: text('role').notNull().$type(), + // Durable audit identity. This may name an account deleted later. + added_by_user_id: text('added_by_user_id'), + created_at: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(), + updated_at: timestamp('updated_at', { mode: 'string' }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.project_id, table.user_id] }), + index('idx_project_members_user_project').on(table.user_id, table.project_id), + check('project_members_role_check', sql`${table.role} IN ('reader', 'collaborator', 'admin')`), + ], +); + +export const projectInvitations = pgTable( + 'project_invitations', + { + id: text('id').primaryKey(), + project_id: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + target_user_id: text('target_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + target_email_snapshot: text('target_email_snapshot').notNull(), + role: text('role').notNull().$type(), + status: text('status').notNull().default('pending').$type(), + // Actor ids are durable audit data and intentionally have no user FK. + invited_by_user_id: text('invited_by_user_id').notNull(), + accepted_by_user_id: text('accepted_by_user_id'), + created_at: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(), + expires_at: timestamp('expires_at', { mode: 'string' }).notNull(), + accepted_at: timestamp('accepted_at', { mode: 'string' }), + revoked_at: timestamp('revoked_at', { mode: 'string' }), + revoked_by_user_id: text('revoked_by_user_id'), + }, + (table) => [ + index('idx_project_invitations_project_status').on(table.project_id, table.status), + index('idx_project_invitations_target_status').on(table.target_user_id, table.status), + uniqueIndex('idx_project_invitations_pending_target') + .on(table.project_id, table.target_user_id) + .where(sql`${table.status} = 'pending'`), + check( + 'project_invitations_role_check', + sql`${table.role} IN ('reader', 'collaborator', 'admin')`, + ), + check( + 'project_invitations_status_check', + sql`${table.status} IN ('pending', 'accepted', 'declined', 'revoked', 'expired')`, + ), + ], +); + +export const projectOwnershipPeriods = pgTable( + 'project_ownership_periods', + { + id: text('id').primaryKey(), + project_id: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + // Billing and operator actor identities must survive account deletion. + owner_user_id: text('owner_user_id').notNull(), + starts_at: timestamp('starts_at', { mode: 'string' }).notNull(), + ends_at: timestamp('ends_at', { mode: 'string' }), + changed_by_user_id: text('changed_by_user_id'), + }, + (table) => [ + index('idx_project_ownership_periods_owner_time').on( + table.owner_user_id, + table.starts_at, + table.ends_at, + ), + index('idx_project_ownership_periods_project_time').on(table.project_id, table.starts_at), + uniqueIndex('idx_project_ownership_periods_open') + .on(table.project_id) + .where(sql`${table.ends_at} IS NULL`), + check( + 'project_ownership_periods_time_check', + sql`${table.ends_at} IS NULL OR ${table.ends_at} >= ${table.starts_at}`, + ), + ], +); + +export const projectAccessEvents = pgTable( + 'project_access_events', + { + id: text('id').primaryKey(), + project_id: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + actor_user_id: text('actor_user_id').notNull(), + subject_user_id: text('subject_user_id'), + invitation_email_snapshot: text('invitation_email_snapshot'), + previous_role: text('previous_role').$type(), + new_role: text('new_role').$type(), + action: text('action').notNull().$type(), + created_at: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(), + }, + (table) => [ + index('idx_project_access_events_project_created').on(table.project_id, table.created_at), + index('idx_project_access_events_subject_created').on(table.subject_user_id, table.created_at), + check( + 'project_access_events_previous_role_check', + sql`${table.previous_role} IS NULL OR ${table.previous_role} IN ('reader', 'collaborator', 'admin')`, + ), + check( + 'project_access_events_new_role_check', + sql`${table.new_role} IS NULL OR ${table.new_role} IN ('reader', 'collaborator', 'admin')`, + ), + check( + 'project_access_events_action_check', + sql`${table.action} IN ('invited', 'invitation_resent', 'accepted', 'declined', 'invitation_revoked', 'role_changed', 'member_removed', 'left')`, + ), + ], +); + // --------------------------------------------------------------------------- // deployments // --------------------------------------------------------------------------- @@ -382,6 +515,11 @@ export const deployments = pgTable( retained: boolean('retained').notNull().default(false), applied_migrations: text('applied_migrations'), bundle_stored: boolean('bundle_stored').notNull().default(false), + // billing_user_id is the project owner at admission time. The actor is + // recorded separately and deliberately does not have a user FK so audit + // attribution survives account deletion. + billing_user_id: text('billing_user_id'), + initiated_by_user_id: text('initiated_by_user_id'), }, (table) => [ index('idx_deployments_project_id').on(table.project_id, table.created_at), @@ -1133,6 +1271,7 @@ export const builds = pgTable( user_id: text('user_id') .notNull() .references(() => users.id), + triggered_by_user_id: text('triggered_by_user_id'), executor: text('executor') .notNull() .default('container') diff --git a/platform/packages/api/src/types.ts b/platform/packages/api/src/types.ts index 52827922a..7def769bd 100644 --- a/platform/packages/api/src/types.ts +++ b/platform/packages/api/src/types.ts @@ -10,7 +10,11 @@ import type { invitations, oauthAccounts, platformEvents, + projectAccessEvents, projectDatabaseCredentials, + projectInvitations, + projectMembers, + projectOwnershipPeriods, projects, projectSecrets, resources, @@ -49,10 +53,27 @@ export type { } from '@void/deploy-core'; export type InvitationStatus = 'pending' | 'accepted' | 'revoked'; +export type ProjectMemberRole = 'reader' | 'collaborator' | 'admin'; +export type ProjectRole = ProjectMemberRole | 'owner'; +export type ProjectCapability = 'read' | 'deploy' | 'configure' | 'settings' | 'members' | 'delete'; +export type ProjectInvitationStatus = 'pending' | 'accepted' | 'declined' | 'revoked' | 'expired'; +export type ProjectAccessEventAction = + | 'invited' + | 'invitation_resent' + | 'accepted' + | 'declined' + | 'invitation_revoked' + | 'role_changed' + | 'member_removed' + | 'left'; export type User = InferSelectModel; export type OAuthAccount = InferSelectModel; export type Project = InferSelectModel; +export type ProjectMember = InferSelectModel; +export type ProjectInvitation = InferSelectModel; +export type ProjectOwnershipPeriod = InferSelectModel; +export type ProjectAccessEvent = InferSelectModel; export type Deployment = InferSelectModel; export type DeploymentStatus = Deployment['status']; export type ProjectSecret = InferSelectModel; diff --git a/platform/packages/api/src/usage-counter.ts b/platform/packages/api/src/usage-counter.ts index b1635d3ae..889c56d92 100644 --- a/platform/packages/api/src/usage-counter.ts +++ b/platform/packages/api/src/usage-counter.ts @@ -1,5 +1,12 @@ import { DurableObject } from 'cloudflare:workers'; -import { createDb, suspendUser, unsuspendUserIfReason, type Db } from './db.ts'; +import { + createDb, + getProjectOwnershipPeriodsByProjectIds, + getProjectsByIds, + suspendUser, + unsuspendUserIfReason, + type Db, +} from './db.ts'; import type { ApiEnv } from './env.ts'; import { queryProjectAiUsage } from './routes/usage.ts'; import { sandboxBlockKey, setProjectsRoutingSuspension } from './routing.ts'; @@ -10,6 +17,8 @@ import { SANDBOX_USAGE_RETENTION_MS } from './usage-auth.ts'; // --- Exported types --- export type CounterState = { + /** Account represented by this counter, learned from trusted API requests. */ + ownerUserId: string | null; totalRequests: number; totalNeurons: number; totalSandboxSeconds: number; @@ -20,6 +29,22 @@ export type CounterState = { aiSuspended: boolean; sandboxRuntimeSuspended: boolean; projectSlugs: Record; // projectId → slug + /** Start of the current ownership period for each actively owned project. */ + projectOwnershipStarts: Record; + /** Latest per-project AI readings for actively owned projects. */ + projectNeurons: Record; + /** AI usage retained from projects transferred away during this billing period. */ + historicalNeurons: number; + /** Closed ownership windows that remain re-queryable while Analytics ingestion settles. */ + historicalAiWindows: Record< + string, + { + projectId: string; + ownershipStartedAt: string; + ownershipEndedAt: string; + neurons: number; + } + >; }; const SANDBOX_WATERMARK_PREFIX = 'sandbox-usage:watermark:'; @@ -53,6 +78,10 @@ export type UsageEvent = Readonly<{ /** Cumulative server-owned lease generation; from stays fixed across heartbeats. */ sandboxMeteredFrom?: number; sandboxMeteredThrough?: number; + /** Set by the API consumer after resolving the event's ownership period. */ + currentOwner?: boolean; + /** Current ownership-period boundary, when `currentOwner` is true. */ + ownershipStartedAt?: string; }>; // --- Pure functions (exported for unit testing) --- @@ -82,6 +111,7 @@ export function evaluateSandboxRuntimeLimit(state: CounterState, incomingSeconds function defaultState(): CounterState { return { + ownerUserId: null, totalRequests: 0, totalNeurons: 0, totalSandboxSeconds: 0, @@ -92,6 +122,10 @@ function defaultState(): CounterState { aiSuspended: false, sandboxRuntimeSuspended: false, projectSlugs: {}, + projectOwnershipStarts: {}, + projectNeurons: {}, + historicalNeurons: 0, + historicalAiWindows: {}, }; } @@ -140,6 +174,9 @@ export class UsageCounter extends DurableObject { return { ...this.state, projectSlugs: { ...this.state.projectSlugs }, + projectOwnershipStarts: { ...this.state.projectOwnershipStarts }, + projectNeurons: { ...this.state.projectNeurons }, + historicalAiWindows: structuredClone(this.state.historicalAiWindows), }; } @@ -202,6 +239,12 @@ export class UsageCounter extends DurableObject { if (request.method === 'POST' && path === '/init') { return this.handleInit(request); } + if (request.method === 'POST' && path === '/attach-project') { + return this.handleAttachProject(request); + } + if (request.method === 'POST' && path === '/detach-project') { + return this.handleDetachProject(request); + } if (request.method === 'POST' && path === '/reevaluate-sandbox') { return this.handleReevaluateSandbox(request); } @@ -223,6 +266,9 @@ export class UsageCounter extends DurableObject { billingPeriodStart?: string; }; const state = await this.getState(); + if (body.userId) { + state.ownerUserId = body.userId; + } // A new counter can first see an interval after that interval has ended. // Its construction time is not the account's billing-period boundary. if (!state.billingPeriodInitialized && body.billingPeriodStart) { @@ -274,9 +320,16 @@ export class UsageCounter extends DurableObject { } state.totalSandboxSeconds += incomingSandboxSeconds; - // Update projectSlugs map + // Only the current owner may keep a project in the active enforcement set. + // A delayed event from a closed ownership period still contributes to the + // former owner's already-billed totals, but must not reattach the project. for (const event of body.events) { - state.projectSlugs[event.projectId] = event.slug; + if (event.currentOwner !== false) { + state.projectSlugs[event.projectId] = event.slug; + if (event.ownershipStartedAt) { + state.projectOwnershipStarts[event.projectId] = event.ownershipStartedAt; + } + } } // Update plan if provided @@ -295,7 +348,7 @@ export class UsageCounter extends DurableObject { const exceedsLimit = evaluateRequestLimit(state, 0); // already added above if (exceedsLimit && !state.requestsSuspended) { await settleWrites([ - this.suspendProjects(getDb(), state.projectSlugs, REASON_REQUESTS_EXCEEDED), + this.suspendProjects(getDb(), state.projectSlugs, body.userId, REASON_REQUESTS_EXCEEDED), suspendUser(getDb(), body.userId, REASON_REQUESTS_EXCEEDED), ]); state.requestsSuspended = true; @@ -333,6 +386,9 @@ export class UsageCounter extends DurableObject { userId: string; }; const state = await this.getState(); + if (body.userId) { + state.ownerUserId = body.userId; + } // Only act if billingPeriodStart differs if (state.billingPeriodStart === body.billingPeriodStart) { @@ -358,7 +414,12 @@ export class UsageCounter extends DurableObject { // The alarm's delete branch requires aiSuspended to still be true AND returns // early for unlimited-AI plans, so without this the proxy keeps 429ing AI // routes after rollover. - for (const projectId of Object.keys(state.projectSlugs)) { + const ownedProjectIds = await this.ownedProjectIds( + createDb(this.env.HYPERDRIVE.connectionString), + Object.keys(state.projectSlugs), + body.userId ?? state.ownerUserId, + ); + for (const projectId of ownedProjectIds) { await this.env.ROUTING.delete(`ai-block:${projectId}`); } } @@ -374,6 +435,9 @@ export class UsageCounter extends DurableObject { state.totalRequests = 0; state.totalNeurons = 0; state.totalSandboxSeconds = 0; + state.historicalNeurons = 0; + state.historicalAiWindows = {}; + state.projectNeurons = {}; state.plan = body.plan; state.billingPeriodStart = body.billingPeriodStart; state.billingPeriodInitialized = true; @@ -406,27 +470,155 @@ export class UsageCounter extends DurableObject { plan: string; billingPeriodStart: string; projectSlugs: Record; + projectOwnershipStarts?: Record; + userId?: string; }; const state = await this.getState(); + if (body.userId) { + state.ownerUserId = body.userId; + } - // Merge projectSlugs - Object.assign(state.projectSlugs, body.projectSlugs); + // PostgreSQL owns the current project set. Replace instead of merge so an + // ownership transfer cannot leave the old owner's DO able to restamp the + // transferred project's routing or AI block. + const previousProjectIds = new Set(Object.keys(state.projectSlugs)); + const removedProjectIds = [...previousProjectIds].filter( + (projectId) => !(projectId in body.projectSlugs), + ); + const db = createDb(this.env.HYPERDRIVE.connectionString); + await this.retainTransferredProjectWindows(db, state, removedProjectIds); + state.projectSlugs = { ...body.projectSlugs }; + state.projectOwnershipStarts = { ...(body.projectOwnershipStarts ?? {}) }; state.plan = body.plan; state.billingPeriodStart = body.billingPeriodStart; state.billingPeriodInitialized = true; // Re-suspend any newly added project slugs if user is currently suspended if (state.requestsSuspended) { - const db = createDb(this.env.HYPERDRIVE.connectionString); - await this.suspendProjects(db, state.projectSlugs, REASON_REQUESTS_EXCEEDED); + await this.suspendProjects( + db, + state.projectSlugs, + state.ownerUserId, + REASON_REQUESTS_EXCEEDED, + ); } - if (state.aiSuspended) { - for (const projectId of Object.keys(state.projectSlugs)) { + const ownedProjectIds = await this.ownedProjectIds( + db, + Object.keys(state.projectSlugs), + state.ownerUserId, + ); + for (const projectId of ownedProjectIds) { + if (state.aiSuspended) { await this.env.ROUTING.put(`ai-block:${projectId}`, '1'); + } else { + await this.env.ROUTING.delete(`ai-block:${projectId}`); } } await this.saveState(state); + if (Object.keys(state.historicalAiWindows).length > 0 && !(await this.ctx.storage.getAlarm())) { + await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000); + } + return Response.json({ ok: true }); + } + + private async handleAttachProject(request: Request): Promise { + const body = (await request.json()) as { + projectId: string; + slug: string; + ownershipStartedAt: string; + plan?: string; + userId?: string; + }; + if ( + !body.projectId || + !body.slug || + !body.ownershipStartedAt || + !Number.isFinite(Date.parse(body.ownershipStartedAt)) + ) { + return Response.json({ error: 'invalid project attachment' }, { status: 400 }); + } + const state = await this.getState(); + if (body.userId) { + state.ownerUserId = body.userId; + } + const db = createDb(this.env.HYPERDRIVE.connectionString); + const ownedProjectIds = await this.ownedProjectIds(db, [body.projectId], state.ownerUserId); + if (!ownedProjectIds.has(body.projectId)) { + return Response.json({ error: 'project owner changed' }, { status: 409 }); + } + state.projectSlugs[body.projectId] = body.slug; + state.projectOwnershipStarts[body.projectId] = body.ownershipStartedAt; + if (body.plan) { + state.plan = body.plan; + } + if (state.requestsSuspended) { + await this.suspendProjects( + db, + { [body.projectId]: body.slug }, + state.ownerUserId, + REASON_REQUESTS_EXCEEDED, + ); + } + if (state.aiSuspended) { + await this.env.ROUTING.put(`ai-block:${body.projectId}`, '1'); + } else { + // The key is project-scoped and may have been left by the former + // owner's counter. The new owner's latch is authoritative after attach. + await this.env.ROUTING.delete(`ai-block:${body.projectId}`); + } + await this.saveState(state); + return Response.json({ ok: true }); + } + + private async handleDetachProject(request: Request): Promise { + const body = (await request.json()) as { + projectId: string; + userId?: string; + ownershipStartedAt?: string; + ownershipEndedAt?: string; + }; + if (!body.projectId) { + return Response.json({ error: 'projectId is required' }, { status: 400 }); + } + if ( + (body.ownershipStartedAt && !Number.isFinite(Date.parse(body.ownershipStartedAt))) || + (body.ownershipEndedAt && !Number.isFinite(Date.parse(body.ownershipEndedAt))) + ) { + return Response.json({ error: 'invalid ownership window' }, { status: 400 }); + } + const state = await this.getState(); + if (body.userId) { + state.ownerUserId = body.userId; + } + if (body.ownershipEndedAt) { + const windowKey = this.retainHistoricalAiWindow( + state, + body.projectId, + body.ownershipStartedAt ?? state.projectOwnershipStarts[body.projectId], + body.ownershipEndedAt, + ); + if (windowKey) { + await this.refreshHistoricalAiWindows(state, [windowKey]); + } + } else { + this.retainHistoricalAiSample(state, body.projectId); + } + if (state.aiSuspended) { + const ownedProjectIds = await this.ownedProjectIds( + createDb(this.env.HYPERDRIVE.connectionString), + [body.projectId], + state.ownerUserId, + ); + if (ownedProjectIds.has(body.projectId)) { + await this.env.ROUTING.delete(`ai-block:${body.projectId}`); + } + } + this.recomputeTotalNeurons(state); + await this.saveState(state); + if (Object.keys(state.historicalAiWindows).length > 0 && !(await this.ctx.storage.getAlarm())) { + await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000); + } return Response.json({ ok: true }); } @@ -448,7 +640,12 @@ export class UsageCounter extends DurableObject { state.sandboxRuntimeSuspended = false; if (state.aiSuspended) { // Keep the durable latch set until every idempotent delete succeeds. - for (const projectId of Object.keys(state.projectSlugs)) { + const ownedProjectIds = await this.ownedProjectIds( + createDb(this.env.HYPERDRIVE.connectionString), + Object.keys(state.projectSlugs), + state.ownerUserId, + ); + for (const projectId of ownedProjectIds) { await this.env.ROUTING.delete(`ai-block:${projectId}`); } state.aiSuspended = false; @@ -490,20 +687,51 @@ export class UsageCounter extends DurableObject { const state = await this.getState(); const limits = getPlanLimits(state.plan); - const projectIds = Object.keys(state.projectSlugs); + // Older persisted states do not identify their account. Wait for the next + // trusted record/rollover/init instead of risking a cross-owner KV write. + if (!state.ownerUserId) { + await this.saveState(state); + await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000); + return; + } + + const db = createDb(this.env.HYPERDRIVE.connectionString); + const ownedProjectIds = await this.ownedProjectIds( + db, + Object.keys(state.projectSlugs), + state.ownerUserId, + ); + const transferredProjectIds = Object.keys(state.projectSlugs).filter( + (projectId) => !ownedProjectIds.has(projectId), + ); + await this.retainTransferredProjectWindows(db, state, transferredProjectIds); + const projectIds = [...ownedProjectIds]; // Unlimited/no-project accounts still need bounded receipt cleanup. Keep // waking only while the scan may have retained or not-yet-visited records. - if (limits.aiNeuronsPerMonth === 0 || projectIds.length === 0) { + if ( + limits.aiNeuronsPerMonth === 0 || + (projectIds.length === 0 && Object.keys(state.historicalAiWindows).length === 0) + ) { if (await this.saveState(state)) { await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000); } return; } - // Query AI Gateway for each project's neuron usage - const since = new Date(state.billingPeriodStart); - let totalNeurons = 0; + // Re-query closed ownership windows because Analytics ingestion can lag the + // transfer transaction. Exact half-open cutoffs prevent either owner from + // being charged for the other side of the transfer boundary. + await this.refreshHistoricalAiWindows(state); + + // Query AI Gateway for each currently owned project's neuron usage. + const billingSince = new Date(state.billingPeriodStart); for (const projectId of projectIds) { + const ownershipStartedAt = state.projectOwnershipStarts[projectId]; + const ownershipSince = ownershipStartedAt ? new Date(ownershipStartedAt) : billingSince; + const since = + Number.isFinite(ownershipSince.getTime()) && ownershipSince > billingSince + ? ownershipSince + : billingSince; const result = await queryProjectAiUsage( this.env.CF_ACCOUNT_ID, this.env.CF_API_TOKEN, @@ -511,10 +739,10 @@ export class UsageCounter extends DurableObject { projectId, since, ); - totalNeurons += result.neurons; + state.projectNeurons[projectId] = result.neurons; } - state.totalNeurons = totalNeurons; + this.recomputeTotalNeurons(state); // Evaluate AI limit const exceedsAi = evaluateAiLimit(state); @@ -540,11 +768,139 @@ export class UsageCounter extends DurableObject { // --- Private helpers --- + private retainHistoricalAiSample(state: CounterState, projectId: string): void { + const billed = state.projectNeurons[projectId]; + if (typeof billed === 'number' && Number.isFinite(billed)) { + state.historicalNeurons += billed; + } + delete state.projectNeurons[projectId]; + delete state.projectOwnershipStarts[projectId]; + delete state.projectSlugs[projectId]; + } + + private retainHistoricalAiWindow( + state: CounterState, + projectId: string, + ownershipStartedAt: string | undefined, + ownershipEndedAt: string, + ): string | null { + const billingStartedAt = new Date(state.billingPeriodStart); + const startedAt = new Date(ownershipStartedAt ?? state.billingPeriodStart); + const endedAt = new Date(ownershipEndedAt); + if ( + !Number.isFinite(billingStartedAt.getTime()) || + !Number.isFinite(startedAt.getTime()) || + !Number.isFinite(endedAt.getTime()) || + endedAt <= startedAt + ) { + this.retainHistoricalAiSample(state, projectId); + return null; + } + const normalizedStart = startedAt.toISOString(); + const normalizedEnd = endedAt.toISOString(); + const key = JSON.stringify([projectId, normalizedStart, normalizedEnd]); + const existing = state.historicalAiWindows[key]; + const sampled = state.projectNeurons[projectId]; + state.historicalAiWindows[key] = { + projectId, + ownershipStartedAt: normalizedStart, + ownershipEndedAt: normalizedEnd, + neurons: + existing?.neurons ?? + (typeof sampled === 'number' && Number.isFinite(sampled) ? sampled : 0), + }; + delete state.projectNeurons[projectId]; + delete state.projectOwnershipStarts[projectId]; + delete state.projectSlugs[projectId]; + return key; + } + + private async retainTransferredProjectWindows( + db: Db, + state: CounterState, + projectIds: ReadonlyArray, + ): Promise { + if (projectIds.length === 0) { + return; + } + const periods = await getProjectOwnershipPeriodsByProjectIds(db, projectIds); + for (const projectId of projectIds) { + const expectedStart = state.projectOwnershipStarts[projectId]; + const candidates = periods + .filter( + (period) => + period.project_id === projectId && + period.owner_user_id === state.ownerUserId && + period.ends_at !== null, + ) + .sort((left, right) => Date.parse(right.ends_at!) - Date.parse(left.ends_at!)); + const period = + candidates.find( + (candidate) => + expectedStart && Date.parse(candidate.starts_at) === Date.parse(expectedStart), + ) ?? candidates[0]; + if (period?.ends_at) { + this.retainHistoricalAiWindow(state, projectId, period.starts_at, period.ends_at); + } else { + // Project deletion removes ownership history with the project. Retain + // the last sampled amount when no closed transfer window survives. + this.retainHistoricalAiSample(state, projectId); + } + } + } + + private async refreshHistoricalAiWindows( + state: CounterState, + onlyKeys?: ReadonlyArray, + ): Promise { + const billingSince = new Date(state.billingPeriodStart); + const keys = onlyKeys ?? Object.keys(state.historicalAiWindows); + for (const key of keys) { + const window = state.historicalAiWindows[key]; + if (!window) { + continue; + } + const ownershipSince = new Date(window.ownershipStartedAt); + const until = new Date(window.ownershipEndedAt); + const since = ownershipSince > billingSince ? ownershipSince : billingSince; + if (until <= since) { + window.neurons = 0; + continue; + } + const result = await queryProjectAiUsage( + this.env.CF_ACCOUNT_ID, + this.env.CF_API_TOKEN, + this.env.AI_GATEWAY_ID ?? 'void', + window.projectId, + since, + until, + true, + ); + window.neurons = result.neurons; + } + } + + private recomputeTotalNeurons(state: CounterState): void { + const current = Object.values(state.projectNeurons).reduce( + (total, neurons) => total + neurons, + 0, + ); + const historicalWindows = Object.values(state.historicalAiWindows).reduce( + (total, window) => total + window.neurons, + 0, + ); + state.totalNeurons = state.historicalNeurons + historicalWindows + current; + } + private async suspendProjects( db: Db, projectSlugs: Record, + expectedOwnerUserId: string | null, reason: string, ): Promise { + if (!expectedOwnerUserId) { + return; + } // Stamp `suspended` on each project's slug route AND its active custom-domain // routes — dispatch enforces suspension from all of them, so a slug-only // stamp left over-limit projects serving on their custom domains. @@ -554,11 +910,35 @@ export class UsageCounter extends DurableObject { // route carries `admin_suspended` and this leaves it intact (the admin // reason already blocks dispatch; downgrading it to `requests_exceeded` // would let the next admin unsuspend silently un-block an admin action). - const projects = Object.entries(projectSlugs).map(([id, slug]) => ({ id, slug })); + const ownedProjectIds = await this.ownedProjectIds( + db, + Object.keys(projectSlugs), + expectedOwnerUserId, + ); + const projects = Object.entries(projectSlugs).flatMap(([id, slug]) => + ownedProjectIds.has(id) ? [{ id, slug, user_id: expectedOwnerUserId }] : [], + ); await setProjectsRoutingSuspension(this.env.ROUTING, db, projects, { op: 'suspend', reason, force: false, }); } + + /** Prevent a stale former-owner counter from mutating transferred projects. */ + private async ownedProjectIds( + db: Db, + projectIds: ReadonlyArray, + expectedOwnerUserId: string | null, + ): Promise> { + if (!expectedOwnerUserId || projectIds.length === 0) { + return new Set(); + } + const projects = await getProjectsByIds(db, projectIds); + return new Set( + projects + .filter((project) => project.user_id === expectedOwnerUserId) + .map((project) => project.id), + ); + } } diff --git a/platform/packages/api/src/usage-queue.ts b/platform/packages/api/src/usage-queue.ts index a426f9547..8602d0e61 100644 --- a/platform/packages/api/src/usage-queue.ts +++ b/platform/packages/api/src/usage-queue.ts @@ -1,4 +1,9 @@ -import { createDb, getProjectsByIds, getUser } from './db.ts'; +import { + createDb, + getProjectOwnershipPeriodsByProjectIds, + getProjectsByIds, + getUser, +} from './db.ts'; import type { ApiEnv } from './env.ts'; import { getCurrentBillingPeriodStart, getPlanLimits } from './tiers.ts'; import { @@ -138,14 +143,55 @@ export async function processUsageEventBatch( return; } const projectsById = new Map(projects.map((project) => [project.id, project])); + let ownershipPeriodsByProject: Map< + string, + Array>[number]> + >; + try { + const periods = await getProjectOwnershipPeriodsByProjectIds( + db, + projects.map((project) => project.id), + ); + ownershipPeriodsByProject = new Map(projects.map((project) => [project.id, []])); + for (const period of periods) { + ownershipPeriodsByProject.get(period.project_id)?.push(period); + } + } catch (error) { + console.error('[usage-queue] project ownership lookup failed:', error); + batch.retryAll(); + return; + } const records = authenticatedRecords.flatMap(({ event, at }) => { const project = projectsById.get(event.projectId); if (!project) { return []; } + const occurredAt = event.sandboxMeteredThrough !== undefined ? event.sandboxMeteredThrough : at; + const ownershipPeriod = ownershipPeriodsByProject + .get(event.projectId) + ?.find( + (period) => + Date.parse(period.starts_at) <= occurredAt && + (period.ends_at === null || occurredAt < Date.parse(period.ends_at)), + ); + const ownerUserId = ownershipPeriod?.owner_user_id ?? project.user_id; // PostgreSQL owns account identity and the canonical slug. Producer - // hints are never forwarded into enforcement or alert routing. - return [{ event: { ...event, userId: project.user_id, slug: project.slug }, at }]; + // hints are never forwarded into enforcement or alert routing. Ownership + // is resolved at the event timestamp so a delayed queue message crossing + // a transfer remains billed to the correct account. + return [ + { + event: { + ...event, + userId: ownerUserId, + slug: project.slug, + currentOwner: ownershipPeriod ? ownershipPeriod.ends_at === null : true, + ownershipStartedAt: + ownershipPeriod?.starts_at ?? project.created_at ?? new Date(occurredAt).toISOString(), + }, + at, + }, + ]; }); const events = records.map((record) => record.event); diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 7f54c844c..749d77d4b 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -8,6 +8,7 @@ import { invitations, platformLoginConnections, platformLoginRevisions, + operatorEvents, projects, users, } from '../../src/schema.ts'; @@ -964,6 +965,8 @@ describe('GET /admin/projects/:id', () => { expect(html).toContain('detail-proj'); expect(html).toContain('Resources'); expect(html).toContain('Recent Deployments'); + expect(html).toContain('Transfer Ownership'); + expect(html).toContain(`/admin/projects/${project.id}/owner/preview`); }); test('shows deployment error in project detail table', async () => { @@ -1344,6 +1347,93 @@ describe('GET /admin/logout', () => { }); }); +describe('POST /admin/projects/:id/owner', () => { + test('previews owners, plans, suspension, and active-work blockers without auditing', async () => { + const admin = await createTestUser( + env.db, + 'admin-transfer-preview', + 'github', + undefined, + 'free', + 'admin', + ); + const owner = await createTestUser(env.db, 'browser-transfer-owner'); + const target = await createTestUser( + env.db, + 'browser-transfer-target', + 'github', + undefined, + 'pro', + ); + await env.db + .update(users) + .set({ suspended_reason: 'operator_review' }) + .where(eq(users.id, target.id)); + const project = await createTestProject(env.db, owner.id, 'browser-transfer-project'); + const deployment = await createTestDeployment(env.db, project.id, 'pending'); + + const response = await req(`/admin/projects/${project.id}/owner/preview`, admin.adminJwt, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ userId: target.id }), + }); + + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain(`Transfer Ownership: ${project.slug}`); + expect(html).toContain(owner.login); + expect(html).toContain(target.login); + expect(html).toContain('pro'); + expect(html).toContain('Suspended (operator_review)'); + expect(html).toContain(`deployment: ${deployment.id} (pending)`); + expect(html).toContain('Resolve or cancel all active work'); + expect(html).not.toContain(`action="/admin/projects/${project.id}/owner"`); + expect(await env.db.select().from(operatorEvents)).toEqual([]); + expect( + (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], + ).toMatchObject({ user_id: owner.id }); + }); + + test('applies the confirmed transfer, audits it, and redirects to project detail', async () => { + const admin = await createTestUser( + env.db, + 'admin-transfer-apply', + 'github', + undefined, + 'free', + 'admin', + ); + const owner = await createTestUser(env.db, 'browser-apply-owner'); + const target = await createTestUser(env.db, 'browser-apply-target'); + const project = await createTestProject(env.db, owner.id, 'browser-apply-project'); + + const response = await req(`/admin/projects/${project.id}/owner`, admin.adminJwt, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ userId: target.id }), + }); + + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe(`/admin/projects/${project.id}`); + expect(response.headers.get('X-Void-Audit-Id')).toBeTruthy(); + expect( + (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], + ).toMatchObject({ user_id: target.id }); + expect(await env.db.select().from(operatorEvents)).toEqual([ + expect.objectContaining({ + action: `projects/${project.id}/owner`, + target: JSON.stringify({ projectId: project.id, userId: target.id }), + status: 'success', + http_status: 302, + }), + ]); + }); +}); + describe('POST /admin/projects/:id/delete', () => { test('deletes project and redirects to list', async () => { mockCfApi(); diff --git a/platform/packages/api/test/integration/api-delete.test.ts b/platform/packages/api/test/integration/api-delete.test.ts index 0a7778b00..fa707cf20 100644 --- a/platform/packages/api/test/integration/api-delete.test.ts +++ b/platform/packages/api/test/integration/api-delete.test.ts @@ -12,6 +12,7 @@ import { import { createBuild } from '../../src/db.ts'; import * as db from '../../src/db.ts'; import app from '../../src/index.ts'; +import * as projectAccess from '../../src/project-access.ts'; import { resources, projects, @@ -20,6 +21,7 @@ import { emailDestinationCleanups, emailDestinations, users, + projectMembers, } from '../../src/schema.ts'; import { setupEnv, @@ -94,6 +96,53 @@ async function addResource( } describe('DELETE /projects/:id', () => { + it('rechecks delete capability under the lock after ownership transfers', async () => { + const formerOwner = await createTestUser(env.db, 'former-delete-owner'); + const newOwner = await createTestUser(env.db, 'new-delete-owner'); + const project = await createTestProject(env.db, formerOwner.id, 'transferred-delete'); + const cf = mockCfApi(); + + // Drive the transfer after the route's fast authorization snapshot but + // before teardown takes the project lock. The stale snapshot still says + // owner; the locked recheck must observe the former owner's new admin role. + const requireCapability = projectAccess.requireProjectCapability; + let transferred = false; + vi.spyOn(projectAccess, 'requireProjectCapability').mockImplementation(async (...args) => { + const result = await requireCapability(...args); + if (!transferred && args[3] === 'delete') { + transferred = true; + await env.db + .update(projects) + .set({ user_id: newOwner.id }) + .where(eq(projects.id, project.id)); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: formerOwner.id, + role: 'admin', + added_by_user_id: newOwner.id, + }); + } + return result; + }); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${formerOwner.jwt}` }, + }), + env, + ); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(transferred).toBe(true); + expect(cf).not.toHaveBeenCalled(); + expect(await db.getProject(env.db, project.id)).toMatchObject({ + user_id: newOwner.id, + status: 'active', + }); + }); + it('returns 401 without auth', async () => { const res = await app.fetch( new Request('http://localhost/projects/proj_fake', { diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index a82cd0669..48f94805b 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -17,6 +17,7 @@ import { failStaleDeployments, getDeployment, getProject, + removeProjectMember, updateDeploymentStatus, type Db, } from '../../src/db.ts'; @@ -29,6 +30,7 @@ import { domains, projects, projectMigrations, + projectMembers, projectSecrets, users, } from '../../src/schema.ts'; @@ -366,6 +368,127 @@ describe('POST /projects/:id/deploy', () => { expect(await res.json()).toEqual({ error: 'forbidden' }); }); + it('denies readers and bills collaborator deploys to the project owner', async () => { + const owner = await createTestUser(env.db, 'team-owner'); + const reader = await createTestUser(env.db, 'team-reader'); + const collaborator = await createTestUser( + env.db, + 'team-collaborator', + 'github', + undefined, + 'custom', + ); + const project = await createTestProject(env.db, owner.id, 'team-deploy'); + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }, + ]); + mockCfApi(); + + const readerResponse = await app.fetch( + await buildDeployRequest(env, project.id, reader.jwt), + env, + ); + expect(readerResponse.status).toBe(403); + + const collaboratorResponse = await app.fetch( + await buildDeployRequest(env, project.id, collaborator.jwt), + env, + ); + expect(collaboratorResponse.status).toBe(200); + const done = requireEvent(await readAllEvents(collaboratorResponse), 'done'); + const deployment = await getDeployment(env.db, done.deployment); + expect(deployment).toMatchObject({ + billing_user_id: owner.id, + initiated_by_user_id: collaborator.id, + status: 'live', + }); + const routing = (await env.ROUTING.get(project.slug, 'json')) as { userId: string }; + expect(routing.userId).toBe(owner.id); + }); + + it("uses the owner's plan rather than a collaborator's plan", async () => { + const owner = await createTestUser(env.db, 'limited-owner'); + const collaborator = await createTestUser( + env.db, + 'unlimited-collaborator', + 'github', + undefined, + 'custom', + ); + const project = await createTestProject(env.db, owner.id, 'owner-plan-deploy'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + mockCfApi(); + + const response = await app.fetch( + await buildDeployRequest(env, project.id, collaborator.jwt, { + manifest: { version: 3, bindings: {}, limits: { cpuMs: 60_000 } }, + }), + env, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + message: 'limits.cpuMs 60000 exceeds your plan maximum of 10', + }); + }); + + it('revokes a collaborator before final publication even with an already-started deploy', async () => { + const owner = await createTestUser(env.db, 'final-owner'); + const collaborator = await createTestUser(env.db, 'final-collaborator'); + const project = await createTestProject(env.db, owner.id, 'final-revocation'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + mockCfApi(); + + const originalPut = env.ROUTING.put.bind(env.ROUTING); + let revoked = false; + vi.spyOn(env.ROUTING, 'put').mockImplementation(async (key, value, options) => { + await originalPut(key, value, options); + if (!revoked && key.startsWith('cutover-ready:')) { + revoked = true; + await removeProjectMember(env.db, { + projectId: project.id, + subjectUserId: collaborator.id, + actorUserId: owner.id, + }); + } + }); + + const response = await app.fetch( + await buildDeployRequest(env, project.id, collaborator.jwt), + env, + ); + expect(response.status).toBe(200); + const events = await readAllEvents(response); + expect(events.some((event) => isDeployEvent(event) && event.event === 'done')).toBe(false); + expect(requireEvent(events, 'error').message).toBe('forbidden'); + expect(revoked).toBe(true); + const [deployment] = await env.db + .select({ status: deployments.status }) + .from(deployments) + .where(eq(deployments.project_id, project.id)); + expect(deployment?.status).not.toBe('live'); + }); + it('succeeds with minimal deploy (no bindings, no assets)', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'minimal-app'); diff --git a/platform/packages/api/test/integration/api-domains.test.ts b/platform/packages/api/test/integration/api-domains.test.ts index 14e305b69..93de15e26 100644 --- a/platform/packages/api/test/integration/api-domains.test.ts +++ b/platform/packages/api/test/integration/api-domains.test.ts @@ -17,7 +17,7 @@ import { import type { ApiEnv } from '../../src/env.ts'; import app, { scheduled, cleanupStalePendingDomains, FAST_CRON } from '../../src/index.ts'; import { tryActivateDomain } from '../../src/provisioning/activate-domain.ts'; -import { domains, users } from '../../src/schema.ts'; +import { domains, projectMembers, users } from '../../src/schema.ts'; import type { Domain, Project } from '../../src/types.ts'; import { setupEnv, @@ -157,6 +157,60 @@ describe('POST /projects/:id/domains', () => { expect(cfBody).not.toHaveProperty('custom_metadata'); }); + it('rolls back the CF hostname when an admin is downgraded before locked persistence', async () => { + const owner = await createTestUser(env.db, 'domain-race-owner'); + const admin = await createTestUser(env.db, 'domain-race-admin'); + const project = await createTestProject(env.db, owner.id, 'domain-access-race'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }); + const fetchMock = mockCfApi({ + createCustomHostname: async () => { + // The unlocked route check already granted settings. The downgrade + // wins before the final project lock, so the CF first phase must be + // compensated and no domain row may become durable. + await env.db + .update(projectMembers) + .set({ role: 'reader' }) + .where(eq(projectMembers.user_id, admin.id)); + return cfSuccess({ + id: 'ch-access-race', + hostname: 'access-race.example.com', + status: 'pending', + ssl: { status: 'pending_validation', method: 'http', type: 'dv' }, + }); + }, + }); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}/domains`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${admin.jwt}`, + }, + body: JSON.stringify({ hostname: 'access-race.example.com' }), + }), + env, + ); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'forbidden' }); + expect( + await env.db.select().from(domains).where(eq(domains.hostname, 'access-race.example.com')), + ).toEqual([]); + expect( + fetchMock.mock.calls.filter( + ([url, init]) => + String(url).includes('/custom_hostnames/ch-access-race') && + (init as RequestInit | undefined)?.method === 'DELETE', + ), + ).toHaveLength(1); + }); + it('returns 404 for non-existent project', async () => { const user = await createTestUser(env.db); mockCfApi(); @@ -479,9 +533,8 @@ describe('POST /projects/:id/domains', () => { env, ); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('Internal Server Error'); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'hostname already in use' }); const deletes = fetchMock.mock.calls.filter((call: Array) => { const url = typeof call[0] === 'string' ? call[0] : String(call[0]); diff --git a/platform/packages/api/test/integration/api-github.test.ts b/platform/packages/api/test/integration/api-github.test.ts index f4b472729..5beb4b4c0 100644 --- a/platform/packages/api/test/integration/api-github.test.ts +++ b/platform/packages/api/test/integration/api-github.test.ts @@ -24,7 +24,10 @@ import { getGitHubInstallationMembers, getInstallationRevocationAt, getProjectBuilds, + hasGitHubConnectionAuthority, recordInstallationRevocations, + removeProjectMember, + setProjectMemberRole, upsertGitHubInstallation, upsertGitHubInstallationMember, } from '../../src/db.ts'; @@ -40,6 +43,7 @@ import { githubInstallationMembers, githubInstallationRevocations, oauthAccounts, + projectMembers, } from '../../src/schema.ts'; import { JWT_SECRET, @@ -458,6 +462,106 @@ function mockEmptyInstallAccount(opts: { } describe('POST /projects/:id/github', () => { + it('lets project admins manage connections while readers and collaborators can only read them', async () => { + const owner = await createTestUser(env.db, 'team-github-owner'); + const admin = await createTestUser(env.db, 'team-github-admin'); + const collaborator = await createTestUser(env.db, 'team-github-collaborator'); + const reader = await createTestUser(env.db, 'team-github-reader'); + const project = await createTestProject(env.db, owner.id, 'team-github'); + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }, + ]); + await upsertGitHubInstallation(env.db, { + installationId: 'team-111', + userId: admin.id, + accountLogin: admin.login, + accountId: 'team-admin-account', + accountType: 'User', + repositories: [{ full_name: `${admin.login}/app`, id: 9001 }], + }); + + const connect = await app.fetch( + new Request(`http://localhost/projects/${project.id}/github`, { + method: 'POST', + headers: { Authorization: `Bearer ${admin.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + installationId: 'team-111', + repoFullName: `${admin.login}/app`, + }), + }), + env, + ); + expect(connect.status).toBe(200); + expect((await getGitHubConnection(env.db, project.id))?.authorized_by_user_id).toBe(admin.id); + + for (const user of [reader, collaborator]) { + const read = await app.fetch( + new Request(`http://localhost/projects/${project.id}/github`, authed(user.jwt)), + env, + ); + expect(read.status).toBe(200); + } + + const collaboratorPatch = await app.fetch( + new Request(`http://localhost/projects/${project.id}/github`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${collaborator.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ branch: 'blocked' }), + }), + env, + ); + expect(collaboratorPatch.status).toBe(403); + + const adminPatch = await app.fetch( + new Request(`http://localhost/projects/${project.id}/github`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${admin.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch: 'release' }), + }), + env, + ); + expect(adminPatch.status).toBe(200); + + const readerDelete = await app.fetch( + new Request(`http://localhost/projects/${project.id}/github`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${reader.jwt}` }, + }), + env, + ); + expect(readerDelete.status).toBe(403); + + const adminDelete = await app.fetch( + new Request(`http://localhost/projects/${project.id}/github`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${admin.jwt}` }, + }), + env, + ); + expect(adminDelete.status).toBe(200); + expect(await getGitHubConnection(env.db, project.id)).toBeNull(); + }); + it('connects a project to a repo within the caller installation', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'gh-app'); @@ -973,6 +1077,101 @@ describe('POST /projects/:id/github', () => { }); }); +describe('GitHub connection project-admin provenance', () => { + it('invalidates the connection and active builds when its authorizer loses admin access', async () => { + const owner = await createTestUser(env.db, 'provenance-owner'); + const admin = await createTestUser(env.db, 'provenance-admin'); + const project = await createTestProject(env.db, owner.id, 'provenance-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }); + await upsertGitHubInstallation(env.db, { + installationId: 'provenance-install', + userId: admin.id, + accountLogin: admin.login, + accountId: 'provenance-account', + accountType: 'User', + repositories: [{ full_name: `${admin.login}/app`, id: 9101 }], + }); + const connection = await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'provenance-install', + repoFullName: `${admin.login}/app`, + repositoryId: '9101', + authorizedByUserId: admin.id, + }); + const build = await createBuild(env.db, { + projectId: project.id, + userId: owner.id, + executor: 'container', + status: 'building', + }); + expect(await hasGitHubConnectionAuthority(env.db, connection.id, project.id)).toBe(true); + + await setProjectMemberRole(env.db, { + projectId: project.id, + subjectUserId: admin.id, + role: 'collaborator', + actorUserId: owner.id, + }); + + expect(await getGitHubConnection(env.db, project.id)).toBeNull(); + expect((await getBuild(env.db, build.id))?.status).toBe('cancelled'); + }); + + it('keeps a connection reauthorized by another current admin', async () => { + const owner = await createTestUser(env.db, 'reauthorize-owner'); + const firstAdmin = await createTestUser(env.db, 'reauthorize-first'); + const secondAdmin = await createTestUser(env.db, 'reauthorize-second'); + const project = await createTestProject(env.db, owner.id, 'reauthorize-project'); + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: firstAdmin.id, + role: 'admin', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: secondAdmin.id, + role: 'admin', + added_by_user_id: owner.id, + }, + ]); + await upsertGitHubInstallation(env.db, { + installationId: 'reauthorize-install', + userId: secondAdmin.id, + accountLogin: secondAdmin.login, + accountId: 'reauthorize-account', + accountType: 'User', + repositories: [{ full_name: `${secondAdmin.login}/app`, id: 9201 }], + }); + const connection = await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'reauthorize-install', + repoFullName: `${secondAdmin.login}/app`, + repositoryId: '9201', + authorizedByUserId: firstAdmin.id, + }); + await env.db + .update(githubConnections) + .set({ authorized_by_user_id: secondAdmin.id }) + .where(eq(githubConnections.id, connection.id)); + + await removeProjectMember(env.db, { + projectId: project.id, + subjectUserId: firstAdmin.id, + actorUserId: owner.id, + }); + + expect((await getGitHubConnection(env.db, project.id))?.id).toBe(connection.id); + expect(await hasGitHubConnectionAuthority(env.db, connection.id, project.id)).toBe(true); + }); +}); + // Org-shared installs (RFC 0074): a non-owner ORG MEMBER connects THEIR project // to a repo in an install owned by someone else, by redeeming a per-repo // `connect_grant` minted under the member's OWN GitHub token (Task 7). The grant's diff --git a/platform/packages/api/test/integration/api-migrations.test.ts b/platform/packages/api/test/integration/api-migrations.test.ts index 4329101ff..71d911d2d 100644 --- a/platform/packages/api/test/integration/api-migrations.test.ts +++ b/platform/packages/api/test/integration/api-migrations.test.ts @@ -6229,14 +6229,15 @@ describe('POST /projects/:id/migrations/rename', () => { expect(source).not.toMatch(/hashtext\s*\(/); // No `pg_advisory_xact_lock` call remains. expect(source).not.toMatch(/pg_advisory_xact_lock/); - // Sanity: the row lock variant is present at the three call - // sites (/apply, /rename, /rollback). + // Sanity: the row lock variant is present for migrations, rollback, and + // the database provisioning mutations that share this route module. const rowLockMatches = source.match( /SELECT id FROM projects WHERE id = \$\{projectId\} FOR UPDATE/g, ); expect(rowLockMatches).not.toBeNull(); - // Apply, rename, rollback lease/cutover, and rollback's post-transaction - // compensation all use the collision-free project row lock. - expect(rowLockMatches!.length).toBe(5); + // Apply, rename, rollback lease/cutover, rollback's post-transaction + // compensation, database create, and database URL update all use the + // collision-free project row lock. + expect(rowLockMatches!.length).toBe(7); }); }); diff --git a/platform/packages/api/test/integration/api-project-access-routes.test.ts b/platform/packages/api/test/integration/api-project-access-routes.test.ts new file mode 100644 index 000000000..31a7a4a9a --- /dev/null +++ b/platform/packages/api/test/integration/api-project-access-routes.test.ts @@ -0,0 +1,202 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { verifyPrerenderToken } from '../../src/auth/deploy-token.ts'; +import app from '../../src/index.ts'; +import { projectMembers, projectOwnershipPeriods, users } from '../../src/schema.ts'; +import { + JWT_SECRET, + clearTables, + createTestProject, + createTestUser, + mockCfApi, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function authed(path: string, jwt: string, method = 'GET'): Request { + return new Request(`http://localhost${path}`, { + method, + headers: { Authorization: `Bearer ${jwt}` }, + }); +} + +describe('project route membership capabilities', () => { + it('lists shared projects with role and owner and grants readers view routes', async () => { + const owner = await createTestUser(env.db, 'owner'); + const reader = await createTestUser(env.db, 'reader'); + const project = await createTestProject(env.db, owner.id, 'shared-reader-app'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }); + mockCfApi(); + + const listResponse = await app.fetch(authed('/projects', reader.jwt), env); + expect(listResponse.status).toBe(200); + expect(await listResponse.json()).toMatchObject([ + { + id: project.id, + slug: project.slug, + role: 'reader', + owner: { id: owner.id, login: 'owner' }, + }, + ]); + + const detail = await app.fetch(authed(`/projects/${project.id}`, reader.jwt), env); + expect(detail.status).toBe(200); + expect(await detail.json()).toMatchObject({ + id: project.id, + role: 'reader', + owner: { id: owner.id }, + }); + + const resources = await app.fetch(authed(`/projects/${project.id}/resources`, reader.jwt), env); + expect(resources.status).toBe(200); + expect(await resources.json()).toEqual([]); + + const secrets = await app.fetch(authed(`/projects/${project.id}/secrets`, reader.jwt), env); + expect(secrets.status).toBe(403); + const deletion = await app.fetch(authed(`/projects/${project.id}`, reader.jwt, 'DELETE'), env); + expect(deletion.status).toBe(403); + }); + + it('allows collaborators to use deploy prerequisites under the owner account', async () => { + const owner = await createTestUser(env.db, 'paid-owner', 'github', undefined, 'pro'); + const collaborator = await createTestUser(env.db, 'collaborator'); + const project = await createTestProject(env.db, owner.id, 'shared-deploy-app'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + + const secrets = await app.fetch( + authed(`/projects/${project.id}/secrets`, collaborator.jwt), + env, + ); + expect(secrets.status).toBe(200); + + const tokenResponse = await app.fetch( + authed(`/projects/${project.id}/prerender-token`, collaborator.jwt, 'POST'), + env, + ); + expect(tokenResponse.status).toBe(200); + const { token } = (await tokenResponse.json()) as { token: string }; + const payload = await verifyPrerenderToken(token, JWT_SECRET); + expect(payload).toMatchObject({ + sub: collaborator.id, + login: collaborator.login, + project: project.id, + aud: 'prerender', + }); + }); +}); + +describe('ownership-period account usage', () => { + it('queries only the former owner interval that overlaps the billing window', async () => { + const formerOwner = await createTestUser(env.db, 'former-owner'); + const currentOwner = await createTestUser(env.db, 'current-owner'); + const project = await createTestProject(env.db, currentOwner.id, 'transferred-app'); + const now = Date.now(); + const billingStart = new Date(now - 10 * 24 * 60 * 60 * 1_000); + const ownedFrom = new Date(now - 8 * 24 * 60 * 60 * 1_000); + const transferredAt = new Date(now - 2 * 24 * 60 * 60 * 1_000); + await env.db + .update(users) + .set({ billing_cycle_start: billingStart.toISOString() }) + .where(eq(users.id, formerOwner.id)); + await env.db.insert(projectOwnershipPeriods).values([ + { + id: 'pown_former', + project_id: project.id, + owner_user_id: formerOwner.id, + starts_at: ownedFrom.toISOString(), + ends_at: transferredAt.toISOString(), + }, + { + id: 'pown_current', + project_id: project.id, + owner_user_id: currentOwner.id, + starts_at: transferredAt.toISOString(), + }, + ]); + + const analyticsQueries: Array = []; + const graphqlQueries: Array = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.includes('/analytics_engine/sql')) { + analyticsQueries.push(String(init?.body)); + return Response.json({ + data: [{ blob1: project.id, requests: 12, bandwidth_bytes: 34 }], + }); + } + if (url.includes('/graphql')) { + const body = JSON.parse(String(init?.body)) as { query: string }; + graphqlQueries.push(body.query); + return Response.json({ + data: { + viewer: { + accounts: [{ aiGatewayRequests: [{ sum: { cost: 0.000022 } }] }], + }, + }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }), + ); + + const response = await app.fetch(authed('/usage', formerOwner.jwt), env); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + requests: 12, + bandwidthBytes: 34, + aiNeurons: 2, + projects: [{ projectId: project.id, slug: project.slug, aiNeurons: 2 }], + }); + expect(analyticsQueries).toHaveLength(1); + expect(analyticsQueries[0]).toContain(ownedFrom.toISOString().replace('T', ' ').slice(0, 19)); + expect(analyticsQueries[0]).toContain( + transferredAt.toISOString().replace('T', ' ').slice(0, 19), + ); + expect(graphqlQueries).toHaveLength(1); + expect(graphqlQueries[0]).toContain(`datetime_geq: "${ownedFrom.toISOString()}"`); + expect(graphqlQueries[0]).toContain(`datetime_lt: "${transferredAt.toISOString()}"`); + }); + + it('keeps current-owner usage for legacy projects without ownership periods', async () => { + const owner = await createTestUser(env.db, 'legacy-owner'); + const project = await createTestProject(env.db, owner.id, 'legacy-app'); + const response = await app.fetch(authed('/usage', owner.jwt), { + ...env, + DEV_AUTH_BYPASS: 'true', + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + projects: [{ projectId: project.id, slug: project.slug }], + }); + }); +}); diff --git a/platform/packages/api/test/integration/api-project-team.test.ts b/platform/packages/api/test/integration/api-project-team.test.ts new file mode 100644 index 000000000..2332d9001 --- /dev/null +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -0,0 +1,551 @@ +import { and, eq } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import app from '../../src/index.ts'; +import { + projectAccessEvents, + projectInvitations, + projectMembers, + projects, + users, +} from '../../src/schema.ts'; +import { + clearTables, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); +}); + +function request( + path: string, + jwt: string, + init: { method?: string; body?: Record } = {}, +): Request { + return new Request(`http://localhost${path}`, { + method: init.method ?? 'GET', + headers: { + Authorization: `Bearer ${jwt}`, + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + }, + body: init.body ? JSON.stringify(init.body) : undefined, + }); +} + +async function setEmail(userId: string, email: string): Promise { + await env.db.update(users).set({ email }).where(eq(users.id, userId)); +} + +async function invite( + projectId: string, + ownerJwt: string, + email: string, + role: 'reader' | 'collaborator' | 'admin', +): Promise<{ id: string; refreshed: boolean; delivery: { status: string } }> { + const response = await app.fetch( + request(`/projects/${projectId}/invitations`, ownerJwt, { + method: 'POST', + body: { email, role }, + }), + env, + ); + expect([200, 201]).toContain(response.status); + const body = (await response.json()) as { + invitation: { id: string }; + refreshed: boolean; + delivery: { status: string }; + }; + return { id: body.invitation.id, refreshed: body.refreshed, delivery: body.delivery }; +} + +describe('project team API', () => { + it('lists the implicit owner and lets every member view the roster', async () => { + const owner = await createTestUser(env.db, 'owner'); + const reader = await createTestUser(env.db, 'reader'); + await setEmail(owner.id, 'owner@example.com'); + await setEmail(reader.id, 'reader@example.com'); + const project = await createTestProject(env.db, owner.id); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }); + + const response = await app.fetch(request(`/projects/${project.id}/members`, reader.jwt), env); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + members: [ + { user: { id: owner.id, login: 'owner' }, role: 'owner' }, + { user: { id: reader.id, login: 'reader' }, role: 'reader' }, + ], + }); + + const forbidden = await app.fetch( + request(`/projects/${project.id}/invitations`, reader.jwt), + env, + ); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: 'forbidden' }); + }); + + it('requires an existing registered user and supports target-only acceptance', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + const bystander = await createTestUser(env.db, 'bystander'); + await setEmail(owner.id, 'owner@example.com'); + await setEmail(target.id, 'Target@Example.com'); + await setEmail(bystander.id, 'bystander@example.com'); + const project = await createTestProject(env.db, owner.id, 'shared-app'); + + const unknown = await app.fetch( + request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { email: 'missing@example.com', role: 'reader' }, + }), + env, + ); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toEqual({ error: 'registered user not found' }); + + const created = await invite(project.id, owner.jwt, 'target@example.com', 'collaborator'); + expect(created.delivery).toEqual({ status: 'not_configured' }); + + const pending = await app.fetch(request('/me/project-invitations', target.jwt), env); + expect(pending.status).toBe(200); + expect(await pending.json()).toMatchObject({ + invitations: [ + { + id: created.id, + project: { id: project.id, slug: 'shared-app', owner: { id: owner.id } }, + invited_by: { id: owner.id }, + role: 'collaborator', + }, + ], + }); + + const forwarded = await app.fetch( + request(`/project-invitations/${created.id}/accept`, bystander.jwt, { method: 'POST' }), + env, + ); + expect(forwarded.status).toBe(403); + + const accepted = await app.fetch( + request(`/project-invitations/${created.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toMatchObject({ + member: { user: { id: target.id }, role: 'collaborator' }, + }); + + const acceptedAgain = await app.fetch( + request(`/project-invitations/${created.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(acceptedAgain.status).toBe(200); + + const events = await env.db + .select() + .from(projectAccessEvents) + .where(eq(projectAccessEvents.project_id, project.id)); + expect(events.map((event) => event.action)).toEqual(['invited', 'accepted']); + }); + + it('binds invitations to exactly one registered account and rejects owner/member conflicts', async () => { + const owner = await createTestUser(env.db, 'owner'); + const member = await createTestUser(env.db, 'member'); + const duplicateA = await createTestUser(env.db, 'duplicate-a'); + const duplicateB = await createTestUser(env.db, 'duplicate-b'); + await setEmail(owner.id, 'owner@example.com'); + await setEmail(member.id, 'member@example.com'); + await setEmail(duplicateA.id, 'duplicate@example.com'); + await setEmail(duplicateB.id, 'duplicate@example.com'); + const project = await createTestProject(env.db, owner.id); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: member.id, + role: 'reader', + added_by_user_id: owner.id, + }); + + const inviteOwner = await app.fetch( + request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { email: 'OWNER@example.com', role: 'admin' }, + }), + env, + ); + expect(inviteOwner.status).toBe(409); + expect(await inviteOwner.json()).toEqual({ + error: 'the project owner cannot be invited or changed as a member', + }); + + const inviteMember = await app.fetch( + request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { email: 'member@example.com', role: 'admin' }, + }), + env, + ); + expect(inviteMember.status).toBe(409); + expect(await inviteMember.json()).toEqual({ error: 'user is already a project member' }); + + const ambiguous = await app.fetch( + request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { email: 'duplicate@example.com', role: 'reader' }, + }), + env, + ); + expect(ambiguous.status).toBe(404); + expect(await ambiguous.json()).toEqual({ error: 'registered user not found' }); + expect(await env.db.select().from(projectInvitations)).toEqual([]); + }); + + it('serializes concurrent acceptance and records one membership and event', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + await setEmail(target.id, 'target@example.com'); + const project = await createTestProject(env.db, owner.id); + const invitation = await invite(project.id, owner.jwt, 'target@example.com', 'collaborator'); + + const responses = await Promise.all([ + app.fetch( + request(`/project-invitations/${invitation.id}/accept`, target.jwt, { method: 'POST' }), + env, + ), + app.fetch( + request(`/project-invitations/${invitation.id}/accept`, target.jwt, { method: 'POST' }), + env, + ), + ]); + + expect(responses.map((response) => response.status)).toEqual([200, 200]); + expect( + await env.db + .select() + .from(projectMembers) + .where( + and(eq(projectMembers.project_id, project.id), eq(projectMembers.user_id, target.id)), + ), + ).toHaveLength(1); + const events = await env.db + .select() + .from(projectAccessEvents) + .where(eq(projectAccessEvents.project_id, project.id)); + expect(events.map((event) => event.action)).toEqual(['invited', 'accepted']); + }); + + it('does not let an accepted invitation recreate access after member removal', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + await setEmail(target.id, 'target@example.com'); + const project = await createTestProject(env.db, owner.id); + const invitation = await invite(project.id, owner.jwt, 'target@example.com', 'reader'); + + expect( + ( + await app.fetch( + request(`/project-invitations/${invitation.id}/accept`, target.jwt, { method: 'POST' }), + env, + ) + ).status, + ).toBe(200); + expect( + ( + await app.fetch( + request(`/projects/${project.id}/members/${target.id}`, owner.jwt, { + method: 'DELETE', + }), + env, + ) + ).status, + ).toBe(204); + + const replay = await app.fetch( + request(`/project-invitations/${invitation.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(replay.status).toBe(409); + expect(await replay.json()).toEqual({ error: 'project invitation is no longer pending' }); + expect( + await env.db + .select() + .from(projectMembers) + .where( + and(eq(projectMembers.project_id, project.id), eq(projectMembers.user_id, target.id)), + ), + ).toEqual([]); + + const fresh = await invite(project.id, owner.jwt, 'target@example.com', 'admin'); + expect(fresh.id).not.toBe(invitation.id); + const acceptedFresh = await app.fetch( + request(`/project-invitations/${fresh.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(acceptedFresh.status).toBe(200); + expect(await acceptedFresh.json()).toMatchObject({ member: { role: 'admin' } }); + }); + + it('rejects forwarded response and cross-project management IDs without consuming the invite', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + const bystander = await createTestUser(env.db, 'bystander'); + await setEmail(target.id, 'target@example.com'); + const source = await createTestProject(env.db, owner.id, 'source-project'); + const other = await createTestProject(env.db, owner.id, 'other-project'); + const invitation = await invite(source.id, owner.jwt, 'target@example.com', 'reader'); + + for (const action of ['accept', 'decline']) { + const response = await app.fetch( + request(`/project-invitations/${invitation.id}/${action}`, bystander.jwt, { + method: 'POST', + }), + env, + ); + expect(response.status).toBe(403); + } + const crossProjectResend = await app.fetch( + request(`/projects/${other.id}/invitations/${invitation.id}/resend`, owner.jwt, { + method: 'POST', + }), + env, + ); + expect(crossProjectResend.status).toBe(404); + const crossProjectRevoke = await app.fetch( + request(`/projects/${other.id}/invitations/${invitation.id}`, owner.jwt, { + method: 'DELETE', + }), + env, + ); + expect(crossProjectRevoke.status).toBe(404); + + const targetAccept = await app.fetch( + request(`/project-invitations/${invitation.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(targetAccept.status).toBe(200); + }); + + it('refreshes, resends to the current account email, and revokes invitations', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + await setEmail(owner.id, 'owner@example.com'); + await setEmail(target.id, 'old@example.com'); + const project = await createTestProject(env.db, owner.id); + + const first = await invite(project.id, owner.jwt, 'old@example.com', 'reader'); + const refreshed = await invite(project.id, owner.jwt, 'OLD@example.com', 'admin'); + expect(refreshed).toMatchObject({ id: first.id, refreshed: true }); + + await setEmail(target.id, 'new@example.com'); + const resentResponse = await app.fetch( + request(`/projects/${project.id}/invitations/${first.id}/resend`, owner.jwt, { + method: 'POST', + }), + env, + ); + expect(resentResponse.status).toBe(200); + expect(await resentResponse.json()).toMatchObject({ + invitation: { + id: first.id, + target: { id: target.id, email: 'new@example.com' }, + target_email: 'new@example.com', + role: 'admin', + }, + delivery: { status: 'not_configured' }, + }); + + const revoked = await app.fetch( + request(`/projects/${project.id}/invitations/${first.id}`, owner.jwt, { + method: 'DELETE', + }), + env, + ); + expect(revoked.status).toBe(204); + + const acceptRevoked = await app.fetch( + request(`/project-invitations/${first.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(acceptRevoked.status).toBe(409); + }); + + it('lets project admins manage any non-owner member and members leave', async () => { + const owner = await createTestUser(env.db, 'owner'); + const admin = await createTestUser(env.db, 'admin'); + const collaborator = await createTestUser(env.db, 'collaborator'); + const peerAdmin = await createTestUser(env.db, 'peer-admin'); + const project = await createTestProject(env.db, owner.id); + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: peerAdmin.id, + role: 'admin', + added_by_user_id: owner.id, + }, + ]); + + const changed = await app.fetch( + request(`/projects/${project.id}/members/${collaborator.id}`, admin.jwt, { + method: 'PATCH', + body: { role: 'admin' }, + }), + env, + ); + expect(changed.status).toBe(200); + expect(await changed.json()).toMatchObject({ member: { role: 'admin' } }); + + const removedPeerAdmin = await app.fetch( + request(`/projects/${project.id}/members/${peerAdmin.id}`, admin.jwt, { + method: 'DELETE', + }), + env, + ); + expect(removedPeerAdmin.status).toBe(204); + + const ownerConflict = await app.fetch( + request(`/projects/${project.id}/members/${owner.id}`, admin.jwt, { + method: 'PATCH', + body: { role: 'reader' }, + }), + env, + ); + expect(ownerConflict.status).toBe(409); + + const ownerRemovalConflict = await app.fetch( + request(`/projects/${project.id}/members/${owner.id}`, admin.jwt, { + method: 'DELETE', + }), + env, + ); + expect(ownerRemovalConflict.status).toBe(409); + + const leave = await app.fetch( + request(`/projects/${project.id}/leave`, collaborator.jwt, { method: 'POST' }), + env, + ); + expect(leave.status).toBe(204); + const [remaining] = await env.db + .select() + .from(projectMembers) + .where( + and(eq(projectMembers.project_id, project.id), eq(projectMembers.user_id, collaborator.id)), + ); + expect(remaining).toBeUndefined(); + + const ownerLeave = await app.fetch( + request(`/projects/${project.id}/leave`, owner.jwt, { method: 'POST' }), + env, + ); + expect(ownerLeave.status).toBe(409); + }); + + it('filters expired invitations and allows the target to decline a live invitation', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + await setEmail(target.id, 'target@example.com'); + const project = await createTestProject(env.db, owner.id); + const expired = await invite(project.id, owner.jwt, 'target@example.com', 'reader'); + await env.db + .update(projectInvitations) + .set({ expires_at: new Date(Date.now() - 1_000).toISOString() }) + .where(eq(projectInvitations.id, expired.id)); + + const pending = await app.fetch(request('/me/project-invitations', target.jwt), env); + expect(await pending.json()).toEqual({ invitations: [] }); + + const acceptExpired = await app.fetch( + request(`/project-invitations/${expired.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(acceptExpired.status).toBe(409); + expect(await acceptExpired.json()).toEqual({ error: 'project invitation has expired' }); + expect( + ( + await env.db.select().from(projectInvitations).where(eq(projectInvitations.id, expired.id)) + )[0], + ).toMatchObject({ status: 'expired' }); + + const live = await invite(project.id, owner.jwt, 'target@example.com', 'reader'); + expect(live.id).not.toBe(expired.id); + const declined = await app.fetch( + request(`/project-invitations/${live.id}/decline`, target.jwt, { method: 'POST' }), + env, + ); + expect(declined.status).toBe(204); + const declinedAgain = await app.fetch( + request(`/project-invitations/${live.id}/decline`, target.jwt, { method: 'POST' }), + env, + ); + expect(declinedAgain.status).toBe(204); + }); + + it('refreshes an expired pending invitation instead of consuming another pending slot', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + await setEmail(target.id, 'target@example.com'); + const project = await createTestProject(env.db, owner.id); + const expiredPending = await invite(project.id, owner.jwt, 'target@example.com', 'reader'); + await env.db + .update(projectInvitations) + .set({ expires_at: new Date(Date.now() - 1_000).toISOString() }) + .where(eq(projectInvitations.id, expiredPending.id)); + + const refreshed = await invite(project.id, owner.jwt, 'target@example.com', 'admin'); + expect(refreshed).toMatchObject({ id: expiredPending.id, refreshed: true }); + const [row] = await env.db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.id, expiredPending.id)); + expect(row).toMatchObject({ status: 'pending', role: 'admin' }); + expect(Date.parse(row.expires_at)).toBeGreaterThan(Date.now()); + expect(await env.db.select().from(projectInvitations)).toHaveLength(1); + }); + + it('rejects acceptance when the invited account has become the project owner', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + await setEmail(target.id, 'target@example.com'); + const project = await createTestProject(env.db, owner.id); + const invitation = await invite(project.id, owner.jwt, 'target@example.com', 'reader'); + await env.db.update(projects).set({ user_id: target.id }).where(eq(projects.id, project.id)); + + const response = await app.fetch( + request(`/project-invitations/${invitation.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: 'the project owner cannot be invited or changed as a member', + }); + expect(await env.db.select().from(projectMembers)).toEqual([]); + }); +}); diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index ec9c33d5a..4a2389458 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -18,12 +18,14 @@ import { } from '../../src/db.ts'; import * as db from '../../src/db.ts'; import app from '../../src/index.ts'; +import * as projectAccess from '../../src/project-access.ts'; import { users } from '../../src/schema.ts'; import { deployments, domains, emailDestinations, projects, + projectMembers, resources, projectDatabaseCredentials, projectDeployCredentials, @@ -584,6 +586,79 @@ describe('POST /projects/:id/prerender-token', () => { }); describe('PUT /projects/:id/database-url', () => { + it.each([ + { method: 'POST', suffix: 'database' }, + { method: 'PUT', suffix: 'database-url' }, + ] as const)( + 'rechecks configure capability under the lock for $method /$suffix', + async ({ method, suffix }) => { + const owner = await createTestUser(env.db, `db-race-owner-${method.toLowerCase()}`); + const collaborator = await createTestUser( + env.db, + `db-race-collaborator-${method.toLowerCase()}`, + ); + const project = await createTestProject( + env.db, + owner.id, + `db-access-race-${method.toLowerCase()}`, + ); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + const cf = mockCfApi(); + + // Return the route's first access snapshot, then commit the downgrade. + // The handler must re-resolve the collaborator under the project lock + // before either creating or updating a live Hyperdrive. + const requireCapability = projectAccess.requireProjectCapability; + let downgraded = false; + vi.spyOn(projectAccess, 'requireProjectCapability').mockImplementation(async (...args) => { + const result = await requireCapability(...args); + if (!downgraded && args[3] === 'configure') { + downgraded = true; + await env.db + .update(projectMembers) + .set({ role: 'reader' }) + .where(eq(projectMembers.user_id, collaborator.id)); + } + return result; + }); + + const requestUrl = `http://localhost/projects/${project.id}/${suffix}`; + const requestOptions = { + headers: { + Authorization: `Bearer ${collaborator.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + connectionString: 'postgresql://void_user:pass@db.example.com/appdb', + }), + }; + const request = + method === 'POST' + ? new Request(requestUrl, { method: 'POST', ...requestOptions }) + : new Request(requestUrl, { method: 'PUT', ...requestOptions }); + const res = await app.fetch(request, env); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(downgraded).toBe(true); + expect(cf).not.toHaveBeenCalled(); + expect( + await env.db.select().from(resources).where(eq(resources.project_id, project.id)), + ).toEqual([]); + expect( + await env.db + .select() + .from(projectDatabaseCredentials) + .where(eq(projectDatabaseCredentials.project_id, project.id)), + ).toEqual([]); + }, + ); + it('provisions Hyperdrive with a Cloudflare-safe name', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'pg-app-post'); diff --git a/platform/packages/api/test/integration/db-builds.test.ts b/platform/packages/api/test/integration/db-builds.test.ts index 7135cd5d5..290741ac9 100644 --- a/platform/packages/api/test/integration/db-builds.test.ts +++ b/platform/packages/api/test/integration/db-builds.test.ts @@ -41,6 +41,7 @@ import { githubConnections, githubInstallations, githubInstallationMembers, + projectMembers, projects, resources, } from '../../src/schema.ts'; @@ -605,6 +606,51 @@ describe('builds', () => { expect(reusedComplete.cancelled).toEqual([]); }); + it('uses the locked current owner and plan when ownership changed after webhook resolution', async () => { + const staleOwner = await createTestUser( + env.db, + 'container-stale-owner', + 'github', + undefined, + 'pro', + ); + const currentOwner = await createTestUser(env.db, 'container-current-owner'); + const project = await createTestProject(env.db, staleOwner.id); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'container-owner-race', + repoFullName: 'acme/owner-race', + repositoryId: DEFAULT_REPO_ID, + branch: 'main', + buildExecutor: 'container', + }); + await seedActiveInstall(env.db, 'container-owner-race'); + await env.db + .update(projects) + .set({ user_id: currentOwner.id }) + .where(eq(projects.id, project.id)); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: staleOwner.id, + role: 'admin', + added_by_user_id: currentOwner.id, + }); + + const result = await supersedeAndCreateContainerBuild(env.db, { + projectId: project.id, + connectionId: (await getGitHubConnection(env.db, project.id))!.id, + userId: staleOwner.id, + branch: 'main', + commitSha: 'owner-race', + installationId: 'container-owner-race', + repositoryId: DEFAULT_REPO_ID, + instanceType: 'standard-4', + }); + + expect(result.build?.user_id).toBe(currentOwner.id); + expect(result.build?.instance_type).toBe('standard-3'); + }); + // Same-SHA recreate race — a branch-delete cancellation samples a DB-clock fence // BEFORE confirming the branch absent, then cancels active builds with // created_at <= fence. If the branch is deleted then recreated at the SAME commit @@ -1922,6 +1968,68 @@ describe('builds', () => { // --------------------------------------------------------------------------- describe('createGithubActionsBuildWithinLimit', () => { + it('recomputes concurrency and attribution from the locked current owner after transfer', async () => { + const staleOwner = await createTestUser( + env.db, + 'actions-stale-owner', + 'github', + undefined, + 'pro', + ); + const currentOwner = await createTestUser(env.db, 'actions-current-owner'); + const project = await createTestProject(env.db, staleOwner.id); + const otherProject = await createTestProject(env.db, currentOwner.id); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'actions-owner-race', + repoFullName: 'acme/actions-owner-race', + repositoryId: DEFAULT_REPO_ID, + buildExecutor: 'github_actions', + }); + await seedActiveInstall(env.db, 'actions-owner-race'); + await env.db + .update(projects) + .set({ user_id: currentOwner.id }) + .where(eq(projects.id, project.id)); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: staleOwner.id, + role: 'admin', + added_by_user_id: currentOwner.id, + }); + const occupying = await createBuild(env.db, { + projectId: otherProject.id, + userId: currentOwner.id, + executor: 'container', + status: 'building', + }); + const args = { + projectId: project.id, + connectionId: (await getGitHubConnection(env.db, project.id))!.id, + userId: staleOwner.id, + limit: 3, + branch: 'main', + githubRunId: 'owner-race-1', + installationId: 'actions-owner-race', + repositoryId: DEFAULT_REPO_ID, + expectedWorkflowPath: '.github/workflows/void-deploy.yml', + } as const; + + expect((await createGithubActionsBuildWithinLimit(env.db, args)).outcome).toBe( + 'limit-exceeded', + ); + await updateBuildStatus(env.db, occupying.id, 'complete'); + const created = await createGithubActionsBuildWithinLimit(env.db, { + ...args, + githubRunId: 'owner-race-2', + }); + expect(created.outcome).toBe('created'); + if (created.outcome === 'created') { + expect(created.build.user_id).toBe(currentOwner.id); + expect(created.build.triggered_by_user_id).toBeNull(); + } + }); + // The workflow_path gate in auth-oidc runs against the connection SNAPSHOT; the // locked re-read here is the authority. A concurrent PATCH that re-pins // workflow_path between the gate and this lock must fail the exchange closed so diff --git a/platform/packages/api/test/integration/deploy-direct-r2.test.ts b/platform/packages/api/test/integration/deploy-direct-r2.test.ts index 4c468cec0..2649fbbbc 100644 --- a/platform/packages/api/test/integration/deploy-direct-r2.test.ts +++ b/platform/packages/api/test/integration/deploy-direct-r2.test.ts @@ -238,10 +238,10 @@ describe('POST /projects/:id/deploy/upload-urls', () => { // the in-tx re-read. Without the in-tx check, the handler would proceed // to issue presigned PUT URLs against a project that's being deleted, // letting the CLI write durable orphan bytes that teardown's R2 sweep - // races to clean up. We simulate the race by intercepting the second - // `getProject` call (the one made inside the FOR SHARE transaction) so - // it sees `status='deleting'` while the first (unlocked) call sees - // `active`. + // races to clean up. We simulate the race by intercepting the direct + // `getProject` call made inside the FOR SHARE transaction. The unlocked + // read now comes from the central project-access query, so this locked + // read must see `status='deleting'`. it('returns 410 when status flips to deleting between unlocked check and FOR SHARE re-read', async () => { const { setGetProjectOverride } = await import('./setup.ts'); const user = await createTestUser(env.db); @@ -250,12 +250,6 @@ describe('POST /projects/:id/deploy/upload-urls', () => { let calls = 0; setGetProjectOverride(({ original, argsList }) => { calls++; - // First call (unlocked early-out) → original = active. - // Second call (in-tx, post FOR SHARE) → simulate teardown that - // committed `status='deleting'` between the two reads. - if (calls === 1) { - return original(...argsList); - } return original(...argsList).then((p) => (p ? { ...p, status: 'deleting' as const } : p)); }); @@ -277,8 +271,7 @@ describe('POST /projects/:id/deploy/upload-urls', () => { const body = (await res.json()) as { error: string; urls?: unknown }; expect(body.error).toMatch(/being deleted/); expect(body.urls).toBeUndefined(); - // Both reads must have run: unlocked early-out + in-tx re-read. - expect(calls).toBeGreaterThanOrEqual(2); + expect(calls).toBe(1); } finally { setGetProjectOverride(null); } diff --git a/platform/packages/api/test/integration/operator-audit.test.ts b/platform/packages/api/test/integration/operator-audit.test.ts index 2db2a4917..ccb276d56 100644 --- a/platform/packages/api/test/integration/operator-audit.test.ts +++ b/platform/packages/api/test/integration/operator-audit.test.ts @@ -4,6 +4,7 @@ import { operatorEvents } from '../../src/schema.ts'; import { clearTables, createOperatorToken, + createTestProject, createTestUser, setupEnv, teardownEnv, @@ -357,3 +358,27 @@ test('records sanitized action targets and change arguments', async () => { .map((event) => JSON.parse(event.target).pattern), ).toEqual(['alice@example.com', 'bob@example.com']); }); + +test('records only the project and registered user IDs for ownership transfer', async () => { + const owner = await createTestUser(env.db, 'audit-transfer-owner'); + const target = await createTestUser(env.db, 'audit-transfer-target'); + const project = await createTestProject(env.db, owner.id, 'audit-transfer-project'); + + const response = await request(`/projects/${project.id}/owner`, { + userId: target.id, + credential: 'must-not-be-audited', + }); + + expect(response.status).toBe(200); + expect(await env.db.select().from(operatorEvents)).toEqual([ + expect.objectContaining({ + action: `projects/${project.id}/owner`, + target: JSON.stringify({ projectId: project.id, userId: target.id }), + status: 'success', + http_status: 200, + }), + ]); + expect(JSON.stringify(await env.db.select().from(operatorEvents))).not.toContain( + 'must-not-be-audited', + ); +}); diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index 505b92a77..bfe0d6ebb 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -19,10 +19,16 @@ import { import type { ApiEnv } from '../../src/env.ts'; import { app } from '../../src/index.ts'; import { + builds, + deployments, invitations, platformLoginConnections, platformLoginIdentities, platformLoginRevisions, + projectInvitations, + projectMembers, + projectOwnershipPeriods, + projects, resources, users, } from '../../src/schema.ts'; @@ -41,6 +47,7 @@ import { let env: TestEnv; let token: string; +let operatorAdminId: string; beforeAll(async () => { env = await setupEnv(); @@ -60,6 +67,7 @@ beforeEach(async () => { 'free', 'admin', ); + operatorAdminId = admin.id; token = await createOperatorToken(env.db, admin); }); afterEach(() => { @@ -85,6 +93,25 @@ function req( ); } +function captureUsageCounter( + calls: Array<{ ownerId: string; path: string; body: Record }>, + failPath?: string, +) { + return { + idFromName: (name: string) => name, + get: (ownerId: string) => ({ + fetch: async (request: Request) => { + const path = new URL(request.url).pathname; + const body = (await request.json()) as Record; + calls.push({ ownerId, path, body }); + return path === failPath + ? new Response('counter unavailable', { status: 500 }) + : Response.json({ ok: true }); + }, + }), + } as unknown as DurableObjectNamespace; +} + describe('operator tenant reads', () => { test('paginates filtered users without truncating the total to the page size', async () => { for (const login of ['find-a', 'find-b', 'find-c', 'unrelated']) { @@ -155,6 +182,366 @@ describe('operator tenant reads', () => { }); describe('operator tenant mutations', () => { + test('previews ownership transfer details without writes or external calls', async () => { + const owner = await createTestUser(env.db, 'transfer-preview-owner'); + const target = await createTestUser( + env.db, + 'transfer-preview-target', + 'github', + undefined, + 'pro', + ); + await env.db + .update(users) + .set({ suspended_reason: 'operator_review' }) + .where(eq(users.id, target.id)); + const project = await createTestProject(env.db, owner.id, 'transfer-preview-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: target.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + await env.db.insert(projectInvitations).values({ + id: 'pinv_transfer_target', + project_id: project.id, + target_user_id: target.id, + target_email_snapshot: 'transfer-target@example.com', + role: 'admin', + invited_by_user_id: owner.id, + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }); + const deployment = await createTestDeployment(env.db, project.id, 'pending'); + const putSpy = vi.spyOn(env.ROUTING, 'put'); + const doGet = vi.fn(); + + const response = await req( + `/projects/${project.id}/owner`, + { userId: target.id, dryRun: true }, + { + USAGE_COUNTER: { + idFromName: (name: string) => name, + get: doGet, + } as unknown as DurableObjectNamespace, + }, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + dryRun: true, + action: 'project.owner', + target: { id: project.id, slug: project.slug, userId: target.id }, + previousOwner: { id: owner.id, plan: 'free', suspended: false }, + newOwner: { + id: target.id, + plan: 'pro', + suspended: true, + suspendedReason: 'operator_review', + }, + formerOwnerRole: 'admin', + targetPreviousRole: 'collaborator', + blockers: [{ kind: 'deployment', id: deployment.id, status: 'pending' }], + counterUpdates: { detachUserId: owner.id, attachUserId: target.id }, + usageTransfer: 'non-retroactive', + }); + expect( + (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], + ).toMatchObject({ user_id: owner.id }); + expect(await env.db.select().from(projectOwnershipPeriods)).toEqual([]); + expect( + await env.db.select().from(projectMembers).where(eq(projectMembers.project_id, project.id)), + ).toMatchObject([{ user_id: target.id, role: 'collaborator' }]); + expect( + await env.db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.id, 'pinv_transfer_target')), + ).toMatchObject([{ status: 'pending' }]); + expect(putSpy).not.toHaveBeenCalled(); + expect(doGet).not.toHaveBeenCalled(); + }); + + test('transfers ownership atomically and converges counters and routing idempotently', async () => { + const owner = await createTestUser(env.db, 'transfer-owner'); + const target = await createTestUser(env.db, 'transfer-target', 'github', undefined, 'pro'); + await env.db + .update(users) + .set({ suspended_reason: 'operator_review' }) + .where(eq(users.id, target.id)); + const project = await createTestProject(env.db, owner.id, 'transfer-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: target.id, + role: 'reader', + added_by_user_id: owner.id, + }); + await env.db.insert(projectInvitations).values({ + id: 'pinv_transfer_target', + project_id: project.id, + target_user_id: target.id, + target_email_snapshot: 'transfer-target@example.com', + role: 'admin', + invited_by_user_id: owner.id, + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }); + await env.ROUTING.put( + project.slug, + JSON.stringify({ workerName: 'worker', projectId: project.id, userId: owner.id }), + ); + const counterCalls: Array<{ + ownerId: string; + path: string; + body: Record; + }> = []; + const USAGE_COUNTER = captureUsageCounter(counterCalls); + + const response = await req( + `/projects/${project.id}/owner`, + { userId: target.id }, + { USAGE_COUNTER }, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + action: 'project.owner', + changed: true, + previousOwner: { id: owner.id }, + newOwner: { id: target.id }, + formerOwnerRole: 'admin', + convergence: { countersComplete: true, routingComplete: true }, + }); + expect( + (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], + ).toMatchObject({ user_id: target.id }); + expect( + await env.db.select().from(projectMembers).where(eq(projectMembers.project_id, project.id)), + ).toMatchObject([{ user_id: owner.id, role: 'admin', added_by_user_id: operatorAdminId }]); + expect( + await env.db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.id, 'pinv_transfer_target')), + ).toMatchObject([ + { + status: 'revoked', + revoked_by_user_id: operatorAdminId, + revoked_at: expect.any(String), + }, + ]); + const periods = await env.db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)); + expect(periods).toHaveLength(2); + const former = periods.find((period) => period.owner_user_id === owner.id)!; + const current = periods.find((period) => period.owner_user_id === target.id)!; + expect(former.ends_at).toBe(current.starts_at); + expect(current).toMatchObject({ ends_at: null, changed_by_user_id: operatorAdminId }); + expect(counterCalls).toEqual([ + { + ownerId: owner.id, + path: '/detach-project', + body: { + projectId: project.id, + userId: owner.id, + ownershipStartedAt: former.starts_at, + ownershipEndedAt: current.starts_at, + }, + }, + { + ownerId: target.id, + path: '/attach-project', + body: expect.objectContaining({ + projectId: project.id, + userId: target.id, + slug: project.slug, + ownershipStartedAt: current.starts_at, + plan: 'pro', + }), + }, + ]); + expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ + projectId: project.id, + userId: target.id, + suspended: 'operator_review', + }); + expect(JSON.parse((await env.ROUTING.get(`project:${project.id}`))!)).toEqual({ + userId: target.id, + plan: 'pro', + }); + + counterCalls.length = 0; + const retry = await req( + `/projects/${project.id}/owner`, + { userId: target.id }, + { USAGE_COUNTER }, + ); + expect(retry.status).toBe(200); + expect(await retry.json()).toMatchObject({ changed: false, newOwner: { id: target.id } }); + expect( + await env.db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)), + ).toHaveLength(2); + expect(counterCalls.map((call) => call.path)).toEqual(['/detach-project', '/attach-project']); + }); + + test('rejects unknown owners and deleting, deploy, rollback, and build work', async () => { + const owner = await createTestUser(env.db, 'blocked-transfer-owner'); + const target = await createTestUser(env.db, 'blocked-transfer-target'); + const project = await createTestProject(env.db, owner.id, 'blocked-transfer-project'); + + expect( + (await req(`/projects/${project.id}/owner`, { userId: 'user_missing', dryRun: true })).status, + ).toBe(404); + + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + const deleting = await req(`/projects/${project.id}/owner`, { userId: target.id }); + expect(deleting.status).toBe(409); + expect(await deleting.json()).toMatchObject({ + error: 'ownership_transfer_blocked', + details: { blockers: [{ kind: 'deletion', id: project.id, status: 'deleting' }] }, + }); + await env.db.update(projects).set({ status: 'active' }).where(eq(projects.id, project.id)); + + const deployment = await createTestDeployment(env.db, project.id, 'uploading'); + const deploying = await req(`/projects/${project.id}/owner`, { userId: target.id }); + expect(deploying.status).toBe(409); + expect(await deploying.json()).toMatchObject({ + details: { blockers: [{ kind: 'deployment', id: deployment.id, status: 'uploading' }] }, + }); + await env.db + .update(deployments) + .set({ status: 'failed' }) + .where(eq(deployments.id, deployment.id)); + + const rollback = await createTestDeployment(env.db, project.id, 'pending', 'rollback-lease'); + const rollingBack = await req(`/projects/${project.id}/owner`, { userId: target.id }); + expect(rollingBack.status).toBe(409); + expect(await rollingBack.json()).toMatchObject({ + details: { blockers: [{ kind: 'rollback', id: rollback.id, status: 'pending' }] }, + }); + await env.db + .update(deployments) + .set({ status: 'canceled' }) + .where(eq(deployments.id, rollback.id)); + + await env.db.insert(builds).values({ + id: 'build_transfer_blocker', + project_id: project.id, + user_id: owner.id, + status: 'queued', + }); + for (const status of ['queued', 'building'] as const) { + await env.db.update(builds).set({ status }).where(eq(builds.id, 'build_transfer_blocker')); + const building = await req(`/projects/${project.id}/owner`, { userId: target.id }); + expect(building.status).toBe(409); + expect(await building.json()).toMatchObject({ + details: { + blockers: [{ kind: 'build', id: 'build_transfer_blocker', status }], + }, + }); + } + }); + + test('reports post-commit counter failure and resumes convergence on retry', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const owner = await createTestUser(env.db, 'partial-transfer-owner'); + const target = await createTestUser(env.db, 'partial-transfer-target'); + const project = await createTestProject(env.db, owner.id, 'partial-transfer-project'); + await env.ROUTING.put( + project.slug, + JSON.stringify({ workerName: 'worker', projectId: project.id, userId: owner.id }), + ); + const failedCalls: Array<{ + ownerId: string; + path: string; + body: Record; + }> = []; + const failed = await req( + `/projects/${project.id}/owner`, + { userId: target.id }, + { USAGE_COUNTER: captureUsageCounter(failedCalls, '/attach-project') }, + ); + + expect(failed.status).toBe(500); + expect(await failed.json()).toMatchObject({ + error: 'partial_failure', + details: { + partial: true, + ownershipTransferred: true, + previousOwner: { id: owner.id }, + newOwner: { id: target.id }, + convergence: { countersComplete: false, routingComplete: false }, + }, + }); + expect( + (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], + ).toMatchObject({ user_id: target.id }); + expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ userId: owner.id }); + + const retryCalls: Array<{ + ownerId: string; + path: string; + body: Record; + }> = []; + const retry = await req( + `/projects/${project.id}/owner`, + { userId: target.id }, + { USAGE_COUNTER: captureUsageCounter(retryCalls) }, + ); + expect(retry.status).toBe(200); + expect(await retry.json()).toMatchObject({ + changed: false, + convergence: { countersComplete: true, routingComplete: true }, + }); + expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ userId: target.id }); + }); + + test('reports a routing partial failure after counters converge', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const owner = await createTestUser(env.db, 'routing-partial-owner'); + const target = await createTestUser(env.db, 'routing-partial-target'); + const project = await createTestProject(env.db, owner.id, 'routing-partial-project'); + await env.ROUTING.put( + project.slug, + JSON.stringify({ workerName: 'worker', projectId: project.id, userId: owner.id }), + ); + const counterCalls: Array<{ + ownerId: string; + path: string; + body: Record; + }> = []; + const failingRouting = { + get: (key: string) => env.ROUTING.get(key), + put: async () => { + throw new Error('KV unavailable'); + }, + delete: (key: string) => env.ROUTING.delete(key), + } as unknown as KVNamespace; + + const response = await req( + `/projects/${project.id}/owner`, + { userId: target.id }, + { ROUTING: failingRouting, USAGE_COUNTER: captureUsageCounter(counterCalls) }, + ); + + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ + error: 'partial_failure', + details: { + ownershipTransferred: true, + convergence: { countersComplete: true, routingComplete: false }, + }, + }); + expect( + (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], + ).toMatchObject({ user_id: target.id }); + expect(counterCalls.map((call) => call.path)).toEqual(['/detach-project', '/attach-project']); + expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ userId: owner.id }); + }); + test('previews every user mutation and project deletion without changing data or calling services', async () => { const user = await createTestUser(env.db, 'preview-user'); const project = await createTestProject(env.db, user.id, 'preview-project'); diff --git a/platform/packages/api/test/integration/project-access.test.ts b/platform/packages/api/test/integration/project-access.test.ts new file mode 100644 index 000000000..53374931d --- /dev/null +++ b/platform/packages/api/test/integration/project-access.test.ts @@ -0,0 +1,266 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { hmacSignState } from '../../src/auth/hmac.ts'; +import { + createBuild, + createDeployment, + createOrRefreshProjectInvitation, + createProject, + getAccessibleProjectsByUserId, + getProjectAccessEvents, + getProjectMember, + getProjectOwnerAt, + removeProjectMember, + respondToProjectInvitation, + setProjectMemberRole, +} from '../../src/db.ts'; +import app from '../../src/index.ts'; +import { + ProjectAccessDeniedError, + getProjectAccess, + requireProjectCapability, +} from '../../src/project-access.ts'; +import { users } from '../../src/schema.ts'; +import { + clearTables, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); +}); + +async function userWithEmail(login: string) { + const user = await createTestUser(env.db, login); + const email = `${login}@example.com`; + await env.db.update(users).set({ email }).where(eq(users.id, user.id)); + return { ...user, email }; +} + +describe('project access', () => { + it('grants implicit owner access to a legacy project with no member row', async () => { + const owner = await createTestUser(env.db, 'legacy-owner'); + const project = await createTestProject(env.db, owner.id, 'legacy-access'); + + const access = await requireProjectCapability(env.db, project.id, owner.id, 'delete'); + expect(access.role).toBe('owner'); + expect(access.owner.id).toBe(owner.id); + expect(await getProjectMember(env.db, project.id, owner.id)).toBeNull(); + }); + + it('enforces reader and collaborator capabilities using the owner for billing', async () => { + const owner = await userWithEmail('access-owner'); + const reader = await userWithEmail('access-reader'); + const collaborator = await userWithEmail('access-collaborator'); + const project = await createProject(env.db, owner.id, 'shared-access'); + + const readerInvite = await createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: reader.email, + role: 'reader', + actorUserId: owner.id, + }); + await respondToProjectInvitation(env.db, { + invitationId: readerInvite.invitation.id, + actorUserId: reader.id, + response: 'accept', + }); + const collaboratorInvite = await createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: collaborator.email, + role: 'collaborator', + actorUserId: owner.id, + }); + await respondToProjectInvitation(env.db, { + invitationId: collaboratorInvite.invitation.id, + actorUserId: collaborator.id, + response: 'accept', + }); + + expect((await requireProjectCapability(env.db, project.id, reader.id, 'read')).owner.id).toBe( + owner.id, + ); + await expect( + requireProjectCapability(env.db, project.id, reader.id, 'deploy'), + ).rejects.toBeInstanceOf(ProjectAccessDeniedError); + expect( + (await requireProjectCapability(env.db, project.id, collaborator.id, 'deploy')).role, + ).toBe('collaborator'); + await expect( + requireProjectCapability(env.db, project.id, collaborator.id, 'settings'), + ).rejects.toBeInstanceOf(ProjectAccessDeniedError); + + const accessible = await getAccessibleProjectsByUserId(env.db, reader.id); + expect(accessible.map(({ project: item, role }) => [item.id, role])).toEqual([ + [project.id, 'reader'], + ]); + }); + + it('requires a registered target, binds acceptance to it, and audits mutations', async () => { + const owner = await userWithEmail('invite-owner'); + const target = await userWithEmail('invite-target'); + const forwardedTo = await userWithEmail('invite-forwarded'); + const project = await createProject(env.db, owner.id, 'invitation-security'); + + await expect( + createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: 'missing@example.com', + role: 'reader', + actorUserId: owner.id, + }), + ).rejects.toMatchObject({ reason: 'target-user-not-found', status: 404 }); + + const { invitation } = await createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: target.email.toUpperCase(), + role: 'reader', + actorUserId: owner.id, + }); + await expect( + respondToProjectInvitation(env.db, { + invitationId: invitation.id, + actorUserId: forwardedTo.id, + response: 'accept', + }), + ).rejects.toMatchObject({ reason: 'invitation-not-target', status: 403 }); + + const accepted = await respondToProjectInvitation(env.db, { + invitationId: invitation.id, + actorUserId: target.id, + response: 'accept', + }); + expect(accepted.member?.role).toBe('reader'); + await setProjectMemberRole(env.db, { + projectId: project.id, + subjectUserId: target.id, + role: 'collaborator', + actorUserId: owner.id, + }); + await removeProjectMember(env.db, { + projectId: project.id, + subjectUserId: target.id, + actorUserId: target.id, + }); + + expect(await getProjectAccess(env.db, project.id, target.id)).toBeNull(); + expect((await getProjectAccessEvents(env.db, project.id)).map((event) => event.action)).toEqual( + ['left', 'role_changed', 'accepted', 'invited'], + ); + }); + + it('records owner attribution on new projects, deployments, and human builds', async () => { + const owner = await createTestUser(env.db, 'attribution-owner'); + const actor = await createTestUser(env.db, 'attribution-actor'); + const project = await createProject(env.db, owner.id, 'attribution'); + const period = await getProjectOwnerAt(env.db, project.id, project.created_at); + expect(period).toBe(owner.id); + + const deployment = await createDeployment(env.db, project.id, '{}', 'manual', null, { + initiatedByUserId: actor.id, + }); + expect(deployment.billing_user_id).toBe(owner.id); + expect(deployment.initiated_by_user_id).toBe(actor.id); + + const build = await createBuild(env.db, { + projectId: project.id, + userId: owner.id, + triggeredByUserId: actor.id, + }); + expect(build.user_id).toBe(owner.id); + expect(build.triggered_by_user_id).toBe(actor.id); + }); + + it('rejects a team mutation after the actor loses admin access', async () => { + const owner = await userWithEmail('lock-owner'); + const admin = await userWithEmail('lock-admin'); + const target = await userWithEmail('lock-target'); + const project = await createProject(env.db, owner.id, 'locked-auth'); + const invite = await createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: admin.email, + role: 'admin', + actorUserId: owner.id, + }); + await respondToProjectInvitation(env.db, { + invitationId: invite.invitation.id, + actorUserId: admin.id, + response: 'accept', + }); + await setProjectMemberRole(env.db, { + projectId: project.id, + subjectUserId: admin.id, + role: 'reader', + actorUserId: owner.id, + }); + + await expect( + createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: target.email, + role: 'reader', + actorUserId: admin.id, + }), + ).rejects.toMatchObject({ reason: 'forbidden', status: 403 }); + }); + + it('keeps the legacy internal verifier URL but live-checks deploy capability', async () => { + const owner = await userWithEmail('verify-owner'); + const reader = await userWithEmail('verify-reader'); + const collaborator = await userWithEmail('verify-collaborator'); + const project = await createProject(env.db, owner.id, 'verify-access'); + for (const [user, role] of [ + [reader, 'reader'], + [collaborator, 'collaborator'], + ] as const) { + const invite = await createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: user.email, + role, + actorUserId: owner.id, + }); + await respondToProjectInvitation(env.db, { + invitationId: invite.invitation.id, + actorUserId: user.id, + response: 'accept', + }); + } + const internalAuth = await hmacSignState(project.id, env.JWT_SECRET); + const verify = (userId: string) => + app.fetch( + new Request(`http://localhost/internal/verify-owner/${project.id}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-void-internal': internalAuth, + }, + body: JSON.stringify({ userId }), + }), + env, + ); + + expect((await verify(owner.id)).status).toBe(200); + expect((await verify(collaborator.id)).status).toBe(200); + expect((await verify(reader.id)).status).toBe(403); + await removeProjectMember(env.db, { + projectId: project.id, + subjectUserId: collaborator.id, + actorUserId: owner.id, + }); + expect((await verify(collaborator.id)).status).toBe(403); + }); +}); diff --git a/platform/packages/api/test/integration/project-team-lifecycle.test.ts b/platform/packages/api/test/integration/project-team-lifecycle.test.ts new file mode 100644 index 000000000..84b41ecac --- /dev/null +++ b/platform/packages/api/test/integration/project-team-lifecycle.test.ts @@ -0,0 +1,174 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { + createOrRefreshProjectInvitation, + createProject, + deleteProjectCascade, + deleteUser, + getAccessibleProjectsByUserId, + getOwnedProjectsByUserId, + getProject, + getProjectsByUserId, + respondToProjectInvitation, +} from '../../src/db.ts'; +import { + projectAccessEvents, + projectInvitations, + projectMembers, + projectOwnershipPeriods, + users, +} from '../../src/schema.ts'; +import { clearTables, createTestUser, setupEnv, teardownEnv, type TestEnv } from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); +}); + +async function setEmail(userId: string, email: string): Promise { + await env.db.update(users).set({ email }).where(eq(users.id, userId)); +} + +describe('project team lifecycle', () => { + it('deleting a non-owner removes their live access but preserves the project audit trail', async () => { + const owner = await createTestUser(env.db, 'owner'); + const member = await createTestUser(env.db, 'member'); + await setEmail(member.id, 'member@example.com'); + const project = await createProject(env.db, owner.id, 'member-lifecycle'); + + const { invitation } = await createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: 'member@example.com', + role: 'collaborator', + actorUserId: owner.id, + }); + await respondToProjectInvitation(env.db, { + invitationId: invitation.id, + actorUserId: member.id, + response: 'accept', + }); + + await deleteUser(env.db, member.id); + + expect(await getProject(env.db, project.id)).not.toBeNull(); + expect( + await env.db.select().from(projectMembers).where(eq(projectMembers.user_id, member.id)), + ).toEqual([]); + expect( + await env.db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.target_user_id, member.id)), + ).toEqual([]); + + const events = await env.db + .select() + .from(projectAccessEvents) + .where(eq(projectAccessEvents.project_id, project.id)); + expect(events.map((event) => event.action)).toEqual(['invited', 'accepted']); + expect(events.every((event) => event.subject_user_id === member.id)).toBe(true); + }); + + it('keeps owned-project lifecycle enumeration separate from accessible projects', async () => { + const owner = await createTestUser(env.db, 'owner'); + const member = await createTestUser(env.db, 'member'); + const owned = await createProject(env.db, member.id, 'member-owned'); + const shared = await createProject(env.db, owner.id, 'owner-shared'); + await env.db.insert(projectMembers).values({ + project_id: shared.id, + user_id: member.id, + role: 'reader', + added_by_user_id: owner.id, + }); + + expect( + (await getOwnedProjectsByUserId(env.db, member.id)).map((project) => project.id), + ).toEqual([owned.id]); + expect((await getProjectsByUserId(env.db, member.id)).map((project) => project.id)).toEqual([ + owned.id, + ]); + expect( + (await getAccessibleProjectsByUserId(env.db, member.id)).map(({ project, role }) => ({ + id: project.id, + role, + })), + ).toEqual([ + { id: shared.id, role: 'reader' }, + { id: owned.id, role: 'owner' }, + ]); + }); + + it('project deletion cascades all team, invitation, ownership, and access-history rows', async () => { + const owner = await createTestUser(env.db, 'owner'); + const member = await createTestUser(env.db, 'member'); + const invitee = await createTestUser(env.db, 'invitee'); + await setEmail(invitee.id, 'invitee@example.com'); + const project = await createProject(env.db, owner.id, 'project-lifecycle'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: member.id, + role: 'admin', + added_by_user_id: owner.id, + }); + await createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: 'invitee@example.com', + role: 'reader', + actorUserId: owner.id, + }); + + expect( + await env.db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)), + ).toHaveLength(1); + + await deleteProjectCascade(env.db, project.id); + + expect(await getProject(env.db, project.id)).toBeNull(); + for (const table of [ + projectMembers, + projectInvitations, + projectOwnershipPeriods, + projectAccessEvents, + ] as const) { + expect(await env.db.select().from(table).where(eq(table.project_id, project.id))).toEqual([]); + } + }); + + it('requires owned projects to be torn down before deleting an owner, without deleting shared projects', async () => { + const owner = await createTestUser(env.db, 'departing-owner'); + const otherOwner = await createTestUser(env.db, 'other-owner'); + const owned = await createProject(env.db, owner.id, 'departing-owned'); + const shared = await createProject(env.db, otherOwner.id, 'departing-shared'); + await env.db.insert(projectMembers).values({ + project_id: shared.id, + user_id: owner.id, + role: 'collaborator', + added_by_user_id: otherOwner.id, + }); + + await expect(deleteUser(env.db, owner.id)).rejects.toThrow(); + expect(await getProject(env.db, owned.id)).not.toBeNull(); + expect(await getProject(env.db, shared.id)).not.toBeNull(); + + await deleteProjectCascade(env.db, owned.id); + await expect(deleteUser(env.db, owner.id)).resolves.toBeUndefined(); + + expect(await getProject(env.db, owned.id)).toBeNull(); + expect(await getProject(env.db, shared.id)).not.toBeNull(); + expect( + await env.db.select().from(projectMembers).where(eq(projectMembers.user_id, owner.id)), + ).toEqual([]); + }); +}); diff --git a/platform/packages/api/test/unit/project-access.test.ts b/platform/packages/api/test/unit/project-access.test.ts new file mode 100644 index 000000000..cd0f5398b --- /dev/null +++ b/platform/packages/api/test/unit/project-access.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { hasProjectCapability, PROJECT_ROLE_CAPABILITIES } from '../../src/project-access.ts'; +import type { ProjectCapability, ProjectRole } from '../../src/types.ts'; + +const capabilities: ReadonlyArray = [ + 'read', + 'deploy', + 'configure', + 'settings', + 'members', + 'delete', +]; + +describe('project role capabilities', () => { + const expected: Record> = { + reader: ['read'], + collaborator: ['read', 'deploy', 'configure'], + admin: ['read', 'deploy', 'configure', 'settings', 'members'], + owner: ['read', 'deploy', 'configure', 'settings', 'members', 'delete'], + }; + + for (const role of Object.keys(expected) as Array) { + it(`${role} has only its explicit capabilities`, () => { + expect(PROJECT_ROLE_CAPABILITIES[role]).toEqual(expected[role]); + for (const capability of capabilities) { + expect(hasProjectCapability(role, capability)).toBe(expected[role].includes(capability)); + } + }); + } +}); diff --git a/platform/packages/api/test/unit/routing.test.ts b/platform/packages/api/test/unit/routing.test.ts index 97336efeb..409327d85 100644 --- a/platform/packages/api/test/unit/routing.test.ts +++ b/platform/packages/api/test/unit/routing.test.ts @@ -288,7 +288,7 @@ describe('refreshProjectKvForUser', () => { it('rewrites a project entry per project the user owns', async () => { const { puts, kv } = createKv(); - const db = {} as Parameters[0]; + const db = createRoutingDb(); vi.spyOn(await import('../../src/db.ts'), 'getUser').mockResolvedValue({ id: 'user_1', plan: 'pro', @@ -297,6 +297,11 @@ describe('refreshProjectKvForUser', () => { { id: 'proj_a' }, { id: 'proj_b' }, ] as unknown as Awaited>); + vi.spyOn(await import('../../src/db.ts'), 'getProject').mockImplementation(async (_db, id) => { + return { id, user_id: 'user_1' } as Awaited< + ReturnType + >; + }); await refreshProjectKvForUser(db, kv, 'user_1'); @@ -330,6 +335,27 @@ describe('refreshProjectKvForUser', () => { expect(puts).toEqual([]); vi.restoreAllMocks(); }); + + it('does not overwrite a project transferred after the owned-project query', async () => { + const { puts, kv } = createKv(); + const db = createRoutingDb(); + vi.spyOn(await import('../../src/db.ts'), 'getUser').mockResolvedValue({ + id: 'former_owner', + plan: 'pro', + } as Awaited>); + vi.spyOn(await import('../../src/db.ts'), 'getProjectsByUserId').mockResolvedValue([ + { id: 'proj_a', user_id: 'former_owner' }, + ] as unknown as Awaited>); + vi.spyOn(await import('../../src/db.ts'), 'getProject').mockResolvedValue({ + id: 'proj_a', + user_id: 'new_owner', + } as Awaited>); + + await refreshProjectKvForUser(db, kv, 'former_owner'); + + expect(puts).toEqual([]); + vi.restoreAllMocks(); + }); }); describe('applyRoutingSuspension', () => { @@ -531,6 +557,38 @@ describe('setProjectsRoutingSuspension', () => { vi.restoreAllMocks(); }); + it('does not apply a former owner snapshot after ownership transfers', async () => { + const { store, kv, puts } = createKvStore({ + 'slug-a': JSON.stringify({ + projectId: 'proj_a', + userId: 'new_owner', + suspended: 'new_owner_reason', + }), + }); + const domainsSpy = vi.spyOn(await import('../../src/db.ts'), 'getDomainsByProjectIds'); + vi.spyOn(await import('../../src/db.ts'), 'getProject').mockResolvedValue({ + id: 'proj_a', + user_id: 'new_owner', + } as Awaited>); + const db = createRoutingDb(); + + await setProjectsRoutingSuspension( + kv, + db, + [{ id: 'proj_a', slug: 'slug-a', user_id: 'former_owner' }], + { op: 'unsuspend', matchReason: null }, + ); + + expect(JSON.parse(store.get('slug-a')!)).toEqual({ + projectId: 'proj_a', + userId: 'new_owner', + suspended: 'new_owner_reason', + }); + expect(puts).toEqual([]); + expect(domainsSpy).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + it('no-ops without a domains query when given zero projects', async () => { const { kv, puts } = createKvStore({}); const spy = vi.spyOn(await import('../../src/db.ts'), 'getDomainsByProjectIds'); diff --git a/platform/packages/api/test/unit/suspension.test.ts b/platform/packages/api/test/unit/suspension.test.ts index e2f3dadd3..1af5a6970 100644 --- a/platform/packages/api/test/unit/suspension.test.ts +++ b/platform/packages/api/test/unit/suspension.test.ts @@ -48,6 +48,7 @@ describe('isAdminSuspended', () => { describe('evaluateRequestLimit (real implementation)', () => { function makeState(overrides: Partial = {}): CounterState { return { + ownerUserId: 'u1', totalRequests: 0, totalNeurons: 0, totalSandboxSeconds: 0, @@ -57,6 +58,10 @@ describe('evaluateRequestLimit (real implementation)', () => { aiSuspended: false, sandboxRuntimeSuspended: false, projectSlugs: {}, + projectOwnershipStarts: {}, + projectNeurons: {}, + historicalNeurons: 0, + historicalAiWindows: {}, ...overrides, }; } @@ -83,6 +88,7 @@ describe('evaluateRequestLimit (real implementation)', () => { describe('evaluateAiLimit (real implementation)', () => { function makeState(overrides: Partial = {}): CounterState { return { + ownerUserId: 'u1', totalRequests: 0, totalNeurons: 0, totalSandboxSeconds: 0, @@ -92,6 +98,10 @@ describe('evaluateAiLimit (real implementation)', () => { aiSuspended: false, sandboxRuntimeSuspended: false, projectSlugs: {}, + projectOwnershipStarts: {}, + projectNeurons: {}, + historicalNeurons: 0, + historicalAiWindows: {}, ...overrides, }; } diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index 362c402b4..84f00b332 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -27,13 +27,23 @@ vi.mock('../../src/db.ts', () => ({ // The DO module also re-exports nothing else from db.ts at module scope, // so a minimal mock is sufficient. getUser: vi.fn(), + getProject: vi.fn(async (_db: unknown, id: string) => ({ id, user_id: 'u1', slug: id })), + getProjectsByIds: vi.fn(async (_db: unknown, ids: ReadonlyArray) => + ids.map((id) => ({ id, user_id: 'u1', slug: id })), + ), + getProjectOwnershipPeriodsByProjectIds: vi.fn(async () => []), // routing.ts (imported transitively via setProjectsRoutingSuspension) reads a // project's active domains here. Default to none so request-suspension tests // that don't seed domains keep their slug-only behaviour. getDomainsByProjectIds: vi.fn(async () => new Map()), })); -import { suspendUser } from '../../src/db.ts'; +import { + getProject, + getProjectOwnershipPeriodsByProjectIds, + getProjectsByIds, + suspendUser, +} from '../../src/db.ts'; import { queryProjectAiUsage } from '../../src/routes/usage.ts'; import { SANDBOX_USAGE_RETENTION_MS } from '../../src/usage-auth.ts'; import { @@ -51,6 +61,7 @@ vi.mock('../../src/routes/usage.ts', () => ({ function makeState(overrides: Partial = {}): CounterState { return { + ownerUserId: 'u1', totalRequests: 0, totalNeurons: 0, totalSandboxSeconds: 0, @@ -60,6 +71,10 @@ function makeState(overrides: Partial = {}): CounterState { aiSuspended: false, sandboxRuntimeSuspended: false, projectSlugs: {}, + projectOwnershipStarts: {}, + projectNeurons: {}, + historicalNeurons: 0, + historicalAiWindows: {}, ...overrides, }; } @@ -633,7 +648,7 @@ describe('UsageCounter.handleRecord — sandbox', () => { it('retains lease watermarks across rollover so delayed prior-period envelopes add nothing', async () => { const boundary = Math.floor(Date.now() / 1000) * 1000; const boundaryIso = new Date(boundary).toISOString(); - const { counter, storage } = makeCounter({ + const { counter, storage, kv } = makeCounter({ plan: 'pro', billingPeriodStart: '2026-01-01T00:00:00Z', }); @@ -1004,6 +1019,33 @@ describe('UsageCounter.handleRecord — request suspension fans out to custom do // overwrite it, or the next admin unsuspend would silently un-block. expect(JSON.parse(kv.store.get('s1')!).suspended).toBe('admin_suspended'); }); + + it('does not suspend a project transferred after the counter ownership precheck', async () => { + vi.mocked(getProjectsByIds).mockResolvedValueOnce([ + { id: 'p1', user_id: 'u1', slug: 's1' } as never, + ]); + vi.mocked(getProject).mockResolvedValueOnce({ + id: 'p1', + user_id: 'new-owner', + slug: 's1', + } as never); + const { counter, kv } = makeCounter({ plan: 'free', totalRequests: 3_000_000 }); + kv.store.set('s1', JSON.stringify({ workerName: 'w', projectId: 'p1', userId: 'new-owner' })); + + await counter.fetch( + recordRequest( + [{ projectId: 'p1', userId: 'u1', slug: 's1', requests: 1, responseBytes: 0 }], + 'u1', + 'free', + ), + ); + + expect(JSON.parse(kv.store.get('s1')!)).toEqual({ + workerName: 'w', + projectId: 'p1', + userId: 'new-owner', + }); + }); }); describe('UsageCounter.handleRollover — request latch in response', () => { @@ -1033,6 +1075,243 @@ describe('UsageCounter.handleRollover — request latch in response', () => { }); }); +describe('UsageCounter ownership changes', () => { + beforeEach(() => { + vi.mocked(queryProjectAiUsage).mockReset().mockResolvedValue({ neurons: 0 }); + vi.mocked(getProjectOwnershipPeriodsByProjectIds).mockReset().mockResolvedValue([]); + }); + + it('bills a delayed former-owner event without reattaching the project', async () => { + const { counter, storage } = makeCounter({ plan: 'solo' }); + await counter.fetch( + recordRequest( + [ + { + projectId: 'transferred', + userId: 'former-owner', + slug: 'app', + requests: 1, + responseBytes: 10, + currentOwner: false, + }, + ], + 'former-owner', + ), + ); + + expect(storage.data.get('state')).toMatchObject({ + totalRequests: 1, + projectSlugs: {}, + }); + }); + + it('detaches and attaches a project without erasing already billed AI usage', async () => { + const { counter, storage, kv } = makeCounter({ + plan: 'solo', + projectSlugs: { transferred: 'app' }, + projectNeurons: { transferred: 42 }, + }); + const detached = await counter.fetch( + new Request('https://do/detach-project', { + method: 'POST', + body: JSON.stringify({ projectId: 'transferred' }), + }), + ); + expect(detached.ok).toBe(true); + expect(storage.data.get('state')).toMatchObject({ + projectSlugs: {}, + projectNeurons: {}, + historicalNeurons: 42, + }); + + const startedAt = '2026-06-01T00:00:00.000Z'; + const attached = await counter.fetch( + new Request('https://do/attach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'next', + slug: 'next-app', + ownershipStartedAt: startedAt, + plan: 'pro', + }), + }), + ); + expect(attached.ok).toBe(true); + expect(storage.data.get('state')).toMatchObject({ + plan: 'pro', + projectSlugs: { next: 'next-app' }, + projectOwnershipStarts: { next: startedAt }, + historicalNeurons: 42, + }); + expect(kv.calls).toContainEqual(['delete', 'ai-block:next']); + }); + + it('reconciles the complete pre-transfer AI window and resamples it idempotently', async () => { + const ownershipStartedAt = '2026-02-01T00:00:00.000Z'; + const ownershipEndedAt = '2026-03-01T00:00:00.000Z'; + vi.mocked(queryProjectAiUsage) + .mockResolvedValueOnce({ neurons: 57 }) + .mockResolvedValueOnce({ neurons: 61 }); + const { counter, storage } = makeCounter({ + ownerUserId: 'former-owner', + plan: 'solo', + billingPeriodStart: '2026-01-01T00:00:00.000Z', + totalNeurons: 42, + projectSlugs: { transferred: 'app' }, + projectOwnershipStarts: { transferred: ownershipStartedAt }, + projectNeurons: { transferred: 42 }, + }); + const detachRequest = () => + new Request('https://do/detach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'transferred', + userId: 'former-owner', + ownershipStartedAt, + ownershipEndedAt, + }), + }); + + expect((await counter.fetch(detachRequest())).ok).toBe(true); + expect(queryProjectAiUsage).toHaveBeenLastCalledWith( + undefined, + undefined, + 'void', + 'transferred', + new Date(ownershipStartedAt), + new Date(ownershipEndedAt), + true, + ); + expect(storage.data.get('state')).toMatchObject({ + totalNeurons: 57, + projectSlugs: {}, + projectNeurons: {}, + historicalNeurons: 0, + historicalAiWindows: { + [JSON.stringify(['transferred', ownershipStartedAt, ownershipEndedAt])]: { + projectId: 'transferred', + ownershipStartedAt, + ownershipEndedAt, + neurons: 57, + }, + }, + }); + + expect((await counter.fetch(detachRequest())).ok).toBe(true); + expect(storage.data.get('state')).toMatchObject({ + totalNeurons: 61, + historicalNeurons: 0, + historicalAiWindows: { + [JSON.stringify(['transferred', ownershipStartedAt, ownershipEndedAt])]: { + neurons: 61, + }, + }, + }); + }); + + it('keeps current ownership state retryable when cutoff reconciliation fails', async () => { + vi.mocked(queryProjectAiUsage).mockRejectedValueOnce(new Error('analytics unavailable')); + const { counter, storage } = makeCounter({ + ownerUserId: 'former-owner', + plan: 'solo', + totalNeurons: 42, + projectSlugs: { transferred: 'app' }, + projectOwnershipStarts: { transferred: '2026-02-01T00:00:00.000Z' }, + projectNeurons: { transferred: 42 }, + }); + + await expect( + counter.fetch( + new Request('https://do/detach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'transferred', + userId: 'former-owner', + ownershipStartedAt: '2026-02-01T00:00:00.000Z', + ownershipEndedAt: '2026-03-01T00:00:00.000Z', + }), + }), + ), + ).rejects.toThrow('analytics unavailable'); + expect(storage.data.get('state')).toMatchObject({ + totalNeurons: 42, + projectSlugs: { transferred: 'app' }, + projectNeurons: { transferred: 42 }, + historicalAiWindows: {}, + }); + }); + + it('queries AI only from the current ownership boundary', async () => { + const ownershipStartedAt = '2026-02-01T00:00:00.000Z'; + const { counter } = makeCounter({ + plan: 'solo', + billingPeriodStart: '2026-01-01T00:00:00.000Z', + projectSlugs: { p1: 'app' }, + projectOwnershipStarts: { p1: ownershipStartedAt }, + }); + + await counter.alarm(); + + expect(queryProjectAiUsage).toHaveBeenCalledWith( + undefined, + undefined, + 'void', + 'p1', + new Date(ownershipStartedAt), + ); + }); + + it('reconciles a stale transferred project through its ownership cutoff before dropping it', async () => { + const ownershipStartedAt = '2026-01-01T00:00:00.000Z'; + const ownershipEndedAt = '2026-02-01T00:00:00.000Z'; + vi.mocked(getProjectsByIds).mockResolvedValueOnce([ + { id: 'transferred', user_id: 'new-owner', slug: 'app' } as never, + ]); + vi.mocked(getProjectOwnershipPeriodsByProjectIds).mockResolvedValueOnce([ + { + project_id: 'transferred', + owner_user_id: 'former-owner', + starts_at: ownershipStartedAt, + ends_at: ownershipEndedAt, + } as never, + ]); + vi.mocked(queryProjectAiUsage).mockResolvedValueOnce({ neurons: 57 }); + const { counter, storage, kv } = makeCounter({ + ownerUserId: 'former-owner', + plan: 'solo', + aiSuspended: true, + projectSlugs: { transferred: 'app' }, + projectOwnershipStarts: { transferred: ownershipStartedAt }, + projectNeurons: { transferred: 42 }, + }); + kv.store.set('ai-block:transferred', '1'); + + await counter.alarm(); + + expect(queryProjectAiUsage).toHaveBeenCalledWith( + undefined, + undefined, + 'void', + 'transferred', + new Date(ownershipStartedAt), + new Date(ownershipEndedAt), + true, + ); + expect(kv.store.has('ai-block:transferred')).toBe(true); + expect(storage.data.get('state')).toMatchObject({ + totalNeurons: 57, + projectSlugs: {}, + projectNeurons: {}, + historicalNeurons: 0, + historicalAiWindows: { + [JSON.stringify(['transferred', ownershipStartedAt, ownershipEndedAt])]: { + neurons: 57, + }, + }, + }); + }); +}); + describe('UsageCounter operation ordering and persistence', () => { beforeEach(() => { vi.mocked(queryProjectAiUsage).mockReset().mockResolvedValue({ neurons: 0 }); diff --git a/platform/packages/api/test/unit/usage-queue.test.ts b/platform/packages/api/test/unit/usage-queue.test.ts index 20702e9d1..a20329481 100644 --- a/platform/packages/api/test/unit/usage-queue.test.ts +++ b/platform/packages/api/test/unit/usage-queue.test.ts @@ -8,6 +8,7 @@ vi.mock('cloudflare:workers', () => ({ // Mock db.ts (createDb is called inside processUsageEventBatch). const getUserMock = vi.fn(); const getProjectsByIdsMock = vi.fn(); +const getProjectOwnershipPeriodsByProjectIdsMock = vi.fn(); vi.mock('../../src/db.ts', () => ({ createDb: () => ({ __mock: 'db' }), getUser: async (...args: Array) => { @@ -15,6 +16,8 @@ vi.mock('../../src/db.ts', () => ({ return user ? { created_at: '2020-01-01T00:00:00.000Z', ...user } : user; }, getProjectsByIds: (...args: Array) => getProjectsByIdsMock(...args), + getProjectOwnershipPeriodsByProjectIds: (...args: Array) => + getProjectOwnershipPeriodsByProjectIdsMock(...args), suspendUser: vi.fn(), unsuspendUserIfReason: vi.fn(), })); @@ -123,9 +126,16 @@ describe('processUsageEventBatch — sandbox events', () => { beforeEach(() => { getUserMock.mockReset(); getProjectsByIdsMock.mockReset(); + getProjectOwnershipPeriodsByProjectIdsMock.mockReset(); getProjectsByIdsMock.mockImplementation(async (_db, ids: ReadonlyArray) => - ids.map((id) => ({ id, user_id: 'u1', slug: `slug-${id}` })), + ids.map((id) => ({ + id, + user_id: 'u1', + slug: `slug-${id}`, + created_at: '2020-01-01T00:00:00.000Z', + })), ); + getProjectOwnershipPeriodsByProjectIdsMock.mockResolvedValue([]); }); it('forwards a sandbox event with userId to the DO', async () => { @@ -173,6 +183,8 @@ describe('processUsageEventBatch — trusted provenance', () => { beforeEach(() => { getUserMock.mockReset(); getProjectsByIdsMock.mockReset(); + getProjectOwnershipPeriodsByProjectIdsMock.mockReset(); + getProjectOwnershipPeriodsByProjectIdsMock.mockResolvedValue([]); }); it('ignores expired signed sandbox intervals before their replay receipts can be pruned', async () => { @@ -247,6 +259,69 @@ describe('processUsageEventBatch — trusted provenance', () => { expect(batch.calls.ackAll).toBe(1); }); + it('bills delayed events to the owner at the queue timestamp', async () => { + const stubFetch = vi.fn(async () => new Response(null, { status: 200 })); + getProjectsByIdsMock.mockResolvedValue([ + { + id: 'p1', + user_id: 'new-owner', + slug: 'canonical-slug', + created_at: '2026-01-01T00:00:00.000Z', + }, + ]); + getProjectOwnershipPeriodsByProjectIdsMock.mockResolvedValue([ + { + id: 'period-old', + project_id: 'p1', + owner_user_id: 'old-owner', + starts_at: '2026-01-01T00:00:00.000Z', + ends_at: '2026-06-01T00:00:00.000Z', + changed_by_user_id: null, + }, + { + id: 'period-new', + project_id: 'p1', + owner_user_id: 'new-owner', + starts_at: '2026-06-01T00:00:00.000Z', + ends_at: null, + changed_by_user_id: 'admin', + }, + ]); + getUserMock.mockImplementation(async (_db, userId) => ({ id: userId, plan: 'solo' })); + const event = { + projectId: 'p1', + requests: 1, + responseBytes: 12, + } as UsageEvent; + const batch = await makeSignedBatch( + [event, event], + [Date.parse('2026-05-31T23:59:59.000Z'), Date.parse('2026-06-01T00:00:00.000Z')], + ); + + await processUsageEventBatch(batch as never, makeEnv(stubFetch) as never); + + expect(getUserMock).toHaveBeenCalledWith(expect.anything(), 'old-owner'); + expect(getUserMock).toHaveBeenCalledWith(expect.anything(), 'new-owner'); + const payloads = await Promise.all( + fetchCalls(stubFetch).map((call) => call[0].json() as Promise<{ events: Array }>), + ); + expect(payloads.flatMap((payload) => payload.events)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + userId: 'old-owner', + currentOwner: false, + ownershipStartedAt: '2026-01-01T00:00:00.000Z', + }), + expect.objectContaining({ + userId: 'new-owner', + currentOwner: true, + ownershipStartedAt: '2026-06-01T00:00:00.000Z', + }), + ]), + ); + expect(batch.calls.ackAll).toBe(1); + }); + it('rejects a correctly signed event with invalid quantities', async () => { const stubFetch = vi.fn(async () => new Response(null, { status: 200 })); const invalid = { @@ -268,8 +343,15 @@ describe('processUsageEventBatch — 5xx alert window', () => { beforeEach(() => { getUserMock.mockReset(); getProjectsByIdsMock.mockReset(); + getProjectOwnershipPeriodsByProjectIdsMock.mockReset(); + getProjectOwnershipPeriodsByProjectIdsMock.mockResolvedValue([]); getProjectsByIdsMock.mockImplementation(async (_db, ids: ReadonlyArray) => - ids.map((id) => ({ id, user_id: 'u1', slug: id })), + ids.map((id) => ({ + id, + user_id: 'u1', + slug: id, + created_at: '2020-01-01T00:00:00.000Z', + })), ); }); @@ -354,7 +436,13 @@ describe('processUsageEventBatch — 5xx alert window', () => { const counterBody = (await fetchCalls(stubFetch)[0][0].json()) as { events: Array; }; - expect(counterBody.events).toEqual(platformFailures); + expect(counterBody.events).toEqual( + platformFailures.map((event) => ({ + ...event, + currentOwner: true, + ownershipStartedAt: '2020-01-01T00:00:00.000Z', + })), + ); expect(errorStubFetch).not.toHaveBeenCalled(); expect(batch.calls.ackAll).toBe(1); }); diff --git a/platform/packages/proxy/src/index.ts b/platform/packages/proxy/src/index.ts index 507b9114c..c43045e04 100644 --- a/platform/packages/proxy/src/index.ts +++ b/platform/packages/proxy/src/index.ts @@ -11,7 +11,7 @@ import { guardHumanStream } from './human-stream.ts'; import { kv } from './kv.ts'; import { PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE } from './platform-lifecycle.ts'; import { r2 } from './r2.ts'; -import { verifyPrerenderAuthority, verifyProjectOwner } from './resolve.ts'; +import { verifyPrerenderAuthority, verifyProjectDeployAccess } from './resolve.ts'; import { revalidate } from './revalidate.ts'; import type { Env, JwtPayload, ProxyBody, ProxyEnv } from './types.ts'; @@ -196,7 +196,7 @@ app.post('/*', async (context, next) => { } else { // Full API tokens retain the local-development ownership lookup. const checkOwner = () => - verifyProjectOwner( + verifyProjectDeployAccess( context.env.API, context.env.JWT_SECRET, projectId, diff --git a/platform/packages/proxy/src/resolve.ts b/platform/packages/proxy/src/resolve.ts index 571e0d622..80994203b 100644 --- a/platform/packages/proxy/src/resolve.ts +++ b/platform/packages/proxy/src/resolve.ts @@ -46,10 +46,10 @@ export async function resolveResource( } /** - * Verifies that a user owns a project via the API service binding. - * Called on the JWT auth path to prevent cross-tenant access. + * Verifies that an actor still has deploy-equivalent project access through + * the API service binding. The endpoint keeps its legacy name during rollout. */ -export async function verifyProjectOwner( +export async function verifyProjectDeployAccess( api: Fetcher, jwtSecret: string, projectId: string, diff --git a/platform/packages/proxy/test/unit/proxy.test.ts b/platform/packages/proxy/test/unit/proxy.test.ts index 505c95ed8..8248cc2ad 100644 --- a/platform/packages/proxy/test/unit/proxy.test.ts +++ b/platform/packages/proxy/test/unit/proxy.test.ts @@ -86,8 +86,8 @@ function createMockKv(data: Record = {}) { } /** - * User-to-project ownership map used by the mock API for verify-owner checks. - * Default: user_1 owns proj_1, proj_42, proj_99, and p. + * User-to-project deploy-access map used by the mock API. The endpoint retains + * its legacy verify-owner URL while the API now resolves roles dynamically. */ const DEFAULT_OWNERSHIP: Record> = { user_1: ['proj_1', 'proj_42', 'proj_99', 'p'], @@ -104,7 +104,7 @@ function createMockApi( const url = typeof input === 'string' ? input : input.url; const body = init?.body ?? (input instanceof Request ? await input.text() : undefined); - // Handle verify-owner requests + // Handle live project deploy-access requests. if (url.includes('/internal/verify-owner/')) { const projectId = url.split('/').pop()!; const parsed = typeof body === 'string' ? JSON.parse(body) : {}; @@ -541,7 +541,8 @@ describe('proxy worker', () => { env, ); expect(allowed.status).toBe(200); - expect(apiFetch).not.toHaveBeenCalled(); + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(String(apiFetch.mock.calls[0]?.[0])).toContain('/internal/verify-owner/proj_1'); const denied = await worker.fetch( post( @@ -569,6 +570,9 @@ describe('proxy worker', () => { let active = true; const api = createMockApi(async (input) => { const url = typeof input === 'string' ? input : input.url; + if (url.includes('/internal/verify-owner/')) { + return Response.json({ ok: true }); + } expect(url).toContain('/internal/verify-prerender-build/proj_1'); return active ? Response.json({ ok: true }) @@ -608,7 +612,7 @@ describe('proxy worker', () => { }); it('rejects access when user does not own the project', async () => { - // user_1 does not own "proj_other" + // user_1 has no deploy role on "proj_other" const env = createEnv(); const res = await worker.fetch( post( @@ -640,7 +644,26 @@ describe('proxy worker', () => { expect(await res.json()).toEqual({ error: 'forbidden' }); }); - it('returns 403 when verify-owner API call fails', async () => { + it('allows a non-owner whose live role has deploy access', async () => { + const collaboratorToken = await signJwt( + { sub: 'user_2', login: 'collaborator', aud: 'api' }, + JWT_SECRET, + ); + const access = { user_2: ['proj_shared'] }; + const env = createEnv(undefined, undefined, createMockApi(undefined, access)); + const res = await worker.fetch( + post( + '/ai/run', + { model: 'test-model', inputs: {}, projectId: 'proj_shared' }, + { authorization: `Bearer ${collaboratorToken}` }, + ), + env, + ); + + expect(res.status).toBe(200); + }); + + it('returns 403 when the live deploy-access API call fails', async () => { // API returns 500 for all requests const failingApi = createMockApi(async () => new Response('error', { status: 500 })); const env = createEnv(undefined, undefined, failingApi); From 331031d19bf381e33fd37e5ba63626af162dc2d8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 18 Sep 2026 01:32:05 +0800 Subject: [PATCH 02/18] fix(platform): harden project team access --- docs/guide/platform-administration.md | 2 +- docs/guide/project-collaboration.md | 3 + docs/reference/cli.md | 5 +- packages/void/skills/void/SKILL.md | 2 +- packages/void/src/cli/client.ts | 46 ++-- packages/void/src/cli/project-cmd.ts | 4 +- packages/void/src/cli/project-team.ts | 12 +- .../cloudflare-deploy-build.test.ts | 30 --- .../void/test/unit/cloudflare-deploy.test.ts | 26 -- .../test/unit/project-team-command.test.ts | 179 +++++++++++++ .../meta/design-docs/0080-project-teams.md | 94 +++---- .../api/drizzle/0048_project_teams.sql | 1 - .../api/drizzle/meta/0048_snapshot.json | 8 +- .../api/src/admin/routes/projects.tsx | 49 +++- platform/packages/api/src/db.ts | 224 +++++++--------- platform/packages/api/src/email.ts | 4 +- platform/packages/api/src/index.ts | 27 +- .../api/src/operator/tenant-service.ts | 165 ++++++++---- platform/packages/api/src/project-access.ts | 13 - .../packages/api/src/provisioning/cleanup.ts | 31 ++- platform/packages/api/src/revalidate-core.ts | 4 +- platform/packages/api/src/routes/github.ts | 2 +- .../packages/api/src/routes/project-team.ts | 70 ++++- platform/packages/api/src/routes/projects.ts | 54 ++-- .../packages/api/src/routes/revalidate.ts | 19 +- platform/packages/api/src/schema.ts | 20 +- platform/packages/api/src/usage-counter.ts | 22 +- platform/packages/api/src/usage-queue.ts | 4 +- .../test/integration/admin-dashboard.test.ts | 31 +++ .../api/test/integration/api-deploy.test.ts | 52 ++++ .../test/integration/api-migrations.test.ts | 34 +-- .../test/integration/api-project-team.test.ts | 196 +++++++++++++- .../test/integration/api-revalidate.test.ts | 48 +++- .../auth-email-verification.test.ts | 38 ++- .../api/test/integration/db-builds.test.ts | 1 - .../test/integration/operator-tenants.test.ts | 251 +++++++++++++++++- .../integration/platform-onboarding.test.ts | 18 ++ .../test/integration/project-access.test.ts | 143 +++++----- .../project-team-lifecycle.test.ts | 28 +- .../api/test/integration/scheduled.test.ts | 50 ++++ platform/packages/api/test/unit/email.test.ts | 22 ++ .../api/test/unit/usage-counter.test.ts | 42 ++- 42 files changed, 1493 insertions(+), 581 deletions(-) diff --git a/docs/guide/platform-administration.md b/docs/guide/platform-administration.md index 6846cb920..77da08be4 100644 --- a/docs/guide/platform-administration.md +++ b/docs/guide/platform-administration.md @@ -194,7 +194,7 @@ void platform project owner --plan void platform project owner --yes ``` -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, build, or Sandbox lifecycle operation before retrying. +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. After the transfer, the former owner becomes a project administrator. 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. diff --git a/docs/guide/project-collaboration.md b/docs/guide/project-collaboration.md index fbf4a8cc5..23bc58b2d 100644 --- a/docs/guide/project-collaboration.md +++ b/docs/guide/project-collaboration.md @@ -46,6 +46,9 @@ void project team decline ``` An invitation is bound to the registered account. Forwarding its ID does not let another user accept it. +These three invitation-response commands are account-scoped and do not depend +on the current project's deploy target. They can run from a direct Cloudflare +project directory when it resolves your connected Void platform account. ## Change or Remove Access diff --git a/docs/reference/cli.md b/docs/reference/cli.md index bc053b89b..7af670a91 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -251,7 +251,10 @@ Project-scoped commands use `--project`, then `VOID_PROJECT`, then the linked pr Readers can view the project but cannot deploy. Collaborators can deploy and manage deploy prerequisites. Project administrators can additionally configure the project and manage its team. The owner alone can delete the project. See [Project Collaboration](../guide/project-collaboration.md) for the full role boundaries. -Team commands are not available for projects deployed directly to Cloudflare. +Project-scoped team management commands are not available for projects deployed +directly to Cloudflare. The account-scoped `pending`, `accept`, and `decline` +commands do not depend on the project deploy target and can be run from a direct +Cloudflare project directory that resolves the connected Void platform account. ### `void project token ` diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 47bd34ae7..061ed2ca6 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -75,7 +75,7 @@ mutation failure before retrying. Use `void connect` for deployment onboarding: no arguments offers Cloudflare or a Void platform, `--platform cloudflare` signs in and selects an account, and a platform URL verifies discovery and signs in with a supported provider. It preserves existing project links when connecting elsewhere. `--no-login` saves only verified Void connection metadata; authenticated headless connection requires a valid origin-scoped keychain session or `VOID_TOKEN` with matching `VOID_API_URL`. Platform installation and administration use `void platform`. -For Void platform project access, use `void project team`: invite only an email already registered on that platform, assign `reader`, `collaborator`, or `admin`, and use the pending/accept/decline commands for the invited account. These commands do not apply to direct Cloudflare deployments. Installation administrators transfer ownership with `void platform project owner --plan` and apply the reviewed transfer with `--yes`. +For Void platform project access, use `void project team`: invite only an email already registered on that platform, assign `reader`, `collaborator`, or `admin`, and use the account-scoped pending/accept/decline commands for the invited account. Project-scoped team management does not apply to direct Cloudflare deployments, but invitation responses do not depend on the project deploy target and can run from a direct Cloudflare project directory that resolves the connected Void platform account. Installation administrators transfer ownership with `void platform project owner --plan` and apply the reviewed transfer with `--yes`. Use Void commands for every user-facing workflow. Never ask the user to install, authenticate, or run Wrangler directly. Say Cloudflare or Void instead, except when naming literal `wrangler.jsonc` / `wrangler.json` files or `WRANGLER_*` environment variables the user must inspect. diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index eb2af471c..9098bd03c 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -125,23 +125,17 @@ export type ProjectResponse = { export type ProjectRole = 'reader' | 'collaborator' | 'admin' | 'owner'; export type ProjectMemberResponse = { - user_id?: string; role: ProjectRole; - login?: string; - email?: string; - created_at?: string; - user?: { + created_at: string; + user: { id: string; - login?: string; - email?: string; + login: string | null; + email: string | null; }; }; export type ProjectInvitationResponse = { id: string; - project_id?: string; - target_user_id?: string; - target_email_snapshot?: string; target_email?: string; role: Exclude; status?: 'pending' | 'accepted' | 'declined' | 'revoked' | 'expired'; @@ -154,24 +148,24 @@ export type ProjectInvitationResponse = { slug: string; owner?: { id: string; - login?: string; - email?: string; + login: string | null; + email: string | null; }; }; target?: { id: string; - login?: string; - email?: string; + login: string | null; + email: string | null; }; invited_by?: { id: string; - login?: string; - email?: string; + login: string | null; + email: string | null; }; }; export type ProjectInvitationDelivery = { - status: 'sent' | 'not_configured' | 'failed'; + status: 'sent' | 'not_configured' | 'rate_limited' | 'failed'; }; export type ProjectDeployCredentialResponse = { @@ -657,10 +651,8 @@ export class PlatformClient { if (!res.ok) { await throwPlatformApiError('Failed to list project members', res); } - const body = (await res.json()) as - | Array - | { members: Array }; - return Array.isArray(body) ? body : body.members; + const body = (await res.json()) as { members: Array }; + return body.members; } async listProjectInvitations(projectId: string): Promise> { @@ -670,10 +662,8 @@ export class PlatformClient { if (!res.ok) { await throwPlatformApiError('Failed to list project invitations', res); } - const body = (await res.json()) as - | Array - | { invitations: Array }; - return Array.isArray(body) ? body : body.invitations; + const body = (await res.json()) as { invitations: Array }; + return body.invitations; } async inviteProjectMember( @@ -785,10 +775,8 @@ export class PlatformClient { if (!res.ok) { await throwPlatformApiError('Failed to list pending project invitations', res); } - const body = (await res.json()) as - | Array - | { invitations: Array }; - return Array.isArray(body) ? body : body.invitations; + const body = (await res.json()) as { invitations: Array }; + return body.invitations; } async respondToProjectInvitation( diff --git a/packages/void/src/cli/project-cmd.ts b/packages/void/src/cli/project-cmd.ts index 21cadde07..fd8c5bee4 100644 --- a/packages/void/src/cli/project-cmd.ts +++ b/packages/void/src/cli/project-cmd.ts @@ -39,7 +39,9 @@ export async function runProjectCommand(root: string, args: ProjectArgs): Promis printHelpPage(['project', 'team']); return; } - if (cloudflare) { + const accountInvitationAction = + args.action === 'pending' || args.action === 'accept' || args.action === 'decline'; + if (cloudflare && !accountInvitationAction) { throw new Error( 'project: Teams are available only for projects hosted on a Void platform, not direct Cloudflare deployments.', ); diff --git a/packages/void/src/cli/project-team.ts b/packages/void/src/cli/project-team.ts index 18a5f25c9..f30ce5e98 100644 --- a/packages/void/src/cli/project-team.ts +++ b/packages/void/src/cli/project-team.ts @@ -12,7 +12,7 @@ import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project type ProjectTeamArgs = Exclude, { action: '' }>; -function clean(value: string | undefined): string { +function clean(value: string | null | undefined): string { // Tenant text must not execute terminal control sequences. // eslint-disable-next-line no-control-regex return (value ?? '—').replace(/[\u0000-\u001f\u007f]/g, ' '); @@ -20,9 +20,9 @@ function clean(value: string | undefined): string { function memberLines(members: Array): string { const rows = members.map((member) => ({ - id: member.user?.id ?? member.user_id, - login: member.user?.login ?? member.login ?? '—', - email: member.user?.email ?? member.email ?? '—', + id: member.user.id, + login: member.user.login ?? '—', + email: member.user.email ?? '—', role: member.role, })); const widths = { @@ -40,9 +40,7 @@ function memberLines(members: Array): string { } function invitationEmail(invitation: ProjectInvitationResponse): string { - return ( - invitation.target?.email ?? invitation.target_email ?? invitation.target_email_snapshot ?? '—' - ); + return invitation.target?.email ?? invitation.target_email ?? '—'; } function invitationLines(invitations: Array, pending: boolean): string { diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts index fe36499ec..61a3173ea 100644 --- a/packages/void/test/integration/cloudflare-deploy-build.test.ts +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -219,36 +219,6 @@ describe('playground/kitchen-sink --backend cloudflare real-build deploy guard', expect(queues.consumers).toContainEqual(expect.objectContaining({ queue: 'demo-queue' })); }); - it('emits no Void Platform project-team binding or variable', () => { - const teamTerms = /(?:project[_-]?)?(?:team|members?|invitations?)/i; - const matches: Array = []; - const visit = (value: unknown, path: string): void => { - if (typeof value === 'string') { - if (teamTerms.test(value)) { - matches.push(`${path}=${value}`); - } - return; - } - if (Array.isArray(value)) { - value.forEach((entry, index) => visit(entry, `${path}[${index}]`)); - return; - } - if (!value || typeof value !== 'object') { - return; - } - for (const [key, entry] of Object.entries(value)) { - const entryPath = path ? `${path}.${key}` : key; - if (teamTerms.test(key)) { - matches.push(entryPath); - } - visit(entry, entryPath); - } - }; - - visit(pristineEmitted, ''); - expect(matches).toEqual([]); - }); - it('reconstructs a container set byte-identical to the emitted sandbox container', () => { // The highest-value FP-safety lock: `reconstructExpectedContainers` (a pure pre-build function) // must produce EXACTLY what the CF plugin emitted, or every sandbox deploy would false-positive. diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index b54d8c70d..c3792283c 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -513,32 +513,6 @@ function harness(overrides: Partial = {}): { return { deps, trace, wranglerArgs, exitCalls }; } -describe('direct Cloudflare deploy isolation', () => { - it('does not contact Void Platform project-team endpoints', async () => { - const root = makeProject({ - 'package.json': JSON.stringify({ name: 'site', dependencies: { void: '*' } }), - 'void.json': '{}', - 'routes/index.ts': 'export const GET = () => "site";', - 'wrangler.jsonc': JSON.stringify({ - name: 'site', - account_id: 'personal', - compatibility_date: '2026-02-24', - }), - }); - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockRejectedValue(new Error('direct Cloudflare deploy attempted an unexpected HTTP call')); - const { deps } = harness(); - - try { - await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); - expect(fetchSpy).not.toHaveBeenCalled(); - } finally { - fetchSpy.mockRestore(); - } - }); -}); - describe('existing Cloudflare site handoff', () => { const existing = { versionId: 'original', diff --git a/packages/void/test/unit/project-team-command.test.ts b/packages/void/test/unit/project-team-command.test.ts index 1ea5b1f6f..33fb447b3 100644 --- a/packages/void/test/unit/project-team-command.test.ts +++ b/packages/void/test/unit/project-team-command.test.ts @@ -39,6 +39,39 @@ describe('project team target selection', () => { ); }); + it('allows account invitation responses from a direct Cloudflare project directory', async () => { + const root = rootWithPlatform('cloudflare'); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + return url.endsWith('/me/project-invitations') + ? Response.json({ invitations: [] }) + : Response.json({}); + }); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await runProjectCommand(root, { subcommand: 'team', action: 'pending' }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'accept', + invitationId: 'pinv_1', + }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'decline', + invitationId: 'pinv_1', + }); + + expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual([ + 'https://api.example.test/me/project-invitations', + 'https://api.example.test/project-invitations/pinv_1/accept', + 'https://api.example.test/project-invitations/pinv_1/decline', + ]); + }); + it('uses the linked Void platform project for member listing', async () => { const root = rootWithPlatform('void'); vi.stubEnv('VOID_TOKEN', 'test-token'); @@ -65,4 +98,150 @@ describe('project team target selection', () => { }), ); }); + + it('dispatches project and account invitation actions to their API routes', async () => { + const root = rootWithPlatform('void'); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith('/me/project-invitations')) { + return Response.json({ invitations: [] }); + } + if (url.endsWith('/accept')) { + return Response.json({ + member: { + user: { id: 'user_2', login: 'member', email: 'member@example.com' }, + role: 'reader', + created_at: '2026-09-17T00:00:00Z', + }, + }); + } + if (url.endsWith('/invitations') && init?.method !== 'POST') { + return Response.json({ invitations: [] }); + } + if (url.endsWith('/invitations') && init?.method === 'POST') { + return Response.json({ + invitation: { + id: 'pinv_1', + target: { id: 'user_2', login: 'member', email: 'member@example.com' }, + target_email: 'member@example.com', + role: 'reader', + status: 'pending', + created_at: '2026-09-17T00:00:00Z', + expires_at: '2026-09-24T00:00:00Z', + }, + delivery: { status: 'not_configured' }, + }); + } + if (init?.method === 'PATCH') { + return Response.json({ + member: { + user: { id: 'user_2', login: 'member', email: 'member@example.com' }, + role: 'admin', + created_at: '2026-09-17T00:00:00Z', + }, + }); + } + return new Response(null, { status: 204 }); + }); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await runProjectCommand(root, { subcommand: 'team', action: 'pending' }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'accept', + invitationId: 'pinv_1', + }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'decline', + invitationId: 'pinv_1', + }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'invitations', + projectSlug: '', + }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'invite', + email: 'member@example.com', + role: 'reader', + projectSlug: '', + }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'role', + userId: 'user_2', + role: 'admin', + projectSlug: '', + }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'remove', + userId: 'user_2', + projectSlug: '', + }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'revoke', + invitationId: 'pinv_1', + projectSlug: '', + }); + await runProjectCommand(root, { subcommand: 'team', action: 'leave', projectSlug: '' }); + + expect( + fetchMock.mock.calls.map(([input, init]) => [ + String(input), + (init as RequestInit | undefined)?.method ?? 'GET', + ]), + ).toEqual([ + ['https://api.example.test/me/project-invitations', 'GET'], + ['https://api.example.test/project-invitations/pinv_1/accept', 'POST'], + ['https://api.example.test/project-invitations/pinv_1/decline', 'POST'], + ['https://api.example.test/projects/project_1/invitations', 'GET'], + ['https://api.example.test/projects/project_1/invitations', 'POST'], + ['https://api.example.test/projects/project_1/members/user_2', 'PATCH'], + ['https://api.example.test/projects/project_1/members/user_2', 'DELETE'], + ['https://api.example.test/projects/project_1/invitations/pinv_1', 'DELETE'], + ['https://api.example.test/projects/project_1/leave', 'POST'], + ]); + }); + + it('resolves an explicit project override before acting', async () => { + const root = rootWithPlatform('void'); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json([ + { + id: 'project_2', + user_id: 'owner_1', + slug: 'other-app', + type: 'worker', + created_at: '2026-09-17T00:00:00Z', + }, + ]), + ) + .mockResolvedValueOnce(Response.json({ members: [] })); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await runProjectCommand(root, { + subcommand: 'team', + action: 'list', + projectSlug: 'other-app', + }); + + expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual([ + 'https://api.example.test/projects', + 'https://api.example.test/projects/project_2/members', + ]); + }); }); diff --git a/platform/meta/design-docs/0080-project-teams.md b/platform/meta/design-docs/0080-project-teams.md index b99542c92..70c120673 100644 --- a/platform/meta/design-docs/0080-project-teams.md +++ b/platform/meta/design-docs/0080-project-teams.md @@ -98,9 +98,11 @@ For every project operation, authorization has two independent inputs: 2. the current owner's account determines the applicable plan and quota. The actor's personal plan never raises or lowers the project's limits. This -applies to request and AI usage, Sandbox runtime and concurrency, deploys per -day, build concurrency and timeout, build instance size, CPU and subrequest -limits, deployment retention, and future account-level storage enforcement. +applies to request and AI usage, deploys per day, build concurrency and timeout, +build instance size, CPU and subrequest limits, deployment retention, and future +account-level storage enforcement. Managed Sandbox is currently hard-disabled; +its runtime and concurrency attribution must be specified before that capability +is reintroduced. A manually suspended actor cannot authenticate, even if they are a member. An actor automatically suspended for usage on projects they own may still work on @@ -131,10 +133,9 @@ former owner afterward. The target must be an existing Platform user. The preview names the target's plan and suspension state. A transfer is rejected while the project is deleting -or has a nonterminal deploy, rollback, managed build, or Sandbox lifecycle -operation. The administrator can cancel or wait for that work and retry. This -keeps a single operation from beginning under one owner's plan and publishing -under another's. +or has a nonterminal deploy, rollback, or managed build. The administrator can +cancel or wait for that work and retry. This keeps a single operation from +beginning under one owner's plan and publishing under another's. ## Data model @@ -202,9 +203,9 @@ event occurred. Account usage queries intersect these ownership periods with each user's billing window. Project usage remains a complete project history, while an owner's aggregate contains only the portions billed to that owner. Request -events use their queue enqueue time, Sandbox intervals are split at an -ownership boundary, and AI queries start at the later of the billing-period or -ownership-period boundary. +events use their queue enqueue time, and AI queries start at the later of the +billing-period or ownership-period boundary. Managed Sandbox interval splitting +is deferred until that hard-disabled capability is reintroduced. ### Actor and billing attribution @@ -214,10 +215,9 @@ Daily deploy counts use `billing_user_id` instead of joining to the project's current owner. `builds.user_id` remains the billing owner for compatibility with the build -scheduler and its per-user locks. Add `builds.triggered_by_user_id` for a human -trigger where one exists. Automated GitHub builds use the current project owner -for `builds.user_id`; their GitHub authorization provenance remains on the -connection. +scheduler and its per-user locks. Automated GitHub builds use the current +project owner for `builds.user_id`; their GitHub authorization provenance +remains on the connection. ### Audit events @@ -278,10 +278,9 @@ has `deploy` capability as well as checking the bound build. Therefore: - publication that gets the lock first completes before the membership change; - a leaked unexpired deploy token cannot start new work after removal. -The binding proxy's internal `verify-owner` route becomes a capability check -for `deploy` and is renamed or versioned so old and new proxies can coexist -during rollout. Build-bound prerender credentials retain their build-state -check. +The binding proxy's existing internal `verify-owner` route checks the `deploy` +capability without changing its wire contract. Build-bound prerender +credentials retain their build-state check. ## Invitation flow @@ -350,13 +349,14 @@ void project team decline ``` The existing project selector and repository link identify the project when -`--project` is absent. Commands require a connected Void Platform and give a -clear error for a direct Cloudflare target. Project list output gains a role -column so shared projects are distinguishable. +`--project` is absent. Project-scoped commands require a connected Void Platform +project and give a clear error for a direct Cloudflare target. The account-scoped +`pending`, `accept`, and `decline` commands use the connected Void Platform even +when the current directory targets Cloudflare directly. Project list output +gains a role column so shared projects are distinguishable. -The optional user dashboard adds a Team tab to project details and a pending -invitation page. The core acceptance landing route belongs to the API so a -self-hosted installation does not require the optional dashboard. +The API-owned invitation landing route shows the CLI connection and acceptance +commands, so self-hosted installations do not require the optional dashboard. ### Installation administrator API @@ -397,26 +397,22 @@ new ownership period. The operator transfer performs these steps: -1. Write a retryable transfer fence that prevents new Sandbox acquisitions for - the project while ordinary application requests continue. -2. In one database transaction, lock the project then both users in stable ID +1. In one database transaction, lock the project then both users in stable ID order; reject active lifecycle work; close/open the ownership periods; update `projects.user_id`; remove the target's membership; add the former owner as project admin; and record the event. -3. Detach/attach the UsageCounter project mapping. -4. Republish the project-account and active routing entries with the new owner +2. Detach/attach the UsageCounter project mapping. +3. Republish the project-account and active routing entries with the new owner ID, plan limits, and suspension state. Usage ingestion already uses the database period, so stale producer hints cannot misbill requests. -5. Clear the transfer fence. If a post-commit external update fails, the database transfer remains committed -and the fence stays closed for Sandboxes. The operator response identifies the -partial state and a repeat apply resumes the idempotent convergence steps. -Application requests continue to be metered to the database-authoritative -owner during recovery. +and the operator response identifies the partial state. A repeat apply resumes +the idempotent convergence steps. Application requests continue to be metered +to the database-authoritative owner during recovery. GitHub connection authorization provenance may name any member who had -`configure` capability when the connection was created. Removing or +`settings` capability when the connection was created. Removing or downgrading that authorizer below project administrator invalidates the connection and cancels its active builds unless another project administrator reauthorizes it first. A transfer normally preserves it because the former @@ -445,17 +441,15 @@ acquire capacity and select a build size from the current billing owner. deploy readers that tolerate absent optional data. 2. **Central authorization.** Introduce the capability service and move every project route to it while owner remains the only possible role. Move all - plan lookups to the returned owner. Version the proxy verification endpoint. + plan lookups to the returned owner. Update the existing proxy verification + endpoint to enforce deploy capability without changing its wire contract. 3. **Counter correctness.** Resolve usage by ownership period, split billed - totals from current project mappings, and add the Sandbox transfer fence and - stale-counter ownership checks. + totals from current project mappings, and add stale-counter ownership checks. 4. **Membership and invitations.** Enable member rows, registered-user lookup, acceptance/decline, access audit, email delivery, API routes, CLI commands, - and dashboard surfaces. + and the API-owned CLI instruction page. 5. **Operator transfer.** Enable dry-run/apply in the shared operator service, browser administrator UI, and CLI after quota convergence is deployed. -6. **Cleanup.** Remove the old owner-only proxy endpoint and temporary fallback - paths after all packaged runtime revisions require the new capability. No phase changes direct Cloudflare deployment configuration, bindings, upload logic, or manifests. @@ -486,9 +480,7 @@ Coverage must include: rejection, delayed events on both sides of the timestamp, billing rollover, stale DO writes, routing partial failure, and idempotent recovery; - plan changes, suspension, user deletion, and project deletion ignoring shared - membership where ownership is required; -- `void deploy --platform cloudflare` emitting no team binding/configuration, - making no team API request, and retaining its existing Free-compatible path. + membership where ownership is required. When all batches are complete, run `vp lint`, `vp fmt`, `vp run check`, and the full `vp test run` suite. @@ -504,17 +496,3 @@ After verification, document only the public behavior and workflows: - update `packages/void/skills/void` when the CLI guidance changes; - leave `packages/void/AGENTS_TEMPLATE.md` unchanged unless a brief generated app instruction is genuinely necessary. - -## Confirmed product decisions - -Confirmed on 2026-09-17: - -1. Project administrators can change roles and remove any non-owner member, - including another project administrator. -2. Collaborators receive all deploy-equivalent production authority described - in the role table; project settings and team management remain admin-only. -3. The former owner becomes a project administrator on transfer. -4. Ownership transfer is non-retroactive for usage already incurred. -5. Invitations resolve a registered email to an existing Void Platform user. - They cannot create an account or admit an unregistered user through - restricted signup. diff --git a/platform/packages/api/drizzle/0048_project_teams.sql b/platform/packages/api/drizzle/0048_project_teams.sql index 9e26b14b2..49c0489fa 100644 --- a/platform/packages/api/drizzle/0048_project_teams.sql +++ b/platform/packages/api/drizzle/0048_project_teams.sql @@ -52,7 +52,6 @@ CREATE TABLE "project_ownership_periods" ( CONSTRAINT "project_ownership_periods_time_check" CHECK ("project_ownership_periods"."ends_at" IS NULL OR "project_ownership_periods"."ends_at" >= "project_ownership_periods"."starts_at") ); --> statement-breakpoint -ALTER TABLE "builds" ADD COLUMN "triggered_by_user_id" text;--> statement-breakpoint ALTER TABLE "deployments" ADD COLUMN "billing_user_id" text;--> statement-breakpoint ALTER TABLE "deployments" ADD COLUMN "initiated_by_user_id" text;--> statement-breakpoint UPDATE "deployments" AS "deployment" diff --git a/platform/packages/api/drizzle/meta/0048_snapshot.json b/platform/packages/api/drizzle/meta/0048_snapshot.json index b3f0d5e91..4790927e1 100644 --- a/platform/packages/api/drizzle/meta/0048_snapshot.json +++ b/platform/packages/api/drizzle/meta/0048_snapshot.json @@ -75,12 +75,6 @@ "primaryKey": false, "notNull": true }, - "triggered_by_user_id": { - "name": "triggered_by_user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, "executor": { "name": "executor", "type": "text", @@ -6150,4 +6144,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/platform/packages/api/src/admin/routes/projects.tsx b/platform/packages/api/src/admin/routes/projects.tsx index db85558f9..1319a6eb9 100644 --- a/platform/packages/api/src/admin/routes/projects.tsx +++ b/platform/packages/api/src/admin/routes/projects.tsx @@ -33,7 +33,7 @@ type OwnerTransferPreview = Readonly<{ target: { id: string; slug: string; userId: string }; previousOwner: OwnerSummary; newOwner: OwnerSummary; - formerOwnerRole: 'admin'; + formerOwnerRole: 'admin' | null; targetPreviousRole: string | null; blockers: ReadonlyArray<{ kind: string; id: string; status: string }>; github: { @@ -43,7 +43,7 @@ type OwnerTransferPreview = Readonly<{ executor: string; } | null; routingUpdates: ReadonlyArray; - counterUpdates: { detachUserId: string; attachUserId: string }; + counterUpdates: { detachUserId: string | null; attachUserId: string }; usageTransfer: string; }>; @@ -258,10 +258,16 @@ app.post('/:id/owner/preview', async (context) => { userId, true, )) as unknown as OwnerTransferPreview; + const convergenceOnly = result.formerOwnerRole === null; return context.html( - -

Transfer Ownership: {result.target.slug}

+ +

+ {convergenceOnly ? 'Converge Ownership State' : 'Transfer Ownership'}: {result.target.slug} +

@@ -291,11 +297,21 @@ app.post('/:id/owner/preview', async (context) => {
-

- The former owner will become a {result.formerOwnerRole}. The target's - current project role is {result.targetPreviousRole ?? 'none'}. Usage - transfer is {result.usageTransfer}. -

+ {convergenceOnly ? ( +

+ This project is already owned by the target. This is a convergence-only operation: + applying it repairs membership, usage-counter, routing, and account metadata without + changing ownership. The owner's stored project role is{' '} + {result.targetPreviousRole ?? 'none'}. Usage transfer is{' '} + {result.usageTransfer}. +

+ ) : ( +

+ The former owner will become a {result.formerOwnerRole}. The target's + current project role is {result.targetPreviousRole ?? 'none'}. Usage + transfer is {result.usageTransfer}. +

+ )}

Active-work blockers

{result.blockers.length > 0 ? ( @@ -312,8 +328,17 @@ app.post('/:id/owner/preview', async (context) => {

Convergence

- Usage counters: detach from {result.counterUpdates.detachUserId}, attach to{' '} - {result.counterUpdates.attachUserId}. + {result.counterUpdates.detachUserId ? ( + <> + Usage counters: detach from {result.counterUpdates.detachUserId}, attach to{' '} + {result.counterUpdates.attachUserId}. + + ) : ( + <> + Usage counters: attach or reconcile with{' '} + {result.counterUpdates.attachUserId}; no former-owner detachment is needed. + + )}

Routing and account entries: {result.routingUpdates.join(', ')}

@@ -331,7 +356,7 @@ app.post('/:id/owner/preview', async (context) => {
) : ( diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 5b357d865..f328059ca 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -7,7 +7,6 @@ import { eq, ne, lt, - lte, gt, gte, inArray, @@ -610,19 +609,6 @@ export async function createProjectAccessEvent( return event; } -export async function getProjectAccessEvents( - db: Db, - projectId: string, - limit = 100, -): Promise> { - return db - .select() - .from(projectAccessEvents) - .where(eq(projectAccessEvents.project_id, projectId)) - .orderBy(desc(projectAccessEvents.created_at)) - .limit(limit); -} - export async function setProjectMemberRole( db: Db, args: { @@ -631,7 +617,7 @@ export async function setProjectMemberRole( role: ProjectMemberRole; actorUserId: string; }, -): Promise { +): Promise<{ member: ProjectMember; cancelledBuilds: ReadonlyArray }> { return db.transaction(async (tx) => { const txDb = tx as unknown as Db; const project = await lockProject(txDb, args.projectId); @@ -644,7 +630,7 @@ export async function setProjectMemberRole( throw new ProjectTeamMutationError('member-not-found'); } if (current.role === args.role) { - return current; + return { member: current, cancelledBuilds: [] }; } const [member] = await txDb .update(projectMembers) @@ -664,22 +650,23 @@ export async function setProjectMemberRole( previousRole: current.role, newRole: args.role, }); - if (args.role !== 'admin') { - await invalidateGitHubConnectionAuthorizedBy( - txDb, - args.projectId, - args.subjectUserId, - 'github connection authorizer lost admin access', - ); - } - return member; + const cancelledBuilds = + args.role === 'admin' + ? [] + : await invalidateGitHubConnectionAuthorizedBy( + txDb, + args.projectId, + args.subjectUserId, + 'github connection authorizer lost admin access', + ); + return { member, cancelledBuilds }; }); } export async function removeProjectMember( db: Db, args: { projectId: string; subjectUserId: string; actorUserId: string }, -): Promise { +): Promise<{ member: ProjectMember; cancelledBuilds: ReadonlyArray }> { return db.transaction(async (tx) => { const txDb = tx as unknown as Db; const project = await lockProject(txDb, args.projectId); @@ -712,13 +699,13 @@ export async function removeProjectMember( action: args.actorUserId === args.subjectUserId ? 'left' : 'member_removed', previousRole: member.role, }); - await invalidateGitHubConnectionAuthorizedBy( + const cancelledBuilds = await invalidateGitHubConnectionAuthorizedBy( txDb, args.projectId, args.subjectUserId, 'github connection authorizer removed from project', ); - return member; + return { member, cancelledBuilds }; }); } @@ -1078,17 +1065,6 @@ export async function respondToProjectInvitation( // --- Project ownership history --- -export async function getProjectOwnershipPeriods( - db: Db, - projectId: string, -): Promise> { - return db - .select() - .from(projectOwnershipPeriods) - .where(eq(projectOwnershipPeriods.project_id, projectId)) - .orderBy(asc(projectOwnershipPeriods.starts_at)); -} - export async function getProjectOwnershipPeriodsByProjectIds( db: Db, projectIds: ReadonlyArray, @@ -1120,30 +1096,6 @@ export async function getOpenProjectOwnershipPeriod( return row ?? null; } -/** Ownership intervals use [starts_at, ends_at), so a boundary bills new owner. */ -export async function getProjectOwnerAt( - db: Db, - projectId: string, - occurredAt: string, -): Promise { - const [row] = await db - .select({ ownerUserId: projectOwnershipPeriods.owner_user_id }) - .from(projectOwnershipPeriods) - .where( - and( - eq(projectOwnershipPeriods.project_id, projectId), - lte(projectOwnershipPeriods.starts_at, occurredAt), - or( - isNull(projectOwnershipPeriods.ends_at), - gt(projectOwnershipPeriods.ends_at, occurredAt), - ), - ), - ) - .orderBy(desc(projectOwnershipPeriods.starts_at)) - .limit(1); - return row?.ownerUserId ?? null; -} - export async function getLatestDeploymentsByProjectIds( db: Db, projectIds: ReadonlyArray, @@ -3053,9 +3005,63 @@ export async function updateUserRole(db: Db, id: string, role: 'user' | 'admin') } } -export async function deleteUser(db: Db, id: string): Promise { - await withAdminRemovalGuard(db, id, async (tx) => { +export async function deleteUser(db: Db, id: string): Promise> { + return withAdminRemovalGuard(db, id, async (tx) => { + const cancelledBuilds: Array = []; + const [memberships, existingConnections] = await Promise.all([ + tx + .select({ projectId: projectMembers.project_id }) + .from(projectMembers) + .where(eq(projectMembers.user_id, id)), + tx + .select({ projectId: githubConnections.project_id }) + .from(githubConnections) + .where(eq(githubConnections.authorized_by_user_id, id)), + ]); + const projectIds = [ + ...new Set([ + ...memberships.map(({ projectId }) => projectId), + ...existingConnections.map(({ projectId }) => projectId), + ]), + ].sort(); + if (projectIds.length > 0) { + // GitHub connection CRUD and build creation take the project lock before + // the connection lock. Preserve that order here so deleting an + // authorizing member serializes with both paths without deadlocking. + await tx + .select({ id: projects.id }) + .from(projects) + .where(inArray(projects.id, projectIds)) + .orderBy(asc(projects.id)) + .for('update'); + + // A connection creator that held one of the project locks before this + // deletion may have inserted after the initial discovery query. Re-read + // under all membership project locks so its connection cannot escape + // invalidation and be reduced to legacy NULL provenance by the user FK. + const authorizedConnections = await tx + .select({ projectId: githubConnections.project_id }) + .from(githubConnections) + .where( + and( + eq(githubConnections.authorized_by_user_id, id), + inArray(githubConnections.project_id, projectIds), + ), + ) + .orderBy(asc(githubConnections.project_id)); + for (const { projectId } of authorizedConnections) { + cancelledBuilds.push( + ...(await invalidateGitHubConnectionAuthorizedBy( + tx, + projectId, + id, + 'github connection authorizer account deleted', + )), + ); + } + } await tx.delete(users).where(eq(users.id, id)); + return cancelledBuilds; }); } @@ -3635,7 +3641,6 @@ export async function createBuild( logsUrl?: string | null; instanceType?: string | null; status?: Build['status']; - triggeredByUserId?: string | null; }, ): Promise { const id = generateId('bld'); @@ -3645,7 +3650,6 @@ export async function createBuild( id, project_id: args.projectId, user_id: args.userId, - triggered_by_user_id: args.triggeredByUserId ?? null, executor: args.executor ?? 'container', status: args.status ?? 'queued', commit_sha: args.commitSha ?? null, @@ -3972,57 +3976,6 @@ export async function hasQueuedContainerBuilds(db: Db): Promise { return row !== undefined; } -/** - * Atomically supersede older queued container builds and enqueue a new one for - * a push, under a per-project FOR UPDATE lock so concurrent webhook deliveries - * for the same project+branch can't interleave their supersede+create steps - * (which previously let an older push cancel a newer queued build and then - * enqueue the older commit). Semantics are LAST-PROCESSED-WINS — the honest - * guarantee for unordered webhooks; the GitHub push payload carries no reliable - * commit-ordering signal, so true latest-commit-wins under out-of-order delivery - * of DIFFERENT commits is intentionally NOT attempted. - * - * Reuse vs. rebuild for this EXACT commit on this project+branch: - * - An ACTIVE build (queued/starting/building/deploying) is ALWAYS reused - * (in-flight idempotency for a redelivered push). - * - A `complete` build is reused ONLY when this commit is what the project is - * CURRENTLY LIVE ON (a true webhook redelivery). If the live deployment is a - * DIFFERENT commit — a force-push ROLLBACK to a previously-deployed SHA - * (deploy A, deploy B, force-push branch back to A) — the old `complete` - * build is NOT reused; a fresh `queued` build is enqueued so the rollback - * actually re-deploys A. "Live is this commit" is decided inside the same - * transaction under the project lock: prefer the authoritative - * `builds.deployment_id == projects.active_deployment_id` link, falling back - * to `deployments.commit == args.commitSha`. Container builds record NEITHER - * (`deployment_id` and `deployments.commit` are both NULL for the default - * executor), so a complete-SHA push that can't be PROVEN live conservatively - * rebuilds — at worst a redundant rebuild of an identical commit, never a - * missed rollback. - * - A prior `failed`/`cancelled` build does NOT block a new one, so re-pushing - * a commit after a failed build still retries. - * - * Conflicting active builds are ALWAYS terminated first — before the reuse - * lookup — so this holds even on a `complete`-SHA reuse/rebuild: a force-push - * ROLLBACK to a previously-deployed SHA still cancels any newer in-flight build - * for the now-off-branch commit (which would otherwise keep a live deploy token - * and publish the stale newer commit). `exceptCommitSha` spares this push's own - * commit, so a same-commit redelivery still dedups with `cancelled: []` and - * never self-cancels. - * - * Serializes against a concurrent `PATCH /projects/:id/github` (which locks - * `github_connections`, can re-point the branch / switch executor, and - * terminates old-config builds). After taking the project lock, this LOCKS and - * RE-READS the project's single `github_connections` row (the column is UNIQUE - * per project) and rejects — returning `{ build: null, skipped: 'config-changed' }` - * with `cancelled: []` — when the connection no longer matches this push's - * branch or its executor is no longer `container`. Either ordering is safe: a - * PATCH that committed first is observed here (we see the new config and skip); - * if we committed first, the PATCH's own terminate step (it holds the same lock) - * catches the build we just enqueued. Lock order is projects -> github_connections - * (no path inverts it), so no deadlock. This closes the gap where an orphan - * queued build for a now-disabled branch/executor escaped both the scheduler and - * the JIT freshness gate. - */ /** * Re-check, INSIDE a build-creation transaction, that the GitHub App installation * still grants access to the repo. Both build txns already hold a `FOR UPDATE` @@ -4075,7 +4028,7 @@ async function invalidateGitHubConnectionAuthorizedBy( projectId: string, userId: string, reason: string, -): Promise { +): Promise> { const [connection] = await db .select({ id: githubConnections.id }) .from(githubConnections) @@ -4088,10 +4041,11 @@ async function invalidateGitHubConnectionAuthorizedBy( .for('update') .limit(1); if (!connection) { - return; + return []; } - await terminateBuildsForProject(db, projectId, { reason }); + const cancelledBuilds = await terminateBuildsForProject(db, projectId, { reason }); await db.delete(githubConnections).where(eq(githubConnections.id, connection.id)); + return cancelledBuilds; } async function installationCoversRepoInTx( @@ -4192,6 +4146,12 @@ export async function installationCoversRepo( return row !== undefined; } +/** + * Serialize webhook enqueue with project and connection changes. The last + * processed push wins; an active identical commit is reused, while a completed + * commit is reused only when it is still live so force-push rollbacks rebuild. + * Connection configuration and repository authority are rechecked under lock. + */ export async function supersedeAndCreateContainerBuild( db: Db, args: { @@ -4459,6 +4419,13 @@ export async function getBuildByProjectRunIdAndAttempt( * prior status) so the caller can best-effort stop active container BuildManager * DOs. */ +export type TerminatedBuild = Readonly<{ + id: string; + executor: Build['executor']; + status: Build['status']; + instance_type: string | null; +}>; + export async function terminateBuildsForProject( db: Db, projectId: string, @@ -4483,14 +4450,7 @@ export async function terminateBuildsForProject( // and misfires under any non-UTC session timezone. createdBefore?: string; }, -): Promise< - ReadonlyArray<{ - id: string; - executor: Build['executor']; - status: Build['status']; - instance_type: string | null; - }> -> { +): Promise> { const conds = [ eq(builds.project_id, projectId), inArray(builds.status, ['queued', 'starting', 'building', 'deploying']), @@ -5008,9 +4968,9 @@ export async function createGitHubConnection( // pre-existing callers/tests compile; the connect handler always supplies it // and webhook/OIDC resolution keys on it (full_name is mutable). repositoryId?: string | null; - // Set only after an organization repository was proven with the project - // owner's own GitHub OAuth token. Legacy organization connections remain - // null and are rejected by every runtime resolver until reconnected. + // Set after repository access was proven with the project owner or + // administrator's own GitHub OAuth token. Legacy organization connections + // remain null and are rejected by every runtime resolver until reconnected. authorizedByUserId?: string | null; branch?: string; buildExecutor?: GitHubConnection['build_executor']; diff --git a/platform/packages/api/src/email.ts b/platform/packages/api/src/email.ts index 7e4fe91e4..17a6ddfeb 100644 --- a/platform/packages/api/src/email.ts +++ b/platform/packages/api/src/email.ts @@ -113,13 +113,13 @@ export async function sendProjectInvitationEmail( html: emailHtml( `

You're invited to ${escapeHtml(params.projectSlug)}

${escapeHtml(params.inviterLogin)} invited you to join ${escapeHtml(params.projectSlug)} as ${escapeHtml(params.role)} on ${escapeHtml(name)}.

-

Sign in with this email address, then accept the invitation from the dashboard or CLI.

+

Connect the Void CLI with this email address, then accept the invitation:

${escapeHtml(command)}
 void project team accept ${escapeHtml(params.invitationId)}

${button(inviteUrl.href, 'View Invitation')}

`, "If you didn't expect this invitation, you can decline it or ignore this email.", ), - text: `You're invited to ${params.projectSlug}\n\n${params.inviterLogin} invited you to join ${params.projectSlug} as ${params.role} on ${name}.\n\nSign in with this email address, then accept the invitation from the dashboard or CLI:\n\n${command}\nvoid project team accept ${params.invitationId}\n\nView invitation: ${inviteUrl.href}\n\nIf you didn't expect this invitation, you can decline it or ignore this email.`, + text: `You're invited to ${params.projectSlug}\n\n${params.inviterLogin} invited you to join ${params.projectSlug} as ${params.role} on ${name}.\n\nConnect the Void CLI with this email address, then accept the invitation:\n\n${command}\nvoid project team accept ${params.invitationId}\n\nView invitation: ${inviteUrl.href}\n\nIf you didn't expect this invitation, you can decline it or ignore this email.`, }); } diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index f7566ab12..4a094d2a9 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -401,20 +401,30 @@ const app = new Hono() }) .get('/invite', (context) => { const name = escapeHtml(getPlatformDisplayName(context.env, context.req.url)); + const invitationId = context.req.query('project-invitation')?.trim(); const command = escapeHtml( platformConnectCommand(getPlatformApiUrl(context.env, context.req.url)), ); + const invitationCommand = invitationId + ? escapeHtml(`void project team accept ${invitationId}`) + : null; return context.html(` -Get Started - ${name} +${invitationCommand ? 'Accept Project Invitation' : 'Get Started'} - ${name}
-

Get Started with ${name}

+

${invitationCommand ? 'Accept your project invitation' : `Get Started with ${name}`}

Connect the Void CLI to this platform. When prompted, sign in with an account whose verified email address matches your invitation:

${command}
+${ + invitationCommand + ? `

Then accept the project invitation:

+
${invitationCommand}
` + : '' +}

The CLI opens your browser to sign in when needed. If your application is already linked to another project, run void project link from its directory to choose a project on this platform before deploying.

Need to create an application? Read the Void quickstart, then return here to connect to this platform before deploying.

@@ -1048,10 +1058,15 @@ async function processUserRollover( // the latch is true. (`undefined` from an old DO during a deploy: skipped, // and reconciled on the next pass once the DO is updated.) if (result.requestsSuspended === false) { - await setProjectsRoutingSuspension(env.ROUTING, db, userProjects, { - op: 'unsuspend', - matchReason: REASON_REQUESTS_EXCEEDED, - }); + await setProjectsRoutingSuspension( + env.ROUTING, + db, + userProjects.map((project) => ({ ...project, user_id: user.id })), + { + op: 'unsuspend', + matchReason: REASON_REQUESTS_EXCEEDED, + }, + ); } } diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 03dcf4b76..8e0da4fcf 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -35,6 +35,7 @@ import { updateUserPlan, } from '../db.ts'; import type { ApiEnv } from '../env.ts'; +import { cancelContainerBuildDos } from '../github/cancel-builds.ts'; import { generateId } from '../id.ts'; import { teardownProject } from '../provisioning/cleanup.ts'; import { getRequestsDataset, queryBatchProjectUsage, ZERO_USAGE } from '../routes/usage.ts'; @@ -42,7 +43,6 @@ import { buildProjectAccountJson, computeDispatchLimits, projectAccountKey, - refreshProjectKvForUser, sandboxBlockKey, setProjectsRoutingSuspension, } from '../routing.ts'; @@ -312,7 +312,42 @@ async function inspectOwnershipTransfer(db: Db, projectId: string, targetUserId: : []), ...activeWork, ]; - return { project, currentOwner, newOwner, blockers, targetMembership, github, domains }; + const previousTransfer = + currentOwner.id === newOwner.id + ? await getPreviousOwnershipTransfer(db, projectId, newOwner) + : null; + return { + project, + previousOwner: previousTransfer?.owner ?? currentOwner, + newOwner, + blockers, + targetMembership, + github, + domains, + }; +} + +async function getPreviousOwnershipTransfer( + db: Db, + projectId: string, + fallbackOwner: User, +): Promise<{ owner: User; startsAt: string } | null> { + const [period] = await db + .select() + .from(projectOwnershipPeriods) + .where( + and( + eq(projectOwnershipPeriods.project_id, projectId), + isNotNull(projectOwnershipPeriods.ends_at), + ), + ) + .orderBy(desc(projectOwnershipPeriods.ends_at)) + .limit(1); + if (!period) { + return null; + } + const [owner] = await db.select().from(users).where(eq(users.id, period.owner_user_id)).limit(1); + return { owner: owner ?? fallbackOwner, startsAt: period.starts_at }; } type OwnershipTransferCommit = Readonly<{ @@ -401,31 +436,12 @@ async function commitOwnershipTransfer( ); if (lockedProject.user_id === targetUserId) { - const [previousPeriod] = await txDb - .select() - .from(projectOwnershipPeriods) - .where( - and( - eq(projectOwnershipPeriods.project_id, projectId), - isNotNull(projectOwnershipPeriods.ends_at), - ), - ) - .orderBy(desc(projectOwnershipPeriods.ends_at)) - .limit(1); - const previousOwner = previousPeriod - ? (( - await txDb - .select() - .from(users) - .where(eq(users.id, previousPeriod.owner_user_id)) - .limit(1) - )[0] ?? newOwner) - : newOwner; + const previousTransfer = await getPreviousOwnershipTransfer(txDb, projectId, newOwner); return { project: lockedProject, - previousOwner, + previousOwner: previousTransfer?.owner ?? newOwner, newOwner, - previousOwnershipStartedAt: previousPeriod?.starts_at ?? lockedProject.created_at, + previousOwnershipStartedAt: previousTransfer?.startsAt ?? lockedProject.created_at, transferredAt: openPeriod?.starts_at ?? lockedProject.created_at, changed: false, }; @@ -530,27 +546,28 @@ async function updateOwnershipCounters( userId: transfer.newOwner.id, slug: transfer.project.slug, ownershipStartedAt: transfer.transferredAt, - plan: transfer.newOwner.plan, }); } -async function republishTransferredProject( +async function republishProjectOwnerState( context: Context, - transfer: OwnershipTransferCommit, -): Promise { + projectId: string, + expectedOwnerUserId: string, +): Promise { const db = context.get('db'); - await db.transaction(async (tx) => { + return db.transaction(async (tx) => { const txDb = tx as unknown as Db; const [project] = await txDb .select() .from(projects) - .where(eq(projects.id, transfer.project.id)) + .where(eq(projects.id, projectId)) .for('update') .limit(1); - if (!project || project.user_id !== transfer.newOwner.id || project.status === 'deleting') { - throw new Error('Project ownership changed again before routing convergence.'); + if (!project || project.user_id !== expectedOwnerUserId || project.status === 'deleting') { + return null; } - const [activeDeployment, domains] = await Promise.all([ + const [owner, activeDeployment, domains] = await Promise.all([ + requireUser(txDb, project.user_id), project.active_deployment_id ? txDb .select({ manifest: deployments.manifest }) @@ -565,17 +582,14 @@ async function republishTransferredProject( if (activeDeployment?.manifest) { manifest = JSON.parse(activeDeployment.manifest) as DeployManifest; } - const dispatchLimits = computeDispatchLimits( - PLAN_LIMITS[transfer.newOwner.plan], - manifest?.limits, - ); + const dispatchLimits = computeDispatchLimits(PLAN_LIMITS[owner.plan], manifest?.limits); for (const key of [project.slug, ...domains.map((domain) => `domain:${domain.hostname}`)]) { const raw = await context.env.ROUTING.get(key); if (!raw) { continue; } const entry = JSON.parse(raw) as Record; - entry.userId = transfer.newOwner.id; + entry.userId = owner.id; if ('workerName' in entry) { if (dispatchLimits) { entry.limits = dispatchLimits; @@ -583,8 +597,8 @@ async function republishTransferredProject( delete entry.limits; } } - if (transfer.newOwner.suspended_reason) { - entry.suspended = transfer.newOwner.suspended_reason; + if (owner.suspended_reason) { + entry.suspended = owner.suspended_reason; } else { delete entry.suspended; } @@ -592,11 +606,37 @@ async function republishTransferredProject( } await context.env.ROUTING.put( projectAccountKey(project.id), - buildProjectAccountJson({ userId: transfer.newOwner.id, plan: transfer.newOwner.plan }), + buildProjectAccountJson({ userId: owner.id, plan: owner.plan }), ); + return owner; }); } +async function republishTransferredProject( + context: Context, + transfer: OwnershipTransferCommit, +): Promise { + const owner = await republishProjectOwnerState( + context, + transfer.project.id, + transfer.newOwner.id, + ); + if (!owner) { + throw new Error('Project ownership changed again before routing convergence.'); + } + return owner; +} + +async function reconcileOperatorUserProjects( + context: Context, + userId: string, +): Promise { + const projects = await getProjectsByUserId(context.get('db'), userId); + for (const project of projects) { + await republishProjectOwnerState(context, project.id, userId); + } +} + export async function transferOperatorProjectOwner( context: Context, projectId: string, @@ -610,9 +650,9 @@ export async function transferOperatorProjectOwner( 'project.owner', { id: inspection.project.id, slug: inspection.project.slug, userId: targetUserId }, { - previousOwner: accountSummary(inspection.currentOwner), + previousOwner: accountSummary(inspection.previousOwner), newOwner: accountSummary(inspection.newOwner), - formerOwnerRole: 'admin', + formerOwnerRole: inspection.previousOwner.id === inspection.newOwner.id ? null : 'admin', targetPreviousRole: inspection.targetMembership?.role ?? null, blockers: inspection.blockers, github: inspection.github, @@ -622,7 +662,10 @@ export async function transferOperatorProjectOwner( projectAccountKey(inspection.project.id), ], counterUpdates: { - detachUserId: inspection.currentOwner.id, + detachUserId: + inspection.previousOwner.id === inspection.newOwner.id + ? null + : inspection.previousOwner.id, attachUserId: inspection.newOwner.id, }, usageTransfer: 'non-retroactive', @@ -637,10 +680,11 @@ export async function transferOperatorProjectOwner( context.get('user').id, ); const convergence = { countersComplete: false, routingComplete: false }; + let convergedOwner = transfer.newOwner; try { await updateOwnershipCounters(context, transfer); convergence.countersComplete = true; - await republishTransferredProject(context, transfer); + convergedOwner = await republishTransferredProject(context, transfer); convergence.routingComplete = true; } catch (error) { console.error('[admin] project owner convergence failed', { projectId, targetUserId, error }); @@ -654,7 +698,7 @@ export async function transferOperatorProjectOwner( ownershipTransferred: true, transferredAt: transfer.transferredAt, previousOwner: accountSummary(transfer.previousOwner), - newOwner: accountSummary(transfer.newOwner), + newOwner: accountSummary(convergedOwner), convergence, }, ); @@ -663,7 +707,7 @@ export async function transferOperatorProjectOwner( action: 'project.owner', target: { id: transfer.project.id, slug: transfer.project.slug, userId: targetUserId }, previousOwner: accountSummary(transfer.previousOwner), - newOwner: accountSummary(transfer.newOwner), + newOwner: accountSummary(convergedOwner), transferredAt: transfer.transferredAt, formerOwnerRole: transfer.previousOwner.id === transfer.newOwner.id ? null : 'admin', changed: transfer.changed, @@ -698,11 +742,6 @@ export async function changeOperatorUserPlan( try { const stub = context.env.USAGE_COUNTER.get(context.env.USAGE_COUNTER.idFromName(id)); const results = await Promise.allSettled([ - refreshProjectKvForUser(db, context.env.ROUTING, id), - setProjectsRoutingSuspension(context.env.ROUTING, db, projects, { - op: 'unsuspend', - matchReason: REASON_REQUESTS_EXCEEDED, - }), context.env.ROUTING.delete(sandboxBlockKey(id)), unsuspendUserIfReason(db, id, REASON_REQUESTS_EXCEEDED), unsuspendUserIfReason(db, id, REASON_SANDBOX_RUNTIME_EXCEEDED), @@ -718,10 +757,14 @@ export async function changeOperatorUserPlan( if (failed?.status === 'rejected') { throw failed.reason; } - const reeval = results[5]; + const reeval = results[3]; if (reeval.status === 'fulfilled' && !reeval.value.ok) { throw new Error(`reevaluate-sandbox DO returned HTTP ${reeval.value.status}`); } + // A transfer can republish between the mixed-order cleanup writes above. + // Re-enumerate current ownership after the account row has settled and + // publish the authoritative plan and suspension state once more. + await reconcileOperatorUserProjects(context, id); } catch (error) { console.error('[admin] plan-change cleanup failed', { userId: id, error }); throw new OperatorError( @@ -765,6 +808,7 @@ export async function suspendOperatorUser( force: true, }); await suspendUser(db, id, reason); + await reconcileOperatorUserProjects(context, id); } catch (error) { console.error('[admin] suspend failed', { userId: id, error }); throw new OperatorError( @@ -810,6 +854,7 @@ export async function restoreOperatorUser(context: Context, id: string, throw new Error(`clear-suspension DO returned HTTP ${cleared.value.status}`); } await unsuspendUser(db, id); + await reconcileOperatorUserProjects(context, id); } catch (error) { console.error('[admin] unsuspend cleanup failed', { userId: id, error }); throw new OperatorError( @@ -863,12 +908,22 @@ export async function deleteOperatorUser(context: Context, id: string, d try { for (const project of projects) { currentProject = project.id; - const result = await teardownProject(context, project.id, project); + const result = await teardownProject(context, project.id, project, { + expectedOwnerUserId: id, + }); + if (result.ownerChanged) { + continue; + } deletedProjects.push(project.id); warnings.push(...result.warnings); } currentProject = undefined; - await deleteUser(db, id); + const cancelledBuilds = await deleteUser(db, id); + await cancelContainerBuildDos( + context.env, + cancelledBuilds, + 'github connection authorizer account deleted', + ); } catch (error) { console.error('[admin] user delete failed', { userId: id, error }); throw new OperatorError( diff --git a/platform/packages/api/src/project-access.ts b/platform/packages/api/src/project-access.ts index 5358d7544..2906833e5 100644 --- a/platform/packages/api/src/project-access.ts +++ b/platform/packages/api/src/project-access.ts @@ -53,19 +53,6 @@ async function selectProjectAccess( }; } -/** Resolve current access without asserting a capability. */ -export async function getProjectAccess( - db: Db, - projectId: string, - actorUserId: string, -): Promise { - const row = await selectProjectAccess(db, projectId, actorUserId); - if (!row?.memberRole) { - return null; - } - return { project: row.project, role: row.memberRole, owner: row.owner }; -} - export class ProjectNotFoundError extends Error { readonly status = 404 as const; diff --git a/platform/packages/api/src/provisioning/cleanup.ts b/platform/packages/api/src/provisioning/cleanup.ts index a3fa6fdb6..4444873d1 100644 --- a/platform/packages/api/src/provisioning/cleanup.ts +++ b/platform/packages/api/src/provisioning/cleanup.ts @@ -353,14 +353,13 @@ async function lockAndMarkDeleting( db: Db, projectId: string, actorUserId?: string, -): Promise< - ReadonlyArray<{ - id: string; - executor: Build['executor']; - status: Build['status']; - instance_type: string | null; - }> -> { + expectedOwnerUserId?: string, +): Promise | null> { return await db.transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); const txDb = tx as unknown as Db; @@ -368,6 +367,14 @@ async function lockAndMarkDeleting( if (!lockedProject) { throw new Error('project disappeared during deletion'); } + // Operator user deletion enumerates the user's owned projects before it + // starts their slow external teardown. Ownership transfer can complete in + // between. Treat that stale snapshot as a skip while holding the same lock + // transfer uses; deleting the new owner's project is never a valid cleanup + // consequence of deleting the former owner's account. + if (expectedOwnerUserId && lockedProject.user_id !== expectedOwnerUserId) { + return null; + } // API-initiated teardown starts with an unlocked route check so it can // return the established 404/403 response quickly. Ownership can transfer // before this transaction acquires the project lock, however. Re-resolve @@ -496,8 +503,8 @@ export async function teardownProject( context: Context, projectId: string, project: Pick, - options: Readonly<{ actorUserId?: string }> = {}, -): Promise<{ warnings: ReadonlyArray }> { + options: Readonly<{ actorUserId?: string; expectedOwnerUserId?: string }> = {}, +): Promise<{ warnings: ReadonlyArray; ownerChanged?: true }> { // Always run lock + mark — idempotent for `status='deleting'` (the helper // skips the flip and just (re-)marks any non-terminal deployments + builds). // This covers retried teardown after partial failure where the previous @@ -506,7 +513,11 @@ export async function teardownProject( context.get('db'), projectId, options.actorUserId, + options.expectedOwnerUserId, ); + if (cancelledOnLock === null) { + return { warnings: [], ownerChanged: true }; + } // Stop the container DOs for the builds revoked above BEFORE any CF cleanup // begins (post-commit, never under the row lock — it's a DO round-trip), so // their tokens are dead and their containers stopped before we touch CF. diff --git a/platform/packages/api/src/revalidate-core.ts b/platform/packages/api/src/revalidate-core.ts index 76adeab80..ee853f42d 100644 --- a/platform/packages/api/src/revalidate-core.ts +++ b/platform/packages/api/src/revalidate-core.ts @@ -13,7 +13,7 @@ import { import { platformProjectHostnames, readPlatformApplication } from '@void/platform'; import type { Context } from 'hono'; import { R2_CONCURRENCY, uploadPool } from './concurrency.ts'; -import { getDomainsByProjectId } from './db.ts'; +import { getDomainsByProjectId, type Db } from './db.ts'; import type { ApiEnv } from './env.ts'; import { resolveCurrentIsrDeployments, type IsrRoutingTarget } from './isr-cache.ts'; import { purgeZoneCache } from './provisioning/cloudflare-api.ts'; @@ -71,8 +71,8 @@ export async function runRevalidate( projectId: string, project: RevalidateProject, body: RevalidateRequestBody, + db: Db = context.get('db'), ): Promise { - const db = context.get('db'); const application = readPlatformApplication(context.env); const customDomains = await getDomainsByProjectId(db, projectId); diff --git a/platform/packages/api/src/routes/github.ts b/platform/packages/api/src/routes/github.ts index c47b7710b..feac30c92 100644 --- a/platform/packages/api/src/routes/github.ts +++ b/platform/packages/api/src/routes/github.ts @@ -1363,7 +1363,7 @@ export const githubRoutes = new Hono() // The OIDC exchange (`routes/auth-oidc.ts`) binds a deploy token to the // workflow file at the connection's `workflow_path`. GitHub only runs workflows // under `.github/workflows/`, so a path outside it (or one with `..`) can never -// match a real OIDC `workflow_ref` — reject it here at the owner-authenticated +// match a real OIDC `workflow_ref` — reject it here at the settings-authorized // setter for a clear error, rather than a silently-never-authorized deploy. function isValidWorkflowPath(path: string): boolean { return ( diff --git a/platform/packages/api/src/routes/project-team.ts b/platform/packages/api/src/routes/project-team.ts index 0b064f6da..1c4a06d7b 100644 --- a/platform/packages/api/src/routes/project-team.ts +++ b/platform/packages/api/src/routes/project-team.ts @@ -16,6 +16,7 @@ import { } from '../db.ts'; import { sendProjectInvitationEmail } from '../email.ts'; import type { ApiEnv } from '../env.ts'; +import { cancelContainerBuildDos } from '../github/cancel-builds.ts'; import { ProjectAccessDeniedError, ProjectNotFoundError, @@ -31,6 +32,8 @@ import type { } from '../types.ts'; const MEMBER_ROLES = new Set(['reader', 'collaborator', 'admin']); +const INVITATION_EMAILS_PER_MINUTE = 5; +const INVITATION_EMAILS_PER_DAY = 50; type MemberJson = Readonly<{ user: Readonly>; @@ -149,11 +152,14 @@ async function deliverInvitation( invitation: ProjectInvitation, target: User, projectSlug: string, -): Promise<{ status: 'sent' | 'not_configured' | 'failed' }> { +): Promise<{ status: 'sent' | 'not_configured' | 'rate_limited' | 'failed' }> { if (!context.env.EMAIL || !target.email) { return { status: 'not_configured' }; } try { + if (!(await consumeInvitationEmailDelivery(context.env.ROUTING, invitation.project_id))) { + return { status: 'rate_limited' }; + } await sendProjectInvitationEmail(context.env, { to: target.email, invitationId: invitation.id, @@ -171,6 +177,47 @@ async function deliverInvitation( } } +/** + * Approximate per-project delivery limit. KV can briefly undercount concurrent + * requests across colos, which is acceptable for an email abuse guard. The + * counter is consumed before sending so provider failures cannot be retried to + * generate unlimited delivery attempts. + */ +async function consumeInvitationEmailDelivery( + kv: KVNamespace, + projectId: string, +): Promise { + const now = new Date(); + const minute = `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, '0')}${String( + now.getUTCDate(), + ).padStart(2, '0')}T${String(now.getUTCHours()).padStart(2, '0')}${String( + now.getUTCMinutes(), + ).padStart(2, '0')}`; + const day = minute.slice(0, 8); + const minuteKey = `project-invitation-email-rate:${projectId}:${minute}`; + const dayKey = `project-invitation-email-rate:${projectId}:${day}`; + const [minuteRaw, dayRaw] = await Promise.all([kv.get(minuteKey), kv.get(dayKey)]); + const minuteCount = parseDeliveryCounter(minuteRaw); + const dayCount = parseDeliveryCounter(dayRaw); + + if (minuteCount >= INVITATION_EMAILS_PER_MINUTE || dayCount >= INVITATION_EMAILS_PER_DAY) { + return false; + } + await Promise.all([ + kv.put(minuteKey, String(minuteCount + 1), { expirationTtl: 5 * 60 }), + kv.put(dayKey, String(dayCount + 1), { expirationTtl: 25 * 60 * 60 }), + ]); + return true; +} + +function parseDeliveryCounter(raw: string | null): number { + if (!raw) { + return 0; + } + const count = parseInt(raw, 10); + return Number.isFinite(count) && count >= 0 ? count : 0; +} + export const projectTeamRoutes = new Hono() .get('/:id/members', async (context) => { const projectId = context.req.param('id'); @@ -320,12 +367,17 @@ export const projectTeamRoutes = new Hono() return context.json({ error: 'role must be reader, collaborator, or admin' }, 400); } try { - const member = await setProjectMemberRole(context.get('db'), { + const { member, cancelledBuilds } = await setProjectMemberRole(context.get('db'), { projectId, subjectUserId: context.req.param('userId'), role, actorUserId: context.get('user').id, }); + await cancelContainerBuildDos( + context.env, + cancelledBuilds, + 'github connection authorizer lost admin access', + ); const user = await getUser(context.get('db'), member.user_id); if (!user) { return context.json({ error: 'project member not found' }, 404); @@ -347,11 +399,16 @@ export const projectTeamRoutes = new Hono() return access; } try { - await removeProjectMember(context.get('db'), { + const { cancelledBuilds } = await removeProjectMember(context.get('db'), { projectId, subjectUserId: context.req.param('userId'), actorUserId: context.get('user').id, }); + await cancelContainerBuildDos( + context.env, + cancelledBuilds, + 'github connection authorizer removed from project', + ); return context.body(null, 204); } catch (error) { const response = mutationErrorResponse(context, error); @@ -372,11 +429,16 @@ export const projectTeamRoutes = new Hono() return context.json({ error: 'the project owner cannot leave the project' }, 409); } try { - await removeProjectMember(context.get('db'), { + const { cancelledBuilds } = await removeProjectMember(context.get('db'), { projectId, subjectUserId: context.get('user').id, actorUserId: context.get('user').id, }); + await cancelContainerBuildDos( + context.env, + cancelledBuilds, + 'github connection authorizer removed from project', + ); return context.body(null, 204); } catch (error) { const response = mutationErrorResponse(context, error); diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index a7c585992..361673687 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -179,7 +179,7 @@ const DESTRUCTIVE_PRAGMA = '-- void:allow-destructive'; /** * Sentinel error used by the /rollback handler to signal an HTTP - * response status from inside the advisory-locked tx callback. + * response status from inside the row-locked transaction callback. * `db.transaction` rolls back on any throw, so we use this wrapper to * distinguish "user-facing 404/400" from unexpected exceptions that * should surface as 500. Handling is local to the /rollback handler. @@ -839,20 +839,29 @@ const app = new Hono() return access; } - const deployment = await getDeployment(context.get('db'), deploymentId); - if (!deployment || deployment.project_id !== projectId) { - return context.json({ error: 'deployment not found' }, 404); - } + return context.get('db').transaction(async (tx) => { + await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + const txDb = tx as unknown as Db; + const lockedAccess = await requireProjectRouteCapability(context, projectId, 'deploy', txDb); + if (lockedAccess instanceof Response) { + return lockedAccess; + } - const canceling = await cancelDeployment(context.get('db'), deploymentId, 'canceled by user'); - if (!canceling) { - return context.json( - { error: 'deployment already completed', status: deployment.status }, - 409, - ); - } + const deployment = await getDeployment(txDb, deploymentId); + if (!deployment || deployment.project_id !== projectId) { + return context.json({ error: 'deployment not found' }, 404); + } + + const canceling = await cancelDeployment(txDb, deploymentId, 'canceled by user'); + if (!canceling) { + return context.json( + { error: 'deployment already completed', status: deployment.status }, + 409, + ); + } - return context.json({ ok: true, deploymentId, status: 'canceling' }); + return context.json({ ok: true, deploymentId, status: 'canceling' }); + }); }) // Rollback to a previous deployment @@ -2769,17 +2778,8 @@ const app = new Hono() return access; } const project = access.project; - // Refuse to provision once teardown has begun. Project teardown revokes - // in-flight build deploy tokens up front, but a token captured just before - // that (or a full `api` token used by the owner concurrently with their own - // delete) could otherwise still reach this provisioning route and create a - // Hyperdrive whose `resources` row the teardown cascade then deletes WITHOUT - // deleting the CF Hyperdrive (no name-based orphan sweep for hyperdrive). - // This closes the COMMON case (teardown already started at read time). The - // narrow residual — status flips after this unlocked read but before the - // resources-row INSERT lands — is the pre-existing orphan-on-concurrent- - // create class (design-doc 0067; the full fix is project-level locking, not - // scoped here). Bail before touching Cloudflare. + // Fast rejection; the transaction below rechecks status and capability + // under the project lock before touching Cloudflare. if (project.status === 'deleting') { return context.json({ error: 'Project is being deleted' }, 409); } @@ -2935,11 +2935,7 @@ const app = new Hono() return access; } const project = access.project; - // Same teardown guard as POST /:id/database — its create-new-Hyperdrive - // branch below can orphan a CF Hyperdrive the same way. This route is - // `api`-token-only (not deploy-token-reachable), so the only racer is the - // owner deleting their own project concurrently; bail once teardown has - // begun. Same pre-existing residual TOCTOU applies (design-doc 0067). + // Fast rejection; the transaction below performs the authoritative check. if (project.status === 'deleting') { return context.json({ error: 'Project is being deleted' }, 409); } diff --git a/platform/packages/api/src/routes/revalidate.ts b/platform/packages/api/src/routes/revalidate.ts index bade2a0e9..16e3b7280 100644 --- a/platform/packages/api/src/routes/revalidate.ts +++ b/platform/packages/api/src/routes/revalidate.ts @@ -1,4 +1,6 @@ +import { sql } from 'drizzle-orm'; import { Hono } from 'hono'; +import type { Db } from '../db.ts'; import type { ApiEnv } from '../env.ts'; import { runRevalidate } from '../revalidate-core.ts'; import { requireProjectRouteCapability } from './project-access.ts'; @@ -13,7 +15,22 @@ app.post('/:id/revalidate', async (context) => { } const body = await context.req.json<{ paths?: ReadonlyArray; all?: boolean }>(); - return runRevalidate(context, projectId, access.project, body); + return context.get('db').transaction(async (tx) => { + // Membership and ownership changes use the same project lock. Holding it + // through the R2/KV/edge purge gives revalidation one live authorization + // point instead of letting a revoked deploy credential keep mutating + // externally visible cache state after removal or downgrade. + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + const txDb = tx as unknown as Db; + const lockedAccess = await requireProjectRouteCapability(context, projectId, 'deploy', txDb); + if (lockedAccess instanceof Response) { + return lockedAccess; + } + if (lockedAccess.project.status === 'deleting') { + return context.json({ error: 'Project is being deleted' }, 409); + } + return runRevalidate(context, projectId, lockedAccess.project, body, txDb); + }); }); export default app; diff --git a/platform/packages/api/src/schema.ts b/platform/packages/api/src/schema.ts index 5fd6a4abc..3c95fd3b3 100644 --- a/platform/packages/api/src/schema.ts +++ b/platform/packages/api/src/schema.ts @@ -1088,11 +1088,12 @@ export const githubConnections = pgTable( // name fallback left is for callers with no id at all (malformed/partial // webhook). A `repo_full_name` match is used solely in that no-id case. repository_id: text('repository_id'), - // The project owner whose own GitHub OAuth token proved access to this - // organization repository during connect. NULL on legacy rows and personal - // installations. Runtime authorization accepts an organization connection - // only when this still names the owning Void user; rows created before the - // per-repository proof flow therefore fail closed until reconnected. + // The project owner or administrator whose own GitHub OAuth token proved + // repository access during connect. NULL on legacy rows. Runtime + // authorization accepts a current row only while this still names the + // owner or a project administrator. Legacy organization rows without + // per-repository proof fail closed; legacy personal rows retain their + // owner-installation fallback. authorized_by_user_id: text('authorized_by_user_id').references(() => users.id, { onDelete: 'set null', }), @@ -1102,10 +1103,10 @@ export const githubConnections = pgTable( .default('container') .$type<'container' | 'github_actions'>(), // The `.github/workflows/*.yml` file the GitHub Actions OIDC exchange binds - // the deploy token to (see `routes/auth-oidc.ts`). Owner-configurable per - // connection; defaults to the file `void init --github` scaffolds. Only the - // owner-authenticated connect/update routes write this — never the webhook - // or the OIDC path — so it stays a trusted, server-side pin. + // the deploy token to (see `routes/auth-oidc.ts`). Configurable by members + // with project settings access; defaults to the file `void init --github` + // scaffolds. Only the authenticated connect/update routes write this — + // never the webhook or the OIDC path — so it stays a trusted server-side pin. workflow_path: text('workflow_path').notNull().default('.github/workflows/void-deploy.yml'), created_at: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(), updated_at: timestamp('updated_at', { mode: 'string' }).notNull().defaultNow(), @@ -1271,7 +1272,6 @@ export const builds = pgTable( user_id: text('user_id') .notNull() .references(() => users.id), - triggered_by_user_id: text('triggered_by_user_id'), executor: text('executor') .notNull() .default('container') diff --git a/platform/packages/api/src/usage-counter.ts b/platform/packages/api/src/usage-counter.ts index 889c56d92..60f8f4e14 100644 --- a/platform/packages/api/src/usage-counter.ts +++ b/platform/packages/api/src/usage-counter.ts @@ -1,6 +1,7 @@ import { DurableObject } from 'cloudflare:workers'; import { createDb, + getUser, getProjectOwnershipPeriodsByProjectIds, getProjectsByIds, suspendUser, @@ -351,6 +352,15 @@ export class UsageCounter extends DurableObject { this.suspendProjects(getDb(), state.projectSlugs, body.userId, REASON_REQUESTS_EXCEEDED), suspendUser(getDb(), body.userId, REASON_REQUESTS_EXCEEDED), ]); + // The first route stamp and account-row update intentionally run + // together. An ownership-transfer republish can land between them after + // reading the old account row, so reconcile once more after both settle. + await this.suspendProjects( + getDb(), + state.projectSlugs, + body.userId, + REASON_REQUESTS_EXCEEDED, + ); state.requestsSuspended = true; } @@ -527,7 +537,6 @@ export class UsageCounter extends DurableObject { projectId: string; slug: string; ownershipStartedAt: string; - plan?: string; userId?: string; }; if ( @@ -547,11 +556,16 @@ export class UsageCounter extends DurableObject { if (!ownedProjectIds.has(body.projectId)) { return Response.json({ error: 'project owner changed' }, { status: 409 }); } + const owner = state.ownerUserId ? await getUser(db, state.ownerUserId) : null; + if (!owner) { + return Response.json({ error: 'project owner changed' }, { status: 409 }); + } state.projectSlugs[body.projectId] = body.slug; state.projectOwnershipStarts[body.projectId] = body.ownershipStartedAt; - if (body.plan) { - state.plan = body.plan; - } + // Ownership transfer requests may have waited behind a concurrent plan + // change. PostgreSQL is authoritative; never restore the caller's stale + // pre-transfer snapshot into this counter. + state.plan = owner.plan; if (state.requestsSuspended) { await this.suspendProjects( db, diff --git a/platform/packages/api/src/usage-queue.ts b/platform/packages/api/src/usage-queue.ts index 8602d0e61..992edb6cd 100644 --- a/platform/packages/api/src/usage-queue.ts +++ b/platform/packages/api/src/usage-queue.ts @@ -166,7 +166,9 @@ export async function processUsageEventBatch( if (!project) { return []; } - const occurredAt = event.sandboxMeteredThrough !== undefined ? event.sandboxMeteredThrough : at; + // Queue enqueue time is the single attribution timestamp. Managed Sandbox + // is hard-disabled; split interval attribution is deferred until reintroduction. + const occurredAt = at; const ownershipPeriod = ownershipPeriodsByProject .get(event.projectId) ?.find( diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 749d77d4b..2ad33d938 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -1396,6 +1396,37 @@ describe('POST /admin/projects/:id/owner', () => { ).toMatchObject({ user_id: owner.id }); }); + test('renders a same-owner preview as convergence-only without null placeholders', async () => { + const admin = await createTestUser( + env.db, + 'admin-convergence-preview', + 'github', + undefined, + 'free', + 'admin', + ); + const owner = await createTestUser(env.db, 'browser-convergence-owner'); + const project = await createTestProject(env.db, owner.id, 'browser-convergence-project'); + + const response = await req(`/admin/projects/${project.id}/owner/preview`, admin.adminJwt, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ userId: owner.id }), + }); + + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain(`Converge Ownership State: ${project.slug}`); + expect(html).toContain('This is a convergence-only operation'); + expect(html).toContain('no former-owner detachment is needed'); + expect(html).toContain('Apply Convergence'); + expect(html).not.toContain('The former owner will become'); + expect(html).not.toContain('detach from '); + expect(await env.db.select().from(operatorEvents)).toEqual([]); + }); + test('applies the confirmed transfer, audits it, and redirects to project detail', async () => { const admin = await createTestUser( env.db, diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index 48f94805b..324268cc6 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -22,6 +22,7 @@ import { type Db, } from '../../src/db.ts'; import app from '../../src/index.ts'; +import * as projectAccess from '../../src/project-access.ts'; import { validateManifestShape } from '../../src/routes/deploy.ts'; import { builds, @@ -2981,6 +2982,57 @@ describe('Deploy serialization (concurrent deploy guard)', () => { expect(stillCanceling?.status).toBe('canceling'); }); + it('rechecks deploy access under the project lock before cancelling', async () => { + const owner = await createTestUser(env.db, 'cancel-race-owner'); + const collaborator = await createTestUser(env.db, 'cancel-race-collaborator'); + const project = await createTestProject(env.db, owner.id, 'cancel-access-race'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + await env.db.insert(deployments).values({ + id: 'dpl_cancel_access_race', + project_id: project.id, + status: 'uploading', + created_at: sql`now()`, + }); + + const requireCapability = projectAccess.requireProjectCapability; + let downgraded = false; + vi.spyOn(projectAccess, 'requireProjectCapability').mockImplementation(async (...args) => { + const result = await requireCapability(...args); + if (!downgraded && args[3] === 'deploy') { + downgraded = true; + await env.db + .update(projectMembers) + .set({ role: 'reader' }) + .where(eq(projectMembers.user_id, collaborator.id)); + } + return result; + }); + + const response = await app.fetch( + new Request( + `http://localhost/projects/${project.id}/deployments/dpl_cancel_access_race/cancel`, + { + method: 'POST', + headers: { Authorization: `Bearer ${collaborator.jwt}` }, + }, + ), + env, + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'forbidden' }); + expect(downgraded).toBe(true); + expect(await getDeployment(env.db, 'dpl_cancel_access_race')).toMatchObject({ + status: 'uploading', + error_message: null, + }); + }); + it('does not cancel a terminal deployment', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'cancel-live-app'); diff --git a/platform/packages/api/test/integration/api-migrations.test.ts b/platform/packages/api/test/integration/api-migrations.test.ts index 71d911d2d..4c8ca457d 100644 --- a/platform/packages/api/test/integration/api-migrations.test.ts +++ b/platform/packages/api/test/integration/api-migrations.test.ts @@ -2546,7 +2546,7 @@ describe('POST /projects/:id/migrations/apply', () => { expect(state.trackingRows.has('001_init.sql')).toBe(false); }); - it('two concurrent /apply requests serialize via advisory lock and end with exactly one tracking row', async () => { + it('two concurrent /apply requests serialize via a project row lock and end with exactly one tracking row', async () => { // Two deploys fire `/migrations/apply` against the same project at // nearly the same time. The per-project pg transaction-scoped row // lock serializes them: the second request blocks at the transaction @@ -4333,7 +4333,7 @@ describe('POST /projects/:id/migrations/rename', () => { mockCfApi({ queryD1: makeQueryD1(state) }); // Kick rollback first — it will route through all the KV work and - // then hit the advisory-locked setActiveDeployment. /apply kicks off + // then enter the row-locked setActiveDeployment. /apply kicks off // right after and races to acquire the same lock. const rollbackPromise = app.fetch( authedReq(`/projects/${project.id}/rollback`, user.jwt, { @@ -4362,7 +4362,7 @@ describe('POST /projects/:id/migrations/rename', () => { // Final state: project.active_deployment_id points at target, and // target's applied_migrations contains `002_new.sql` — /apply's // metadata append landed on the post-rollback active, not the old - // one. Without the advisory lock, /apply's re-read could observe + // one. Without the project row lock, /apply's re-read could observe // the pre-rollback active and write to oldActive. const targetRow = await env.db.select().from(deployments).where(eq(deployments.id, target.id)); const oldActiveRow = await env.db @@ -6204,40 +6204,14 @@ describe('POST /projects/:id/migrations/rename', () => { expect(parsed).toEqual([exoticName]); }); - it('rollback/apply/rename use a project row lock, not a 32-bit hashtext advisory lock', async () => { - // The per-project serialization primitive is `SELECT id FROM projects - // WHERE id = $projectId FOR UPDATE` — collision-free and auto-released - // at tx end. A 32-bit `pg_advisory_xact_lock(hashtext($projectId))` - // would have a ~25% birthday-collision rate across ~50k projects, - // causing unrelated projects to serialize against each other. - // - // A behavioral regression test for hash collisions is impractical, so - // grep the handler source for any remaining `hashtext` / advisory- - // lock call site. + it('does not use a collision-prone hashtext advisory lock', async () => { const { readFileSync } = await import('node:fs'); const { fileURLToPath } = await import('node:url'); const path = fileURLToPath(new URL('../../src/routes/projects.ts', import.meta.url)); const rawSource = readFileSync(path, 'utf8'); - - // Strip single-line `//` comments so that historical references - // to `hashtext(projectId)` or `pg_advisory_xact_lock` in rationale - // comments do not false-positive this guard. The point of the - // assertion is "no executable call site uses the hashtext key." const source = rawSource.replaceAll(/\/\/[^\n]*/g, ''); - // No `hashtext(` function call remains in executable code. expect(source).not.toMatch(/hashtext\s*\(/); - // No `pg_advisory_xact_lock` call remains. expect(source).not.toMatch(/pg_advisory_xact_lock/); - // Sanity: the row lock variant is present for migrations, rollback, and - // the database provisioning mutations that share this route module. - const rowLockMatches = source.match( - /SELECT id FROM projects WHERE id = \$\{projectId\} FOR UPDATE/g, - ); - expect(rowLockMatches).not.toBeNull(); - // Apply, rename, rollback lease/cutover, rollback's post-transaction - // compensation, database create, and database URL update all use the - // collision-free project row lock. - expect(rowLockMatches!.length).toBe(7); }); }); diff --git a/platform/packages/api/test/integration/api-project-team.test.ts b/platform/packages/api/test/integration/api-project-team.test.ts index 2332d9001..0cac48544 100644 --- a/platform/packages/api/test/integration/api-project-team.test.ts +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -1,5 +1,11 @@ import { and, eq } from 'drizzle-orm'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + createBuild, + createGitHubConnection, + getBuild, + upsertGitHubInstallation, +} from '../../src/db.ts'; import app from '../../src/index.ts'; import { projectAccessEvents, @@ -19,6 +25,22 @@ import { let env: TestEnv; +function recordingBuildManager(): { + namespace: DurableObjectNamespace; + cancelledIds: ReadonlyArray; +} { + const cancelledIds: Array = []; + const namespace = { + idFromName: (name: string) => ({ toString: () => name }) as unknown as DurableObjectId, + get: (id: DurableObjectId) => ({ + cancelBuild: async () => { + cancelledIds.push(id.toString()); + }, + }), + } as unknown as DurableObjectNamespace; + return { namespace, cancelledIds }; +} + beforeAll(async () => { env = await setupEnv(); }); @@ -103,6 +125,52 @@ describe('project team API', () => { expect(await forbidden.json()).toEqual({ error: 'forbidden' }); }); + it('stops container builds when an authorizing admin is downgraded', async () => { + const owner = await createTestUser(env.db, 'build-owner'); + const admin = await createTestUser(env.db, 'build-admin'); + const project = await createTestProject(env.db, owner.id, 'build-access-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }); + await upsertGitHubInstallation(env.db, { + installationId: 'build-access-installation', + userId: admin.id, + accountLogin: admin.login, + accountId: 'build-access-account', + accountType: 'User', + repositories: [{ full_name: `${admin.login}/app`, id: 7711 }], + }); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'build-access-installation', + repoFullName: `${admin.login}/app`, + repositoryId: '7711', + authorizedByUserId: admin.id, + }); + const build = await createBuild(env.db, { + projectId: project.id, + userId: owner.id, + executor: 'container', + status: 'building', + }); + const { namespace, cancelledIds } = recordingBuildManager(); + + const response = await app.fetch( + request(`/projects/${project.id}/members/${admin.id}`, owner.jwt, { + method: 'PATCH', + body: { role: 'collaborator' }, + }), + { ...env, BUILD_MANAGER: namespace }, + ); + + expect(response.status).toBe(200); + expect((await getBuild(env.db, build.id))?.status).toBe('cancelled'); + expect(cancelledIds).toEqual([build.id]); + }); + it('requires an existing registered user and supports target-only acceptance', async () => { const owner = await createTestUser(env.db, 'owner'); const target = await createTestUser(env.db, 'target'); @@ -386,11 +454,127 @@ describe('project team API', () => { expect(acceptRevoked.status).toBe(409); }); - it('lets project admins manage any non-owner member and members leave', async () => { + it('rate-limits create, refresh, and resend delivery while preserving invitations', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + await setEmail(target.id, 'target@example.com'); + const project = await createTestProject(env.db, owner.id, 'delivery-limited'); + const send = vi.fn(async () => {}); + const emailEnv = { + ...env, + EMAIL: { send } as unknown as SendEmail, + EMAIL_FROM: 'Void Platform ', + AUTH_REDIRECT_URI: 'http://localhost/auth/callback', + PLATFORM_INSTALLATION_ID: 'test-installation', + }; + const createOrRefresh = (role: 'reader' | 'collaborator' | 'admin') => + app.fetch( + request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { email: 'target@example.com', role }, + }), + emailEnv, + ); + + const created = await createOrRefresh('reader'); + expect(created.status).toBe(201); + const invitationId = ((await created.json()) as { invitation: { id: string } }).invitation.id; + + const refreshed = await createOrRefresh('admin'); + expect(refreshed.status).toBe(200); + expect(await refreshed.json()).toMatchObject({ + invitation: { id: invitationId, role: 'admin' }, + refreshed: true, + delivery: { status: 'sent' }, + }); + + const resend = () => + app.fetch( + request(`/projects/${project.id}/invitations/${invitationId}/resend`, owner.jwt, { + method: 'POST', + }), + emailEnv, + ); + for (let attempt = 0; attempt < 2; attempt++) { + const response = await resend(); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ delivery: { status: 'sent' } }); + } + + send.mockRejectedValueOnce(new Error('provider unavailable')); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + let failed: Response; + try { + failed = await resend(); + } finally { + consoleError.mockRestore(); + } + expect(failed.status).toBe(200); + expect(await failed.json()).toMatchObject({ delivery: { status: 'failed' } }); + + const shortExpiry = new Date(Date.now() + 60_000).toISOString(); + await env.db + .update(projectInvitations) + .set({ expires_at: shortExpiry }) + .where(eq(projectInvitations.id, invitationId)); + const limited = await resend(); + expect(limited.status).toBe(200); + expect(await limited.json()).toMatchObject({ + invitation: { id: invitationId, role: 'admin' }, + delivery: { status: 'rate_limited' }, + }); + expect(send).toHaveBeenCalledTimes(5); + + const [persisted] = await env.db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.id, invitationId)); + expect(persisted.status).toBe('pending'); + expect(Date.parse(persisted.expires_at)).toBeGreaterThan(Date.parse(shortExpiry)); + }); + + it('applies the daily delivery limit without rolling back a new invitation', async () => { + const owner = await createTestUser(env.db, 'owner'); + const target = await createTestUser(env.db, 'target'); + await setEmail(target.id, 'target@example.com'); + const project = await createTestProject(env.db, owner.id, 'daily-delivery-limited'); + const now = new Date(); + const day = `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, '0')}${String( + now.getUTCDate(), + ).padStart(2, '0')}`; + await env.ROUTING.put(`project-invitation-email-rate:${project.id}:${day}`, '50'); + const send = vi.fn(async () => {}); + + const response = await app.fetch( + request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { email: 'target@example.com', role: 'reader' }, + }), + { + ...env, + EMAIL: { send } as unknown as SendEmail, + EMAIL_FROM: 'Void Platform ', + AUTH_REDIRECT_URI: 'http://localhost/auth/callback', + PLATFORM_INSTALLATION_ID: 'test-installation', + }, + ); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ + invitation: { target: { id: target.id }, role: 'reader', status: 'pending' }, + delivery: { status: 'rate_limited' }, + }); + expect(send).not.toHaveBeenCalled(); + expect(await env.db.select().from(projectInvitations)).toHaveLength(1); + }); + + it('lets project admins invite and manage any non-owner member, and lets members leave', async () => { const owner = await createTestUser(env.db, 'owner'); const admin = await createTestUser(env.db, 'admin'); const collaborator = await createTestUser(env.db, 'collaborator'); const peerAdmin = await createTestUser(env.db, 'peer-admin'); + const invitee = await createTestUser(env.db, 'invitee'); + await setEmail(invitee.id, 'invitee@example.com'); const project = await createTestProject(env.db, owner.id); await env.db.insert(projectMembers).values([ { @@ -423,6 +607,14 @@ describe('project team API', () => { expect(changed.status).toBe(200); expect(await changed.json()).toMatchObject({ member: { role: 'admin' } }); + const adminInvitation = await invite(project.id, admin.jwt, 'invitee@example.com', 'reader'); + const accepted = await app.fetch( + request(`/project-invitations/${adminInvitation.id}/accept`, invitee.jwt, { method: 'POST' }), + env, + ); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toMatchObject({ member: { role: 'reader' } }); + const removedPeerAdmin = await app.fetch( request(`/projects/${project.id}/members/${peerAdmin.id}`, admin.jwt, { method: 'DELETE', diff --git a/platform/packages/api/test/integration/api-revalidate.test.ts b/platform/packages/api/test/integration/api-revalidate.test.ts index bb4d2a694..524e0f2e4 100644 --- a/platform/packages/api/test/integration/api-revalidate.test.ts +++ b/platform/packages/api/test/integration/api-revalidate.test.ts @@ -1,4 +1,5 @@ import { hashOriginalUrl } from '@void/isr'; +import { eq } from 'drizzle-orm'; import { describe, it, @@ -11,7 +12,7 @@ import { } from 'vite-plus/test'; import { hmacSignState } from '../../src/auth/hmac.ts'; import app from '../../src/index.ts'; -import { domains } from '../../src/schema.ts'; +import { domains, projectMembers } from '../../src/schema.ts'; import { setupEnv, teardownEnv, @@ -111,6 +112,51 @@ describe('POST /projects/:id/revalidate', () => { expect(res.status).toBe(403); }); + it('rechecks deploy access after body parsing before purging cache state', async () => { + const owner = await createTestUser(env.db, 'owner'); + const collaborator = await createTestUser(env.db, 'collaborator'); + const project = await createTestProject(env.db, owner.id); + const deployment = await createTestDeployment(env.db, project.id, 'live'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + const key = isrKey(project.id, deployment.id, `${project.slug}.void.app`, 'cached'); + await env.ISR_CACHE.put(key, 'cached'); + const fetchMock = mockCfApi(); + const bodyStream = new TransformStream(); + const writer = bodyStream.writable.getWriter(); + const request = new Request(`http://localhost/projects/${project.id}/revalidate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${collaborator.jwt}`, + 'Content-Type': 'application/json', + }, + body: bodyStream.readable, + duplex: 'half', + } as RequestInit & { duplex: 'half' }); + + const responsePromise = app.fetch(request, env); + await writer.write(new TextEncoder().encode(JSON.stringify({ paths: ['/cached'] }))); + // The initial deploy-capability check has already passed. A downgrade + // committed while the body is read must prevent both R2 and edge purges. + await env.db + .update(projectMembers) + .set({ role: 'reader' }) + .where(eq(projectMembers.user_id, collaborator.id)); + await writer.close(); + const res = await responsePromise; + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await env.ISR_CACHE.get(key)).toBe('cached'); + expect( + fetchMock.mock.calls.filter(([url]) => String(url).includes('/purge_cache')), + ).toHaveLength(0); + }); + it('returns 400 without paths or all', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id); diff --git a/platform/packages/api/test/integration/auth-email-verification.test.ts b/platform/packages/api/test/integration/auth-email-verification.test.ts index d90473f67..cf22bf237 100644 --- a/platform/packages/api/test/integration/auth-email-verification.test.ts +++ b/platform/packages/api/test/integration/auth-email-verification.test.ts @@ -971,20 +971,24 @@ describe('email verification gating on account linking', () => { ], }); }); - // The route's owner read returns the OLD address; the login lands - // right after it and before the insert. The auth middleware resolves - // the caller through the same helper first, so fire on the SECOND - // read only — the one the route makes after Cloudflare answered. - const realGetUser = dbModule.getUser; - let loginDuringAdd: Response | undefined; - let reads = 0; - vi.spyOn(dbModule, 'getUser').mockImplementation(async (...args) => { - const owner = await realGetUser(...args); - reads += 1; - if (reads === 2) { - loginDuringAdd = await loginAs('949494', 'racing-manual'); + // The route has already captured the OLD owner address in + // `lockedAccess` when it reaches the owner-audited insert. Rotate the + // user row at that seam, then run the real helper: its owner-row + // re-check must see the NEW address and refuse the stale inheritance. + // + // The production interleaving uses the login transaction on a second + // Postgres session. PGlite exposes one session, so model the same + // committed row state through the route's existing transaction. There + // are no inherited destinations yet, making this update the complete + // database effect of that login for this fixture. + const realTryCreate = dbModule.tryCreateEmailDestination; + let rotatedDuringAdd = false; + vi.spyOn(dbModule, 'tryCreateEmailDestination').mockImplementation(async (...args) => { + if (!rotatedDuringAdd) { + rotatedDuringAdd = true; + await dbModule.updateUser(args[0], existing.id, 'racing-manual', 'new-owner@example.com'); } - return owner; + return realTryCreate(...args); }); const res = await app.fetch( @@ -996,10 +1000,16 @@ describe('email verification gating on account linking', () => { emailEnv(), executionCtx, ); - expect(loginDuringAdd?.status).toBe(302); + expect(rotatedDuringAdd).toBe(true); expect(res.status).toBe(409); expect(await res.json()).toEqual({ error: 'destination concurrently modified, try again' }); + const [rotatedOwner] = await env.db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, existing.id)); + expect(rotatedOwner?.email).toBe('new-owner@example.com'); + const rows = await env.db .select() .from(emailDestinations) diff --git a/platform/packages/api/test/integration/db-builds.test.ts b/platform/packages/api/test/integration/db-builds.test.ts index 290741ac9..00aa872fb 100644 --- a/platform/packages/api/test/integration/db-builds.test.ts +++ b/platform/packages/api/test/integration/db-builds.test.ts @@ -2026,7 +2026,6 @@ describe('createGithubActionsBuildWithinLimit', () => { expect(created.outcome).toBe('created'); if (created.outcome === 'created') { expect(created.build.user_id).toBe(currentOwner.id); - expect(created.build.triggered_by_user_id).toBeNull(); } }); diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index bfe0d6ebb..1603c1fe3 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -11,11 +11,16 @@ import { } from 'vite-plus/test'; import { acceptInvitationByEmail, + createBuild, + createGitHubConnection, createInvitation, + getBuild, getInvitation, getUser, revokeInvitation, + upsertGitHubInstallation, } from '../../src/db.ts'; +import * as dbModule from '../../src/db.ts'; import type { ApiEnv } from '../../src/env.ts'; import { app } from '../../src/index.ts'; import { @@ -49,6 +54,22 @@ let env: TestEnv; let token: string; let operatorAdminId: string; +function recordingBuildManager(): { + namespace: DurableObjectNamespace; + cancelledIds: ReadonlyArray; +} { + const cancelledIds: Array = []; + const namespace = { + idFromName: (name: string) => ({ toString: () => name }) as unknown as DurableObjectId, + get: (id: DurableObjectId) => ({ + cancelBuild: async () => { + cancelledIds.push(id.toString()); + }, + }), + } as unknown as DurableObjectNamespace; + return { namespace, cancelledIds }; +} + beforeAll(async () => { env = await setupEnv(); }); @@ -112,6 +133,14 @@ function captureUsageCounter( } as unknown as DurableObjectNamespace; } +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + describe('operator tenant reads', () => { test('paginates filtered users without truncating the total to the page size', async () => { for (const login of ['find-a', 'find-b', 'find-c', 'unrelated']) { @@ -351,13 +380,12 @@ describe('operator tenant mutations', () => { { ownerId: target.id, path: '/attach-project', - body: expect.objectContaining({ + body: { projectId: project.id, userId: target.id, slug: project.slug, ownershipStartedAt: current.starts_at, - plan: 'pro', - }), + }, }, ]); expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ @@ -387,6 +415,147 @@ describe('operator tenant mutations', () => { expect(counterCalls.map((call) => call.path)).toEqual(['/detach-project', '/attach-project']); }); + test('converges plan, suspension, and restore changes interleaved with a transfer', async () => { + const owner = await createTestUser(env.db, 'concurrent-transfer-owner'); + const target = await createTestUser(env.db, 'concurrent-transfer-target'); + const project = await createTestProject(env.db, owner.id, 'concurrent-transfer-project'); + await env.ROUTING.put( + project.slug, + JSON.stringify({ workerName: 'worker', projectId: project.id, userId: owner.id }), + ); + + const transferCounterCalls: Array<{ + ownerId: string; + path: string; + body: Record; + }> = []; + const pausingCounter = ( + detachStarted: ReturnType, + resumeTransfer: ReturnType, + ) => + ({ + idFromName: (name: string) => name, + get: (ownerId: string) => ({ + fetch: async (request: Request) => { + const path = new URL(request.url).pathname; + const body = (await request.json()) as Record; + transferCounterCalls.push({ ownerId, path, body }); + if (path === '/detach-project') { + detachStarted.resolve(); + await resumeTransfer.promise; + } + return Response.json({ ok: true }); + }, + }), + }) as unknown as DurableObjectNamespace; + + const detachStarted = deferred(); + const resumeTransfer = deferred(); + + const transferPromise = req( + `/projects/${project.id}/owner`, + { userId: target.id }, + { USAGE_COUNTER: pausingCounter(detachStarted, resumeTransfer) }, + ); + await detachStarted.promise; + + const planCounterCalls: Array<{ + ownerId: string; + path: string; + body: Record; + }> = []; + const planChange = await req( + `/users/${target.id}/plan`, + { plan: 'pro' }, + { USAGE_COUNTER: captureUsageCounter(planCounterCalls) }, + ); + expect(planChange.status).toBe(200); + + const suspendDbStarted = deferred(); + const resumeSuspendDb = deferred(); + const suspendUser = dbModule.suspendUser; + const suspendSpy = vi.spyOn(dbModule, 'suspendUser').mockImplementation(async (...args) => { + suspendDbStarted.resolve(); + await resumeSuspendDb.promise; + return suspendUser(...args); + }); + const suspensionPromise = req(`/users/${target.id}/suspend`, { + reason: 'operator_review', + }); + await suspendDbStarted.promise; + + resumeTransfer.resolve(); + const transfer = await transferPromise; + + expect(transfer.status).toBe(200); + expect(await transfer.json()).toMatchObject({ + newOwner: { + id: target.id, + plan: 'pro', + suspended: false, + }, + }); + expect(transferCounterCalls).toMatchObject([ + { ownerId: owner.id, path: '/detach-project' }, + { + ownerId: target.id, + path: '/attach-project', + body: { projectId: project.id, userId: target.id }, + }, + ]); + expect(transferCounterCalls[1]!.body).not.toHaveProperty('plan'); + expect(planCounterCalls.map((call) => call.path)).toEqual(['/reevaluate-sandbox']); + expect(JSON.parse((await env.ROUTING.get(project.slug))!).suspended).toBeUndefined(); + + resumeSuspendDb.resolve(); + const suspension = await suspensionPromise; + suspendSpy.mockRestore(); + expect(suspension.status).toBe(200); + expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ + userId: target.id, + suspended: 'operator_review', + limits: { cpuMs: 50, subRequests: 1000 }, + }); + expect(JSON.parse((await env.ROUTING.get(`project:${project.id}`))!)).toEqual({ + userId: target.id, + plan: 'pro', + }); + + const retryDetachStarted = deferred(); + const resumeRetry = deferred(); + const retryPromise = req( + `/projects/${project.id}/owner`, + { userId: target.id }, + { USAGE_COUNTER: pausingCounter(retryDetachStarted, resumeRetry) }, + ); + await retryDetachStarted.promise; + + const restoreDbStarted = deferred(); + const resumeRestoreDb = deferred(); + const unsuspendUser = dbModule.unsuspendUser; + const restoreSpy = vi.spyOn(dbModule, 'unsuspendUser').mockImplementation(async (...args) => { + restoreDbStarted.resolve(); + await resumeRestoreDb.promise; + return unsuspendUser(...args); + }); + const restorePromise = req(`/users/${target.id}/restore`, {}); + await restoreDbStarted.promise; + + resumeRetry.resolve(); + expect((await retryPromise).status).toBe(200); + expect(JSON.parse((await env.ROUTING.get(project.slug))!).suspended).toBe('operator_review'); + + resumeRestoreDb.resolve(); + const restore = await restorePromise; + restoreSpy.mockRestore(); + expect(restore.status).toBe(200); + expect(await getUser(env.db, target.id)).toMatchObject({ + plan: 'pro', + suspended_reason: null, + }); + expect(JSON.parse((await env.ROUTING.get(project.slug))!).suspended).toBeUndefined(); + }); + test('rejects unknown owners and deleting, deploy, rollback, and build work', async () => { const owner = await createTestUser(env.db, 'blocked-transfer-owner'); const target = await createTestUser(env.db, 'blocked-transfer-target'); @@ -481,6 +650,18 @@ describe('operator tenant mutations', () => { ).toMatchObject({ user_id: target.id }); expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ userId: owner.id }); + const recoveryPreview = await req(`/projects/${project.id}/owner`, { + userId: target.id, + dryRun: true, + }); + expect(recoveryPreview.status).toBe(200); + expect(await recoveryPreview.json()).toMatchObject({ + previousOwner: { id: owner.id }, + newOwner: { id: target.id }, + formerOwnerRole: 'admin', + counterUpdates: { detachUserId: owner.id, attachUserId: target.id }, + }); + const retryCalls: Array<{ ownerId: string; path: string; @@ -650,6 +831,70 @@ describe('operator tenant mutations', () => { expect((await req(`/projects/${project.id}`)).status).toBe(404); }); + test('does not delete a project transferred away after owned-project discovery', async () => { + const formerOwner = await createTestUser(env.db, 'former-delete-owner'); + const newOwner = await createTestUser(env.db, 'new-delete-owner'); + const project = await createTestProject(env.db, formerOwner.id, 'transferred-before-delete'); + const staleProject = await dbModule.getProject(env.db, project.id); + expect(staleProject).not.toBeNull(); + + // Force the operator workflow to consume the pre-transfer enumeration + // while the authoritative project row already names the new owner. The + // teardown lock must treat this as a stale snapshot and skip all cleanup. + await env.db.update(projects).set({ user_id: newOwner.id }).where(eq(projects.id, project.id)); + vi.spyOn(dbModule, 'getProjectsByUserId').mockResolvedValue([staleProject!]); + const cf = mockCfApi(); + + const response = await req(`/users/${formerOwner.id}/delete`, {}); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ deleted: true, deletedProjects: [] }); + expect(await dbModule.getProject(env.db, project.id)).toMatchObject({ user_id: newOwner.id }); + expect(await getUser(env.db, formerOwner.id)).toBeNull(); + expect(cf).not.toHaveBeenCalled(); + }); + + test('stops shared-project container builds invalidated by deleting their authorizer', async () => { + const owner = await createTestUser(env.db, 'surviving-owner'); + const authorizer = await createTestUser(env.db, 'deleted-authorizer'); + const project = await createTestProject(env.db, owner.id, 'surviving-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: authorizer.id, + role: 'admin', + added_by_user_id: owner.id, + }); + await upsertGitHubInstallation(env.db, { + installationId: 'deleted-authorizer-installation', + userId: authorizer.id, + accountLogin: authorizer.login, + accountId: 'deleted-authorizer-account', + accountType: 'User', + repositories: [{ full_name: `${authorizer.login}/app`, id: 8811 }], + }); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'deleted-authorizer-installation', + repoFullName: `${authorizer.login}/app`, + repositoryId: '8811', + authorizedByUserId: authorizer.id, + }); + const build = await createBuild(env.db, { + projectId: project.id, + userId: owner.id, + executor: 'container', + status: 'building', + }); + const { namespace, cancelledIds } = recordingBuildManager(); + + const response = await req(`/users/${authorizer.id}/delete`, {}, { BUILD_MANAGER: namespace }); + + expect(response.status).toBe(200); + expect(await getUser(env.db, authorizer.id)).toBeNull(); + expect((await getBuild(env.db, build.id))?.status).toBe('cancelled'); + expect(cancelledIds).toEqual([build.id]); + }); + test('deletes invitation actors while preserving invitation history and revoking sessions', async () => { const inviter = await createTestUser( env.db, diff --git a/platform/packages/api/test/integration/platform-onboarding.test.ts b/platform/packages/api/test/integration/platform-onboarding.test.ts index e3cb5d733..c61e23307 100644 --- a/platform/packages/api/test/integration/platform-onboarding.test.ts +++ b/platform/packages/api/test/integration/platform-onboarding.test.ts @@ -55,6 +55,24 @@ describe('platform onboarding pages', () => { expect(html).not.toContain('attacker.example'); }); + it('turns a project invitation link into CLI acceptance instructions', async () => { + const response = await app.fetch( + new Request('https://api.example.com/invite?project-invitation=pinv_a1b2c3d4e5f6'), + { + ...env, + AUTH_REDIRECT_URI: 'https://api.example.com/auth/callback', + PLATFORM_INSTALLATION_ID: 'example-installation', + }, + ); + + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain('Accept Project Invitation - Void Platform'); + expect(html).toContain('void connect 'https://api.example.com''); + expect(html).toContain('void project team accept pinv_a1b2c3d4e5f6'); + expect(html).not.toMatch(/dashboard/i); + }); + it.each(['/invite', '/signup-restricted'])( 'uses a generic name for source deployments at %s', async (path) => { diff --git a/platform/packages/api/test/integration/project-access.test.ts b/platform/packages/api/test/integration/project-access.test.ts index 53374931d..4e9ec8ad4 100644 --- a/platform/packages/api/test/integration/project-access.test.ts +++ b/platform/packages/api/test/integration/project-access.test.ts @@ -1,26 +1,24 @@ -import { eq } from 'drizzle-orm'; +import { desc, eq } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; import { hmacSignState } from '../../src/auth/hmac.ts'; import { - createBuild, createDeployment, createOrRefreshProjectInvitation, createProject, getAccessibleProjectsByUserId, - getProjectAccessEvents, getProjectMember, - getProjectOwnerAt, removeProjectMember, respondToProjectInvitation, setProjectMemberRole, } from '../../src/db.ts'; import app from '../../src/index.ts'; +import { ProjectAccessDeniedError, requireProjectCapability } from '../../src/project-access.ts'; import { - ProjectAccessDeniedError, - getProjectAccess, - requireProjectCapability, -} from '../../src/project-access.ts'; -import { users } from '../../src/schema.ts'; + projectAccessEvents, + projectMembers, + projectOwnershipPeriods, + users, +} from '../../src/schema.ts'; import { clearTables, createTestProject, @@ -63,33 +61,24 @@ describe('project access', () => { }); it('enforces reader and collaborator capabilities using the owner for billing', async () => { - const owner = await userWithEmail('access-owner'); - const reader = await userWithEmail('access-reader'); - const collaborator = await userWithEmail('access-collaborator'); + const owner = await createTestUser(env.db, 'access-owner'); + const reader = await createTestUser(env.db, 'access-reader'); + const collaborator = await createTestUser(env.db, 'access-collaborator'); const project = await createProject(env.db, owner.id, 'shared-access'); - - const readerInvite = await createOrRefreshProjectInvitation(env.db, { - projectId: project.id, - email: reader.email, - role: 'reader', - actorUserId: owner.id, - }); - await respondToProjectInvitation(env.db, { - invitationId: readerInvite.invitation.id, - actorUserId: reader.id, - response: 'accept', - }); - const collaboratorInvite = await createOrRefreshProjectInvitation(env.db, { - projectId: project.id, - email: collaborator.email, - role: 'collaborator', - actorUserId: owner.id, - }); - await respondToProjectInvitation(env.db, { - invitationId: collaboratorInvite.invitation.id, - actorUserId: collaborator.id, - response: 'accept', - }); + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }, + ]); expect((await requireProjectCapability(env.db, project.id, reader.id, 'read')).owner.id).toBe( owner.id, @@ -157,32 +146,37 @@ describe('project access', () => { actorUserId: target.id, }); - expect(await getProjectAccess(env.db, project.id, target.id)).toBeNull(); - expect((await getProjectAccessEvents(env.db, project.id)).map((event) => event.action)).toEqual( - ['left', 'role_changed', 'accepted', 'invited'], - ); + await expect( + requireProjectCapability(env.db, project.id, target.id, 'read'), + ).rejects.toBeInstanceOf(ProjectAccessDeniedError); + const events = await env.db + .select() + .from(projectAccessEvents) + .where(eq(projectAccessEvents.project_id, project.id)) + .orderBy(desc(projectAccessEvents.created_at)); + expect(events.map((event) => event.action)).toEqual([ + 'left', + 'role_changed', + 'accepted', + 'invited', + ]); }); - it('records owner attribution on new projects, deployments, and human builds', async () => { + it('records owner attribution on new projects and deployments', async () => { const owner = await createTestUser(env.db, 'attribution-owner'); const actor = await createTestUser(env.db, 'attribution-actor'); const project = await createProject(env.db, owner.id, 'attribution'); - const period = await getProjectOwnerAt(env.db, project.id, project.created_at); - expect(period).toBe(owner.id); + const [period] = await env.db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)); + expect(period).toMatchObject({ owner_user_id: owner.id, starts_at: project.created_at }); const deployment = await createDeployment(env.db, project.id, '{}', 'manual', null, { initiatedByUserId: actor.id, }); expect(deployment.billing_user_id).toBe(owner.id); expect(deployment.initiated_by_user_id).toBe(actor.id); - - const build = await createBuild(env.db, { - projectId: project.id, - userId: owner.id, - triggeredByUserId: actor.id, - }); - expect(build.user_id).toBe(owner.id); - expect(build.triggered_by_user_id).toBe(actor.id); }); it('rejects a team mutation after the actor loses admin access', async () => { @@ -190,16 +184,11 @@ describe('project access', () => { const admin = await userWithEmail('lock-admin'); const target = await userWithEmail('lock-target'); const project = await createProject(env.db, owner.id, 'locked-auth'); - const invite = await createOrRefreshProjectInvitation(env.db, { - projectId: project.id, - email: admin.email, + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, role: 'admin', - actorUserId: owner.id, - }); - await respondToProjectInvitation(env.db, { - invitationId: invite.invitation.id, - actorUserId: admin.id, - response: 'accept', + added_by_user_id: owner.id, }); await setProjectMemberRole(env.db, { projectId: project.id, @@ -219,26 +208,24 @@ describe('project access', () => { }); it('keeps the legacy internal verifier URL but live-checks deploy capability', async () => { - const owner = await userWithEmail('verify-owner'); - const reader = await userWithEmail('verify-reader'); - const collaborator = await userWithEmail('verify-collaborator'); + const owner = await createTestUser(env.db, 'verify-owner'); + const reader = await createTestUser(env.db, 'verify-reader'); + const collaborator = await createTestUser(env.db, 'verify-collaborator'); const project = await createProject(env.db, owner.id, 'verify-access'); - for (const [user, role] of [ - [reader, 'reader'], - [collaborator, 'collaborator'], - ] as const) { - const invite = await createOrRefreshProjectInvitation(env.db, { - projectId: project.id, - email: user.email, - role, - actorUserId: owner.id, - }); - await respondToProjectInvitation(env.db, { - invitationId: invite.invitation.id, - actorUserId: user.id, - response: 'accept', - }); - } + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }, + ]); const internalAuth = await hmacSignState(project.id, env.JWT_SECRET); const verify = (userId: string) => app.fetch( diff --git a/platform/packages/api/test/integration/project-team-lifecycle.test.ts b/platform/packages/api/test/integration/project-team-lifecycle.test.ts index 84b41ecac..99e4f079e 100644 --- a/platform/packages/api/test/integration/project-team-lifecycle.test.ts +++ b/platform/packages/api/test/integration/project-team-lifecycle.test.ts @@ -1,12 +1,16 @@ import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; import { + createBuild, + createGitHubConnection, createOrRefreshProjectInvitation, createProject, deleteProjectCascade, deleteUser, getAccessibleProjectsByUserId, getOwnedProjectsByUserId, + getBuild, + getGitHubConnection, getProject, getProjectsByUserId, respondToProjectInvitation, @@ -39,7 +43,7 @@ async function setEmail(userId: string, email: string): Promise { } describe('project team lifecycle', () => { - it('deleting a non-owner removes their live access but preserves the project audit trail', async () => { + it('deleting a non-owner revokes their GitHub authority and preserves access audit history', async () => { const owner = await createTestUser(env.db, 'owner'); const member = await createTestUser(env.db, 'member'); await setEmail(member.id, 'member@example.com'); @@ -48,7 +52,7 @@ describe('project team lifecycle', () => { const { invitation } = await createOrRefreshProjectInvitation(env.db, { projectId: project.id, email: 'member@example.com', - role: 'collaborator', + role: 'admin', actorUserId: owner.id, }); await respondToProjectInvitation(env.db, { @@ -56,6 +60,18 @@ describe('project team lifecycle', () => { actorUserId: member.id, response: 'accept', }); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'member-authorized-installation', + repoFullName: 'void/member-authorized', + repositoryId: '101', + authorizedByUserId: member.id, + }); + const activeBuild = await createBuild(env.db, { + projectId: project.id, + userId: owner.id, + status: 'building', + }); await deleteUser(env.db, member.id); @@ -69,6 +85,12 @@ describe('project team lifecycle', () => { .from(projectInvitations) .where(eq(projectInvitations.target_user_id, member.id)), ).toEqual([]); + expect(await getGitHubConnection(env.db, project.id)).toBeNull(); + expect(await getBuild(env.db, activeBuild.id)).toMatchObject({ + status: 'cancelled', + error: 'github connection authorizer account deleted', + completed_at: expect.any(String), + }); const events = await env.db .select() @@ -163,7 +185,7 @@ describe('project team lifecycle', () => { expect(await getProject(env.db, shared.id)).not.toBeNull(); await deleteProjectCascade(env.db, owned.id); - await expect(deleteUser(env.db, owner.id)).resolves.toBeUndefined(); + await expect(deleteUser(env.db, owner.id)).resolves.toEqual([]); expect(await getProject(env.db, owned.id)).toBeNull(); expect(await getProject(env.db, shared.id)).not.toBeNull(); diff --git a/platform/packages/api/test/integration/scheduled.test.ts b/platform/packages/api/test/integration/scheduled.test.ts index 027f7d7cc..70af89a37 100644 --- a/platform/packages/api/test/integration/scheduled.test.ts +++ b/platform/packages/api/test/integration/scheduled.test.ts @@ -560,4 +560,54 @@ describe('scheduled()', () => { expect(JSON.parse((await env.ROUTING.get(proj.slug))!).suspended).toBe('requests_exceeded'); }); + + it('does not clear a transferred project from the former owner rollover snapshot', async () => { + const formerOwner = await createTestUser( + env.db, + 'rollover-former', + 'github', + undefined, + 'free', + ); + const newOwner = await createTestUser(env.db, 'rollover-current', 'github', undefined, 'free'); + const project = await createTestProject(env.db, formerOwner.id, 'rollover-transferred'); + await env.ROUTING.put( + project.slug, + JSON.stringify({ + projectId: project.id, + userId: newOwner.id, + suspended: 'requests_exceeded', + }), + ); + let transferred = false; + const usageCounter = { + idFromName: (name: string) => ({ toString: () => name }), + get: (id: { toString(): string }) => ({ + fetch: async (request: Request) => { + if ( + id.toString() === formerOwner.id && + new URL(request.url).pathname === '/rollover' && + !transferred + ) { + transferred = true; + await env.db + .update(projects) + .set({ user_id: newOwner.id }) + .where(eq(projects.id, project.id)); + } + return new URL(request.url).pathname === '/rollover' + ? Response.json({ rolled: false, requestsSuspended: false }) + : Response.json({ ok: true }); + }, + }), + } as unknown as DurableObjectNamespace; + + await scheduled(mockController, { ...makeBindings(env), USAGE_COUNTER: usageCounter }, mockCtx); + + expect(transferred).toBe(true); + expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ + userId: newOwner.id, + suspended: 'requests_exceeded', + }); + }); }); diff --git a/platform/packages/api/test/unit/email.test.ts b/platform/packages/api/test/unit/email.test.ts index cb55daa89..31ddf8559 100644 --- a/platform/packages/api/test/unit/email.test.ts +++ b/platform/packages/api/test/unit/email.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vite-plus/test'; import { sendErrorAlertEmail, sendInvitationEmail, + sendProjectInvitationEmail, sendWelcomeEmail, sendDeployFailedEmail, } from '../../src/email.ts'; @@ -140,6 +141,27 @@ describe('platform email identity', () => { expect(sent[1].html).toContain('<new-member>'); }); + it('directs project invitees to the CLI without claiming a dashboard flow', async () => { + const { binding, sent } = mockBinding(); + await sendProjectInvitationEmail( + { ...binding, ...identity }, + { + to: 'member@example.com', + invitationId: 'pinv_a1b2c3d4e5f6', + projectSlug: 'shared-project', + inviterLogin: 'owner', + role: 'collaborator', + }, + ); + + expect(sent[0].html + sent[0].text).toContain("void connect 'https://api.example.com'"); + expect(sent[0].html + sent[0].text).toContain('void project team accept pinv_a1b2c3d4e5f6'); + expect(sent[0].html + sent[0].text).toContain( + 'https://api.example.com/invite?project-invitation=pinv_a1b2c3d4e5f6', + ); + expect(sent[0].html + sent[0].text).not.toMatch(/dashboard/i); + }); + it('keeps deployment and error alerts on the selected platform without a dashboard', async () => { const { binding, sent } = mockBinding(); const env = { ...binding, ...identity }; diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index 84f00b332..bb8dba326 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -26,7 +26,7 @@ vi.mock('../../src/db.ts', () => ({ }), // The DO module also re-exports nothing else from db.ts at module scope, // so a minimal mock is sufficient. - getUser: vi.fn(), + getUser: vi.fn(async (_db: unknown, id: string) => ({ id, plan: 'pro' })), getProject: vi.fn(async (_db: unknown, id: string) => ({ id, user_id: 'u1', slug: id })), getProjectsByIds: vi.fn(async (_db: unknown, ids: ReadonlyArray) => ids.map((id) => ({ id, user_id: 'u1', slug: id })), @@ -1002,6 +1002,36 @@ describe('UsageCounter.handleRecord — request suspension fans out to custom do expect(JSON.parse(kv.store.get('domain:custom.com')!).suspended).toBe('requests_exceeded'); }); + it('restamps a request suspension overwritten between its edge and account writes', async () => { + const { counter, kv } = makeCounter({ plan: 'free', totalRequests: 3_000_000 }); + const route = { workerName: 'w', projectId: 'p1', userId: 'u1' }; + kv.store.set('s1', JSON.stringify(route)); + const realPut = kv.put; + let suspensionWrites = 0; + kv.put = async (key, value) => { + await realPut(key, value); + if (key === 's1' && JSON.parse(value).suspended === 'requests_exceeded') { + suspensionWrites += 1; + if (suspensionWrites === 1) { + // A transfer republish read the old user row and landed after the + // edge-first stamp but before suspendUser settled. + kv.store.set('s1', JSON.stringify(route)); + } + } + }; + + await counter.fetch( + recordRequest( + [{ projectId: 'p1', userId: 'u1', slug: 's1', requests: 1, responseBytes: 0 }], + 'u1', + 'free', + ), + ); + + expect(suspensionWrites).toBe(2); + expect(JSON.parse(kv.store.get('s1')!).suspended).toBe('requests_exceeded'); + }); + it('does NOT downgrade an existing admin suspension on the route when crossing the request cap', async () => { const { counter, kv } = makeCounter({ plan: 'free', totalRequests: 3_000_000 }); // An admin already suspended this user — the route carries `admin_suspended`. @@ -1021,9 +1051,9 @@ describe('UsageCounter.handleRecord — request suspension fans out to custom do }); it('does not suspend a project transferred after the counter ownership precheck', async () => { - vi.mocked(getProjectsByIds).mockResolvedValueOnce([ - { id: 'p1', user_id: 'u1', slug: 's1' } as never, - ]); + vi.mocked(getProjectsByIds) + .mockResolvedValueOnce([{ id: 'p1', user_id: 'u1', slug: 's1' } as never]) + .mockResolvedValueOnce([{ id: 'p1', user_id: 'new-owner', slug: 's1' } as never]); vi.mocked(getProject).mockResolvedValueOnce({ id: 'p1', user_id: 'new-owner', @@ -1132,7 +1162,9 @@ describe('UsageCounter ownership changes', () => { projectId: 'next', slug: 'next-app', ownershipStartedAt: startedAt, - plan: 'pro', + // Simulate the transfer caller's pre-plan-change snapshot. The DO + // must use PostgreSQL's current `pro` plan from the getUser mock. + plan: 'free', }), }), ); From 868d3e80c2c6de117312bc2b24bb2d5c2222af1f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 18 Sep 2026 09:33:37 +0800 Subject: [PATCH 03/18] fix(platform): tighten team access enforcement --- .../test/unit/project-team-command.test.ts | 112 ------------ platform/packages/api/src/build-scheduler.ts | 31 +++- platform/packages/api/src/db.ts | 120 +++++++++++-- platform/packages/api/src/project-access.ts | 15 +- platform/packages/api/src/routes/auth-oidc.ts | 7 + platform/packages/api/src/routes/deploy.ts | 35 +--- .../packages/api/src/routes/project-team.ts | 1 + platform/packages/api/src/usage-queue.ts | 3 +- .../api/test/integration/api-github.test.ts | 43 +---- .../test/integration/api-migrations.test.ts | 11 -- .../api/test/integration/api-oidc.test.ts | 34 +++- .../test/integration/api-project-team.test.ts | 84 +++++++-- .../api/test/integration/api-projects.test.ts | 63 +++++++ .../api/test/integration/api-webhooks.test.ts | 24 +-- .../api/test/integration/db-builds.test.ts | 162 +++++++++++++++++- .../packages/api/test/integration/helpers.ts | 26 +++ .../test/integration/operator-tenants.test.ts | 17 +- .../test/integration/project-access.test.ts | 70 +++++++- .../api/test/unit/build-scheduler.test.ts | 40 ++++- 19 files changed, 627 insertions(+), 271 deletions(-) diff --git a/packages/void/test/unit/project-team-command.test.ts b/packages/void/test/unit/project-team-command.test.ts index 33fb447b3..13f84fb79 100644 --- a/packages/void/test/unit/project-team-command.test.ts +++ b/packages/void/test/unit/project-team-command.test.ts @@ -99,118 +99,6 @@ describe('project team target selection', () => { ); }); - it('dispatches project and account invitation actions to their API routes', async () => { - const root = rootWithPlatform('void'); - vi.stubEnv('VOID_TOKEN', 'test-token'); - vi.stubEnv('VOID_API_URL', 'https://api.example.test'); - const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith('/me/project-invitations')) { - return Response.json({ invitations: [] }); - } - if (url.endsWith('/accept')) { - return Response.json({ - member: { - user: { id: 'user_2', login: 'member', email: 'member@example.com' }, - role: 'reader', - created_at: '2026-09-17T00:00:00Z', - }, - }); - } - if (url.endsWith('/invitations') && init?.method !== 'POST') { - return Response.json({ invitations: [] }); - } - if (url.endsWith('/invitations') && init?.method === 'POST') { - return Response.json({ - invitation: { - id: 'pinv_1', - target: { id: 'user_2', login: 'member', email: 'member@example.com' }, - target_email: 'member@example.com', - role: 'reader', - status: 'pending', - created_at: '2026-09-17T00:00:00Z', - expires_at: '2026-09-24T00:00:00Z', - }, - delivery: { status: 'not_configured' }, - }); - } - if (init?.method === 'PATCH') { - return Response.json({ - member: { - user: { id: 'user_2', login: 'member', email: 'member@example.com' }, - role: 'admin', - created_at: '2026-09-17T00:00:00Z', - }, - }); - } - return new Response(null, { status: 204 }); - }); - vi.stubGlobal('fetch', fetchMock); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - - await runProjectCommand(root, { subcommand: 'team', action: 'pending' }); - await runProjectCommand(root, { - subcommand: 'team', - action: 'accept', - invitationId: 'pinv_1', - }); - await runProjectCommand(root, { - subcommand: 'team', - action: 'decline', - invitationId: 'pinv_1', - }); - await runProjectCommand(root, { - subcommand: 'team', - action: 'invitations', - projectSlug: '', - }); - await runProjectCommand(root, { - subcommand: 'team', - action: 'invite', - email: 'member@example.com', - role: 'reader', - projectSlug: '', - }); - await runProjectCommand(root, { - subcommand: 'team', - action: 'role', - userId: 'user_2', - role: 'admin', - projectSlug: '', - }); - await runProjectCommand(root, { - subcommand: 'team', - action: 'remove', - userId: 'user_2', - projectSlug: '', - }); - await runProjectCommand(root, { - subcommand: 'team', - action: 'revoke', - invitationId: 'pinv_1', - projectSlug: '', - }); - await runProjectCommand(root, { subcommand: 'team', action: 'leave', projectSlug: '' }); - - expect( - fetchMock.mock.calls.map(([input, init]) => [ - String(input), - (init as RequestInit | undefined)?.method ?? 'GET', - ]), - ).toEqual([ - ['https://api.example.test/me/project-invitations', 'GET'], - ['https://api.example.test/project-invitations/pinv_1/accept', 'POST'], - ['https://api.example.test/project-invitations/pinv_1/decline', 'POST'], - ['https://api.example.test/projects/project_1/invitations', 'GET'], - ['https://api.example.test/projects/project_1/invitations', 'POST'], - ['https://api.example.test/projects/project_1/members/user_2', 'PATCH'], - ['https://api.example.test/projects/project_1/members/user_2', 'DELETE'], - ['https://api.example.test/projects/project_1/invitations/pinv_1', 'DELETE'], - ['https://api.example.test/projects/project_1/leave', 'POST'], - ]); - }); - it('resolves an explicit project override before acting', async () => { const root = rootWithPlatform('void'); vi.stubEnv('VOID_TOKEN', 'test-token'); diff --git a/platform/packages/api/src/build-scheduler.ts b/platform/packages/api/src/build-scheduler.ts index 0604a07d9..ca6dd79dd 100644 --- a/platform/packages/api/src/build-scheduler.ts +++ b/platform/packages/api/src/build-scheduler.ts @@ -3,6 +3,7 @@ import type { BuildManager } from './build-manager.ts'; import { buildManagerNamespace } from './build-namespace.ts'; import { type BuildConfig, isContainerCapacityError } from './build-types.ts'; import { + cancelQueuedContainerBuildsForUsers, claimBuildForStartWithinLimit, createDb, getGitHubConnection, @@ -17,6 +18,7 @@ import { updateBuildStatus, } from './db.ts'; import type { ApiEnv } from './env.ts'; +import { isAdminSuspended } from './suspension.ts'; import { getBuildInstanceType, getPlanLimits } from './tiers.ts'; /** A poke arms the alarm this far ahead so the next scan runs almost immediately. */ @@ -66,18 +68,22 @@ export class BuildScheduler extends DurableObject { async alarm(): Promise { const db = createDb(this.env.HYPERDRIVE.connectionString); - // Compute which queue owners are AT their plan's concurrency cap and exclude - // them from the scan. The cap is plan-derived (`getPlanLimits`), not a DB - // column, so it can't be filtered in SQL — without this, a capped tenant's - // lowest-project_id rows would fill the scan limit every tick and the claim - // step would reject them all, indefinitely delaying an unrelated under-cap - // tenant. `limit === 0` means unlimited (never capped). `hasQueuedContainer- - // Builds` below still reflects ANY queued work, so the alarm keeps re-arming - // while an excluded owner's work waits — it becomes claimable the moment they - // free a slot. + // Cancel queued work for manually suspended owners before computing cap + // exclusions. The batch also reaches queued rows hidden behind an active + // build, so suspended work cannot keep the scheduler heartbeat alive forever. + // Then exclude owners at their plan's concurrency cap from the scan; without + // that exclusion, a capped tenant can fill the scan window and starve an + // unrelated owner. Usage-limit suspensions are not included in the batch. const owners = await getUsersWithQueuedContainerBuilds(db); + const suspendedUserIds = owners + .filter((owner) => isAdminSuspended(owner.suspendedReason)) + .map((owner) => owner.userId); + await cancelQueuedContainerBuildsForUsers(db, suspendedUserIds); const cappedUserIds = owners .filter((o) => { + if (isAdminSuspended(o.suspendedReason)) { + return false; + } const limit = getPlanLimits(o.plan).concurrentBuilds; return limit > 0 && o.activeBuilds >= limit; }) @@ -119,6 +125,13 @@ export class BuildScheduler extends DurableObject { }); continue; } + if (isAdminSuspended(user.suspended_reason)) { + await updateBuildStatus(db, build.id, 'cancelled', { + error: 'project owner suspended', + completedAt: new Date().toISOString(), + }); + continue; + } // Resolve the connection + project BEFORE claiming. A claim flips the row // queued->starting (it becomes "active"), but the per-build BuildManager — diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index f328059ca..0bb0f1b0f 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -489,11 +489,13 @@ export async function getAccessibleProjectsByUserId( .where(or(eq(projects.user_id, userId), eq(projectMembers.user_id, userId))) .orderBy(desc(projects.created_at)); - return rows.map((row) => ({ - project: row.project, - role: row.project.user_id === userId ? 'owner' : row.memberRole!, - owner: row.owner, - })); + return rows + .filter((row) => !isAdminSuspended(row.owner.suspended_reason)) + .map((row) => ({ + project: row.project, + role: row.project.user_id === userId ? 'owner' : row.memberRole!, + owner: row.owner, + })); } // --- Project team membership and invitations --- @@ -536,6 +538,7 @@ export type ProjectTeamMutationErrorReason = | 'invitation-not-target' | 'invitation-expired' | 'pending-invitation-limit' + | 'owner-suspended' | 'forbidden'; export class ProjectTeamMutationError extends Error { @@ -545,7 +548,7 @@ export class ProjectTeamMutationError extends Error { super(reason); this.name = 'ProjectTeamMutationError'; this.status = - reason === 'invitation-not-target' || reason === 'forbidden' + reason === 'invitation-not-target' || reason === 'owner-suspended' || reason === 'forbidden' ? 403 : reason.endsWith('not-found') ? 404 @@ -572,6 +575,7 @@ async function requireLockedMembersCapability( project: Project, actorUserId: string, ): Promise { + await requireLockedProjectOwnerActive(db, project); if (project.user_id === actorUserId) { return; } @@ -581,6 +585,18 @@ async function requireLockedMembersCapability( } } +async function requireLockedProjectOwnerActive(db: Db, project: Project): Promise { + const [owner] = await db + .select({ suspendedReason: users.suspended_reason }) + .from(users) + .where(eq(users.id, project.user_id)) + .for('share') + .limit(1); + if (owner && isAdminSuspended(owner.suspendedReason)) { + throw new ProjectTeamMutationError('owner-suspended'); + } +} + export async function createProjectAccessEvent( db: Db, args: { @@ -1027,6 +1043,7 @@ export async function respondToProjectInvitation( let member: ProjectMember | null = null; if (args.response === 'accept') { + await requireLockedProjectOwnerActive(txDb, project); [member] = await txDb .insert(projectMembers) .values({ @@ -3915,7 +3932,7 @@ export async function getQueuedBuilds( /** * For each user that owns at least one QUEUED container build, returns their - * plan plus the count of their builds currently in `ACTIVE_BUILD_STATUSES` + * plan, suspension reason, and count of builds in `ACTIVE_BUILD_STATUSES` * (`starting`/`building`/`deploying`). The scheduler uses this to decide which * owners are AT their plan's concurrency cap (cap is plan-derived in * `getPlanLimits`, NOT a DB column) and pass those ids to `getQueuedBuilds` as @@ -3927,13 +3944,19 @@ export async function getQueuedBuilds( * `claimBuildForStartWithinLimit`'s per-user count), so it must count toward the * cap. */ -export async function getUsersWithQueuedContainerBuilds( - db: Db, -): Promise> { +export async function getUsersWithQueuedContainerBuilds(db: Db): Promise< + ReadonlyArray<{ + userId: string; + plan: string; + suspendedReason: string | null; + activeBuilds: number; + }> +> { const rows = await db .select({ userId: users.id, plan: users.plan, + suspendedReason: users.suspended_reason, // Correlated count of this user's in-flight builds (any executor). The // FILTER over a LEFT JOIN keeps users whose only builds are queued // (activeBuilds = 0) in the result. @@ -3956,10 +3979,50 @@ export async function getUsersWithQueuedContainerBuilds( return rows.map((r) => ({ userId: r.userId, plan: r.plan, + suspendedReason: r.suspendedReason, activeBuilds: Number(r.activeBuilds), })); } +/** Cancel queued container work only for users who are still manually suspended. */ +export async function cancelQueuedContainerBuildsForUsers( + db: Db, + userIds: ReadonlyArray, +): Promise { + if (userIds.length === 0) { + return 0; + } + const suspendedOwners = db + .select({ id: users.id }) + .from(users) + .where( + and( + inArray(users.id, [...userIds]), + isNotNull(users.suspended_reason), + notInArray(users.suspended_reason, [ + REASON_REQUESTS_EXCEEDED, + REASON_SANDBOX_RUNTIME_EXCEEDED, + ]), + ), + ); + const cancelled = await db + .update(builds) + .set({ + status: 'cancelled', + error: 'project owner suspended', + completed_at: sql`now()`, + }) + .where( + and( + inArray(builds.user_id, suspendedOwners), + eq(builds.executor, 'container'), + eq(builds.status, 'queued'), + ), + ) + .returning({ id: builds.id }); + return cancelled.length; +} + /** * Whether ANY queued container build exists (regardless of whether its project * is currently active). The build scheduler uses this for its reschedule @@ -4179,7 +4242,12 @@ export async function supersedeAndCreateContainerBuild( status: Build['status']; instance_type: string | null; }>; - skipped?: 'config-changed' | 'install-revoked' | 'connection-replaced' | 'project-deleting'; + skipped?: + | 'config-changed' + | 'install-revoked' + | 'connection-replaced' + | 'project-deleting' + | 'owner-suspended'; }> { return db.transaction(async (tx) => { // Serialize all push processing for this project (project<->branch is 1:1 via @@ -4202,6 +4270,15 @@ export async function supersedeAndCreateContainerBuild( if (!proj || proj.status === 'deleting') { return { build: null, reused: false, cancelled: [], skipped: 'project-deleting' }; } + const [ownerState] = await tx + .select({ suspendedReason: users.suspended_reason }) + .from(users) + .where(eq(users.id, proj.user_id)) + .for('update') + .limit(1); + if (ownerState && isAdminSuspended(ownerState.suspendedReason)) { + return { build: null, reused: false, cancelled: [], skipped: 'owner-suspended' }; + } const ownerChanged = proj.user_id !== args.userId; let instanceType = args.instanceType ?? null; if (ownerChanged) { @@ -4631,6 +4708,7 @@ export async function createGithubActionsBuildWithinLimit( | { outcome: 'workflow-mismatch' } | { outcome: 'install-revoked' } | { outcome: 'project-deleting' } + | { outcome: 'owner-suspended' } > { return db.transaction(async (tx) => { // Lock the PROJECT row FIRST — before the users + github_connections locks @@ -4657,7 +4735,15 @@ export async function createGithubActionsBuildWithinLimit( // Serialize per-user slot acquisition. Concurrent exchanges for the same // owner block here until the holder commits, so the count below always // observes prior inserts. - await tx.execute(sql`SELECT id FROM ${users} WHERE id = ${proj.user_id} FOR UPDATE`); + const [ownerState] = await tx + .select({ suspendedReason: users.suspended_reason }) + .from(users) + .where(eq(users.id, proj.user_id)) + .for('update') + .limit(1); + if (ownerState && isAdminSuspended(ownerState.suspendedReason)) { + return { outcome: 'owner-suspended' }; + } let limit = args.limit; if (proj.user_id !== args.userId) { @@ -4893,7 +4979,15 @@ export async function claimBuildForStartWithinLimit( return db.transaction(async (tx) => { // Serialize per-user slot acquisition against the OIDC path and other // scheduler scans for the same owner. - await tx.execute(sql`SELECT id FROM ${users} WHERE id = ${args.userId} FOR UPDATE`); + const [owner] = await tx + .select({ suspendedReason: users.suspended_reason }) + .from(users) + .where(eq(users.id, args.userId)) + .for('update') + .limit(1); + if (!owner || isAdminSuspended(owner.suspendedReason)) { + return false; + } if (args.limit > 0) { const [result] = await tx diff --git a/platform/packages/api/src/project-access.ts b/platform/packages/api/src/project-access.ts index 2906833e5..9bbd29ccb 100644 --- a/platform/packages/api/src/project-access.ts +++ b/platform/packages/api/src/project-access.ts @@ -2,6 +2,7 @@ import { and, eq } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import type { Db } from './db.ts'; import { projectMembers, projects, users } from './schema.ts'; +import { isAdminSuspended } from './suspension.ts'; import type { Project, ProjectCapability, ProjectRole, User } from './types.ts'; export type ProjectAccess = Readonly<{ @@ -65,12 +66,19 @@ export class ProjectNotFoundError extends Error { export class ProjectAccessDeniedError extends Error { readonly status = 403 as const; - constructor() { - super('forbidden'); + constructor(message = 'forbidden') { + super(message); this.name = 'ProjectAccessDeniedError'; } } +export class ProjectOwnerSuspendedError extends ProjectAccessDeniedError { + constructor() { + super('project owner suspended'); + this.name = 'ProjectOwnerSuspendedError'; + } +} + /** * Assert a live project role from PostgreSQL. The owner account is returned * separately so plan/quota callers do not accidentally consult the actor. @@ -88,5 +96,8 @@ export async function requireProjectCapability( if (!row.memberRole || !hasProjectCapability(row.memberRole, capability)) { throw new ProjectAccessDeniedError(); } + if (isAdminSuspended(row.owner.suspended_reason)) { + throw new ProjectOwnerSuspendedError(); + } return { project: row.project, role: row.memberRole, owner: row.owner }; } diff --git a/platform/packages/api/src/routes/auth-oidc.ts b/platform/packages/api/src/routes/auth-oidc.ts index 29f0f12b3..a4e05c94e 100644 --- a/platform/packages/api/src/routes/auth-oidc.ts +++ b/platform/packages/api/src/routes/auth-oidc.ts @@ -14,6 +14,7 @@ import { getRepoInstallation, } from '../github/installation-token.ts'; import { verifyGithubOidcToken } from '../github/oidc-verify.ts'; +import { isAdminSuspended } from '../suspension.ts'; import { getPlanLimits } from '../tiers.ts'; const app = new Hono(); @@ -178,6 +179,9 @@ app.post('/github-oidc', async (context) => { if (!user) { return context.json({ error: 'connection owner not found' }, 401); } + if (isAdminSuspended(user.suspended_reason)) { + return context.json({ error: 'project owner suspended' }, 403); + } // Only branch pushes to the connected branch may mint a deploy token. This // mirrors the webhook push guard (`branch !== connection.branch`) and rejects @@ -316,6 +320,9 @@ app.post('/github-oidc', async (context) => { // concurrent lifecycle change, same class as the other concurrency rejects. return context.json({ error: 'project is being deleted' }, 409); } + if (result.outcome === 'owner-suspended') { + return context.json({ error: 'project owner suspended' }, 403); + } const build = result.build; // The build helper re-reads and locks the project, so `build.user_id` is the // billing owner that was current at admission even if ownership changed after diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index c3cc9d068..00dd80e2b 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -87,7 +87,11 @@ import { generateId } from '../id.ts'; import { checkCliVersionOrRespond } from '../middleware/cli-version.ts'; import { getProjectQueueName } from '../naming.ts'; import { createDeployLogger, type DeployLogger } from '../observability/logger.ts'; -import { ProjectAccessDeniedError, requireProjectCapability } from '../project-access.ts'; +import { + ProjectAccessDeniedError, + ProjectOwnerSuspendedError, + requireProjectCapability, +} from '../project-access.ts'; import { ensureProjectIngress } from '../project-ingress.ts'; import { BETTER_AUTH_SECRET_NAME, @@ -121,7 +125,6 @@ import { resolveRoutingUndo, } from '../routing.ts'; import { builds, deployments } from '../schema.ts'; -import { isAdminSuspended } from '../suspension.ts'; import { getPlanLimits, isWithinLimit, STATIC_RETENTION_LIMIT, type PlanId } from '../tiers.ts'; import { SUPPORTED_MANIFEST_VERSIONS, @@ -147,19 +150,8 @@ const cutoverReadyKey = (deploymentId: string): string => `cutover-ready:${deplo const cutoverCompensatedKey = (deploymentId: string): string => `cutover-compensated:${deploymentId}`; -class ProjectOwnerSuspendedError extends Error { - constructor() { - super('project owner suspended'); - this.name = 'ProjectOwnerSuspendedError'; - } -} - async function requireActiveDeployAccess(db: Db, projectId: string, actorUserId: string) { - const access = await requireProjectCapability(db, projectId, actorUserId, 'deploy'); - if (isAdminSuspended(access.owner.suspended_reason)) { - throw new ProjectOwnerSuspendedError(); - } - return access; + return requireProjectCapability(db, projectId, actorUserId, 'deploy'); } export function databaseTimestampMs(value: string): number | null { @@ -1850,9 +1842,6 @@ app.post('/:id/deploy/upload-urls', async (context) => { if (access instanceof Response) { return access; } - if (isAdminSuspended(access.owner.suspended_reason)) { - return context.json({ error: 'project owner suspended' }, 403); - } const project = access.project; // Fast 410 — mirror `handleManifestOnlyDeploy`. The unlocked read here // is a TOCTOU best-effort: it short-circuits the common case (project @@ -2081,9 +2070,6 @@ async function handleManifestOnlyDeploy(context: Context): Promise { if (access instanceof Response) { return access; } - if (isAdminSuspended(access.owner.suspended_reason)) { - return context.json({ error: 'project owner suspended' }, 403); - } const project = access.project; // Mirror /upload-urls and handleManifestOnlyDeploy: short-circuit when // the project is being torn down so callers get the more specific 410 @@ -5662,12 +5645,12 @@ app.post('/:id/deploy/preflight', async (context) => { try { await requireActiveDeployAccess(txDb, projectId, context.get('user').id); } catch (error) { - if (error instanceof ProjectAccessDeniedError) { - return { kind: 'forbidden' as const }; - } if (error instanceof ProjectOwnerSuspendedError) { return { kind: 'owner-suspended' as const }; } + if (error instanceof ProjectAccessDeniedError) { + return { kind: 'forbidden' as const }; + } throw error; } if (lockedProject.status === 'deleting') { diff --git a/platform/packages/api/src/routes/project-team.ts b/platform/packages/api/src/routes/project-team.ts index 1c4a06d7b..b65e052b8 100644 --- a/platform/packages/api/src/routes/project-team.ts +++ b/platform/packages/api/src/routes/project-team.ts @@ -121,6 +121,7 @@ function mutationErrorResponse(context: Context, error: unknown): Respon 'invitation-not-target': 'forbidden', 'invitation-expired': 'project invitation has expired', 'pending-invitation-limit': 'project has too many pending invitations', + 'owner-suspended': 'project owner suspended', forbidden: 'forbidden', }; return context.json({ error: messages[error.reason] }, error.status); diff --git a/platform/packages/api/src/usage-queue.ts b/platform/packages/api/src/usage-queue.ts index 992edb6cd..8b838176d 100644 --- a/platform/packages/api/src/usage-queue.ts +++ b/platform/packages/api/src/usage-queue.ts @@ -188,8 +188,7 @@ export async function processUsageEventBatch( userId: ownerUserId, slug: project.slug, currentOwner: ownershipPeriod ? ownershipPeriod.ends_at === null : true, - ownershipStartedAt: - ownershipPeriod?.starts_at ?? project.created_at ?? new Date(occurredAt).toISOString(), + ownershipStartedAt: ownershipPeriod?.starts_at ?? project.created_at, }, at, }, diff --git a/platform/packages/api/test/integration/api-github.test.ts b/platform/packages/api/test/integration/api-github.test.ts index 5beb4b4c0..e1811e304 100644 --- a/platform/packages/api/test/integration/api-github.test.ts +++ b/platform/packages/api/test/integration/api-github.test.ts @@ -50,6 +50,7 @@ import { clearTables, createTestProject, createTestUser, + recordingBuildManager, setupEnv, teardownEnv, type TestEnv, @@ -185,36 +186,6 @@ function authed(jwt: string, body?: unknown): RequestInit { }; } -// A BUILD_MANAGER stand-in that records which build ids (keyed by the DO name, -// which the handler sets to the build id) received a `cancelBuild` RPC. The -// handler tears down orphaned containers via the trusted `cancelBuild` RPC on -// BuildManager; the call is best-effort, so here we only assert it was ATTEMPTED -// for the right (active container) builds. A `fetch` shim is kept for any -// agent-authenticated `POST /cancel` path so the mock degrades gracefully. -function recordingBuildManager(): { - namespace: DurableObjectNamespace; - cancelledIds: ReadonlyArray; -} { - const cancelledIds: Array = []; - const namespace = { - idFromName: (name: string) => ({ toString: () => name }) as unknown as DurableObjectId, - get: (id: DurableObjectId) => ({ - cancelBuild: async (_reason?: string) => { - cancelledIds.push(id.toString()); - }, - fetch: async (input: string | URL | Request) => { - const url = - typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (new URL(url).pathname === '/cancel') { - cancelledIds.push(id.toString()); - } - return Response.json({ ok: true }); - }, - }), - } as unknown as DurableObjectNamespace; - return { namespace, cancelledIds }; -} - // Minimal GitHub App env so isGitHubAppConfigured() is true. The private key is // never parsed because getInstallationToken is mocked above. const GITHUB_APP_ENV = { @@ -2696,7 +2667,7 @@ describe('PATCH /projects/:id/github', () => { branch: 'develop', }); - const { namespace, cancelledIds } = recordingBuildManager(); + const { namespace, cancelledIds } = recordingBuildManager({ recordFetchCancel: true }); const res = await app.fetch( new Request(`http://localhost/projects/${project.id}/github`, { method: 'PATCH', @@ -2754,7 +2725,7 @@ describe('PATCH /projects/:id/github', () => { status: 'building', }); - const { namespace, cancelledIds } = recordingBuildManager(); + const { namespace, cancelledIds } = recordingBuildManager({ recordFetchCancel: true }); const res = await app.fetch( new Request(`http://localhost/projects/${project.id}/github`, { method: 'PATCH', @@ -2815,7 +2786,7 @@ describe('PATCH /projects/:id/github', () => { status: 'building', }); - const { namespace, cancelledIds } = recordingBuildManager(); + const { namespace, cancelledIds } = recordingBuildManager({ recordFetchCancel: true }); const res = await app.fetch( new Request(`http://localhost/projects/${project.id}/github`, { method: 'PATCH', @@ -2872,7 +2843,7 @@ describe('PATCH /projects/:id/github', () => { branch: 'main', }); - const { namespace, cancelledIds } = recordingBuildManager(); + const { namespace, cancelledIds } = recordingBuildManager({ recordFetchCancel: true }); const res = await app.fetch( new Request(`http://localhost/projects/${project.id}/github`, { method: 'PATCH', @@ -2943,7 +2914,7 @@ describe('PATCH /projects/:id/github', () => { branch: 'release', }); - const { namespace, cancelledIds } = recordingBuildManager(); + const { namespace, cancelledIds } = recordingBuildManager({ recordFetchCancel: true }); const res = await app.fetch( new Request(`http://localhost/projects/${project.id}/github`, { method: 'PATCH', @@ -3075,7 +3046,7 @@ describe('DELETE /projects/:id/github', () => { status: 'complete', }); - const { namespace, cancelledIds } = recordingBuildManager(); + const { namespace, cancelledIds } = recordingBuildManager({ recordFetchCancel: true }); const res = await app.fetch( new Request(`http://localhost/projects/${project.id}/github`, { method: 'DELETE', diff --git a/platform/packages/api/test/integration/api-migrations.test.ts b/platform/packages/api/test/integration/api-migrations.test.ts index 4c8ca457d..13991c658 100644 --- a/platform/packages/api/test/integration/api-migrations.test.ts +++ b/platform/packages/api/test/integration/api-migrations.test.ts @@ -6203,15 +6203,4 @@ describe('POST /projects/:id/migrations/rename', () => { const parsed = JSON.parse(match![1]) as Array; expect(parsed).toEqual([exoticName]); }); - - it('does not use a collision-prone hashtext advisory lock', async () => { - const { readFileSync } = await import('node:fs'); - const { fileURLToPath } = await import('node:url'); - const path = fileURLToPath(new URL('../../src/routes/projects.ts', import.meta.url)); - const rawSource = readFileSync(path, 'utf8'); - const source = rawSource.replaceAll(/\/\/[^\n]*/g, ''); - - expect(source).not.toMatch(/hashtext\s*\(/); - expect(source).not.toMatch(/pg_advisory_xact_lock/); - }); }); diff --git a/platform/packages/api/test/integration/api-oidc.test.ts b/platform/packages/api/test/integration/api-oidc.test.ts index edcfe7f5a..0bd1b1735 100644 --- a/platform/packages/api/test/integration/api-oidc.test.ts +++ b/platform/packages/api/test/integration/api-oidc.test.ts @@ -31,7 +31,13 @@ import type { Db } from '../../src/db.ts'; import type { ApiEnv } from '../../src/env.ts'; import type { GithubOidcClaims } from '../../src/github/oidc-verify.ts'; import { oidcRoutes } from '../../src/routes/auth-oidc.ts'; -import { builds, githubConnections, githubInstallations, projects } from '../../src/schema.ts'; +import { + builds, + githubConnections, + githubInstallations, + projects, + users, +} from '../../src/schema.ts'; import { JWT_SECRET, clearTables, @@ -626,6 +632,32 @@ describe('POST /auth/github-oidc', () => { expect(build.logs_url).toBe('https://github.com/octo/repo/actions/runs/424242'); }); + it('returns 403 and creates no build when the project owner is manually suspended', async () => { + const user = await createTestUser(env.db, 'oidc-suspended-owner'); + const project = await createTestProject(env.db, user.id, 'oidc-suspended'); + await seedActiveInstallation(env.db, '1'); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: '1', + repoFullName: 'octo/repo', + repositoryId: '42', + branch: 'main', + buildExecutor: 'github_actions', + }); + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, user.id)); + const app = makeApp(env); + setVerifyResult(makeClaims()); + + const res = await exchange(app, env, { oidcToken: 'good', project: project.slug }); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'project owner suspended' }); + expect(await getProjectBuilds(env.db, project.id)).toEqual([]); + expect(checkRunCreateCount).toBe(0); + }); + // The github_actions executor has no BuildManager DO, so the OIDC exchange is // the only place the "Void Build" check run can be created. A successful create // must persist the returned id onto the build so the workflow_run completion diff --git a/platform/packages/api/test/integration/api-project-team.test.ts b/platform/packages/api/test/integration/api-project-team.test.ts index 0cac48544..a5796ad4d 100644 --- a/platform/packages/api/test/integration/api-project-team.test.ts +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -7,6 +7,7 @@ import { upsertGitHubInstallation, } from '../../src/db.ts'; import app from '../../src/index.ts'; +import * as projectAccess from '../../src/project-access.ts'; import { projectAccessEvents, projectInvitations, @@ -18,6 +19,7 @@ import { clearTables, createTestProject, createTestUser, + recordingBuildManager, setupEnv, teardownEnv, type TestEnv, @@ -25,22 +27,6 @@ import { let env: TestEnv; -function recordingBuildManager(): { - namespace: DurableObjectNamespace; - cancelledIds: ReadonlyArray; -} { - const cancelledIds: Array = []; - const namespace = { - idFromName: (name: string) => ({ toString: () => name }) as unknown as DurableObjectId, - get: (id: DurableObjectId) => ({ - cancelBuild: async () => { - cancelledIds.push(id.toString()); - }, - }), - } as unknown as DurableObjectNamespace; - return { namespace, cancelledIds }; -} - beforeAll(async () => { env = await setupEnv(); }); @@ -51,6 +37,7 @@ afterAll(async () => { beforeEach(async () => { await clearTables(env.db); + vi.restoreAllMocks(); }); function request( @@ -234,6 +221,71 @@ describe('project team API', () => { expect(events.map((event) => event.action)).toEqual(['invited', 'accepted']); }); + it('rejects invitation acceptance while the project owner is manually suspended', async () => { + const owner = await createTestUser(env.db, 'accept-suspended-owner'); + const target = await createTestUser(env.db, 'accept-suspended-target'); + await setEmail(target.id, 'accept-suspended@example.com'); + const project = await createTestProject(env.db, owner.id); + const invitation = await invite( + project.id, + owner.jwt, + 'accept-suspended@example.com', + 'reader', + ); + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, owner.id)); + + const response = await app.fetch( + request(`/project-invitations/${invitation.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'project owner suspended' }); + expect(await env.db.select().from(projectMembers)).toEqual([]); + }); + + it('rechecks owner suspension under the project lock before a team mutation', async () => { + const owner = await createTestUser(env.db, 'team-race-owner'); + const admin = await createTestUser(env.db, 'team-race-admin'); + const target = await createTestUser(env.db, 'team-race-target'); + await setEmail(target.id, 'team-race-target@example.com'); + const project = await createTestProject(env.db, owner.id); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }); + + const requireCapability = projectAccess.requireProjectCapability; + let suspended = false; + vi.spyOn(projectAccess, 'requireProjectCapability').mockImplementation(async (...args) => { + const access = await requireCapability(...args); + if (!suspended && args[3] === 'members') { + suspended = true; + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, owner.id)); + } + return access; + }); + + const response = await app.fetch( + request(`/projects/${project.id}/invitations`, admin.jwt, { + method: 'POST', + body: { email: 'team-race-target@example.com', role: 'reader' }, + }), + env, + ); + expect(suspended).toBe(true); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'project owner suspended' }); + expect(await env.db.select().from(projectInvitations)).toEqual([]); + }); + it('binds invitations to exactly one registered account and rejects owner/member conflicts', async () => { const owner = await createTestUser(env.db, 'owner'); const member = await createTestUser(env.db, 'member'); diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 4a2389458..bfc1fd891 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -147,6 +147,44 @@ describe('GET /projects', () => { expect(projects[0].slug).toBe('alice-app'); }); + it('omits shared projects whose owner is manually suspended but keeps usage-suspended projects', async () => { + const owner = await createTestUser(env.db, 'list-suspended-owner'); + const collaborator = await createTestUser(env.db, 'list-suspended-collaborator'); + const project = await createTestProject(env.db, owner.id, 'list-suspended-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, owner.id)); + + const list = () => + app.fetch( + new Request('http://localhost/projects', { + headers: { Authorization: `Bearer ${collaborator.jwt}` }, + }), + env, + ); + const suspended = await list(); + expect(suspended.status).toBe(200); + expect(await suspended.json()).toEqual([]); + + await env.db + .update(users) + .set({ suspended_reason: 'requests_exceeded' }) + .where(eq(users.id, owner.id)); + mockCfApi(); + const usageSuspended = await list(); + expect(usageSuspended.status).toBe(200); + expect((await usageSuspended.json()) as Array<{ id: string }>).toMatchObject([ + { id: project.id }, + ]); + }); + it('returns deployment, traffic, and health summaries sorted by attention', async () => { const user = await createTestUser(env.db, 'overview-user'); const alpha = await createTestProject(env.db, user.id, 'alpha'); @@ -240,6 +278,31 @@ describe('GET /projects/:id', () => { expect(body.slug).toBe('my-app'); }); + it('rejects a collaborator when the owner is manually suspended', async () => { + const owner = await createTestUser(env.db, 'get-suspended-owner'); + const collaborator = await createTestUser(env.db, 'get-suspended-collaborator'); + const project = await createTestProject(env.db, owner.id, 'get-suspended-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'reader', + added_by_user_id: owner.id, + }); + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, owner.id)); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}`, { + headers: { Authorization: `Bearer ${collaborator.jwt}` }, + }), + env, + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'project owner suspended' }); + }); + it('returns 403 when user does not own the project', async () => { const alice = await createTestUser(env.db, 'alice'); const bob = await createTestUser(env.db, 'bob'); diff --git a/platform/packages/api/test/integration/api-webhooks.test.ts b/platform/packages/api/test/integration/api-webhooks.test.ts index 4bf91af92..88bc5f8b6 100644 --- a/platform/packages/api/test/integration/api-webhooks.test.ts +++ b/platform/packages/api/test/integration/api-webhooks.test.ts @@ -41,6 +41,7 @@ import { createTestDeployment, createTestProject, createTestUser, + recordingBuildManager, setupEnv, teardownEnv, type TestEnv, @@ -202,29 +203,6 @@ async function clearBuildTables(db: Db): Promise { await db.delete(githubInstallations); } -// A BUILD_MANAGER stand-in that records which build ids (keyed by the DO name, -// which the handler sets to the build id) received a `cancelBuild` RPC. The -// webhook revocation handlers tear down orphaned containers via the trusted -// `cancelBuild` RPC on BuildManager (best-effort), so here we only assert it was -// ATTEMPTED for the right (active container) builds. A `fetch` shim is kept for -// any agent-authenticated `POST /cancel` path so the mock degrades gracefully. -function recordingBuildManager(): { - namespace: DurableObjectNamespace; - cancelledIds: ReadonlyArray; -} { - const cancelledIds: Array = []; - const namespace = { - idFromName: (name: string) => ({ toString: () => name }) as unknown as DurableObjectId, - get: (id: DurableObjectId) => ({ - cancelBuild: async (_reason?: string) => { - cancelledIds.push(id.toString()); - }, - fetch: async () => Response.json({ ok: true }), - }), - } as unknown as DurableObjectNamespace; - return { namespace, cancelledIds }; -} - async function createGitHubConnection( db: Db, args: Parameters[1], diff --git a/platform/packages/api/test/integration/db-builds.test.ts b/platform/packages/api/test/integration/db-builds.test.ts index 00aa872fb..eaf1e2a22 100644 --- a/platform/packages/api/test/integration/db-builds.test.ts +++ b/platform/packages/api/test/integration/db-builds.test.ts @@ -1,6 +1,7 @@ import { eq, sql } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; import { + cancelQueuedContainerBuildsForUsers, claimBuildForStartWithinLimit, createBuild, createGitHubConnection as createGitHubConnectionRecord, @@ -44,6 +45,7 @@ import { projectMembers, projects, resources, + users, } from '../../src/schema.ts'; import { clearTables, @@ -365,7 +367,59 @@ describe('builds', () => { const owners = await getUsersWithQueuedContainerBuilds(env.db); expect(owners).toHaveLength(1); - expect(owners[0]).toEqual({ userId: withQueued.id, plan: 'free', activeBuilds: 2 }); + expect(owners[0]).toEqual({ + userId: withQueued.id, + plan: 'free', + suspendedReason: null, + activeBuilds: 2, + }); + }); + + it('cancels all queued container builds for suspended owners, including blocked projects', async () => { + const suspended = await createTestUser(env.db, 'batch-suspended-owner'); + const usageSuspended = await createTestUser(env.db, 'batch-usage-suspended-owner'); + const activeProject = await createTestProject(env.db, suspended.id, 'batch-active'); + const idleProject = await createTestProject(env.db, suspended.id, 'batch-idle'); + const usageProject = await createTestProject(env.db, usageSuspended.id, 'batch-usage'); + await createBuild(env.db, { + projectId: activeProject.id, + userId: suspended.id, + status: 'building', + }); + const blocked = await createBuild(env.db, { + projectId: activeProject.id, + userId: suspended.id, + }); + const idle = await createBuild(env.db, { projectId: idleProject.id, userId: suspended.id }); + const usageQueued = await createBuild(env.db, { + projectId: usageProject.id, + userId: usageSuspended.id, + }); + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, suspended.id)); + await env.db + .update(users) + .set({ suspended_reason: 'requests_exceeded' }) + .where(eq(users.id, usageSuspended.id)); + + expect( + (await getUsersWithQueuedContainerBuilds(env.db)).find( + (owner) => owner.userId === suspended.id, + ), + ).toMatchObject({ + userId: suspended.id, + suspendedReason: 'platform policy violation', + activeBuilds: 1, + }); + expect( + await cancelQueuedContainerBuildsForUsers(env.db, [suspended.id, usageSuspended.id]), + ).toBe(2); + expect((await getBuild(env.db, blocked.id))?.status).toBe('cancelled'); + expect((await getBuild(env.db, idle.id))?.status).toBe('cancelled'); + expect((await getBuild(env.db, usageQueued.id))?.status).toBe('queued'); + expect(await hasQueuedContainerBuilds(env.db)).toBe(true); }); it('hasQueuedContainerBuilds reflects whether any queued container build exists', async () => { @@ -1200,6 +1254,45 @@ describe('builds', () => { expect(await getProjectBuilds(env.db, project.id)).toEqual([]); }); + it('supersedeAndCreateContainerBuild skips manual owner suspension but permits usage suspension', async () => { + const user = await createTestUser(env.db, 'container-suspended-owner'); + const project = await createTestProject(env.db, user.id); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'container-suspended-owner', + repoFullName: 'acme/suspended-owner', + repositoryId: DEFAULT_REPO_ID, + branch: 'main', + buildExecutor: 'container', + }); + await seedActiveInstall(env.db, 'container-suspended-owner'); + const connectionId = (await getGitHubConnection(env.db, project.id))!.id; + const args = { + projectId: project.id, + connectionId, + userId: user.id, + branch: 'main', + commitSha: 'suspended-owner', + installationId: 'container-suspended-owner', + repositoryId: DEFAULT_REPO_ID, + } as const; + + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, user.id)); + const suspended = await supersedeAndCreateContainerBuild(env.db, args); + expect(suspended).toMatchObject({ build: null, skipped: 'owner-suspended' }); + expect(await getProjectBuilds(env.db, project.id)).toEqual([]); + + await env.db + .update(users) + .set({ suspended_reason: 'requests_exceeded' }) + .where(eq(users.id, user.id)); + const usageSuspended = await supersedeAndCreateContainerBuild(env.db, args); + expect(usageSuspended.build?.status).toBe('queued'); + }); + it('supersedeAndCreateContainerBuild inserts when the connection branch+executor match', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id); @@ -1579,6 +1672,27 @@ describe('builds', () => { expect(reclaimed).toBe(false); }); + it('claimBuildForStartWithinLimit does not start a queued build for a manually suspended owner', async () => { + const user = await createTestUser(env.db, 'claim-suspended-owner'); + const project = await createTestProject(env.db, user.id); + const build = await createBuild(env.db, { projectId: project.id, userId: user.id }); + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, user.id)); + + expect( + await claimBuildForStartWithinLimit(env.db, { + buildId: build.id, + userId: user.id, + projectId: project.id, + limit: 1, + instanceType: 'basic', + }), + ).toBe(false); + expect((await getBuild(env.db, build.id))?.status).toBe('queued'); + }); + it('requeueBuildIfStarting returns a starting row to queued (CAS)', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id); @@ -1968,6 +2082,52 @@ describe('builds', () => { // --------------------------------------------------------------------------- describe('createGithubActionsBuildWithinLimit', () => { + it('rejects manual owner suspension but permits usage suspension', async () => { + const user = await createTestUser(env.db, 'actions-suspended-owner'); + const project = await createTestProject(env.db, user.id); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'actions-suspended-owner', + repoFullName: 'acme/actions-suspended-owner', + repositoryId: DEFAULT_REPO_ID, + buildExecutor: 'github_actions', + }); + await seedActiveInstall(env.db, 'actions-suspended-owner'); + const args = { + projectId: project.id, + connectionId: (await getGitHubConnection(env.db, project.id))!.id, + userId: user.id, + limit: 3, + branch: 'main', + githubRunId: 'suspended-owner-1', + installationId: 'actions-suspended-owner', + repositoryId: DEFAULT_REPO_ID, + expectedWorkflowPath: '.github/workflows/void-deploy.yml', + } as const; + + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, user.id)); + expect((await createGithubActionsBuildWithinLimit(env.db, args)).outcome).toBe( + 'owner-suspended', + ); + expect(await getProjectBuilds(env.db, project.id)).toEqual([]); + + await env.db + .update(users) + .set({ suspended_reason: 'requests_exceeded' }) + .where(eq(users.id, user.id)); + expect( + ( + await createGithubActionsBuildWithinLimit(env.db, { + ...args, + githubRunId: 'suspended-owner-2', + }) + ).outcome, + ).toBe('created'); + }); + it('recomputes concurrency and attribution from the locked current owner after transfer', async () => { const staleOwner = await createTestUser( env.db, diff --git a/platform/packages/api/test/integration/helpers.ts b/platform/packages/api/test/integration/helpers.ts index c1df4bdb8..8e8ad9803 100644 --- a/platform/packages/api/test/integration/helpers.ts +++ b/platform/packages/api/test/integration/helpers.ts @@ -132,6 +132,32 @@ function createMockDoNamespace(): DurableObjectNamespace { } as unknown as DurableObjectNamespace; } +export function recordingBuildManager(options: Readonly<{ recordFetchCancel?: boolean }> = {}): { + namespace: DurableObjectNamespace; + cancelledIds: ReadonlyArray; +} { + const cancelledIds: Array = []; + const namespace = { + idFromName: (name: string) => ({ toString: () => name }) as unknown as DurableObjectId, + get: (id: DurableObjectId) => ({ + cancelBuild: async (_reason?: string) => { + cancelledIds.push(id.toString()); + }, + fetch: async (input: string | URL | Request) => { + if (options.recordFetchCancel) { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (new URL(url).pathname === '/cancel') { + cancelledIds.push(id.toString()); + } + } + return Response.json({ ok: true }); + }, + }), + } as unknown as DurableObjectNamespace; + return { namespace, cancelledIds }; +} + type StoredValue = { value: string | ArrayBuffer; metadata?: unknown; diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index 1603c1fe3..72281a6ac 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -45,6 +45,7 @@ import { createTestProject, createTestUser, mockCfApi, + recordingBuildManager, setupEnv, teardownEnv, type TestEnv, @@ -54,22 +55,6 @@ let env: TestEnv; let token: string; let operatorAdminId: string; -function recordingBuildManager(): { - namespace: DurableObjectNamespace; - cancelledIds: ReadonlyArray; -} { - const cancelledIds: Array = []; - const namespace = { - idFromName: (name: string) => ({ toString: () => name }) as unknown as DurableObjectId, - get: (id: DurableObjectId) => ({ - cancelBuild: async () => { - cancelledIds.push(id.toString()); - }, - }), - } as unknown as DurableObjectNamespace; - return { namespace, cancelledIds }; -} - beforeAll(async () => { env = await setupEnv(); }); diff --git a/platform/packages/api/test/integration/project-access.test.ts b/platform/packages/api/test/integration/project-access.test.ts index 4e9ec8ad4..10a7724f4 100644 --- a/platform/packages/api/test/integration/project-access.test.ts +++ b/platform/packages/api/test/integration/project-access.test.ts @@ -12,7 +12,11 @@ import { setProjectMemberRole, } from '../../src/db.ts'; import app from '../../src/index.ts'; -import { ProjectAccessDeniedError, requireProjectCapability } from '../../src/project-access.ts'; +import { + ProjectAccessDeniedError, + ProjectOwnerSuspendedError, + requireProjectCapability, +} from '../../src/project-access.ts'; import { projectAccessEvents, projectMembers, @@ -99,6 +103,34 @@ describe('project access', () => { ]); }); + it('suspends shared access only when the owner is manually suspended', async () => { + const owner = await createTestUser(env.db, 'suspended-owner'); + const collaborator = await createTestUser(env.db, 'suspended-collaborator'); + const project = await createProject(env.db, owner.id, 'suspended-owner-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, owner.id)); + await expect( + requireProjectCapability(env.db, project.id, collaborator.id, 'read'), + ).rejects.toBeInstanceOf(ProjectOwnerSuspendedError); + + await env.db + .update(users) + .set({ suspended_reason: 'requests_exceeded' }) + .where(eq(users.id, owner.id)); + expect( + (await requireProjectCapability(env.db, project.id, collaborator.id, 'deploy')).role, + ).toBe('collaborator'); + }); + it('requires a registered target, binds acceptance to it, and audits mutations', async () => { const owner = await userWithEmail('invite-owner'); const target = await userWithEmail('invite-target'); @@ -162,6 +194,42 @@ describe('project access', () => { ]); }); + it('does not grant invitation access while the project owner is manually suspended', async () => { + const owner = await userWithEmail('suspended-invite-owner'); + const target = await userWithEmail('suspended-invite-target'); + const project = await createProject(env.db, owner.id, 'suspended-invitation'); + const { invitation } = await createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: target.email, + role: 'reader', + actorUserId: owner.id, + }); + + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, owner.id)); + await expect( + respondToProjectInvitation(env.db, { + invitationId: invitation.id, + actorUserId: target.id, + response: 'accept', + }), + ).rejects.toMatchObject({ reason: 'owner-suspended', status: 403 }); + expect(await getProjectMember(env.db, project.id, target.id)).toBeNull(); + + await env.db + .update(users) + .set({ suspended_reason: 'requests_exceeded' }) + .where(eq(users.id, owner.id)); + const accepted = await respondToProjectInvitation(env.db, { + invitationId: invitation.id, + actorUserId: target.id, + response: 'accept', + }); + expect(accepted.member?.role).toBe('reader'); + }); + it('records owner attribution on new projects and deployments', async () => { const owner = await createTestUser(env.db, 'attribution-owner'); const actor = await createTestUser(env.db, 'attribution-actor'); diff --git a/platform/packages/api/test/unit/build-scheduler.test.ts b/platform/packages/api/test/unit/build-scheduler.test.ts index da36525f6..43bd435c9 100644 --- a/platform/packages/api/test/unit/build-scheduler.test.ts +++ b/platform/packages/api/test/unit/build-scheduler.test.ts @@ -43,7 +43,7 @@ const db = { // — queued rows exist but none are startable. queued: [] as Array, startable: null as Array | null, - users: new Map(), + users: new Map(), activeByUser: new Map(), connections: new Map< string, @@ -114,13 +114,30 @@ vi.mock('../../src/db.ts', () => ({ return Array.from(ownerIds).map((userId) => ({ userId, plan: db.users.get(userId)?.plan ?? 'free', + suspendedReason: db.users.get(userId)?.suspended_reason ?? null, activeBuilds: db.activeByUser.get(userId) ?? 0, })); }), + cancelQueuedContainerBuildsForUsers: vi.fn( + async (_db: unknown, userIds: ReadonlyArray) => { + const suspended = new Set(userIds); + const cancelled = db.queued.filter((build) => suspended.has(build.user_id)); + for (const build of cancelled) { + db.cancelled.push({ id: build.id, error: 'project owner suspended' }); + } + db.queued = db.queued.filter((build) => !suspended.has(build.user_id)); + if (db.startable) { + db.startable = db.startable.filter((build) => !suspended.has(build.user_id)); + } + return cancelled.length; + }, + ), hasQueuedContainerBuilds: vi.fn(async (_db: unknown) => db.queued.length > 0), getUser: vi.fn(async (_db: unknown, id: string) => { const u = db.users.get(id); - return u ? { id, plan: u.plan, login: u.login } : null; + return u + ? { id, plan: u.plan, login: u.login, suspended_reason: u.suspended_reason ?? null } + : null; }), claimBuildForStartWithinLimit: vi.fn( async ( @@ -370,6 +387,25 @@ describe('BuildScheduler.alarm', () => { ]); }); + it('cancels a queued build owned by a manually suspended user before claiming it', async () => { + db.users.set('u1', { + plan: 'free', + login: 'octocat', + suspended_reason: 'platform policy violation', + }); + enqueue({ id: 'b1', user_id: 'u1', project_id: 'p1' }); + db.activeByUser.set('u1', 1); + db.startable = []; + const startBuild = vi.fn(async () => {}); + const s = makeScheduler(makeStubCtx(), makeEnv(startBuild)); + + await s.alarm(); + + expect(startBuild).not.toHaveBeenCalled(); + expect(db.claimArgs).toEqual([]); + expect(db.cancelled).toEqual([{ id: 'b1', error: 'project owner suspended' }]); + }); + it('re-arms the alarm when the queue is fully blocked (nothing startable but queued work remains)', async () => { // getQueuedBuilds returns [] (every queued build's project already has an // active build, so none are startable) but hasQueuedContainerBuilds is true. From 1f475a5da91c217ecb1fd6b44d0eed49df7fb67c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 18 Sep 2026 11:03:22 +0800 Subject: [PATCH 04/18] fix(platform): close remaining team access gaps --- platform/packages/api/src/build-scheduler.ts | 5 +- platform/packages/api/src/db.ts | 9 +- .../api/src/operator/tenant-service.ts | 26 ++-- platform/packages/api/src/project-access.ts | 4 +- platform/packages/api/src/routes/auth-oidc.ts | 4 +- platform/packages/api/src/routes/deploy.ts | 4 +- .../api/src/routes/internal-verify-owner.ts | 5 +- platform/packages/api/src/routes/projects.ts | 38 +++++- platform/packages/api/src/routes/usage.ts | 27 +--- platform/packages/api/src/routes/webhooks.ts | 7 +- .../test/integration/api-migrations.test.ts | 49 ++++++- .../api-project-access-routes.test.ts | 13 -- .../test/integration/api-project-team.test.ts | 76 ++++++++--- .../api/test/integration/api-projects.test.ts | 52 ++++++++ .../test/integration/operator-tenants.test.ts | 66 ++++++++++ .../test/integration/project-access.test.ts | 124 +----------------- .../api/test/unit/build-scheduler.test.ts | 13 +- 17 files changed, 305 insertions(+), 217 deletions(-) diff --git a/platform/packages/api/src/build-scheduler.ts b/platform/packages/api/src/build-scheduler.ts index ca6dd79dd..c5dd9fadb 100644 --- a/platform/packages/api/src/build-scheduler.ts +++ b/platform/packages/api/src/build-scheduler.ts @@ -221,8 +221,9 @@ export class BuildScheduler extends DurableObject { // mutually exclusive per user, so a scheduler scan and a parallel OIDC // exchange can't both observe sub-cap capacity and both create active // builds (which the old separate count-then-claim allowed). `limit === 0` - // means unlimited. Returns false when at cap OR when the build was already - // claimed elsewhere; either way leave it for a later tick. The claim also + // means unlimited. Returns false when the owner is unavailable or suspended, + // at cap, the project has active work, or the build was claimed elsewhere; + // either way leave it for a later tick. The claim also // persists the effective instanceType in the same tx, so a cancel/reap in // the START->onStart window reads the correct size and addresses the right // DO namespace. This is the commit point: only pure config assembly + the diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 0bb0f1b0f..5069d559d 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -473,7 +473,7 @@ export type AccessibleProject = Readonly<{ const accessibleProjectOwner = alias(users, 'accessible_project_owner'); -/** Owned and shared projects visible to an actor, with explicit billing owner. */ +/** Accessible projects whose billing owner is not manually suspended. */ export async function getAccessibleProjectsByUserId( db: Db, userId: string, @@ -695,6 +695,8 @@ export async function removeProjectMember( } if (args.actorUserId !== args.subjectUserId) { await requireLockedMembersCapability(txDb, project, args.actorUserId); + } else { + await requireLockedProjectOwnerActive(txDb, project); } const [member] = await txDb .delete(projectMembers) @@ -4957,8 +4959,9 @@ export async function createGithubActionsBuildWithinLimit( * (already claimed elsewhere). * * Returns true iff this call claimed the build (caller should proceed to start - * it); false when at cap, when the project already has an active build, or when - * already claimed (caller leaves it for a later tick). + * it); false when the owner is unavailable or manually suspended, at cap, the + * project already has an active build, or the build was already claimed (caller + * leaves it for a later tick). */ export async function claimBuildForStartWithinLimit( db: Db, diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 8e0da4fcf..ba928dd7b 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -38,7 +38,12 @@ import type { ApiEnv } from '../env.ts'; import { cancelContainerBuildDos } from '../github/cancel-builds.ts'; import { generateId } from '../id.ts'; import { teardownProject } from '../provisioning/cleanup.ts'; -import { getRequestsDataset, queryBatchProjectUsage, ZERO_USAGE } from '../routes/usage.ts'; +import { + getAccountUsageProjectWindows, + getRequestsDataset, + queryBatchProjectUsageWindows, + ZERO_USAGE, +} from '../routes/usage.ts'; import { buildProjectAccountJson, computeDispatchLimits, @@ -182,17 +187,22 @@ export async function getOperatorUser(context: Context, id: string) { const since = user.billing_cycle_start ? getCurrentBillingPeriodStart(user.billing_cycle_start) : new Date(); - const usageMap = await queryBatchProjectUsage( + const usageWindows = await getAccountUsageProjectWindows( + context.get('db'), + id, + since, + new Date(), + ); + const usageMap = await queryBatchProjectUsageWindows( context.env.CF_ACCOUNT_ID, context.env.CF_API_TOKEN, getRequestsDataset(context.env), - projects.map((p) => p.id), - since, + usageWindows, ); - const projectUsages = projects.map((p) => ({ - projectId: p.id, - slug: p.slug, - ...(usageMap.get(p.id) ?? ZERO_USAGE), + const projectUsages = usageWindows.map(({ project }) => ({ + projectId: project.id, + slug: project.slug, + ...(usageMap.get(project.id) ?? ZERO_USAGE), })); return { user, diff --git a/platform/packages/api/src/project-access.ts b/platform/packages/api/src/project-access.ts index 9bbd29ccb..756b46472 100644 --- a/platform/packages/api/src/project-access.ts +++ b/platform/packages/api/src/project-access.ts @@ -80,8 +80,8 @@ export class ProjectOwnerSuspendedError extends ProjectAccessDeniedError { } /** - * Assert a live project role from PostgreSQL. The owner account is returned - * separately so plan/quota callers do not accidentally consult the actor. + * Assert a live project role and an owner not manually suspended. The owner is + * returned separately so plan/quota callers do not consult the actor. */ export async function requireProjectCapability( db: Db, diff --git a/platform/packages/api/src/routes/auth-oidc.ts b/platform/packages/api/src/routes/auth-oidc.ts index a4e05c94e..b7c23e43b 100644 --- a/platform/packages/api/src/routes/auth-oidc.ts +++ b/platform/packages/api/src/routes/auth-oidc.ts @@ -160,7 +160,7 @@ app.post('/github-oidc', async (context) => { } // Bind the deploy token to the connection's authorized workflow file - // (owner-configured `workflow_path`, default `.github/workflows/void-deploy.yml` + // (project-configured `workflow_path`, default `.github/workflows/void-deploy.yml` // as scaffolded by `void init --github`). The running workflow file must equal // that path under the OIDC token's OWN repository, so any OTHER workflow in the // repo that can request `id-token: write` (a hand-added workflow, an unsafe @@ -309,7 +309,7 @@ app.post('/github-oidc', async (context) => { return context.json({ error: 'connected branch changed concurrently' }, 409); } if (result.outcome === 'workflow-mismatch') { - // The owner re-pinned workflow_path between the pre-tx gate and this locked + // The workflow_path was re-pinned between the pre-tx gate and this locked // re-read; the token's workflow is no longer the authorized one. Do not mint. // 409: a concurrent config change, same class as branch-mismatch above. return context.json({ error: 'authorized workflow changed concurrently' }, 409); diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index 00dd80e2b..0b604e752 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -2005,7 +2005,7 @@ app.post('/:id/deploy/upload-urls', async (context) => { }); }); } catch (err) { - if (err instanceof ProjectAccessDeniedError || err instanceof ProjectOwnerSuspendedError) { + if (err instanceof ProjectAccessDeniedError) { return context.json( { error: err instanceof ProjectOwnerSuspendedError ? err.message : 'forbidden' }, 403, @@ -2726,7 +2726,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise { } try { - const access = await requireProjectCapability(context.get('db'), projectId, userId, 'deploy'); - if (isAdminSuspended(access.owner.suspended_reason)) { - throw new Error('suspended'); - } + await requireProjectCapability(context.get('db'), projectId, userId, 'deploy'); } catch { // This internal authorization oracle deliberately does not disclose // whether the project or membership exists. diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index 361673687..bb21ac2f0 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -204,6 +204,23 @@ class RollbackError extends Error { } } +async function getProjectAccessErrorMessage(response: Response): Promise { + try { + const body: unknown = await response.json(); + if ( + typeof body === 'object' && + body !== null && + 'error' in body && + typeof body.error === 'string' + ) { + return body.error; + } + } catch { + // Preserve the established fallback if a future access response is not JSON. + } + return response.status === 404 ? 'project not found' : 'forbidden'; +} + class DatabaseUrlPersistenceError extends Error { constructor(cause: unknown) { super('failed to persist database configuration', { cause }); @@ -777,9 +794,11 @@ const app = new Hono() 'deploy', txDb, ); + if (lockedAccess instanceof Response) { + return lockedAccess; + } const boundBuild = await getBuild(txDb, deployBuild); if ( - lockedAccess instanceof Response || lockedAccess.project.status === 'deleting' || !boundBuild || boundBuild.project_id !== project.id || @@ -789,6 +808,9 @@ const app = new Hono() } return signToken(deployBuild); }); + if (minted instanceof Response) { + return minted; + } if (!minted) { return context.json({ error: 'deploy token build is no longer active' }, 403); } @@ -960,7 +982,10 @@ const app = new Hono() txDb, ); if (lockedAccess instanceof Response) { - throw new RollbackError(lockedAccess.status as 403 | 404, 'forbidden'); + throw new RollbackError( + lockedAccess.status as 403 | 404, + await getProjectAccessErrorMessage(lockedAccess), + ); } const lockedProject = lockedAccess.project; if (lockedProject.status === 'deleting') { @@ -1061,7 +1086,10 @@ const app = new Hono() txDb, ); if (lockedAccess instanceof Response) { - throw new RollbackError(lockedAccess.status as 403 | 404, 'forbidden'); + throw new RollbackError( + lockedAccess.status as 403 | 404, + await getProjectAccessErrorMessage(lockedAccess), + ); } // Re-read target INSIDE the lock. applied_migrations can be @@ -1902,7 +1930,7 @@ const app = new Hono() return { status: lockedAccess.status as 403 | 404, body: { - error: lockedAccess.status === 404 ? 'project not found' : 'forbidden', + error: await getProjectAccessErrorMessage(lockedAccess), }, }; } @@ -2103,7 +2131,7 @@ const app = new Hono() status: lockedAccess.status as 403 | 404, body: { ok: false, - error: lockedAccess.status === 404 ? 'project not found' : 'forbidden', + error: await getProjectAccessErrorMessage(lockedAccess), }, }; } diff --git a/platform/packages/api/src/routes/usage.ts b/platform/packages/api/src/routes/usage.ts index 70d618f01..c20e2eff2 100644 --- a/platform/packages/api/src/routes/usage.ts +++ b/platform/packages/api/src/routes/usage.ts @@ -1,6 +1,6 @@ -import { and, eq, gt, inArray, isNull, lt, or } from 'drizzle-orm'; +import { and, eq, gt, isNull, lt, or } from 'drizzle-orm'; import { Hono } from 'hono'; -import { getOwnedProjectsByUserId, type Db } from '../db.ts'; +import type { Db } from '../db.ts'; import { type ApiEnv, isDevBypass } from '../env.ts'; import { projectOwnershipPeriods, projects as projectsTable } from '../schema.ts'; import { getCurrentBillingPeriodStart } from '../tiers.ts'; @@ -245,8 +245,7 @@ function databaseTimestampMs(value: string): number { /** * Resolve the portions of the actor's current billing window for which they - * owned each project. Projects without ownership-period rows are legacy/test - * data and retain the former current-owner behavior. + * owned each project. */ async function getAccountUsageProjectWindows( db: Db, @@ -285,26 +284,6 @@ async function getAccountUsageProjectWindows( } } - // Compatibility for projects created by an older runtime (and lightweight - // test fixtures) before ownership periods were written. - const currentlyOwned = await getOwnedProjectsByUserId(db, userId); - const currentIds = currentlyOwned.map((project) => project.id); - const projectsWithAnyPeriod = new Set(); - if (currentIds.length > 0) { - const periodRows = await db - .select({ projectId: projectOwnershipPeriods.project_id }) - .from(projectOwnershipPeriods) - .where(inArray(projectOwnershipPeriods.project_id, currentIds)); - for (const row of periodRows) { - projectsWithAnyPeriod.add(row.projectId); - } - } - for (const project of currentlyOwned) { - if (!windows.has(project.id) && !projectsWithAnyPeriod.has(project.id)) { - windows.set(project.id, { project, intervals: [{ start: since, end: until }] }); - } - } - return [...windows.values()].sort((a, b) => b.project.created_at.localeCompare(a.project.created_at), ); diff --git a/platform/packages/api/src/routes/webhooks.ts b/platform/packages/api/src/routes/webhooks.ts index ab732789b..5a53da50b 100644 --- a/platform/packages/api/src/routes/webhooks.ts +++ b/platform/packages/api/src/routes/webhooks.ts @@ -514,7 +514,8 @@ async function rerunBuildForCommit( ); } else { // Everything left is a `skipped` reason from the supersede tx: project - // deleting, connection reconfigured or replaced, or installation revoked. + // deleting, owner suspended, connection reconfigured/replaced, or + // installation revoked. await completeRerun( refusedConclusion, 'Nothing to rebuild', @@ -818,8 +819,8 @@ export async function handleVerifiedGitHubWebhook( }); cancelledContainerBuilds.push(...cancelled); // `build` is null on any skip: a concurrent PATCH /projects/:id/github - // changed the connection's branch/executor first (config-changed), or the - // project is being torn down (project-deleting). Either way no row is + // changed the connection, the project is being torn down, its owner is + // suspended, or installation authority was revoked. Either way no row is // enqueued and there's no id to push; the early-out + DO teardown below // tolerate the empty case. if (build) { diff --git a/platform/packages/api/test/integration/api-migrations.test.ts b/platform/packages/api/test/integration/api-migrations.test.ts index 13991c658..115d6b474 100644 --- a/platform/packages/api/test/integration/api-migrations.test.ts +++ b/platform/packages/api/test/integration/api-migrations.test.ts @@ -13,7 +13,16 @@ import { import { deleteRollbackLease, type Db } from '../../src/db.ts'; import app from '../../src/index.ts'; import { canonicalJson } from '../../src/migrations/canonical-json.ts'; -import { deployments, domains, projectMigrations, projects, resources } from '../../src/schema.ts'; +import * as projectAccess from '../../src/project-access.ts'; +import { + deployments, + domains, + projectMembers, + projectMigrations, + projects, + resources, + users, +} from '../../src/schema.ts'; import { setupEnv, teardownEnv, @@ -655,6 +664,44 @@ describe('POST /projects/:id/migrations/apply', () => { expect(res.status).toBe(404); }); + it('preserves the owner-suspension error from the locked capability recheck', async () => { + const owner = await createTestUser(env.db, 'suspended-apply-owner'); + const collaborator = await createTestUser(env.db, 'suspended-apply-collaborator'); + const project = await createTestProject(env.db, owner.id, 'suspended-apply-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + + const requireCapability = projectAccess.requireProjectCapability; + let suspended = false; + vi.spyOn(projectAccess, 'requireProjectCapability').mockImplementation(async (...args) => { + const access = await requireCapability(...args); + if (!suspended && args[3] === 'configure') { + suspended = true; + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, owner.id)); + } + return access; + }); + + const response = await app.fetch( + authedReq(`/projects/${project.id}/migrations/apply`, collaborator.jwt, { + method: 'POST', + body: JSON.stringify({ migrations: [] }), + }), + env, + ); + + expect(suspended).toBe(true); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'project owner suspended' }); + }); + it('returns 400 when no D1 resource exists', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'no-d1-apply'); diff --git a/platform/packages/api/test/integration/api-project-access-routes.test.ts b/platform/packages/api/test/integration/api-project-access-routes.test.ts index 31a7a4a9a..3e581c946 100644 --- a/platform/packages/api/test/integration/api-project-access-routes.test.ts +++ b/platform/packages/api/test/integration/api-project-access-routes.test.ts @@ -186,17 +186,4 @@ describe('ownership-period account usage', () => { expect(graphqlQueries[0]).toContain(`datetime_geq: "${ownedFrom.toISOString()}"`); expect(graphqlQueries[0]).toContain(`datetime_lt: "${transferredAt.toISOString()}"`); }); - - it('keeps current-owner usage for legacy projects without ownership periods', async () => { - const owner = await createTestUser(env.db, 'legacy-owner'); - const project = await createTestProject(env.db, owner.id, 'legacy-app'); - const response = await app.fetch(authed('/usage', owner.jwt), { - ...env, - DEV_AUTH_BYPASS: 'true', - }); - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - projects: [{ projectId: project.id, slug: project.slug }], - }); - }); }); diff --git a/platform/packages/api/test/integration/api-project-team.test.ts b/platform/packages/api/test/integration/api-project-team.test.ts index a5796ad4d..ea3b2fc7b 100644 --- a/platform/packages/api/test/integration/api-project-team.test.ts +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -158,13 +158,11 @@ describe('project team API', () => { expect(cancelledIds).toEqual([build.id]); }); - it('requires an existing registered user and supports target-only acceptance', async () => { + it('requires an existing registered user and supports invitation acceptance', async () => { const owner = await createTestUser(env.db, 'owner'); const target = await createTestUser(env.db, 'target'); - const bystander = await createTestUser(env.db, 'bystander'); await setEmail(owner.id, 'owner@example.com'); await setEmail(target.id, 'Target@Example.com'); - await setEmail(bystander.id, 'bystander@example.com'); const project = await createTestProject(env.db, owner.id, 'shared-app'); const unknown = await app.fetch( @@ -193,12 +191,6 @@ describe('project team API', () => { ], }); - const forwarded = await app.fetch( - request(`/project-invitations/${created.id}/accept`, bystander.jwt, { method: 'POST' }), - env, - ); - expect(forwarded.status).toBe(403); - const accepted = await app.fetch( request(`/project-invitations/${created.id}/accept`, target.jwt, { method: 'POST' }), env, @@ -207,18 +199,6 @@ describe('project team API', () => { expect(await accepted.json()).toMatchObject({ member: { user: { id: target.id }, role: 'collaborator' }, }); - - const acceptedAgain = await app.fetch( - request(`/project-invitations/${created.id}/accept`, target.jwt, { method: 'POST' }), - env, - ); - expect(acceptedAgain.status).toBe(200); - - const events = await env.db - .select() - .from(projectAccessEvents) - .where(eq(projectAccessEvents.project_id, project.id)); - expect(events.map((event) => event.action)).toEqual(['invited', 'accepted']); }); it('rejects invitation acceptance while the project owner is manually suspended', async () => { @@ -244,6 +224,17 @@ describe('project team API', () => { expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: 'project owner suspended' }); expect(await env.db.select().from(projectMembers)).toEqual([]); + + await env.db + .update(users) + .set({ suspended_reason: 'requests_exceeded' }) + .where(eq(users.id, owner.id)); + const accepted = await app.fetch( + request(`/project-invitations/${invitation.id}/accept`, target.jwt, { method: 'POST' }), + env, + ); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toMatchObject({ member: { role: 'reader' } }); }); it('rechecks owner suspension under the project lock before a team mutation', async () => { @@ -286,6 +277,41 @@ describe('project team API', () => { expect(await env.db.select().from(projectInvitations)).toEqual([]); }); + it('rechecks owner suspension under the project lock before a member leaves', async () => { + const owner = await createTestUser(env.db, 'leave-race-owner'); + const member = await createTestUser(env.db, 'leave-race-member'); + const project = await createTestProject(env.db, owner.id); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: member.id, + role: 'reader', + added_by_user_id: owner.id, + }); + + const requireCapability = projectAccess.requireProjectCapability; + let suspended = false; + vi.spyOn(projectAccess, 'requireProjectCapability').mockImplementation(async (...args) => { + const access = await requireCapability(...args); + if (!suspended && args[3] === 'read') { + suspended = true; + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, owner.id)); + } + return access; + }); + + const response = await app.fetch( + request(`/projects/${project.id}/leave`, member.jwt, { method: 'POST' }), + env, + ); + expect(suspended).toBe(true); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'project owner suspended' }); + expect(await env.db.select().from(projectMembers)).toHaveLength(1); + }); + it('binds invitations to exactly one registered account and rejects owner/member conflicts', async () => { const owner = await createTestUser(env.db, 'owner'); const member = await createTestUser(env.db, 'member'); @@ -710,6 +736,14 @@ describe('project team API', () => { env, ); expect(ownerLeave.status).toBe(409); + + const events = await env.db + .select() + .from(projectAccessEvents) + .where(eq(projectAccessEvents.project_id, project.id)); + expect(events.map((event) => event.action).sort()).toEqual( + ['accepted', 'invited', 'left', 'member_removed', 'role_changed'].sort(), + ); }); it('filters expired invitations and allows the target to decline a live invitation', async () => { diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index bfc1fd891..aacf954ff 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -632,6 +632,58 @@ describe('POST /projects/:id/prerender-token', () => { expect(parentStillValid.status).toBe(200); }); + it('preserves the owner-suspension error from the locked capability recheck', async () => { + const owner = await createTestUser(env.db, 'prerender-suspended-owner'); + const collaborator = await createTestUser(env.db, 'prerender-suspended-collaborator'); + const project = await createTestProject(env.db, owner.id, 'prerender-suspended-app'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + const build = await db.createBuild(env.db, { + projectId: project.id, + userId: collaborator.id, + status: 'building', + }); + const deployToken = await signDeployToken( + { + projectId: project.id, + userId: collaborator.id, + login: collaborator.login, + buildId: build.id, + }, + env.JWT_SECRET, + ); + + const requireCapability = projectAccess.requireProjectCapability; + let suspended = false; + vi.spyOn(projectAccess, 'requireProjectCapability').mockImplementation(async (...args) => { + const access = await requireCapability(...args); + if (!suspended && args[3] === 'deploy') { + suspended = true; + await env.db + .update(users) + .set({ suspended_reason: 'platform policy violation' }) + .where(eq(users.id, owner.id)); + } + return access; + }); + + const response = await app.fetch( + new Request(`http://localhost/projects/${project.id}/prerender-token`, { + method: 'POST', + headers: { Authorization: `Bearer ${deployToken}` }, + }), + env, + ); + + expect(suspended).toBe(true); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'project owner suspended' }); + }); + it("does not mint a token for another user's project", async () => { const owner = await createTestUser(env.db, 'prerender-owner-two'); const attacker = await createTestUser(env.db, 'prerender-attacker'); diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index 72281a6ac..34c1092a1 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -186,6 +186,72 @@ describe('operator tenant reads', () => { }); }); + test('attributes operator user usage only to ownership intervals in the billing window', async () => { + const formerOwner = await createTestUser(env.db, 'operator-usage-former'); + const currentOwner = await createTestUser(env.db, 'operator-usage-current'); + const project = await createTestProject(env.db, currentOwner.id, 'operator-usage-project'); + const ownedFrom = new Date(Date.now() - 5 * 24 * 60 * 60 * 1_000); + const transferredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1_000); + await env.db + .update(users) + .set({ billing_cycle_start: '2020-01-01T00:00:00.000Z' }) + .where(eq(users.id, formerOwner.id)); + await env.db + .update(users) + .set({ billing_cycle_start: '2020-01-01T00:00:00.000Z' }) + .where(eq(users.id, currentOwner.id)); + await env.db.insert(projectOwnershipPeriods).values([ + { + id: 'pown_operator_former', + project_id: project.id, + owner_user_id: formerOwner.id, + starts_at: ownedFrom.toISOString(), + ends_at: transferredAt.toISOString(), + }, + { + id: 'pown_operator_current', + project_id: project.id, + owner_user_id: currentOwner.id, + starts_at: transferredAt.toISOString(), + }, + ]); + + const queries: Array = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + queries.push(String(init?.body)); + return Response.json({ + data: [{ blob1: project.id, requests: 12, bandwidth_bytes: 34 }], + }); + }), + ); + + const formerResponse = await req(`/users/${formerOwner.id}`); + expect(formerResponse.status).toBe(200); + expect(await formerResponse.json()).toMatchObject({ + projects: [], + usage: { + totalRequests: 12, + projects: [{ projectId: project.id, slug: project.slug }], + }, + }); + const currentResponse = await req(`/users/${currentOwner.id}`); + expect(currentResponse.status).toBe(200); + expect(await currentResponse.json()).toMatchObject({ + projects: [{ id: project.id }], + usage: { + totalRequests: 12, + projects: [{ projectId: project.id, slug: project.slug }], + }, + }); + + expect(queries).toHaveLength(2); + expect(queries[0]).toContain(ownedFrom.toISOString().replace('T', ' ').slice(0, 19)); + expect(queries[0]).toContain(transferredAt.toISOString().replace('T', ' ').slice(0, 19)); + expect(queries[1]).toContain(transferredAt.toISOString().replace('T', ' ').slice(0, 19)); + }); + test('rejects invalid pagination and missing detail targets', async () => { for (const query of ['limit=101', 'page=0', 'page=2abc', 'limit=-1']) { expect((await req(`/users?${query}`)).status).toBe(400); diff --git a/platform/packages/api/test/integration/project-access.test.ts b/platform/packages/api/test/integration/project-access.test.ts index 10a7724f4..c6c3d2ead 100644 --- a/platform/packages/api/test/integration/project-access.test.ts +++ b/platform/packages/api/test/integration/project-access.test.ts @@ -1,4 +1,4 @@ -import { desc, eq } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; import { hmacSignState } from '../../src/auth/hmac.ts'; import { @@ -8,7 +8,6 @@ import { getAccessibleProjectsByUserId, getProjectMember, removeProjectMember, - respondToProjectInvitation, setProjectMemberRole, } from '../../src/db.ts'; import app from '../../src/index.ts'; @@ -17,20 +16,8 @@ import { ProjectOwnerSuspendedError, requireProjectCapability, } from '../../src/project-access.ts'; -import { - projectAccessEvents, - projectMembers, - projectOwnershipPeriods, - users, -} from '../../src/schema.ts'; -import { - clearTables, - createTestProject, - createTestUser, - setupEnv, - teardownEnv, - type TestEnv, -} from './helpers.ts'; +import { projectMembers, projectOwnershipPeriods, users } from '../../src/schema.ts'; +import { clearTables, createTestUser, setupEnv, teardownEnv, type TestEnv } from './helpers.ts'; let env: TestEnv; @@ -54,9 +41,9 @@ async function userWithEmail(login: string) { } describe('project access', () => { - it('grants implicit owner access to a legacy project with no member row', async () => { - const owner = await createTestUser(env.db, 'legacy-owner'); - const project = await createTestProject(env.db, owner.id, 'legacy-access'); + it('grants implicit owner access without a member row', async () => { + const owner = await createTestUser(env.db, 'implicit-owner'); + const project = await createProject(env.db, owner.id, 'implicit-access'); const access = await requireProjectCapability(env.db, project.id, owner.id, 'delete'); expect(access.role).toBe('owner'); @@ -131,105 +118,6 @@ describe('project access', () => { ).toBe('collaborator'); }); - it('requires a registered target, binds acceptance to it, and audits mutations', async () => { - const owner = await userWithEmail('invite-owner'); - const target = await userWithEmail('invite-target'); - const forwardedTo = await userWithEmail('invite-forwarded'); - const project = await createProject(env.db, owner.id, 'invitation-security'); - - await expect( - createOrRefreshProjectInvitation(env.db, { - projectId: project.id, - email: 'missing@example.com', - role: 'reader', - actorUserId: owner.id, - }), - ).rejects.toMatchObject({ reason: 'target-user-not-found', status: 404 }); - - const { invitation } = await createOrRefreshProjectInvitation(env.db, { - projectId: project.id, - email: target.email.toUpperCase(), - role: 'reader', - actorUserId: owner.id, - }); - await expect( - respondToProjectInvitation(env.db, { - invitationId: invitation.id, - actorUserId: forwardedTo.id, - response: 'accept', - }), - ).rejects.toMatchObject({ reason: 'invitation-not-target', status: 403 }); - - const accepted = await respondToProjectInvitation(env.db, { - invitationId: invitation.id, - actorUserId: target.id, - response: 'accept', - }); - expect(accepted.member?.role).toBe('reader'); - await setProjectMemberRole(env.db, { - projectId: project.id, - subjectUserId: target.id, - role: 'collaborator', - actorUserId: owner.id, - }); - await removeProjectMember(env.db, { - projectId: project.id, - subjectUserId: target.id, - actorUserId: target.id, - }); - - await expect( - requireProjectCapability(env.db, project.id, target.id, 'read'), - ).rejects.toBeInstanceOf(ProjectAccessDeniedError); - const events = await env.db - .select() - .from(projectAccessEvents) - .where(eq(projectAccessEvents.project_id, project.id)) - .orderBy(desc(projectAccessEvents.created_at)); - expect(events.map((event) => event.action)).toEqual([ - 'left', - 'role_changed', - 'accepted', - 'invited', - ]); - }); - - it('does not grant invitation access while the project owner is manually suspended', async () => { - const owner = await userWithEmail('suspended-invite-owner'); - const target = await userWithEmail('suspended-invite-target'); - const project = await createProject(env.db, owner.id, 'suspended-invitation'); - const { invitation } = await createOrRefreshProjectInvitation(env.db, { - projectId: project.id, - email: target.email, - role: 'reader', - actorUserId: owner.id, - }); - - await env.db - .update(users) - .set({ suspended_reason: 'platform policy violation' }) - .where(eq(users.id, owner.id)); - await expect( - respondToProjectInvitation(env.db, { - invitationId: invitation.id, - actorUserId: target.id, - response: 'accept', - }), - ).rejects.toMatchObject({ reason: 'owner-suspended', status: 403 }); - expect(await getProjectMember(env.db, project.id, target.id)).toBeNull(); - - await env.db - .update(users) - .set({ suspended_reason: 'requests_exceeded' }) - .where(eq(users.id, owner.id)); - const accepted = await respondToProjectInvitation(env.db, { - invitationId: invitation.id, - actorUserId: target.id, - response: 'accept', - }); - expect(accepted.member?.role).toBe('reader'); - }); - it('records owner attribution on new projects and deployments', async () => { const owner = await createTestUser(env.db, 'attribution-owner'); const actor = await createTestUser(env.db, 'attribution-actor'); diff --git a/platform/packages/api/test/unit/build-scheduler.test.ts b/platform/packages/api/test/unit/build-scheduler.test.ts index 43bd435c9..d711c4bf6 100644 --- a/platform/packages/api/test/unit/build-scheduler.test.ts +++ b/platform/packages/api/test/unit/build-scheduler.test.ts @@ -86,10 +86,8 @@ const db = { // The scheduler now enforces the per-user cap AND claims queued->starting in a // SINGLE primitive (`claimBuildForStartWithinLimit`) that holds a per-user // FOR UPDATE lock — the same lock the OIDC path takes — instead of a separate -// countUserActiveBuilds + claimBuildForStart pair. The fake reproduces the -// primitive's contract: false when at cap (limit > 0 && active >= limit) or -// when the build is unclaimable (lost the queued->starting race); true -// otherwise. limit === 0 means unlimited. +// countUserActiveBuilds + claimBuildForStart pair. This fake models the cap and +// queued->starting race outcomes exercised by the scheduler unit tests. vi.mock('../../src/db.ts', () => ({ createDb: () => ({ __mock: 'db' }), // Models the real helper: excludes builds owned by `excludeUserIds`, then @@ -492,11 +490,8 @@ describe('BuildScheduler.alarm', () => { expect(ctx.storage.setAlarm).toHaveBeenCalledOnce(); }); - // the scheduler must start a build ONLY when the atomic cap+claim primitive - // returns true; when it returns false (at cap OR lost the claim race) the build - // is left for a later tick. This mirrors the OIDC path's per-user FOR UPDATE - // lock so the container scan and a parallel OIDC exchange can't both create - // active builds beyond the cap. + // The scheduler starts a build only when the atomic cap+claim primitive + // returns true; false leaves it for a later tick. it('starts only when claimBuildForStartWithinLimit returns true; leaves it otherwise', async () => { db.users.set('u1', { plan: 'pro', login: 'octocat' }); // b1 will be claimed (under cap); b2 is forced unclaimable → primitive false. From 241eaa8a6d7e4edc735c7f4041afb3b70944e58c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 18 Sep 2026 11:33:49 +0800 Subject: [PATCH 05/18] fix(platform): align team access lifecycle --- docs/reference/cli.md | 2 +- platform/packages/api/src/build-scheduler.ts | 7 +- platform/packages/api/src/db.ts | 62 ++++++-------- .../api/src/operator/tenant-service.ts | 2 +- platform/packages/api/src/routes/auth-oidc.ts | 3 - platform/packages/api/src/routes/deploy.ts | 17 +++- .../packages/api/src/routes/project-team.ts | 1 + platform/packages/api/src/routes/webhooks.ts | 11 --- platform/packages/api/src/routing.ts | 39 +-------- .../api/test/integration/api-deploy.test.ts | 31 +++++++ .../api/test/integration/api-oidc.test.ts | 11 ++- .../test/integration/api-project-team.test.ts | 39 +++++++++ .../api/test/integration/db-builds.test.ts | 42 +++++++++- .../api/test/unit/build-scheduler.test.ts | 27 ++++-- .../packages/api/test/unit/routing.test.ts | 84 ------------------- 15 files changed, 182 insertions(+), 196 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7af670a91..1d5e6254f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -557,7 +557,7 @@ void platform project owner 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. -`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 limits that will apply. The former owner becomes a project administrator, and usage already incurred remains with the former owner. +`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, and usage already incurred remains with the former owner. #### Deployments {#operator-deployments} diff --git a/platform/packages/api/src/build-scheduler.ts b/platform/packages/api/src/build-scheduler.ts index c5dd9fadb..1d1801ee2 100644 --- a/platform/packages/api/src/build-scheduler.ts +++ b/platform/packages/api/src/build-scheduler.ts @@ -220,8 +220,9 @@ export class BuildScheduler extends DurableObject { // takes. This makes the container-scheduler path and the OIDC path // mutually exclusive per user, so a scheduler scan and a parallel OIDC // exchange can't both observe sub-cap capacity and both create active - // builds (which the old separate count-then-claim allowed). `limit === 0` - // means unlimited. Returns false when the owner is unavailable or suspended, + // builds (which the old separate count-then-claim allowed). The claim reads + // the owner's plan under that lock, so a concurrent downgrade takes effect + // before admission commits. Returns false when the owner is unavailable or suspended, // at cap, the project has active work, or the build was claimed elsewhere; // either way leave it for a later tick. The claim also // persists the effective instanceType in the same tx, so a cancel/reap in @@ -229,12 +230,10 @@ export class BuildScheduler extends DurableObject { // DO namespace. This is the commit point: only pure config assembly + the // try/catch-wrapped startBuild follow it, so nothing can throw between the // claim and the BuildManager that owns the timeout. - const limit = getPlanLimits(user.plan).concurrentBuilds; const claimed = await claimBuildForStartWithinLimit(db, { buildId: build.id, userId: build.user_id, projectId: build.project_id, - limit, instanceType, }); if (!claimed) { diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 5069d559d..9ba7fb649 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -529,6 +529,7 @@ export async function getProjectMembers( export type ProjectTeamMutationErrorReason = | 'project-not-found' + | 'project-deleting' | 'target-user-not-found' | 'owner-conflict' | 'member-conflict' @@ -566,6 +567,9 @@ async function lockProject(db: Db, projectId: string): Promise { if (!project) { throw new ProjectTeamMutationError('project-not-found'); } + if (project.status === 'deleting') { + throw new ProjectTeamMutationError('project-deleting'); + } return project; } @@ -4221,18 +4225,14 @@ export async function supersedeAndCreateContainerBuild( db: Db, args: { projectId: string; - userId: string; + /** @deprecated Enqueue attribution is resolved from the locked project owner. */ + userId?: string; branch: string; commitSha: string; connectionId: string; installationId: string; repositoryId: string | null; - /** - * Container size of record for this build, resolved from the owner's plan at - * enqueue (`getBuildInstanceType`). Persisted on the row so START routes to - * the matching DO namespace deterministically; omit/null leaves a legacy-null - * row that START recomputes from the plan. - */ + /** @deprecated Enqueue sizing is resolved from the locked owner plan. */ instanceType?: string | null; }, ): Promise<{ @@ -4273,7 +4273,7 @@ export async function supersedeAndCreateContainerBuild( return { build: null, reused: false, cancelled: [], skipped: 'project-deleting' }; } const [ownerState] = await tx - .select({ suspendedReason: users.suspended_reason }) + .select({ plan: users.plan, suspendedReason: users.suspended_reason }) .from(users) .where(eq(users.id, proj.user_id)) .for('update') @@ -4281,16 +4281,10 @@ export async function supersedeAndCreateContainerBuild( if (ownerState && isAdminSuspended(ownerState.suspendedReason)) { return { build: null, reused: false, cancelled: [], skipped: 'owner-suspended' }; } - const ownerChanged = proj.user_id !== args.userId; - let instanceType = args.instanceType ?? null; - if (ownerChanged) { - const [owner] = await tx - .select({ plan: users.plan }) - .from(users) - .where(eq(users.id, proj.user_id)) - .limit(1); - instanceType = owner ? getBuildInstanceType(owner.plan) : null; - } + // Resolve the size of record from the same owner row whose lock serializes + // admission. A plan change that lands while webhook processing is doing its + // network checks must take effect before this enqueue commits. + const instanceType = ownerState ? getBuildInstanceType(ownerState.plan) : null; // Serialize against PATCH /projects/:id/github: lock + re-read the single // connection (project_id is UNIQUE). If a concurrent config change committed @@ -4685,8 +4679,10 @@ export async function createGithubActionsBuildWithinLimit( db: Db, args: { projectId: string; - userId: string; - limit: number; + /** @deprecated Build attribution is resolved from the locked project owner. */ + userId?: string; + /** @deprecated Admission limits are resolved from the locked owner plan. */ + limit?: number; commitSha?: string | null; branch?: string | null; githubRunId: string; @@ -4738,7 +4734,7 @@ export async function createGithubActionsBuildWithinLimit( // owner block here until the holder commits, so the count below always // observes prior inserts. const [ownerState] = await tx - .select({ suspendedReason: users.suspended_reason }) + .select({ plan: users.plan, suspendedReason: users.suspended_reason }) .from(users) .where(eq(users.id, proj.user_id)) .for('update') @@ -4746,16 +4742,7 @@ export async function createGithubActionsBuildWithinLimit( if (ownerState && isAdminSuspended(ownerState.suspendedReason)) { return { outcome: 'owner-suspended' }; } - - let limit = args.limit; - if (proj.user_id !== args.userId) { - const [owner] = await tx - .select({ plan: users.plan }) - .from(users) - .where(eq(users.id, proj.user_id)) - .limit(1); - limit = owner ? getPlanLimits(owner.plan).concurrentBuilds : 0; - } + const limit = ownerState ? getPlanLimits(ownerState.plan).concurrentBuilds : 0; // Serialize against DELETE /projects/:id/github: lock + re-read the connection // inside this tx. If a concurrent disconnect committed first, the row is gone @@ -4939,7 +4926,8 @@ export async function createGithubActionsBuildWithinLimit( * * Steps inside one transaction, under the per-user lock: * 1. `SELECT … FOR UPDATE` the owner's users row (serializes with the OIDC - * path and other scheduler scans for the same owner). + * path and other scheduler scans for the same owner), including the plan + * used for admission. * 2. If `limit > 0` and the owner already has `>= limit` active builds, return * false WITHOUT claiming. `limit === 0` means unlimited (no cap). * 3. If the build's project already has an active build, return false WITHOUT @@ -4969,7 +4957,8 @@ export async function claimBuildForStartWithinLimit( buildId: string; userId: string; projectId: string; - limit: number; + /** @deprecated Admission limits are resolved from the locked owner plan. */ + limit?: number; // The effective container size the scheduler will route this start on (the // persisted value, or a plan recompute for a legacy-null row). Persisted in // THIS claim tx — before the DO is addressed — so a cancel/reap in the @@ -4983,7 +4972,7 @@ export async function claimBuildForStartWithinLimit( // Serialize per-user slot acquisition against the OIDC path and other // scheduler scans for the same owner. const [owner] = await tx - .select({ suspendedReason: users.suspended_reason }) + .select({ plan: users.plan, suspendedReason: users.suspended_reason }) .from(users) .where(eq(users.id, args.userId)) .for('update') @@ -4992,12 +4981,13 @@ export async function claimBuildForStartWithinLimit( return false; } - if (args.limit > 0) { + const limit = getPlanLimits(owner.plan).concurrentBuilds; + if (limit > 0) { const [result] = await tx .select({ count: count() }) .from(builds) .where(and(eq(builds.user_id, args.userId), inArray(builds.status, ACTIVE_BUILD_STATUSES))); - if ((result?.count ?? 0) >= args.limit) { + if ((result?.count ?? 0) >= limit) { return false; } } diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index ba928dd7b..7e8819056 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -737,9 +737,9 @@ export async function changeOperatorUserPlan( throw new OperatorError(400, 'Invalid plan'); } const newPlan = plan as PlanId; - const projects = await getProjectsByUserId(db, id); const target = { id, login: user.login }; if (dryRun) { + const projects = await getProjectsByUserId(db, id); return preview('user.plan', target, { previousPlan: user.plan, plan, diff --git a/platform/packages/api/src/routes/auth-oidc.ts b/platform/packages/api/src/routes/auth-oidc.ts index b7c23e43b..8f675fa81 100644 --- a/platform/packages/api/src/routes/auth-oidc.ts +++ b/platform/packages/api/src/routes/auth-oidc.ts @@ -267,11 +267,8 @@ app.post('/github-oidc', async (context) => { // so the workflow_run completion handler can finalize the RIGHT attempt's // build. Null-tolerant: a token lacking the claim creates a NULL-attempt row. const githubRunAttempt = claims.run_attempt != null ? String(claims.run_attempt) : null; - const limit = getPlanLimits(user.plan).concurrentBuilds; const result = await createGithubActionsBuildWithinLimit(db, { projectId: connection.project_id, - userId: connection.user_id, - limit, commitSha: claims.sha, branch, githubRunId, diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index 0b604e752..cee42f827 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -125,6 +125,7 @@ import { resolveRoutingUndo, } from '../routing.ts'; import { builds, deployments } from '../schema.ts'; +import { REASON_REQUESTS_EXCEEDED } from '../suspension.ts'; import { getPlanLimits, isWithinLimit, STATIC_RETENTION_LIMIT, type PlanId } from '../tiers.ts'; import { SUPPORTED_MANIFEST_VERSIONS, @@ -1812,8 +1813,8 @@ function validateUploadUrlsAsset(asset: unknown, index: number): string | null { /** * Issue presigned R2 PUT URLs for a batch of declared assets. * - * Auth and ownership are handled the same way as `/deploy/preflight`: - * project loaded, `user_id` checked, 403 on mismatch, 404 on missing. + * Project access is handled the same way as `/deploy/preflight`: the actor + * needs deploy capability and the current owner must be active. * Each asset's blake3 keys it under `assets/${projectId}/${blake3}`; MD5 * is signed into the URL's headers so R2 enforces "uploaded bytes' MD5 * matches declared MD5" at write time. URLs expire in 30 minutes. @@ -3541,7 +3542,11 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise 0 ? deployment.id : undefined, assetConfig: assetCount > 0 ? assetConfig : undefined, diff --git a/platform/packages/api/src/routes/project-team.ts b/platform/packages/api/src/routes/project-team.ts index b65e052b8..85f8a23bb 100644 --- a/platform/packages/api/src/routes/project-team.ts +++ b/platform/packages/api/src/routes/project-team.ts @@ -112,6 +112,7 @@ function mutationErrorResponse(context: Context, error: unknown): Respon } const messages: Record = { 'project-not-found': 'project not found', + 'project-deleting': 'project is being deleted', 'target-user-not-found': 'registered user not found', 'owner-conflict': 'the project owner cannot be invited or changed as a member', 'member-conflict': 'user is already a project member', diff --git a/platform/packages/api/src/routes/webhooks.ts b/platform/packages/api/src/routes/webhooks.ts index 5a53da50b..9b42d910f 100644 --- a/platform/packages/api/src/routes/webhooks.ts +++ b/platform/packages/api/src/routes/webhooks.ts @@ -50,7 +50,6 @@ import { import { verifyGithubWebhookSignature } from '../github/webhook-verify.ts'; import { githubConnections, githubInstallations } from '../schema.ts'; import type { GitHubInstallationRepository } from '../schema.ts'; -import { getBuildInstanceType } from '../tiers.ts'; import type { Build } from '../types.ts'; const app = new Hono(); @@ -452,16 +451,13 @@ async function rerunBuildForCommit( sawActions = true; continue; } - const owner = await getUser(db, connection.user_id); const { build, reused, cancelled } = await supersedeAndCreateContainerBuild(db, { projectId: connection.project_id, - userId: connection.user_id, branch: connection.branch, commitSha: headSha, connectionId: connection.id, installationId: connection.installation_id, repositoryId: connection.repository_id, - instanceType: owner ? getBuildInstanceType(owner.plan) : null, }); cancelledContainerBuilds.push(...cancelled); if (!build) { @@ -802,20 +798,13 @@ export async function handleVerifiedGitHubWebhook( // enqueued to re-deploy it. The supersede also terminalizes older ACTIVE // builds (revoking their deploy token + freeing their slot); their DOs are // torn down after the tx commits, below. - // Resolve the owner's plan to fix the build's container size AT ENQUEUE so - // the row carries its own size-of-record. An orphaned connection (owner - // deleted) yields a null instance_type — START recomputes from plan there, - // and a null/unknown value routes to the safe base namespace either way. - const owner = await getUser(db, connection.user_id); const { build, cancelled } = await supersedeAndCreateContainerBuild(db, { projectId: connection.project_id, - userId: connection.user_id, branch, commitSha: after, connectionId: connection.id, installationId: connection.installation_id, repositoryId: connection.repository_id, - instanceType: owner ? getBuildInstanceType(owner.plan) : null, }); cancelledContainerBuilds.push(...cancelled); // `build` is null on any skip: a concurrent PATCH /projects/:id/github diff --git a/platform/packages/api/src/routing.ts b/platform/packages/api/src/routing.ts index 9ccc4d2ff..a3d265537 100644 --- a/platform/packages/api/src/routing.ts +++ b/platform/packages/api/src/routing.ts @@ -1,5 +1,5 @@ import { sql as drizzleSql } from 'drizzle-orm'; -import { getDomainsByProjectIds, getProject, getProjectsByUserId, getUser, type Db } from './db.ts'; +import { getDomainsByProjectIds, getProject, type Db } from './db.ts'; import type { PlanLimits } from './tiers.ts'; import type { AssetConfig, DeployManifest } from './types.ts'; @@ -29,43 +29,6 @@ export function buildProjectAccountJson(entry: ProjectAccountEntry): string { return JSON.stringify({ userId: entry.userId, plan: entry.plan }); } -/** - * Re-publish every `project:{projectId}` routing entry for `userId`. Invoked - * after a plan-change so the proxy's sandbox gate sees the new concurrency - * cap on the next acquire without waiting for the user to redeploy. Best- - * effort: KV writes are idempotent and bounded by the user's project count, - * but a failure here means stale plan data lives in KV until the next deploy - * for that project — callers should log on failure. - */ -export async function refreshProjectKvForUser( - db: Db, - routing: KVNamespace, - userId: string, -): Promise { - const user = await getUser(db, userId); - if (!user) { - return; - } - const projects = await getProjectsByUserId(db, userId); - if (projects.length === 0) { - return; - } - const payload = buildProjectAccountJson({ userId, plan: user.plan }); - for (const project of projects) { - await db.transaction(async (tx) => { - await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${project.id} FOR UPDATE`); - const current = await getProject(tx as unknown as Db, project.id); - // The owned-project list was read before this lock. An ownership transfer - // can commit in between; in that ordering its project-account entry is now - // authoritative and a stale plan refresh must not overwrite it. - if (current?.user_id !== userId) { - return; - } - await routing.put(projectAccountKey(project.id), payload); - }); - } -} - /** * A reason-aware mutation of the `suspended` marker on a routing KV entry. * diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index 324268cc6..e0c7cba6a 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -418,6 +418,37 @@ describe('POST /projects/:id/deploy', () => { expect(routing.userId).toBe(owner.id); }); + it("preserves the owner's request suspension on a collaborator's first deploy", async () => { + const owner = await createTestUser(env.db, 'suspended-team-owner'); + const collaborator = await createTestUser(env.db, 'suspended-team-collaborator'); + const project = await createTestProject(env.db, owner.id, 'suspended-team-deploy'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + await env.db + .update(users) + .set({ suspended_reason: 'requests_exceeded' }) + .where(eq(users.id, owner.id)); + expect(await env.ROUTING.get(project.slug)).toBeNull(); + mockCfApi(); + + const response = await app.fetch( + await buildDeployRequest(env, project.id, collaborator.jwt), + env, + ); + + expect(response.status).toBe(200); + requireEvent(await readAllEvents(response), 'done'); + expect(await env.ROUTING.get(project.slug, 'json')).toMatchObject({ + projectId: project.id, + userId: owner.id, + suspended: 'requests_exceeded', + }); + }); + it("uses the owner's plan rather than a collaborator's plan", async () => { const owner = await createTestUser(env.db, 'limited-owner'); const collaborator = await createTestUser( diff --git a/platform/packages/api/test/integration/api-oidc.test.ts b/platform/packages/api/test/integration/api-oidc.test.ts index 0bd1b1735..45d0bb0d9 100644 --- a/platform/packages/api/test/integration/api-oidc.test.ts +++ b/platform/packages/api/test/integration/api-oidc.test.ts @@ -2532,8 +2532,14 @@ describe('claimBuildForStartWithinLimit', () => { expect(claimed).toBe(false); }); - it('treats limit === 0 as unlimited', async () => { - const user = await createTestUser(env.db); + it('treats the locked custom owner plan as unlimited', async () => { + const user = await createTestUser( + env.db, + 'claim-unlimited-user', + 'github', + undefined, + 'custom', + ); // The active builds live on OTHER projects so they exercise the per-user // cap (here unlimited) without tripping the per-project active-build guard, // which serializes builds within a single project regardless of the cap. @@ -2552,7 +2558,6 @@ describe('claimBuildForStartWithinLimit', () => { buildId: queued.id, userId: user.id, projectId: project.id, - limit: 0, instanceType: 'standard-4', }); expect(claimed).toBe(true); diff --git a/platform/packages/api/test/integration/api-project-team.test.ts b/platform/packages/api/test/integration/api-project-team.test.ts index ea3b2fc7b..411390a46 100644 --- a/platform/packages/api/test/integration/api-project-team.test.ts +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -237,6 +237,45 @@ describe('project team API', () => { expect(await accepted.json()).toMatchObject({ member: { role: 'reader' } }); }); + it('rejects team mutations after project deletion begins', async () => { + const owner = await createTestUser(env.db, 'deleting-team-owner'); + const invited = await createTestUser(env.db, 'deleting-team-invited'); + const other = await createTestUser(env.db, 'deleting-team-other'); + await setEmail(invited.id, 'deleting-team-invited@example.com'); + await setEmail(other.id, 'deleting-team-other@example.com'); + const project = await createTestProject(env.db, owner.id); + const invitation = await invite( + project.id, + owner.jwt, + 'deleting-team-invited@example.com', + 'reader', + ); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + + const createResponse = await app.fetch( + request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { email: 'deleting-team-other@example.com', role: 'admin' }, + }), + env, + ); + expect(createResponse.status).toBe(409); + expect(await createResponse.json()).toEqual({ error: 'project is being deleted' }); + + const acceptResponse = await app.fetch( + request(`/project-invitations/${invitation.id}/accept`, invited.jwt, { method: 'POST' }), + env, + ); + expect(acceptResponse.status).toBe(409); + expect(await acceptResponse.json()).toEqual({ error: 'project is being deleted' }); + + expect(await env.db.select().from(projectMembers)).toEqual([]); + expect(await env.db.select().from(projectInvitations)).toMatchObject([ + { id: invitation.id, status: 'pending' }, + ]); + expect(await env.db.select().from(projectAccessEvents)).toMatchObject([{ action: 'invited' }]); + }); + it('rechecks owner suspension under the project lock before a team mutation', async () => { const owner = await createTestUser(env.db, 'team-race-owner'); const admin = await createTestUser(env.db, 'team-race-admin'); diff --git a/platform/packages/api/test/integration/db-builds.test.ts b/platform/packages/api/test/integration/db-builds.test.ts index eaf1e2a22..3bf125ecb 100644 --- a/platform/packages/api/test/integration/db-builds.test.ts +++ b/platform/packages/api/test/integration/db-builds.test.ts @@ -598,10 +598,14 @@ describe('builds', () => { commitSha: 'aaa', installationId: '12345', repositoryId: DEFAULT_REPO_ID, + // Simulate a plan snapshot resolved before the locked enqueue. The free + // owner row is authoritative, so this stale paid size must be ignored. + instanceType: 'standard-4', }); expect(created.reused).toBe(false); expect(created.build!.status).toBe('queued'); expect(created.build!.commit_sha).toBe('aaa'); + expect(created.build!.instance_type).toBe('standard-3'); expect(created.cancelled).toEqual([]); // Newer push for bbb -> the aaa build is superseded (cancelled) and a new @@ -620,7 +624,12 @@ describe('builds', () => { // The returned `status` reflects the post-UPDATE row (RETURNING returns new // values), so the superseded build reports `cancelled`. expect(superseded.cancelled).toEqual([ - { id: created.build!.id, executor: 'container', status: 'cancelled', instance_type: null }, + { + id: created.build!.id, + executor: 'container', + status: 'cancelled', + instance_type: 'standard-3', + }, ]); const cancelledAaa = await getBuild(env.db, created.build!.id); expect(cancelledAaa!.status).toBe('cancelled'); @@ -1693,6 +1702,33 @@ describe('builds', () => { expect((await getBuild(env.db, build.id))?.status).toBe('queued'); }); + it('claimBuildForStartWithinLimit uses the locked owner plan instead of a stale limit', async () => { + const user = await createTestUser(env.db, 'claim-current-plan'); + const activeProject = await createTestProject(env.db, user.id); + const queuedProject = await createTestProject(env.db, user.id); + await createBuild(env.db, { + projectId: activeProject.id, + userId: user.id, + status: 'building', + }); + const queued = await createBuild(env.db, { + projectId: queuedProject.id, + userId: user.id, + }); + + expect( + await claimBuildForStartWithinLimit(env.db, { + buildId: queued.id, + userId: user.id, + projectId: queuedProject.id, + // Simulate a stale pro-plan snapshot; the locked DB owner is free. + limit: 3, + instanceType: 'standard-3', + }), + ).toBe(false); + expect((await getBuild(env.db, queued.id))?.status).toBe('queued'); + }); + it('requeueBuildIfStarting returns a starting row to queued (CAS)', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id); @@ -2430,7 +2466,9 @@ describe('createGithubActionsBuildWithinLimit', () => { projectId: otherProject.id, connectionId: (await getGitHubConnection(env.db, otherProject.id))!.id, userId: user.id, - limit: 1, + // Simulate a stale pro-plan snapshot; the locked DB owner is free and + // already occupies its single slot. + limit: 3, branch: 'main', githubRunId: '4003', installationId: 'inst-cap-other', diff --git a/platform/packages/api/test/unit/build-scheduler.test.ts b/platform/packages/api/test/unit/build-scheduler.test.ts index d711c4bf6..42e4d13e5 100644 --- a/platform/packages/api/test/unit/build-scheduler.test.ts +++ b/platform/packages/api/test/unit/build-scheduler.test.ts @@ -78,7 +78,6 @@ const db = { buildId: string; userId: string; projectId: string; - limit: number; instanceType: string; }>, }; @@ -144,13 +143,23 @@ vi.mock('../../src/db.ts', () => ({ buildId: string; userId: string; projectId: string; - limit: number; instanceType: string; }, ) => { db.claimArgs.push(args); const active = db.activeByUser.get(args.userId) ?? 0; - if (args.limit > 0 && active >= args.limit) { + const plan = db.users.get(args.userId)?.plan ?? 'free'; + const limit = + plan === 'custom' + ? 0 + : plan === 'sponsored' + ? 6 + : plan === 'pro' + ? 3 + : plan === 'solo' + ? 2 + : 1; + if (limit > 0 && active >= limit) { return false; } if (db.unclaimable.has(args.buildId)) { @@ -461,10 +470,10 @@ describe('BuildScheduler.alarm', () => { .idFromName, ).toHaveBeenCalledWith('b1'); // the cap + claim went through the single atomic primitive holding the - // per-user FOR UPDATE lock, with the plan's concurrency cap (free = 1). + // per-user FOR UPDATE lock; no stale plan-derived cap is passed in. expect(claimBuildForStartWithinLimit).toHaveBeenCalledOnce(); expect(db.claimArgs).toEqual([ - { buildId: 'b1', userId: 'u1', projectId: 'p1', limit: 1, instanceType: 'standard-3' }, + { buildId: 'b1', userId: 'u1', projectId: 'p1', instanceType: 'standard-3' }, ]); }); @@ -507,8 +516,8 @@ describe('BuildScheduler.alarm', () => { // Both builds went through the atomic primitive (FIFO scan preserved). expect(db.claimArgs).toEqual([ - { buildId: 'b1', userId: 'u1', projectId: 'p1', limit: 3, instanceType: 'standard-4' }, - { buildId: 'b2', userId: 'u1', projectId: 'p2', limit: 3, instanceType: 'standard-4' }, + { buildId: 'b1', userId: 'u1', projectId: 'p1', instanceType: 'standard-4' }, + { buildId: 'b2', userId: 'u1', projectId: 'p2', instanceType: 'standard-4' }, ]); // Only the claimed build (b1) was started. expect(startBuild).toHaveBeenCalledOnce(); @@ -725,7 +734,7 @@ describe('BuildScheduler.alarm', () => { // Only the eligible owner's build was claimed + started; the capped owner's // build was never even inspected (no claim attempt for b1). expect(db.claimArgs).toEqual([ - { buildId: 'b2', userId: 'u2', projectId: 'p2', limit: 1, instanceType: 'standard-3' }, + { buildId: 'b2', userId: 'u2', projectId: 'p2', instanceType: 'standard-3' }, ]); expect(startBuild).toHaveBeenCalledOnce(); expect((startBuild.mock.calls[0][0] as { buildId: string }).buildId).toBe('b2'); @@ -869,7 +878,7 @@ describe('BuildScheduler.alarm', () => { expect((base.mock.calls[0][0] as { buildId: string }).buildId).toBe('b1'); // Only b1 was claimed; b2 (same saturated fleet) was never claimed or started. expect(db.claimArgs).toEqual([ - { buildId: 'b1', userId: 'uf', projectId: 'p1', limit: 1, instanceType: 'standard-3' }, + { buildId: 'b1', userId: 'uf', projectId: 'p1', instanceType: 'standard-3' }, ]); // Only b1 (the claimed row) was requeued; b2 stays queued; nothing dropped. expect(db.requeued).toEqual(['b1']); diff --git a/platform/packages/api/test/unit/routing.test.ts b/platform/packages/api/test/unit/routing.test.ts index 409327d85..512006b47 100644 --- a/platform/packages/api/test/unit/routing.test.ts +++ b/platform/packages/api/test/unit/routing.test.ts @@ -6,7 +6,6 @@ import { buildStaticRoutingJson, computeDispatchLimits, projectAccountKey, - refreshProjectKvForUser, resolveRoutingUndo, setProjectsRoutingSuspension, } from '../../src/routing.ts'; @@ -275,89 +274,6 @@ describe('buildProjectAccountJson', () => { }); }); -describe('refreshProjectKvForUser', () => { - function createKv(): { puts: Array<[string, string]>; kv: KVNamespace } { - const puts: Array<[string, string]> = []; - const kv = { - put: async (key: string, value: string) => { - puts.push([key, value]); - }, - } as unknown as KVNamespace; - return { puts, kv }; - } - - it('rewrites a project entry per project the user owns', async () => { - const { puts, kv } = createKv(); - const db = createRoutingDb(); - vi.spyOn(await import('../../src/db.ts'), 'getUser').mockResolvedValue({ - id: 'user_1', - plan: 'pro', - } as Awaited>); - vi.spyOn(await import('../../src/db.ts'), 'getProjectsByUserId').mockResolvedValue([ - { id: 'proj_a' }, - { id: 'proj_b' }, - ] as unknown as Awaited>); - vi.spyOn(await import('../../src/db.ts'), 'getProject').mockImplementation(async (_db, id) => { - return { id, user_id: 'user_1' } as Awaited< - ReturnType - >; - }); - - await refreshProjectKvForUser(db, kv, 'user_1'); - - expect(puts).toHaveLength(2); - const keys = puts.map(([k]) => k).sort(); - expect(keys).toEqual(['project:proj_a', 'project:proj_b']); - for (const [, value] of puts) { - expect(JSON.parse(value)).toEqual({ userId: 'user_1', plan: 'pro' }); - } - vi.restoreAllMocks(); - }); - - it('no-ops when the user is missing (treats deletion as nothing to refresh)', async () => { - const { puts, kv } = createKv(); - const db = {} as Parameters[0]; - vi.spyOn(await import('../../src/db.ts'), 'getUser').mockResolvedValue(null); - await refreshProjectKvForUser(db, kv, 'ghost'); - expect(puts).toEqual([]); - vi.restoreAllMocks(); - }); - - it('no-ops when the user owns zero projects (avoids a wasted KV roundtrip)', async () => { - const { puts, kv } = createKv(); - const db = {} as Parameters[0]; - vi.spyOn(await import('../../src/db.ts'), 'getUser').mockResolvedValue({ - id: 'user_1', - plan: 'free', - } as Awaited>); - vi.spyOn(await import('../../src/db.ts'), 'getProjectsByUserId').mockResolvedValue([]); - await refreshProjectKvForUser(db, kv, 'user_1'); - expect(puts).toEqual([]); - vi.restoreAllMocks(); - }); - - it('does not overwrite a project transferred after the owned-project query', async () => { - const { puts, kv } = createKv(); - const db = createRoutingDb(); - vi.spyOn(await import('../../src/db.ts'), 'getUser').mockResolvedValue({ - id: 'former_owner', - plan: 'pro', - } as Awaited>); - vi.spyOn(await import('../../src/db.ts'), 'getProjectsByUserId').mockResolvedValue([ - { id: 'proj_a', user_id: 'former_owner' }, - ] as unknown as Awaited>); - vi.spyOn(await import('../../src/db.ts'), 'getProject').mockResolvedValue({ - id: 'proj_a', - user_id: 'new_owner', - } as Awaited>); - - await refreshProjectKvForUser(db, kv, 'former_owner'); - - expect(puts).toEqual([]); - vi.restoreAllMocks(); - }); -}); - describe('applyRoutingSuspension', () => { it('stamps suspended on each existing key and skips keys with no entry', async () => { const { store, kv } = createKvStore({ From 21e38317eefb01bdd0b922a4285642c1f5c0abb4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 18 Sep 2026 13:22:35 +0800 Subject: [PATCH 06/18] fix(platform): harden project team lifecycle --- docs/reference/cli.md | 2 +- packages/void/src/cli/client.ts | 7 +- packages/void/src/cli/project-team.ts | 9 +- .../test/unit/project-team-client.test.ts | 8 +- .../meta/design-docs/0080-project-teams.md | 67 +---- .../api/src/admin/routes/projects.tsx | 12 +- platform/packages/api/src/build-scheduler.ts | 74 ++---- platform/packages/api/src/db.ts | 235 +++++++----------- platform/packages/api/src/index.ts | 28 ++- .../api/src/operator/tenant-service.ts | 77 ++++-- platform/packages/api/src/project-access.ts | 4 +- .../packages/api/src/provisioning/cleanup.ts | 68 +++-- platform/packages/api/src/routes/github.ts | 9 + .../packages/api/src/routes/project-access.ts | 1 - .../packages/api/src/routes/project-team.ts | 18 +- platform/packages/api/src/routes/projects.ts | 49 ++-- platform/packages/api/src/routes/usage.ts | 52 ---- platform/packages/api/src/routes/webhooks.ts | 6 +- platform/packages/api/src/routing.ts | 15 +- platform/packages/api/src/types.ts | 2 - platform/packages/api/src/usage-counter.ts | 21 +- platform/packages/api/src/usage-queue.ts | 4 +- .../test/integration/admin-dashboard.test.ts | 21 +- .../api/test/integration/api-delete.test.ts | 25 +- .../api/test/integration/api-github.test.ts | 49 ++++ .../api/test/integration/api-oidc.test.ts | 172 ------------- .../api/test/integration/api-projects.test.ts | 62 ++++- .../auth-email-verification.test.ts | 12 +- .../api/test/integration/db-builds.test.ts | 161 +----------- .../test/integration/operator-tenants.test.ts | 24 +- .../test/integration/project-access.test.ts | 5 +- .../project-team-lifecycle.test.ts | 4 - .../api/test/integration/scheduled.test.ts | 61 ++++- .../api/test/unit/build-scheduler.test.ts | 83 +++---- .../api/test/unit/project-access.test.ts | 30 --- .../packages/api/test/unit/routing.test.ts | 75 ++++-- .../api/test/unit/usage-counter.test.ts | 76 ++++++ .../api/test/unit/usage-queue.test.ts | 35 +++ 38 files changed, 748 insertions(+), 915 deletions(-) delete mode 100644 platform/packages/api/test/unit/project-access.test.ts diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1d5e6254f..f350bafab 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -228,7 +228,7 @@ Link current directory to an existing hosted Void project by slug, or select int ### `void project list` -List all accessible hosted projects (slug, role, mode, 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. +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. ### `void project team` diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 9098bd03c..1a368c910 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -130,7 +130,6 @@ export type ProjectMemberResponse = { user: { id: string; login: string | null; - email: string | null; }; }; @@ -149,7 +148,6 @@ export type ProjectInvitationResponse = { owner?: { id: string; login: string | null; - email: string | null; }; }; target?: { @@ -160,11 +158,10 @@ export type ProjectInvitationResponse = { invited_by?: { id: string; login: string | null; - email: string | null; }; }; -export type ProjectInvitationDelivery = { +type ProjectInvitationDelivery = { status: 'sent' | 'not_configured' | 'rate_limited' | 'failed'; }; @@ -672,7 +669,7 @@ export class PlatformClient { role: Exclude, ): Promise<{ invitation: ProjectInvitationResponse; - refreshed?: boolean; + refreshed: boolean; delivery: ProjectInvitationDelivery; }> { const res = await this.request(`${this.baseUrl}/projects/${projectId}/invitations`, { diff --git a/packages/void/src/cli/project-team.ts b/packages/void/src/cli/project-team.ts index f30ce5e98..2bc35bf0a 100644 --- a/packages/void/src/cli/project-team.ts +++ b/packages/void/src/cli/project-team.ts @@ -22,19 +22,17 @@ function memberLines(members: Array): string { const rows = members.map((member) => ({ id: member.user.id, login: member.user.login ?? '—', - email: member.user.email ?? '—', role: member.role, })); const widths = { id: Math.max(2, ...rows.map((row) => clean(row.id).length)), login: Math.max(5, ...rows.map((row) => clean(row.login).length)), - email: Math.max(5, ...rows.map((row) => clean(row.email).length)), }; return [ - `${'ID'.padEnd(widths.id)} ${'LOGIN'.padEnd(widths.login)} ${'EMAIL'.padEnd(widths.email)} ROLE`, + `${'ID'.padEnd(widths.id)} ${'LOGIN'.padEnd(widths.login)} ROLE`, ...rows.map( (row) => - `${clean(row.id).padEnd(widths.id)} ${clean(row.login).padEnd(widths.login)} ${clean(row.email).padEnd(widths.email)} ${row.role}`, + `${clean(row.id).padEnd(widths.id)} ${clean(row.login).padEnd(widths.login)} ${row.role}`, ), ].join('\n'); } @@ -132,7 +130,8 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs) result.delivery.status === 'sent' ? '' : ` (email delivery: ${result.delivery.status.replace('_', ' ')})`; - outro(`Invited ${invitationEmail(result.invitation)} as ${args.role}${delivery}.`); + const action = result.refreshed ? 'Updated invitation for' : 'Invited'; + outro(`${action} ${invitationEmail(result.invitation)} as ${args.role}${delivery}.`); return; } if (args.action === 'role') { diff --git a/packages/void/test/unit/project-team-client.test.ts b/packages/void/test/unit/project-team-client.test.ts index 43a8015bf..395ad97d6 100644 --- a/packages/void/test/unit/project-team-client.test.ts +++ b/packages/void/test/unit/project-team-client.test.ts @@ -47,6 +47,7 @@ describe('PlatformClient project teams', () => { it('invites only by email and role', async () => { const result = { invitation: { id: 'invite_1', role: 'collaborator' }, + refreshed: false, delivery: { status: 'not_configured' }, }; fetchMock.mockResolvedValueOnce(json(result, 201)); @@ -107,11 +108,4 @@ describe('PlatformClient project teams', () => { expect(request(1)[0]).toBe(`${apiUrl}/project-invitations/invite%20%2F%201/accept`); expect(request(2)[0]).toBe(`${apiUrl}/project-invitations/invite%20%2F%201/decline`); }); - - it('surfaces an unknown registered email response', async () => { - fetchMock.mockResolvedValueOnce(json({ error: 'user_not_found' }, 404)); - await expect( - client().inviteProjectMember(projectId, 'unknown@example.com', 'reader'), - ).rejects.toMatchObject({ name: 'PlatformApiError', status: 404 }); - }); }); diff --git a/platform/meta/design-docs/0080-project-teams.md b/platform/meta/design-docs/0080-project-teams.md index 70c120673..7b740eccf 100644 --- a/platform/meta/design-docs/0080-project-teams.md +++ b/platform/meta/design-docs/0080-project-teams.md @@ -421,8 +421,8 @@ acquire capacity and select a build size from the current billing owner. ## User and project lifecycle -- Deleting a non-owner user cascades their memberships. It also revokes their - pending project invitations and invalidates GitHub connections whose proof +- Deleting a non-owner user cascades their memberships and project invitations + targeting that account. It also invalidates GitHub connections whose proof depended on them. - Deleting an owner keeps the existing behavior: owned projects are torn down before the account is removed. Being a member of other projects does not @@ -433,66 +433,3 @@ acquire capacity and select a build size from the current billing owner. owned projects, never projects on which the user is merely a member. - Invitations remain bound to the resolved target user ID if that account's registered email changes. A resend uses the account's current email. - -## Rollout - -1. **Schema and attribution.** Add the tables and attribution columns, backfill - one ownership period and deployment billing owner per existing project, and - deploy readers that tolerate absent optional data. -2. **Central authorization.** Introduce the capability service and move every - project route to it while owner remains the only possible role. Move all - plan lookups to the returned owner. Update the existing proxy verification - endpoint to enforce deploy capability without changing its wire contract. -3. **Counter correctness.** Resolve usage by ownership period, split billed - totals from current project mappings, and add stale-counter ownership checks. -4. **Membership and invitations.** Enable member rows, registered-user lookup, - acceptance/decline, access audit, email delivery, API routes, CLI commands, - and the API-owned CLI instruction page. -5. **Operator transfer.** Enable dry-run/apply in the shared operator service, - browser administrator UI, and CLI after quota convergence is deployed. - -No phase changes direct Cloudflare deployment configuration, bindings, upload -logic, or manifests. - -## Verification plan - -During each implementation batch: - -- run `vp test run` only for the affected API, proxy, dispatch, dashboard, or - CLI test files; -- run `vp run check` after the batch; -- investigate and fix any newly slow or hanging focused test immediately. - -Coverage must include: - -- every role/capability pair and accessible-project listing; -- owner-implicit access for a pre-feature project; -- reader deploy denial and collaborator deploy success under the owner's plan; -- actor suspension versus owner usage/manual suspension; -- secret, migration, domain, email, database, GitHub, logs, and usage routes; -- rejection of unregistered invitees, invitation expiry, refresh, revocation, - target-only acceptance/decline, forwarded IDs, concurrent acceptance, email - changes, and audit history; -- member removal/role downgrade racing deploy start and final publish; -- deploy and prerender tokens minted before access revocation; -- GitHub connection authorizer removal and builds charged to the owner; -- transfer dry run, former-owner role, existing-member target, active-work - rejection, delayed events on both sides of the timestamp, billing rollover, - stale DO writes, routing partial failure, and idempotent recovery; -- plan changes, suspension, user deletion, and project deletion ignoring shared - membership where ownership is required. - -When all batches are complete, run `vp lint`, `vp fmt`, `vp run check`, and the -full `vp test run` suite. - -After verification, document only the public behavior and workflows: - -- add a project collaboration guide and link it from deployment guidance; -- update CLI help, the no-argument project-group hint, and - `docs/reference/cli.md` for every new command; -- update `docs/guide/platform-administration.md` for ownership transfer; -- update relevant package README files only where their supported interfaces - changed; -- update `packages/void/skills/void` when the CLI guidance changes; -- leave `packages/void/AGENTS_TEMPLATE.md` unchanged unless a brief generated - app instruction is genuinely necessary. diff --git a/platform/packages/api/src/admin/routes/projects.tsx b/platform/packages/api/src/admin/routes/projects.tsx index 1319a6eb9..5c1110bbd 100644 --- a/platform/packages/api/src/admin/routes/projects.tsx +++ b/platform/packages/api/src/admin/routes/projects.tsx @@ -31,8 +31,10 @@ type OwnerSummary = Readonly<{ type OwnerTransferPreview = Readonly<{ target: { id: string; slug: string; userId: string }; - previousOwner: OwnerSummary; + currentOwner: OwnerSummary; + previousOwner: OwnerSummary | { id: string; deleted: true }; newOwner: OwnerSummary; + changed: boolean; formerOwnerRole: 'admin' | null; targetPreviousRole: string | null; blockers: ReadonlyArray<{ kind: string; id: string; status: string }>; @@ -258,7 +260,7 @@ app.post('/:id/owner/preview', async (context) => { userId, true, )) as unknown as OwnerTransferPreview; - const convergenceOnly = result.formerOwnerRole === null; + const convergenceOnly = !result.changed; return context.html( { Current owner - {result.previousOwner.login} ({result.previousOwner.id}) + {result.currentOwner.login} ({result.currentOwner.id}) - {result.previousOwner.plan} - {suspensionLabel(result.previousOwner)} + {result.currentOwner.plan} + {suspensionLabel(result.currentOwner)} New owner diff --git a/platform/packages/api/src/build-scheduler.ts b/platform/packages/api/src/build-scheduler.ts index 1d1801ee2..1dead80e9 100644 --- a/platform/packages/api/src/build-scheduler.ts +++ b/platform/packages/api/src/build-scheduler.ts @@ -19,7 +19,7 @@ import { } from './db.ts'; import type { ApiEnv } from './env.ts'; import { isAdminSuspended } from './suspension.ts'; -import { getBuildInstanceType, getPlanLimits } from './tiers.ts'; +import { getPlanLimits } from './tiers.ts'; /** A poke arms the alarm this far ahead so the next scan runs almost immediately. */ const POKE_DELAY_MS = 50; @@ -68,12 +68,8 @@ export class BuildScheduler extends DurableObject { async alarm(): Promise { const db = createDb(this.env.HYPERDRIVE.connectionString); - // Cancel queued work for manually suspended owners before computing cap - // exclusions. The batch also reaches queued rows hidden behind an active - // build, so suspended work cannot keep the scheduler heartbeat alive forever. - // Then exclude owners at their plan's concurrency cap from the scan; without - // that exclusion, a capped tenant can fill the scan window and starve an - // unrelated owner. Usage-limit suspensions are not included in the batch. + // Clear manually suspended owners' whole queues, including builds hidden + // behind active work, then exclude capped owners to avoid scan starvation. const owners = await getUsersWithQueuedContainerBuilds(db); const suspendedUserIds = owners .filter((owner) => isAdminSuspended(owner.suspendedReason)) @@ -189,56 +185,38 @@ export class BuildScheduler extends DurableObject { continue; } - // Container size of record: prefer the value persisted at enqueue; only - // recompute from the plan for legacy rows queued before enqueue-persistence - // (instance_type === null). The persisted value is authoritative — it is - // what onStart's CAS lands and what dictates which DO namespace this build - // runs on, so a plan change between enqueue and start can't relocate an - // in-flight build to a different container size. Computed BEFORE the claim - // so the claim can persist it in the same tx (see below) and so its target - // namespace can be checked against this tick's saturated set. - const instanceType = build.instance_type ?? getBuildInstanceType(user.plan); - - // Route the container start to the DO class matching the build's size. - // `@cloudflare/containers` has no per-start sizing, so each size is a - // distinct DO class; buildManagerNamespace maps standard-4 → the large - // class and everything else (free/legacy/null) → the base class. - const ns = buildManagerNamespace(this.env, instanceType); - - // Per-namespace shedding: if this build's target fleet already hit capacity - // THIS tick, skip it without attempting a start (and without claiming) — - // it would only hit the same full fleet, burning a GitHub compare + token - // mint. A build targeting the OTHER (non-saturated) fleet is unaffected, so - // the scan keeps draining it. - if (saturatedNamespaces.has(ns)) { + // Persisted sizes can be shed before claiming. Legacy-null rows need the + // owner's plan under lock before their target fleet is known. + if ( + build.instance_type && + saturatedNamespaces.has(buildManagerNamespace(this.env, build.instance_type)) + ) { continue; } - // Atomically enforce the per-user cap AND claim queued->starting in a - // SINGLE transaction holding `SELECT ... FOR UPDATE` on the owner's users - // row — the SAME lock the OIDC path (`createGithubActionsBuildWithinLimit`) - // takes. This makes the container-scheduler path and the OIDC path - // mutually exclusive per user, so a scheduler scan and a parallel OIDC - // exchange can't both observe sub-cap capacity and both create active - // builds (which the old separate count-then-claim allowed). The claim reads - // the owner's plan under that lock, so a concurrent downgrade takes effect - // before admission commits. Returns false when the owner is unavailable or suspended, - // at cap, the project has active work, or the build was claimed elsewhere; - // either way leave it for a later tick. The claim also - // persists the effective instanceType in the same tx, so a cancel/reap in - // the START->onStart window reads the correct size and addresses the right - // DO namespace. This is the commit point: only pure config assembly + the - // try/catch-wrapped startBuild follow it, so nothing can throw between the - // claim and the BuildManager that owns the timeout. + // Admission, legacy size resolution, and the owner plan are read under the + // same user lock used by GitHub Actions admission. const claimed = await claimBuildForStartWithinLimit(db, { buildId: build.id, userId: build.user_id, projectId: build.project_id, - instanceType, }); if (!claimed) { continue; } + const instanceType = claimed.instanceType; + const ns = buildManagerNamespace(this.env, instanceType); + if (saturatedNamespaces.has(ns)) { + try { + await requeueBuildIfStarting(db, build.id); + } catch (error) { + console.error( + `[build-scheduler] failed to requeue build ${build.id} after its fleet saturated; it will be reaped as stale`, + error, + ); + } + continue; + } const config: BuildConfig = { buildId: build.id, @@ -257,10 +235,8 @@ export class BuildScheduler extends DurableObject { // App installation still covers this repo before minting the deploy // token; a null id fails that check closed. repositoryId: connection.repository_id, - // Carry the effective size into the config so onStart's CAS persists it - // for any legacy-null row (back-filling the size-of-record on first start). instanceType, - timeoutMinutes: getPlanLimits(user.plan).buildTimeoutMinutes, + timeoutMinutes: getPlanLimits(claimed.ownerPlan).buildTimeoutMinutes, }; // `ns` (the build's target fleet) was resolved above; address the per-build diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 9ba7fb649..7cf54ab4e 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -73,7 +73,6 @@ import type { Invitation, OAuthAccount, PlatformEvent, - ProjectAccessEvent, ProjectAccessEventAction, ProjectDatabaseCredentials, ProjectInvitation, @@ -449,11 +448,6 @@ export async function getProjectBySlug(db: Db, slug: string): Promise> { - return getOwnedProjectsByUserId(db, userId); -} - -/** Projects for which userId is the billing owner. */ export async function getOwnedProjectsByUserId( db: Db, userId: string, @@ -465,7 +459,7 @@ export async function getOwnedProjectsByUserId( .orderBy(desc(projects.created_at)); } -export type AccessibleProject = Readonly<{ +type AccessibleProject = Readonly<{ project: Project; role: ProjectRole; owner: User; @@ -500,9 +494,9 @@ export async function getAccessibleProjectsByUserId( // --- Project team membership and invitations --- -export type ProjectMemberWithUser = Readonly<{ member: ProjectMember; user: User }>; +type ProjectMemberWithUser = Readonly<{ member: ProjectMember; user: User }>; -export async function getProjectMember( +async function getProjectMember( db: Db, projectId: string, userId: string, @@ -527,7 +521,7 @@ export async function getProjectMembers( .orderBy(asc(projectMembers.created_at), asc(projectMembers.user_id)); } -export type ProjectTeamMutationErrorReason = +type ProjectTeamMutationErrorReason = | 'project-not-found' | 'project-deleting' | 'target-user-not-found' @@ -601,7 +595,7 @@ async function requireLockedProjectOwnerActive(db: Db, project: Project): Promis } } -export async function createProjectAccessEvent( +async function createProjectAccessEvent( db: Db, args: { projectId: string; @@ -612,21 +606,17 @@ export async function createProjectAccessEvent( previousRole?: ProjectMemberRole | null; newRole?: ProjectMemberRole | null; }, -): Promise { - const [event] = await db - .insert(projectAccessEvents) - .values({ - id: generateId('pae'), - project_id: args.projectId, - actor_user_id: args.actorUserId, - subject_user_id: args.subjectUserId ?? null, - invitation_email_snapshot: args.invitationEmailSnapshot ?? null, - previous_role: args.previousRole ?? null, - new_role: args.newRole ?? null, - action: args.action, - }) - .returning(); - return event; +): Promise { + await db.insert(projectAccessEvents).values({ + id: generateId('pae'), + project_id: args.projectId, + actor_user_id: args.actorUserId, + subject_user_id: args.subjectUserId ?? null, + invitation_email_snapshot: args.invitationEmailSnapshot ?? null, + previous_role: args.previousRole ?? null, + new_role: args.newRole ?? null, + action: args.action, + }); } export async function setProjectMemberRole( @@ -686,7 +676,7 @@ export async function setProjectMemberRole( export async function removeProjectMember( db: Db, args: { projectId: string; subjectUserId: string; actorUserId: string }, -): Promise<{ member: ProjectMember; cancelledBuilds: ReadonlyArray }> { +): Promise<{ cancelledBuilds: ReadonlyArray }> { return db.transaction(async (tx) => { const txDb = tx as unknown as Db; const project = await lockProject(txDb, args.projectId); @@ -727,11 +717,11 @@ export async function removeProjectMember( args.subjectUserId, 'github connection authorizer removed from project', ); - return { member, cancelledBuilds }; + return { cancelledBuilds }; }); } -export async function getProjectInvitation( +async function getProjectInvitation( db: Db, invitationId: string, ): Promise { @@ -778,7 +768,6 @@ export async function createOrRefreshProjectInvitation( email: string; role: ProjectMemberRole; actorUserId: string; - expiresAt?: string; }, ): Promise> { const normalizedEmail = args.email.trim().toLowerCase(); @@ -791,7 +780,7 @@ export async function createOrRefreshProjectInvitation( throw new ProjectTeamMutationError('target-user-not-found'); } const target = targets[0]!; - const expiresAt = args.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); return db.transaction(async (tx) => { const txDb = tx as unknown as Db; @@ -885,9 +874,9 @@ export async function createOrRefreshProjectInvitation( export async function resendProjectInvitation( db: Db, - args: { projectId: string; invitationId: string; actorUserId: string; expiresAt?: string }, + args: { projectId: string; invitationId: string; actorUserId: string }, ): Promise> { - const expiresAt = args.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); return db.transaction(async (tx) => { const txDb = tx as unknown as Db; const project = await lockProject(txDb, args.projectId); @@ -938,8 +927,8 @@ export async function resendProjectInvitation( export async function revokeProjectInvitation( db: Db, args: { projectId: string; invitationId: string; actorUserId: string }, -): Promise { - return db.transaction(async (tx) => { +): Promise { + await db.transaction(async (tx) => { const txDb = tx as unknown as Db; const project = await lockProject(txDb, args.projectId); await requireLockedMembersCapability(txDb, project, args.actorUserId); @@ -960,15 +949,14 @@ export async function revokeProjectInvitation( if (invitation.status !== 'pending') { throw new ProjectTeamMutationError('invitation-not-pending'); } - const [updated] = await txDb + await txDb .update(projectInvitations) .set({ status: 'revoked', revoked_at: sql`now()`, revoked_by_user_id: args.actorUserId, }) - .where(eq(projectInvitations.id, invitation.id)) - .returning(); + .where(eq(projectInvitations.id, invitation.id)); await createProjectAccessEvent(txDb, { projectId: args.projectId, actorUserId: args.actorUserId, @@ -977,14 +965,13 @@ export async function revokeProjectInvitation( action: 'invitation_revoked', previousRole: invitation.role, }); - return updated; }); } export async function respondToProjectInvitation( db: Db, args: { invitationId: string; actorUserId: string; response: 'accept' | 'decline' }, -): Promise> { +): Promise> { const locator = await getProjectInvitation(db, args.invitationId); if (!locator) { throw new ProjectTeamMutationError('invitation-not-found'); @@ -1025,10 +1012,7 @@ export async function respondToProjectInvitation( if (args.response === 'accept' && !member) { throw new ProjectTeamMutationError('invitation-not-pending'); } - return { - invitation, - member, - }; + return { member }; } if (invitation.status !== 'pending') { throw new ProjectTeamMutationError('invitation-not-pending'); @@ -1060,15 +1044,14 @@ export async function respondToProjectInvitation( }) .returning(); } - const [updated] = await txDb + await txDb .update(projectInvitations) .set({ status: resultingStatus, accepted_by_user_id: args.response === 'accept' ? args.actorUserId : null, accepted_at: args.response === 'accept' ? sql`now()` : null, }) - .where(eq(projectInvitations.id, invitation.id)) - .returning(); + .where(eq(projectInvitations.id, invitation.id)); await createProjectAccessEvent(txDb, { projectId: project.id, actorUserId: args.actorUserId, @@ -1077,7 +1060,7 @@ export async function respondToProjectInvitation( action: args.response === 'accept' ? 'accepted' : 'declined', newRole: args.response === 'accept' ? invitation.role : null, }); - return { invitation: updated, member }; + return { member }; }); if ('error' in result) { @@ -1102,21 +1085,22 @@ export async function getProjectOwnershipPeriodsByProjectIds( .orderBy(asc(projectOwnershipPeriods.project_id), asc(projectOwnershipPeriods.starts_at)); } -export async function getOpenProjectOwnershipPeriod( +export async function getOpenProjectOwnershipPeriodsByProjectIds( db: Db, - projectId: string, -): Promise { - const [row] = await db + projectIds: ReadonlyArray, +): Promise> { + if (projectIds.length === 0) { + return []; + } + return db .select() .from(projectOwnershipPeriods) .where( and( - eq(projectOwnershipPeriods.project_id, projectId), + inArray(projectOwnershipPeriods.project_id, projectIds), isNull(projectOwnershipPeriods.ends_at), ), - ) - .limit(1); - return row ?? null; + ); } export async function getLatestDeploymentsByProjectIds( @@ -3229,15 +3213,21 @@ export async function getUsersWithProjectsBatch(db: Db, limit: number, offset: n } const userIds = userRows.map((u) => u.id); - const projectRows = await db - .select({ - id: projects.id, - slug: projects.slug, - user_id: projects.user_id, - created_at: projects.created_at, - }) - .from(projects) - .where(inArray(projects.user_id, userIds)); + const [projectRows, ownershipRows] = await Promise.all([ + db + .select({ + id: projects.id, + slug: projects.slug, + user_id: projects.user_id, + created_at: projects.created_at, + }) + .from(projects) + .where(inArray(projects.user_id, userIds)), + db + .selectDistinct({ user_id: projectOwnershipPeriods.owner_user_id }) + .from(projectOwnershipPeriods) + .where(inArray(projectOwnershipPeriods.owner_user_id, userIds)), + ]); const projectsByUser = new Map(); for (const p of projectRows) { @@ -3246,10 +3236,15 @@ export async function getUsersWithProjectsBatch(db: Db, limit: number, offset: n projectsByUser.set(p.user_id, list); } - return userRows.map((user) => ({ - user, - projects: projectsByUser.get(user.id) ?? [], - })); + const usersWithOwnership = new Set(ownershipRows.map((row) => row.user_id)); + return userRows.map((user) => { + const projects = projectsByUser.get(user.id) ?? []; + return { + user, + projects, + needsUsageReconciliation: projects.length > 0 || usersWithOwnership.has(user.id), + }; + }); } export async function getAllowlistEntryStatuses( @@ -4225,15 +4220,11 @@ export async function supersedeAndCreateContainerBuild( db: Db, args: { projectId: string; - /** @deprecated Enqueue attribution is resolved from the locked project owner. */ - userId?: string; branch: string; commitSha: string; connectionId: string; installationId: string; repositoryId: string | null; - /** @deprecated Enqueue sizing is resolved from the locked owner plan. */ - instanceType?: string | null; }, ): Promise<{ build: Build | null; @@ -4679,10 +4670,6 @@ export async function createGithubActionsBuildWithinLimit( db: Db, args: { projectId: string; - /** @deprecated Build attribution is resolved from the locked project owner. */ - userId?: string; - /** @deprecated Admission limits are resolved from the locked owner plan. */ - limit?: number; commitSha?: string | null; branch?: string | null; githubRunId: string; @@ -4913,43 +4900,9 @@ export async function createGithubActionsBuildWithinLimit( } /** - * Atomically claim a QUEUED container build for starting, enforcing the owner's - * per-user active-build cap in the SAME transaction. - * - * This is the container-scheduler counterpart to - * `createGithubActionsBuildWithinLimit`: it takes the SAME `SELECT … FOR UPDATE` - * lock on the owner's `users` row, so the scheduler path and the OIDC path are - * mutually exclusive per user and can't both observe sub-cap capacity and both - * create active builds. The previous flow (`countUserActiveBuilds` then a - * separate `claimBuildForStart`) read the count outside the claim, so a parallel - * OIDC exchange could slip a build in between and bust the cap. - * - * Steps inside one transaction, under the per-user lock: - * 1. `SELECT … FOR UPDATE` the owner's users row (serializes with the OIDC - * path and other scheduler scans for the same owner), including the plan - * used for admission. - * 2. If `limit > 0` and the owner already has `>= limit` active builds, return - * false WITHOUT claiming. `limit === 0` means unlimited (no cap). - * 3. If the build's project already has an active build, return false WITHOUT - * claiming (leave this build queued for a later tick). This enforces AT - * MOST ONE active build per project at a time, which serializes - * same-branch deploys (project↔branch is 1:1 via the unique - * `github_connections.project_id`). Serializing per project avoids the - * project-level deploy guard's 409 (`getActiveDeployment` → - * `ConcurrentDeployError`) and gives latest-wins via FIFO queue drain: - * two rapid pushes no longer both reach `void deploy` and leave the older - * commit live. Liveness: a stuck active build is freed by the build - * wall-clock timeout (`enforceTimeout`) and the deploy stale reaper, so the - * queue can't deadlock. - * 4. Otherwise flip THIS build `queued -> starting` only if it is STILL - * `queued` (guards the read-then-write race: only one caller wins), stamp - * `started_at`, and return true. Return false if it was no longer queued - * (already claimed elsewhere). - * - * Returns true iff this call claimed the build (caller should proceed to start - * it); false when the owner is unavailable or manually suspended, at cap, the - * project already has an active build, or the build was already claimed (caller - * leaves it for a later tick). + * Claims a queued container build under the same owner lock used by GitHub + * Actions admission. The lock protects both the per-owner limit and the + * one-active-build-per-project invariant. */ export async function claimBuildForStartWithinLimit( db: Db, @@ -4957,20 +4910,9 @@ export async function claimBuildForStartWithinLimit( buildId: string; userId: string; projectId: string; - /** @deprecated Admission limits are resolved from the locked owner plan. */ - limit?: number; - // The effective container size the scheduler will route this start on (the - // persisted value, or a plan recompute for a legacy-null row). Persisted in - // THIS claim tx — before the DO is addressed — so a cancel/reap in the - // START->onStart window reads the correct size and addresses the namespace - // that actually owns the running container. onStart's CAS later lands the - // same value (now idempotent). - instanceType: string; }, -): Promise { +): Promise<{ instanceType: string; ownerPlan: PlanId } | null> { return db.transaction(async (tx) => { - // Serialize per-user slot acquisition against the OIDC path and other - // scheduler scans for the same owner. const [owner] = await tx .select({ plan: users.plan, suspendedReason: users.suspended_reason }) .from(users) @@ -4978,7 +4920,7 @@ export async function claimBuildForStartWithinLimit( .for('update') .limit(1); if (!owner || isAdminSuspended(owner.suspendedReason)) { - return false; + return null; } const limit = getPlanLimits(owner.plan).concurrentBuilds; @@ -4988,15 +4930,13 @@ export async function claimBuildForStartWithinLimit( .from(builds) .where(and(eq(builds.user_id, args.userId), inArray(builds.status, ACTIVE_BUILD_STATUSES))); if ((result?.count ?? 0) >= limit) { - return false; + return null; } } - // Per-project serialization: at most one active build per project. All - // builds for a project share one owner (`projects.user_id`), so this read - // is race-free under the per-user FOR UPDATE lock taken above. No executor - // filtering on purpose: an active github_actions build also blocks starting - // a container build for the same project, which is correct. + // All builds for a project share an owner, so this is protected by the + // owner lock. GitHub Actions builds count because deployments serialize at + // the project level regardless of executor. const [active] = await tx .select({ count: count() }) .from(builds) @@ -5004,21 +4944,28 @@ export async function claimBuildForStartWithinLimit( and(eq(builds.project_id, args.projectId), inArray(builds.status, ACTIVE_BUILD_STATUSES)), ); if ((active?.count ?? 0) > 0) { - return false; + return null; } - // Atomic claim: only flips the row if it is still `queued`, so two scans - // racing the same build see exactly one non-empty RETURNING. Persist the - // effective instance_type in the SAME SET so the size-of-record is correct - // the instant the row goes `starting` — a cancel/reap in the START->onStart - // window then addresses the right DO namespace instead of mapping a still- - // NULL value to the base class and leaking the paid container. - const claimed = await tx + const [claimed] = await tx .update(builds) - .set({ status: 'starting', started_at: sql`now()`, instance_type: args.instanceType }) + .set({ + status: 'starting', + started_at: sql`now()`, + instance_type: sql`coalesce(${builds.instance_type}, ${getBuildInstanceType(owner.plan)})`, + }) .where(and(eq(builds.id, args.buildId), eq(builds.status, 'queued'))) - .returning({ id: builds.id }); - return claimed.length > 0; + .returning(); + if (!claimed) { + return null; + } + if (!claimed.instance_type) { + throw new Error('claimed build has no instance type'); + } + return { + instanceType: claimed.instance_type, + ownerPlan: owner.plan, + }; }); } diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index 4a094d2a9..febb49d00 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -42,7 +42,7 @@ import { failStaleActiveGithubActionsBuilds, hasQueuedContainerBuilds, getProject, - getOpenProjectOwnershipPeriod, + getOpenProjectOwnershipPeriodsByProjectIds, getProjectsByIds, getUsersByIds, type Db, @@ -116,7 +116,7 @@ import { platformHumanSessions, } from './schema.ts'; import { isAdminSuspended, REASON_REQUESTS_EXCEEDED } from './suspension.ts'; -import { getCurrentBillingPeriodStart, getPlanLimits } from './tiers.ts'; +import { getCurrentBillingPeriodStart } from './tiers.ts'; import type { Domain, Project } from './types.ts'; import { ensureUsageSigningKey, USAGE_PROTOCOL_VERSION } from './usage-auth.ts'; import { UsageCounter } from './usage-counter.ts'; @@ -754,7 +754,9 @@ async function scheduled( } await Promise.allSettled( - batch.map(({ user, projects }) => processUserRollover(env, db, user, projects)), + batch.map(({ user, projects, needsUsageReconciliation }) => + processUserRollover(env, db, user, projects, needsUsageReconciliation), + ), ); offset += BATCH_SIZE; @@ -950,6 +952,7 @@ async function processUserRollover( db: Db, user: { id: string; plan: string; billing_cycle_start: string; suspended_reason: string | null }, userProjects: ReadonlyArray<{ id: string; slug: string; created_at: string }>, + needsUsageReconciliation: boolean, ): Promise { // Backfill userId into old KV routing entries (all plans, before early return). // Wrapped in try/catch so a malformed KV entry doesn't block rollover/unsuspension. @@ -996,24 +999,23 @@ async function processUserRollover( console.error(`[backfill] userId backfill failed for user ${user.id}:`, err); } - const limits = getPlanLimits(user.plan); - // Skip unlimited plans (no enforcement needed) - if (limits.requestsPerMonth === 0 && limits.aiNeuronsPerMonth === 0) { - return; - } - if (userProjects.length === 0) { + if (!needsUsageReconciliation) { return; } const billingPeriodStart = getCurrentBillingPeriodStart(user.billing_cycle_start).toISOString(); const projectSlugs: Record = {}; const projectOwnershipStarts: Record = {}; - const ownershipPeriods = await Promise.all( - userProjects.map((project) => getOpenProjectOwnershipPeriod(db, project.id)), + const ownershipPeriods = await getOpenProjectOwnershipPeriodsByProjectIds( + db, + userProjects.map((project) => project.id), + ); + const ownershipStarts = new Map( + ownershipPeriods.map((period) => [period.project_id, period.starts_at]), ); - for (const [index, p] of userProjects.entries()) { + for (const p of userProjects) { projectSlugs[p.id] = p.slug; - projectOwnershipStarts[p.id] = ownershipPeriods[index]?.starts_at ?? p.created_at; + projectOwnershipStarts[p.id] = ownershipStarts.get(p.id) ?? p.created_at; } const doId = env.USAGE_COUNTER.idFromName(user.id); diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 7e8819056..51ba1cff7 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -24,7 +24,7 @@ import { getProjectBuilds, getProjectDeployments, getProjectResources, - getProjectsByUserId, + getOwnedProjectsByUserId, getUser, TERMINAL_DEPLOYMENT_STATUSES, LastAdminError, @@ -181,7 +181,7 @@ async function requireProject(db: Db, id: string) { export async function getOperatorUser(context: Context, id: string) { const user = await requireUser(context.get('db'), id); const [projects, deploysToday] = await Promise.all([ - getProjectsByUserId(context.get('db'), id), + getOwnedProjectsByUserId(context.get('db'), id), countUserDeploymentsToday(context.get('db'), id), ]); const since = user.billing_cycle_start @@ -246,6 +246,10 @@ function accountSummary(user: User) { }; } +function historicalAccountSummary(user: User | null, userId: string) { + return user ? accountSummary(user) : { id: userId, deleted: true as const }; +} + async function getOwnershipTransferBlockers( db: Db, projectId: string, @@ -323,12 +327,12 @@ async function inspectOwnershipTransfer(db: Db, projectId: string, targetUserId: ...activeWork, ]; const previousTransfer = - currentOwner.id === newOwner.id - ? await getPreviousOwnershipTransfer(db, projectId, newOwner) - : null; + currentOwner.id === newOwner.id ? await getPreviousOwnershipTransfer(db, projectId) : null; return { project, - previousOwner: previousTransfer?.owner ?? currentOwner, + currentOwner, + previousOwner: previousTransfer ? previousTransfer.owner : currentOwner, + previousOwnerId: previousTransfer?.ownerId ?? currentOwner.id, newOwner, blockers, targetMembership, @@ -340,8 +344,7 @@ async function inspectOwnershipTransfer(db: Db, projectId: string, targetUserId: async function getPreviousOwnershipTransfer( db: Db, projectId: string, - fallbackOwner: User, -): Promise<{ owner: User; startsAt: string } | null> { +): Promise<{ owner: User | null; ownerId: string; startsAt: string } | null> { const [period] = await db .select() .from(projectOwnershipPeriods) @@ -357,12 +360,14 @@ async function getPreviousOwnershipTransfer( return null; } const [owner] = await db.select().from(users).where(eq(users.id, period.owner_user_id)).limit(1); - return { owner: owner ?? fallbackOwner, startsAt: period.starts_at }; + return { owner: owner ?? null, ownerId: period.owner_user_id, startsAt: period.starts_at }; } type OwnershipTransferCommit = Readonly<{ project: Project; - previousOwner: User; + previousOwner: User | null; + previousOwnerId: string; + previousOwnerBillingCycleStart: string | null; newOwner: User; previousOwnershipStartedAt: string; transferredAt: string; @@ -446,10 +451,14 @@ async function commitOwnershipTransfer( ); if (lockedProject.user_id === targetUserId) { - const previousTransfer = await getPreviousOwnershipTransfer(txDb, projectId, newOwner); + const previousTransfer = await getPreviousOwnershipTransfer(txDb, projectId); return { project: lockedProject, - previousOwner: previousTransfer?.owner ?? newOwner, + previousOwner: previousTransfer ? previousTransfer.owner : newOwner, + previousOwnerId: previousTransfer?.ownerId ?? newOwner.id, + previousOwnerBillingCycleStart: previousTransfer + ? (previousTransfer.owner?.billing_cycle_start ?? null) + : newOwner.billing_cycle_start, newOwner, previousOwnershipStartedAt: previousTransfer?.startsAt ?? lockedProject.created_at, transferredAt: openPeriod?.starts_at ?? lockedProject.created_at, @@ -518,6 +527,8 @@ async function commitOwnershipTransfer( return { project: updatedProject!, previousOwner: currentOwner, + previousOwnerId: currentOwner.id, + previousOwnerBillingCycleStart: currentOwner.billing_cycle_start, newOwner, previousOwnershipStartedAt: openPeriod?.starts_at ?? lockedProject.created_at, transferredAt: newPeriod!.starts_at, @@ -543,10 +554,17 @@ async function updateOwnershipCounters( throw new Error(`UsageCounter ${path} returned HTTP ${response.status}`); } }; - if (transfer.previousOwner.id !== transfer.newOwner.id) { - await request(transfer.previousOwner.id, '/detach-project', { + if (transfer.previousOwnerId !== transfer.newOwner.id) { + await request(transfer.previousOwnerId, '/detach-project', { projectId: transfer.project.id, - userId: transfer.previousOwner.id, + userId: transfer.previousOwnerId, + ...(transfer.previousOwnerBillingCycleStart + ? { + billingPeriodStart: getCurrentBillingPeriodStart( + transfer.previousOwnerBillingCycleStart, + ).toISOString(), + } + : {}), ownershipStartedAt: transfer.previousOwnershipStartedAt, ownershipEndedAt: transfer.transferredAt, }); @@ -641,7 +659,7 @@ async function reconcileOperatorUserProjects( context: Context, userId: string, ): Promise { - const projects = await getProjectsByUserId(context.get('db'), userId); + const projects = await getOwnedProjectsByUserId(context.get('db'), userId); for (const project of projects) { await republishProjectOwnerState(context, project.id, userId); } @@ -660,9 +678,14 @@ export async function transferOperatorProjectOwner( 'project.owner', { id: inspection.project.id, slug: inspection.project.slug, userId: targetUserId }, { - previousOwner: accountSummary(inspection.previousOwner), + currentOwner: accountSummary(inspection.currentOwner), + previousOwner: historicalAccountSummary( + inspection.previousOwner, + inspection.previousOwnerId, + ), newOwner: accountSummary(inspection.newOwner), - formerOwnerRole: inspection.previousOwner.id === inspection.newOwner.id ? null : 'admin', + changed: inspection.currentOwner.id !== inspection.newOwner.id, + formerOwnerRole: inspection.previousOwnerId === inspection.newOwner.id ? null : 'admin', targetPreviousRole: inspection.targetMembership?.role ?? null, blockers: inspection.blockers, github: inspection.github, @@ -673,9 +696,9 @@ export async function transferOperatorProjectOwner( ], counterUpdates: { detachUserId: - inspection.previousOwner.id === inspection.newOwner.id + inspection.previousOwnerId === inspection.newOwner.id ? null - : inspection.previousOwner.id, + : inspection.previousOwnerId, attachUserId: inspection.newOwner.id, }, usageTransfer: 'non-retroactive', @@ -707,7 +730,7 @@ export async function transferOperatorProjectOwner( target: { id: transfer.project.id, slug: transfer.project.slug, userId: targetUserId }, ownershipTransferred: true, transferredAt: transfer.transferredAt, - previousOwner: accountSummary(transfer.previousOwner), + previousOwner: historicalAccountSummary(transfer.previousOwner, transfer.previousOwnerId), newOwner: accountSummary(convergedOwner), convergence, }, @@ -716,10 +739,10 @@ export async function transferOperatorProjectOwner( return { action: 'project.owner', target: { id: transfer.project.id, slug: transfer.project.slug, userId: targetUserId }, - previousOwner: accountSummary(transfer.previousOwner), + previousOwner: historicalAccountSummary(transfer.previousOwner, transfer.previousOwnerId), newOwner: accountSummary(convergedOwner), transferredAt: transfer.transferredAt, - formerOwnerRole: transfer.previousOwner.id === transfer.newOwner.id ? null : 'admin', + formerOwnerRole: transfer.previousOwnerId === transfer.newOwner.id ? null : 'admin', changed: transfer.changed, convergence, }; @@ -739,7 +762,7 @@ export async function changeOperatorUserPlan( const newPlan = plan as PlanId; const target = { id, login: user.login }; if (dryRun) { - const projects = await getProjectsByUserId(db, id); + const projects = await getOwnedProjectsByUserId(db, id); return preview('user.plan', target, { previousPlan: user.plan, plan, @@ -800,7 +823,7 @@ export async function suspendOperatorUser( 'Reserved reason: automatic usage enforcement reasons cannot be used for an admin suspension.', ); } - const projects = await getProjectsByUserId(db, id); + const projects = await getOwnedProjectsByUserId(db, id); const user = await prepareRemoval(db, id, { kind: 'suspend', reason }, dryRun); const target = { id, login: user.login }; if (dryRun) { @@ -836,7 +859,7 @@ export async function suspendOperatorUser( export async function restoreOperatorUser(context: Context, id: string, dryRun = false) { const db = context.get('db'); const user = await requireUser(db, id); - const projects = await getProjectsByUserId(db, id); + const projects = await getOwnedProjectsByUserId(db, id); const target = { id, login: user.login }; if (dryRun) { return preview('user.restore', target, { @@ -903,7 +926,7 @@ export async function deleteOperatorProject(context: Context, id: string export async function deleteOperatorUser(context: Context, id: string, dryRun = false) { const db = context.get('db'); - const projects = await getProjectsByUserId(db, id); + const projects = await getOwnedProjectsByUserId(db, id); const user = await prepareRemoval(db, id, { kind: 'delete' }, dryRun); const target = { id, login: user.login }; if (dryRun) { diff --git a/platform/packages/api/src/project-access.ts b/platform/packages/api/src/project-access.ts index 756b46472..13ffde753 100644 --- a/platform/packages/api/src/project-access.ts +++ b/platform/packages/api/src/project-access.ts @@ -11,14 +11,14 @@ export type ProjectAccess = Readonly<{ owner: User; }>; -export const PROJECT_ROLE_CAPABILITIES = { +const PROJECT_ROLE_CAPABILITIES = { reader: ['read'], collaborator: ['read', 'deploy', 'configure'], admin: ['read', 'deploy', 'configure', 'settings', 'members'], owner: ['read', 'deploy', 'configure', 'settings', 'members', 'delete'], } as const satisfies Record>; -export function hasProjectCapability(role: ProjectRole, capability: ProjectCapability): boolean { +function hasProjectCapability(role: ProjectRole, capability: ProjectCapability): boolean { return (PROJECT_ROLE_CAPABILITIES[role] as ReadonlyArray).includes(capability); } diff --git a/platform/packages/api/src/provisioning/cleanup.ts b/platform/packages/api/src/provisioning/cleanup.ts index 4444873d1..047944f41 100644 --- a/platform/packages/api/src/provisioning/cleanup.ts +++ b/platform/packages/api/src/provisioning/cleanup.ts @@ -288,6 +288,11 @@ async function cleanupBestEffortResources( cleanup.push( context.env.ROUTING.delete(project.slug).catch(warn('Failed to delete routing entry')), ); + cleanup.push( + context.env.ROUTING.delete(`ai-block:${projectId}`).catch( + warn('Failed to delete AI block entry'), + ), + ); // NOTE: `projectAccountKey(projectId)` (`project:{projectId}`) is intentionally // NOT deleted here — the sandbox gate's release path in `packages/proxy/src/sandbox.ts` @@ -354,11 +359,14 @@ async function lockAndMarkDeleting( projectId: string, actorUserId?: string, expectedOwnerUserId?: string, -): Promise; }> | null> { return await db.transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); @@ -367,20 +375,13 @@ async function lockAndMarkDeleting( if (!lockedProject) { throw new Error('project disappeared during deletion'); } - // Operator user deletion enumerates the user's owned projects before it - // starts their slow external teardown. Ownership transfer can complete in - // between. Treat that stale snapshot as a skip while holding the same lock - // transfer uses; deleting the new owner's project is never a valid cleanup - // consequence of deleting the former owner's account. + // User deletion discovers projects before teardown. Recheck ownership + // under the lock shared with transfers before deleting anything. if (expectedOwnerUserId && lockedProject.user_id !== expectedOwnerUserId) { return null; } - // API-initiated teardown starts with an unlocked route check so it can - // return the established 404/403 response quickly. Ownership can transfer - // before this transaction acquires the project lock, however. Re-resolve - // the actor under the same lock as the irreversible `deleting` status flip - // so a former owner cannot finish a stale delete request. Operator cleanup - // omits actorUserId and remains authorized by its separate admin guard. + // The route precheck can go stale. Authorize again under the same lock as + // the irreversible status transition. Operator cleanup has no actor here. if (actorUserId) { await requireProjectCapability(txDb, projectId, actorUserId, 'delete'); } @@ -415,10 +416,33 @@ async function lockAndMarkDeleting( ); // Revoke in-flight build deploy tokens up front (see doc above). Idempotent // on replay (already-terminal builds are not re-matched by the CAS). - return await terminateBuildsForProject(txDb, projectId, { reason: 'project deleted' }); + return { + ownerUserId: lockedProject.user_id, + cancelledBuilds: await terminateBuildsForProject(txDb, projectId, { + reason: 'project deleted', + }), + }; }); } +async function detachProjectUsage( + context: Context, + projectId: string, + ownerUserId: string, +): Promise { + const stub = context.env.USAGE_COUNTER.get(context.env.USAGE_COUNTER.idFromName(ownerUserId)); + const response = await stub.fetch( + new Request('https://do/detach-project', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId, userId: ownerUserId }), + }), + ); + if (!response.ok) { + throw new Error(`UsageCounter /detach-project returned HTTP ${response.status}`); + } +} + export class ProjectOperationInProgressError extends Error { constructor(message = 'project operation in progress') { super(message); @@ -509,19 +533,23 @@ export async function teardownProject( // skips the flip and just (re-)marks any non-terminal deployments + builds). // This covers retried teardown after partial failure where the previous // attempt crashed before the cancellations committed. - const cancelledOnLock = await lockAndMarkDeleting( + const deletion = await lockAndMarkDeleting( context.get('db'), projectId, options.actorUserId, options.expectedOwnerUserId, ); - if (cancelledOnLock === null) { + if (deletion === null) { return { warnings: [], ownerChanged: true }; } // Stop the container DOs for the builds revoked above BEFORE any CF cleanup // begins (post-commit, never under the row lock — it's a DO round-trip), so // their tokens are dead and their containers stopped before we touch CF. - await cancelContainerBuildDos(context.env, cancelledOnLock, 'project deleted'); + await cancelContainerBuildDos(context.env, deletion.cancelledBuilds, 'project deleted'); + + // Detach after the durable status change and before cleanup so a failed DO + // call leaves the project row available for retry. + await detachProjectUsage(context, projectId, deletion.ownerUserId); const emailDrain = await drainProjectEmailAdmissions(context.get('db'), projectId); if (emailDrain.inFlight > 0) { diff --git a/platform/packages/api/src/routes/github.ts b/platform/packages/api/src/routes/github.ts index feac30c92..776d9e469 100644 --- a/platform/packages/api/src/routes/github.ts +++ b/platform/packages/api/src/routes/github.ts @@ -1645,6 +1645,9 @@ export const githubProjectRoutes = new Hono() if (lockedAccess instanceof Response) { return lockedAccess; } + if (lockedAccess.project.status === 'deleting') { + return context.json({ error: 'project is being deleted' }, 409); + } // Re-derive ownership from the LOCKED row, not the stale pre-tx snapshot. If // member_removed cleared the owner pointer while we waited on the lock, the // pre-tx owner path is stale: we already resolved repositoryId via the @@ -1843,6 +1846,9 @@ export const githubProjectRoutes = new Hono() if (lockedAccess instanceof Response) { return lockedAccess; } + if (lockedAccess.project.status === 'deleting') { + return context.json({ error: 'project is being deleted' }, 409); + } await tx .select({ id: githubConnections.id }) .from(githubConnections) @@ -1957,6 +1963,9 @@ export const githubProjectRoutes = new Hono() if (lockedAccess instanceof Response) { return lockedAccess; } + if (lockedAccess.project.status === 'deleting') { + return context.json({ error: 'project is being deleted' }, 409); + } // Lock the connection row(s) so an in-flight OIDC exchange (which re-reads + // FOR UPDATE-locks the same row before inserting its build) is serialized: // whichever commits first wins, and a build created just before this commit diff --git a/platform/packages/api/src/routes/project-access.ts b/platform/packages/api/src/routes/project-access.ts index 0dcfe6ee0..e24d0c160 100644 --- a/platform/packages/api/src/routes/project-access.ts +++ b/platform/packages/api/src/routes/project-access.ts @@ -9,7 +9,6 @@ import { } from '../project-access.ts'; import type { ProjectCapability } from '../types.ts'; -/** Preserve the project routes' established 404/403 JSON contract. */ export async function requireProjectRouteCapability( context: Context, projectId: string, diff --git a/platform/packages/api/src/routes/project-team.ts b/platform/packages/api/src/routes/project-team.ts index 85f8a23bb..cb4650400 100644 --- a/platform/packages/api/src/routes/project-team.ts +++ b/platform/packages/api/src/routes/project-team.ts @@ -36,7 +36,7 @@ const INVITATION_EMAILS_PER_MINUTE = 5; const INVITATION_EMAILS_PER_DAY = 50; type MemberJson = Readonly<{ - user: Readonly>; + user: Readonly>; role: ProjectMemberRole | 'owner'; created_at: string; }>; @@ -53,13 +53,13 @@ type InvitationJson = Readonly<{ revoked_at: string | null; }>; -function userJson(user: User): Pick { - return { id: user.id, login: user.login, email: user.email }; +function userSummary(user: User): Pick { + return { id: user.id, login: user.login }; } function memberJson(member: ProjectMember, user: User): MemberJson { return { - user: userJson(user), + user: userSummary(user), role: member.role, created_at: member.created_at, }; @@ -75,7 +75,7 @@ function effectiveInvitationStatus(invitation: ProjectInvitation): ProjectInvita function invitationJson(invitation: ProjectInvitation, target: User): InvitationJson { return { id: invitation.id, - target: userJson(target), + target: { ...userSummary(target), email: target.email }, target_email: invitation.target_email_snapshot, role: invitation.role, status: effectiveInvitationStatus(invitation), @@ -231,7 +231,7 @@ export const projectTeamRoutes = new Hono() return context.json({ members: [ { - user: userJson(access.owner), + user: userSummary(access.owner), role: 'owner' as const, created_at: access.project.created_at, }, @@ -486,11 +486,11 @@ export const myProjectInvitationRoutes = new Hono().get( project: { id: project.id, slug: project.slug, - owner: owner ? userJson(owner) : { id: project.user_id, login: null, email: null }, + owner: owner ? userSummary(owner) : { id: project.user_id, login: null }, }, invited_by: inviter - ? userJson(inviter) - : { id: invitation.invited_by_user_id, login: null, email: null }, + ? userSummary(inviter) + : { id: invitation.invited_by_user_id, login: null }, role: invitation.role, created_at: invitation.created_at, expires_at: invitation.expires_at, diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index bb21ac2f0..015f61210 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -230,7 +230,7 @@ class DatabaseUrlPersistenceError extends Error { type ProjectListItem = Project & { role: ProjectRole; - owner: Pick; + owner: Pick; url?: string; custom_domain: string | null; latest_deployment: Awaited> extends Map< @@ -608,7 +608,7 @@ const app = new Hono() return { ...projectWithUrl(p, context.env), role, - owner: { id: owner.id, login: owner.login, email: owner.email }, + owner: { id: owner.id, login: owner.login }, custom_domain: domain?.hostname ?? null, latest_deployment: latestDeployments.get(p.id) ?? null, traffic: { @@ -735,7 +735,6 @@ const app = new Hono() owner: { id: access.owner.id, login: access.owner.login, - email: access.owner.email, }, }); }) @@ -840,12 +839,13 @@ const app = new Hono() // (bindingName/className) from this endpoint to restore live bindings on // deploy-on-GitHub builds. Full `api` tokens (deployScope unset) leave the // rows untouched, so the dashboard/admin still see complete manifests. - const sanitized = context.get('deployScope') - ? deployments.map((deployment) => ({ - ...deployment, - manifest: sanitizeManifestForDeployToken(deployment.manifest), - })) - : deployments; + const sanitized = + context.get('deployScope') || access.role === 'reader' + ? deployments.map((deployment) => ({ + ...deployment, + manifest: sanitizeDeploymentManifest(deployment.manifest), + })) + : deployments; return context.json({ deployments: sanitized, activeDeploymentId: project.active_deployment_id, @@ -932,8 +932,6 @@ const app = new Hono() ); } - const planLimits = getPlanLimits(access.owner.plan); - // A committed rollback lease blocks deploys and other rollbacks while // the cutover transaction commits or external compensation runs. Keeping // this fence outside the cutover transaction is essential: if PostgreSQL @@ -1200,12 +1198,10 @@ const app = new Hono() const manifest: DeployManifest | null = target.manifest ? JSON.parse(target.manifest) : null; - // Effective dispatch limits honor the TARGET manifest's per-project - // override, clamped to the CURRENT plan ceiling — a rollback under a - // downgraded plan clamps, never rejects. Computed here (pure CPU, no - // I/O added inside the lock) because the manifest is re-read inside - // the lock; `planLimits` itself is resolved before the tx. - const dispatchLimits = computeDispatchLimits(planLimits, manifest?.limits); + const dispatchLimits = computeDispatchLimits( + getPlanLimits(lockedAccess.owner.plan), + manifest?.limits, + ); // Rollback external-write pass depends only on authoritative // persistent sources (pg deployments table for queue activation // history; target's manifest + unconditional DO reconciliation @@ -3187,23 +3183,8 @@ function toDatabaseProvisioningError( return null; } -/** - * Strip plaintext-secret-bearing fields from a deployment manifest before it - * is returned to a project-scoped deploy token (aud:'deploy'). - * - * `manifest.vars` is the only top-level manifest field carrying raw env values - * — they are uploaded as `plain_text` Worker bindings at deploy time (see - * `routes/deploy.ts`). Real secrets travel as `secret_text` sourced from the - * project secrets store, NOT from the manifest, so there is no `manifest.secrets` - * field to strip. The CLI reads ONLY `manifest.live` (bindingName/className) - * from the deployments endpoint to restore live bindings, so every other field - * — including `live` — is preserved. - * - * Fail-safe: a null/empty input, or any manifest that does not parse as JSON, - * collapses to `null`. We never return an unparseable manifest string that - * might still contain secret values to an untrusted build environment. - */ -function sanitizeManifestForDeployToken(raw: string | null): string | null { +/** Remove plaintext environment values from manifests returned to read-only callers. */ +function sanitizeDeploymentManifest(raw: string | null): string | null { if (!raw) { return null; } diff --git a/platform/packages/api/src/routes/usage.ts b/platform/packages/api/src/routes/usage.ts index c20e2eff2..cce95de2f 100644 --- a/platform/packages/api/src/routes/usage.ts +++ b/platform/packages/api/src/routes/usage.ts @@ -344,57 +344,6 @@ async function queryBatchProjectUsageWindows( ); } -async function queryBatchProjectUsage( - accountId: string, - apiToken: string, - requestsDataset: string, - projectIds: ReadonlyArray, - since: Date, -): Promise> { - if (projectIds.length === 0) { - return new Map(); - } - - const sinceStr = since.toISOString().replace('T', ' ').slice(0, 19); - const inList = projectIds.map((id) => `'${id}'`).join(', '); - const query = ` - SELECT - blob1, - SUM(_sample_interval * double1) as requests, - SUM(_sample_interval * double2) as bandwidth_bytes - FROM ${requestsDataset} - WHERE blob1 IN (${inList}) - AND timestamp >= toDateTime('${sinceStr}') - GROUP BY blob1 - `; - - const response = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${apiToken}`, - 'Content-Type': 'text/plain', - }, - body: query, - }, - ); - - if (!response.ok) { - return new Map(); - } - - const json = (await response.json()) as { data: ReadonlyArray }; - const result = new Map(); - for (const row of json.data ?? []) { - result.set(row.blob1, { - requests: Math.round(row.requests ?? 0), - bandwidthBytes: Math.round(row.bandwidth_bytes ?? 0), - }); - } - return result; -} - async function queryTopProjectTraffic( accountId: string, apiToken: string, @@ -641,7 +590,6 @@ async function queryProjectAiUsage( export { getRequestsDataset, queryProjectUsage, - queryBatchProjectUsage, queryBatchProjectUsageWindows, queryTopProjectTraffic, queryProjectRequestOverview, diff --git a/platform/packages/api/src/routes/webhooks.ts b/platform/packages/api/src/routes/webhooks.ts index 9b42d910f..1b61da96d 100644 --- a/platform/packages/api/src/routes/webhooks.ts +++ b/platform/packages/api/src/routes/webhooks.ts @@ -13,7 +13,7 @@ import { getGitHubConnectionsByRepo, getGitHubInstallation, getGitHubInstallationsByAccount, - getProjectsByUserId, + getOwnedProjectsByUserId, getUser, getUserByOAuthAccount, markBuildTerminalIfActive, @@ -1486,7 +1486,9 @@ export async function handleVerifiedGitHubWebhook( instance_type: string | null; }> = []; if (removedUser) { - const projectIds = (await getProjectsByUserId(txDb, removedUser.id)).map((p) => p.id); + const projectIds = (await getOwnedProjectsByUserId(txDb, removedUser.id)).map( + (project) => project.id, + ); if (projectIds.length > 0) { // Single-table FOR UPDATE on githubConnections (NOT joined to // projects) keeps the lock scope identical to the installation path; diff --git a/platform/packages/api/src/routing.ts b/platform/packages/api/src/routing.ts index a3d265537..551017b01 100644 --- a/platform/packages/api/src/routing.ts +++ b/platform/packages/api/src/routing.ts @@ -160,7 +160,7 @@ export async function applyRoutingSuspension( export async function setProjectsRoutingSuspension( routing: KVNamespace, db: Db, - projects: ReadonlyArray<{ id: string; slug: string; user_id?: string }>, + projects: ReadonlyArray<{ id: string; slug: string; user_id: string }>, update: RoutingSuspensionUpdate, ): Promise { if (projects.length === 0) { @@ -169,14 +169,11 @@ export async function setProjectsRoutingSuspension( for (const project of projects) { await db.transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${project.id} FOR UPDATE`); - if (project.user_id !== undefined) { - const current = await getProject(tx as unknown as Db, project.id); - // User-level suspension/restore callers pass Project snapshots from an - // owned-project query. If ownership changed before this lock, transfer - // convergence owns the route and this stale fan-out must stand down. - if (current?.user_id !== project.user_id) { - return; - } + const current = await getProject(tx as unknown as Db, project.id); + // If ownership changed before this lock, transfer convergence owns the + // route and this stale fan-out must stand down. + if (current?.user_id !== project.user_id) { + return; } // Resolve domains only after the project lock. Domain activation takes // the same lock before publishing a route, so this read must happen diff --git a/platform/packages/api/src/types.ts b/platform/packages/api/src/types.ts index 7def769bd..c813cb8c6 100644 --- a/platform/packages/api/src/types.ts +++ b/platform/packages/api/src/types.ts @@ -10,7 +10,6 @@ import type { invitations, oauthAccounts, platformEvents, - projectAccessEvents, projectDatabaseCredentials, projectInvitations, projectMembers, @@ -73,7 +72,6 @@ export type Project = InferSelectModel; export type ProjectMember = InferSelectModel; export type ProjectInvitation = InferSelectModel; export type ProjectOwnershipPeriod = InferSelectModel; -export type ProjectAccessEvent = InferSelectModel; export type Deployment = InferSelectModel; export type DeploymentStatus = Deployment['status']; export type ProjectSecret = InferSelectModel; diff --git a/platform/packages/api/src/usage-counter.ts b/platform/packages/api/src/usage-counter.ts index 60f8f4e14..7b258e3e1 100644 --- a/platform/packages/api/src/usage-counter.ts +++ b/platform/packages/api/src/usage-counter.ts @@ -589,6 +589,7 @@ export class UsageCounter extends DurableObject { const body = (await request.json()) as { projectId: string; userId?: string; + billingPeriodStart?: string; ownershipStartedAt?: string; ownershipEndedAt?: string; }; @@ -596,6 +597,7 @@ export class UsageCounter extends DurableObject { return Response.json({ error: 'projectId is required' }, { status: 400 }); } if ( + (body.billingPeriodStart && !Number.isFinite(Date.parse(body.billingPeriodStart))) || (body.ownershipStartedAt && !Number.isFinite(Date.parse(body.ownershipStartedAt))) || (body.ownershipEndedAt && !Number.isFinite(Date.parse(body.ownershipEndedAt))) ) { @@ -605,6 +607,15 @@ export class UsageCounter extends DurableObject { if (body.userId) { state.ownerUserId = body.userId; } + const db = createDb(this.env.HYPERDRIVE.connectionString); + const owner = state.ownerUserId ? await getUser(db, state.ownerUserId) : null; + if (owner) { + state.plan = owner.plan; + } + if (!state.billingPeriodInitialized && body.billingPeriodStart) { + state.billingPeriodStart = body.billingPeriodStart; + state.billingPeriodInitialized = true; + } if (body.ownershipEndedAt) { const windowKey = this.retainHistoricalAiWindow( state, @@ -619,11 +630,7 @@ export class UsageCounter extends DurableObject { this.retainHistoricalAiSample(state, body.projectId); } if (state.aiSuspended) { - const ownedProjectIds = await this.ownedProjectIds( - createDb(this.env.HYPERDRIVE.connectionString), - [body.projectId], - state.ownerUserId, - ); + const ownedProjectIds = await this.ownedProjectIds(db, [body.projectId], state.ownerUserId); if (ownedProjectIds.has(body.projectId)) { await this.env.ROUTING.delete(`ai-block:${body.projectId}`); } @@ -951,7 +958,9 @@ export class UsageCounter extends DurableObject { const projects = await getProjectsByIds(db, projectIds); return new Set( projects - .filter((project) => project.user_id === expectedOwnerUserId) + .filter( + (project) => project.user_id === expectedOwnerUserId && project.status !== 'deleting', + ) .map((project) => project.id), ); } diff --git a/platform/packages/api/src/usage-queue.ts b/platform/packages/api/src/usage-queue.ts index 8b838176d..65dfc3a37 100644 --- a/platform/packages/api/src/usage-queue.ts +++ b/platform/packages/api/src/usage-queue.ts @@ -187,7 +187,9 @@ export async function processUsageEventBatch( ...event, userId: ownerUserId, slug: project.slug, - currentOwner: ownershipPeriod ? ownershipPeriod.ends_at === null : true, + currentOwner: + project.status !== 'deleting' && + (ownershipPeriod ? ownershipPeriod.ends_at === null : true), ownershipStartedAt: ownershipPeriod?.starts_at ?? project.created_at, }, at, diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 2ad33d938..ab4d4c27b 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -9,6 +9,7 @@ import { platformLoginConnections, platformLoginRevisions, operatorEvents, + projectOwnershipPeriods, projects, users, } from '../../src/schema.ts'; @@ -330,7 +331,7 @@ describe('GET /admin/users/:id', () => { await createTestDeployment(env.db, project.id, 'live'); await createTestDeployment(env.db, project.id, 'live'); - // Mock CF API — the user detail page calls queryBatchProjectUsage which + // Mock CF API — the user detail page calls queryBatchProjectUsageWindows which // fetches from Analytics Engine. Without a mock, the real fetch hangs in CI. mockCfApi(); @@ -1405,8 +1406,24 @@ describe('POST /admin/projects/:id/owner', () => { 'free', 'admin', ); + const formerOwner = await createTestUser(env.db, 'browser-convergence-former-owner'); const owner = await createTestUser(env.db, 'browser-convergence-owner'); const project = await createTestProject(env.db, owner.id, 'browser-convergence-project'); + await env.db.insert(projectOwnershipPeriods).values([ + { + id: 'pown_browser_convergence_former', + project_id: project.id, + owner_user_id: formerOwner.id, + starts_at: '2026-01-01T00:00:00.000Z', + ends_at: '2026-02-01T00:00:00.000Z', + }, + { + id: 'pown_browser_convergence_current', + project_id: project.id, + owner_user_id: owner.id, + starts_at: '2026-02-01T00:00:00.000Z', + }, + ]); const response = await req(`/admin/projects/${project.id}/owner/preview`, admin.adminJwt, { method: 'POST', @@ -1420,7 +1437,7 @@ describe('POST /admin/projects/:id/owner', () => { const html = await response.text(); expect(html).toContain(`Converge Ownership State: ${project.slug}`); expect(html).toContain('This is a convergence-only operation'); - expect(html).toContain('no former-owner detachment is needed'); + expect(html).toContain(`detach from ${formerOwner.id}`); expect(html).toContain('Apply Convergence'); expect(html).not.toContain('The former owner will become'); expect(html).not.toContain('detach from '); diff --git a/platform/packages/api/test/integration/api-delete.test.ts b/platform/packages/api/test/integration/api-delete.test.ts index fa707cf20..18f6274f9 100644 --- a/platform/packages/api/test/integration/api-delete.test.ts +++ b/platform/packages/api/test/integration/api-delete.test.ts @@ -211,14 +211,29 @@ describe('DELETE /projects/:id', () => { it('deletes project with no resources', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'empty-app'); + await env.ROUTING.put(`ai-block:${project.id}`, '1'); mockCfApi(); + const usageCalls: Array<{ ownerId: string; path: string; body: unknown }> = []; + const usageCounter = { + idFromName: (name: string) => name, + get: (ownerId: string) => ({ + fetch: async (request: Request) => { + usageCalls.push({ + ownerId, + path: new URL(request.url).pathname, + body: await request.json(), + }); + return Response.json({ ok: true }); + }, + }), + } as unknown as DurableObjectNamespace; const res = await app.fetch( new Request(`http://localhost/projects/${project.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${user.jwt}` }, }), - env, + { ...env, USAGE_COUNTER: usageCounter }, ); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean; id: string; slug: string }; @@ -227,6 +242,14 @@ describe('DELETE /projects/:id', () => { // Verify project is gone from DB const [dbProject] = await env.db.select().from(projects).where(eq(projects.id, project.id)); expect(dbProject).toBeUndefined(); + expect(await env.ROUTING.get(`ai-block:${project.id}`)).toBeNull(); + expect(usageCalls).toEqual([ + { + ownerId: user.id, + path: '/detach-project', + body: { projectId: project.id, userId: user.id }, + }, + ]); }); it('deletes project with resources, assets, and routing', async () => { diff --git a/platform/packages/api/test/integration/api-github.test.ts b/platform/packages/api/test/integration/api-github.test.ts index e1811e304..cf01c076f 100644 --- a/platform/packages/api/test/integration/api-github.test.ts +++ b/platform/packages/api/test/integration/api-github.test.ts @@ -44,6 +44,7 @@ import { githubInstallationRevocations, oauthAccounts, projectMembers, + projects, } from '../../src/schema.ts'; import { JWT_SECRET, @@ -433,6 +434,54 @@ function mockEmptyInstallAccount(opts: { } describe('POST /projects/:id/github', () => { + it('rejects connection mutations while the project is being deleted', async () => { + const owner = await createTestUser(env.db, 'deleting-github-owner'); + const project = await createTestProject(env.db, owner.id, 'deleting-github'); + await upsertGitHubInstallation(env.db, { + installationId: 'deleting-111', + userId: owner.id, + accountLogin: owner.login, + accountId: 'deleting-owner-account', + accountType: 'User', + repositories: [{ full_name: `${owner.login}/app`, id: 9101 }], + }); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: 'deleting-111', + repoFullName: `${owner.login}/app`, + repositoryId: '9101', + authorizedByUserId: owner.id, + }); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + + const requests = [ + new Request(`http://localhost/projects/${project.id}/github`, { + method: 'POST', + headers: { Authorization: `Bearer ${owner.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + installationId: 'deleting-111', + repoFullName: `${owner.login}/app`, + }), + }), + new Request(`http://localhost/projects/${project.id}/github`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${owner.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch: 'release' }), + }), + new Request(`http://localhost/projects/${project.id}/github`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${owner.jwt}` }, + }), + ]; + + for (const request of requests) { + const response = await app.fetch(request, env); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: 'project is being deleted' }); + } + expect((await getGitHubConnection(env.db, project.id))?.branch).toBe('main'); + }); + it('lets project admins manage connections while readers and collaborators can only read them', async () => { const owner = await createTestUser(env.db, 'team-github-owner'); const admin = await createTestUser(env.db, 'team-github-admin'); diff --git a/platform/packages/api/test/integration/api-oidc.test.ts b/platform/packages/api/test/integration/api-oidc.test.ts index 45d0bb0d9..a5e9015a1 100644 --- a/platform/packages/api/test/integration/api-oidc.test.ts +++ b/platform/packages/api/test/integration/api-oidc.test.ts @@ -12,7 +12,6 @@ import { } from 'vite-plus/test'; import { verifyDeployToken } from '../../src/auth/deploy-token.ts'; import { - claimBuildForStartWithinLimit, createBuild, createGitHubConnection as createGitHubConnectionRecord, createGithubActionsBuildWithinLimit, @@ -1435,8 +1434,6 @@ describe('POST /auth/github-oidc', () => { const [a, b] = await Promise.all([ createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, - userId: user.id, - limit: 1, githubRunId: '301', connectionId: connection.id, installationId: '1', @@ -1445,8 +1442,6 @@ describe('POST /auth/github-oidc', () => { }), createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, - userId: user.id, - limit: 1, githubRunId: '302', connectionId: connection.id, installationId: '1', @@ -2443,170 +2438,3 @@ describe('POST /auth/github-oidc', () => { expect((await getProjectBuilds(env.db, project.id)).length).toBe(0); }); }); - -// --------------------------------------------------------------------------- -// the shared atomic concurrency primitive. The container-scheduler path -// claims a QUEUED build queued->starting and enforces the per-user cap in ONE -// transaction under the SAME per-user FOR UPDATE lock the OIDC path takes, so -// neither path can bust the cap. (The scheduler-path integration test is owned -// by the build-scheduler suite; these are focused db-level guards.) -// --------------------------------------------------------------------------- - -describe('claimBuildForStartWithinLimit', () => { - beforeEach(async () => { - await clearBuildTables(env.db); - await clearTables(env.db); - }); - - it('claims a queued build queued->starting and stamps started_at', async () => { - const user = await createTestUser(env.db); - const project = await createTestProject(env.db, user.id, 'claim-ok'); - const build = await createBuild(env.db, { - projectId: project.id, - userId: user.id, - executor: 'container', - status: 'queued', - }); - - const claimed = await claimBuildForStartWithinLimit(env.db, { - buildId: build.id, - userId: user.id, - projectId: project.id, - limit: 1, - instanceType: 'standard-4', - }); - expect(claimed).toBe(true); - - const updated = await getBuild(env.db, build.id); - expect(updated!.status).toBe('starting'); - expect(updated!.started_at).not.toBeNull(); - }); - - it('returns false (no claim) when the owner is already at the cap', async () => { - const user = await createTestUser(env.db); - const project = await createTestProject(env.db, user.id, 'claim-capped'); - // One ACTIVE build already occupies the single (limit=1) slot. - await createBuild(env.db, { - projectId: project.id, - userId: user.id, - executor: 'container', - status: 'building', - }); - const queued = await createBuild(env.db, { - projectId: project.id, - userId: user.id, - executor: 'container', - status: 'queued', - }); - - const claimed = await claimBuildForStartWithinLimit(env.db, { - buildId: queued.id, - userId: user.id, - projectId: project.id, - limit: 1, - instanceType: 'standard-4', - }); - expect(claimed).toBe(false); - // The queued build is left untouched for a later tick. - expect((await getBuild(env.db, queued.id))!.status).toBe('queued'); - }); - - it('returns false when the build is no longer queued (lost the claim race)', async () => { - const user = await createTestUser(env.db); - const project = await createTestProject(env.db, user.id, 'claim-raced'); - // Already claimed/started by another scan. - const build = await createBuild(env.db, { - projectId: project.id, - userId: user.id, - executor: 'container', - status: 'starting', - }); - - const claimed = await claimBuildForStartWithinLimit(env.db, { - buildId: build.id, - userId: user.id, - projectId: project.id, - limit: 0, - instanceType: 'standard-4', - }); - expect(claimed).toBe(false); - }); - - it('treats the locked custom owner plan as unlimited', async () => { - const user = await createTestUser( - env.db, - 'claim-unlimited-user', - 'github', - undefined, - 'custom', - ); - // The active builds live on OTHER projects so they exercise the per-user - // cap (here unlimited) without tripping the per-project active-build guard, - // which serializes builds within a single project regardless of the cap. - const busyA = await createTestProject(env.db, user.id, 'claim-unlimited-a'); - const busyB = await createTestProject(env.db, user.id, 'claim-unlimited-b'); - const project = await createTestProject(env.db, user.id, 'claim-unlimited'); - await createBuild(env.db, { projectId: busyA.id, userId: user.id, status: 'building' }); - await createBuild(env.db, { projectId: busyB.id, userId: user.id, status: 'deploying' }); - const queued = await createBuild(env.db, { - projectId: project.id, - userId: user.id, - status: 'queued', - }); - - const claimed = await claimBuildForStartWithinLimit(env.db, { - buildId: queued.id, - userId: user.id, - projectId: project.id, - instanceType: 'standard-4', - }); - expect(claimed).toBe(true); - expect((await getBuild(env.db, queued.id))!.status).toBe('starting'); - }); - - it('admits only ONE of a scheduler claim and an OIDC create racing the same cap', async () => { - // Cross-path mutual exclusion: a queued container build claim and a parallel - // OIDC actions-build create for the SAME owner at a cap of 1 must not both - // produce an active build — both take the SAME per-user FOR UPDATE lock. - const user = await createTestUser(env.db); // free => concurrentBuilds = 1 - const project = await createTestProject(env.db, user.id, 'claim-cross-path'); - const queued = await createBuild(env.db, { - projectId: project.id, - userId: user.id, - executor: 'container', - status: 'queued', - }); - - const [claimed, oidc] = await Promise.all([ - claimBuildForStartWithinLimit(env.db, { - buildId: queued.id, - userId: user.id, - projectId: project.id, - limit: 1, - instanceType: 'standard-4', - }), - createGithubActionsBuildWithinLimit(env.db, { - projectId: project.id, - userId: user.id, - limit: 1, - githubRunId: 'cross-1', - // No github_actions connection is seeded for this project, so the helper - // returns `connection-removed` before the id check — any id is fine. - connectionId: 'ghc_absent', - installationId: 'none', - repositoryId: '42', - expectedWorkflowPath: '.github/workflows/void-deploy.yml', - }), - ]); - - // Exactly one path won the single slot. - const claimedWon = claimed === true; - const oidcWon = oidc.outcome === 'created'; - expect(claimedWon !== oidcWon).toBe(true); - - const active = (await getProjectBuilds(env.db, project.id)).filter((b) => - ['starting', 'building', 'deploying'].includes(b.status), - ); - expect(active.length).toBe(1); - }); -}); diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index aacf954ff..8b9026ef4 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -1678,6 +1678,51 @@ describe('GET /projects/:id/deployments', () => { expect(byId[d2.id].commit_url).toBeNull(); expect(byId[d2.id].status).toBe('pending'); }); + + it('hides manifest environment values from readers but not deploy-capable members', async () => { + const owner = await createTestUser(env.db, 'manifest-owner'); + const reader = await createTestUser(env.db, 'manifest-reader'); + const collaborator = await createTestUser(env.db, 'manifest-collaborator'); + const project = await createTestProject(env.db, owner.id, 'manifest-project'); + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }, + ]); + await createDeployment( + env.db, + project.id, + JSON.stringify({ version: 1, vars: { TOKEN: 'plaintext' }, ssr: true }), + ); + + const listAs = async (jwt: string) => { + const response = await app.fetch( + new Request(`http://localhost/projects/${project.id}/deployments`, { + headers: { Authorization: `Bearer ${jwt}` }, + }), + env, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { deployments: Array<{ manifest: string }> }; + return JSON.parse(body.deployments[0]!.manifest) as Record; + }; + + expect(await listAs(reader.jwt)).toEqual({ version: 1, ssr: true }); + expect(await listAs(collaborator.jwt)).toEqual({ + version: 1, + vars: { TOKEN: 'plaintext' }, + ssr: true, + }); + }); }); describe('POST /projects/:id/rollback', () => { @@ -1730,10 +1775,7 @@ describe('POST /projects/:id/rollback', () => { }); it('clamps a stored manifest limits.cpuMs to the current plan ceiling (never rejects)', async () => { - // Free plan ceiling is 10 cpuMs; the target manifest requests 60000 — - // e.g. deployed under a higher plan before a downgrade. Rollback must - // clamp with min() and go through, not 400. - const user = await createTestUser(env.db); + const user = await createTestUser(env.db, 'rollback-plan-owner', 'github', undefined, 'pro'); const project = await createTestProject(env.db, user.id, 'clamp-rollback-app'); const d1 = await createDeployment( @@ -1753,6 +1795,17 @@ describe('POST /projects/:id/rollback', () => { await markDeploymentRetained(env.db, d2.id, true); await setActiveDeployment(env.db, project.id, d2.id); + const requireCapability = projectAccess.requireProjectCapability; + let downgraded = false; + vi.spyOn(projectAccess, 'requireProjectCapability').mockImplementation(async (...args) => { + const result = await requireCapability(...args); + if (!downgraded && args[3] === 'deploy') { + downgraded = true; + await env.db.update(users).set({ plan: 'free' }).where(eq(users.id, user.id)); + } + return result; + }); + const res = await app.fetch( new Request(`http://localhost/projects/${project.id}/rollback`, { method: 'POST', @@ -1765,6 +1818,7 @@ describe('POST /projects/:id/rollback', () => { env, ); expect(res.status).toBe(200); + expect(downgraded).toBe(true); const routing = (await env.ROUTING.get(project.slug, 'json')) as { limits: { cpuMs: number; subRequests: number }; diff --git a/platform/packages/api/test/integration/auth-email-verification.test.ts b/platform/packages/api/test/integration/auth-email-verification.test.ts index cf22bf237..30ce299ba 100644 --- a/platform/packages/api/test/integration/auth-email-verification.test.ts +++ b/platform/packages/api/test/integration/auth-email-verification.test.ts @@ -971,16 +971,8 @@ describe('email verification gating on account linking', () => { ], }); }); - // The route has already captured the OLD owner address in - // `lockedAccess` when it reaches the owner-audited insert. Rotate the - // user row at that seam, then run the real helper: its owner-row - // re-check must see the NEW address and refuse the stale inheritance. - // - // The production interleaving uses the login transaction on a second - // Postgres session. PGlite exposes one session, so model the same - // committed row state through the route's existing transaction. There - // are no inherited destinations yet, making this update the complete - // database effect of that login for this fixture. + // Rotate the owner after the route captures its access snapshot. PGlite + // has one session, so update through the route's existing transaction. const realTryCreate = dbModule.tryCreateEmailDestination; let rotatedDuringAdd = false; vi.spyOn(dbModule, 'tryCreateEmailDestination').mockImplementation(async (...args) => { diff --git a/platform/packages/api/test/integration/db-builds.test.ts b/platform/packages/api/test/integration/db-builds.test.ts index 3bf125ecb..11a91fd2e 100644 --- a/platform/packages/api/test/integration/db-builds.test.ts +++ b/platform/packages/api/test/integration/db-builds.test.ts @@ -593,14 +593,10 @@ describe('builds', () => { const created = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', repositoryId: DEFAULT_REPO_ID, - // Simulate a plan snapshot resolved before the locked enqueue. The free - // owner row is authoritative, so this stale paid size must be ignored. - instanceType: 'standard-4', }); expect(created.reused).toBe(false); expect(created.build!.status).toBe('queued'); @@ -613,7 +609,6 @@ describe('builds', () => { const superseded = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'bbb', installationId: '12345', @@ -639,7 +634,6 @@ describe('builds', () => { const reusedQueued = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'bbb', installationId: '12345', @@ -658,7 +652,6 @@ describe('builds', () => { const reusedComplete = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'bbb', installationId: '12345', @@ -702,12 +695,10 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: staleOwner.id, branch: 'main', commitSha: 'owner-race', installationId: 'container-owner-race', repositoryId: DEFAULT_REPO_ID, - instanceType: 'standard-4', }); expect(result.build?.user_id).toBe(currentOwner.id); @@ -741,7 +732,6 @@ describe('builds', () => { const first = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: connId, - userId: user.id, branch: 'main', commitSha: 'A', installationId: '12345', @@ -771,7 +761,6 @@ describe('builds', () => { const reused = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: connId, - userId: user.id, branch: 'main', commitSha: 'A', installationId: '12345', @@ -839,7 +828,6 @@ describe('builds', () => { const rollback = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'C1', installationId: '12345', @@ -869,7 +857,6 @@ describe('builds', () => { const redeliver = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'C1', installationId: '12345', @@ -935,7 +922,6 @@ describe('builds', () => { const rollback = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'A', installationId: '12345', @@ -980,7 +966,6 @@ describe('builds', () => { const redeliver = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'A', installationId: '12345', @@ -1024,7 +1009,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'A', installationId: '12345', @@ -1062,7 +1046,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'c2', installationId: '12345', @@ -1109,7 +1092,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'c2', installationId: '12345', @@ -1137,7 +1119,6 @@ describe('builds', () => { const first = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1149,7 +1130,6 @@ describe('builds', () => { const retry = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1181,7 +1161,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1212,7 +1191,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1249,7 +1227,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1318,7 +1295,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1351,7 +1327,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1380,7 +1355,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1409,7 +1383,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1438,7 +1411,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1471,7 +1443,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1503,7 +1474,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1554,7 +1524,6 @@ describe('builds', () => { const replaced = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: cOld.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1570,7 +1539,6 @@ describe('builds', () => { const ok = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId: cNew.id, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1662,10 +1630,8 @@ describe('builds', () => { buildId: build.id, userId: user.id, projectId: project.id, - limit: 1, - instanceType: 'basic', }); - expect(claimed).toBe(true); + expect(claimed).toMatchObject({ instanceType: 'standard-3', ownerPlan: 'free' }); const row = await getBuild(env.db, build.id); expect(row?.status).toBe('starting'); expect(row?.started_at).not.toBeNull(); @@ -1675,10 +1641,8 @@ describe('builds', () => { buildId: build.id, userId: user.id, projectId: project.id, - limit: 1, - instanceType: 'basic', }); - expect(reclaimed).toBe(false); + expect(reclaimed).toBeNull(); }); it('claimBuildForStartWithinLimit does not start a queued build for a manually suspended owner', async () => { @@ -1695,14 +1659,12 @@ describe('builds', () => { buildId: build.id, userId: user.id, projectId: project.id, - limit: 1, - instanceType: 'basic', }), - ).toBe(false); + ).toBeNull(); expect((await getBuild(env.db, build.id))?.status).toBe('queued'); }); - it('claimBuildForStartWithinLimit uses the locked owner plan instead of a stale limit', async () => { + it('claimBuildForStartWithinLimit uses the locked owner plan', async () => { const user = await createTestUser(env.db, 'claim-current-plan'); const activeProject = await createTestProject(env.db, user.id); const queuedProject = await createTestProject(env.db, user.id); @@ -1721,11 +1683,8 @@ describe('builds', () => { buildId: queued.id, userId: user.id, projectId: queuedProject.id, - // Simulate a stale pro-plan snapshot; the locked DB owner is free. - limit: 3, - instanceType: 'standard-3', }), - ).toBe(false); + ).toBeNull(); expect((await getBuild(env.db, queued.id))?.status).toBe('queued'); }); @@ -1739,8 +1698,6 @@ describe('builds', () => { buildId: build.id, userId: user.id, projectId: project.id, - limit: 1, - instanceType: 'basic', }); expect((await getBuild(env.db, build.id))!.status).toBe('starting'); @@ -1759,8 +1716,6 @@ describe('builds', () => { buildId: build.id, userId: user.id, projectId: project.id, - limit: 1, - instanceType: 'basic', }); await terminateBuildsForProject(env.db, project.id, { reason: 'project disconnected' }); expect((await getBuild(env.db, build.id))!.status).toBe('cancelled'); @@ -1780,8 +1735,6 @@ describe('builds', () => { buildId: build.id, userId: user.id, projectId: project.id, - limit: 1, - instanceType: 'basic', }); await markBuildBuildingIfStarting(env.db, build.id); expect((await getBuild(env.db, build.id))!.status).toBe('building'); @@ -1804,8 +1757,6 @@ describe('builds', () => { buildId: build.id, userId: user.id, projectId: project.id, - limit: 1, - instanceType: 'basic', }); await markBuildBuildingIfStarting(env.db, build.id); expect((await getBuild(env.db, build.id))!.status).toBe('building'); @@ -1827,8 +1778,6 @@ describe('builds', () => { buildId: build.id, userId: user.id, projectId: project.id, - limit: 1, - instanceType: 'basic', }); await terminateBuildsForProject(env.db, project.id, { reason: 'project disconnected' }); expect((await getBuild(env.db, build.id))!.status).toBe('cancelled'); @@ -1866,8 +1815,6 @@ describe('builds', () => { // blocks the second build here — the per-project guard is. const user = await createTestUser(env.db, 'serialuser', 'github', undefined, 'pro'); const project = await createTestProject(env.db, user.id); - const limit = 3; - // Two queued container builds for the SAME project: commit A older, B newer. const a = await createBuild(env.db, { projectId: project.id, @@ -1888,10 +1835,8 @@ describe('builds', () => { buildId: a.id, userId: user.id, projectId: project.id, - limit, - instanceType: 'standard-4', }), - ).toBe(true); + ).not.toBeNull(); expect((await getBuild(env.db, a.id))!.status).toBe('starting'); // 2. B is blocked: the project already has an active build (A is starting), @@ -1901,10 +1846,8 @@ describe('builds', () => { buildId: b.id, userId: user.id, projectId: project.id, - limit, - instanceType: 'standard-4', }), - ).toBe(false); + ).toBeNull(); expect((await getBuild(env.db, b.id))!.status).toBe('queued'); // 3. Once A reaches a terminal status, the project has no active build, so @@ -1917,10 +1860,8 @@ describe('builds', () => { buildId: b.id, userId: user.id, projectId: project.id, - limit, - instanceType: 'standard-4', }), - ).toBe(true); + ).not.toBeNull(); expect((await getBuild(env.db, b.id))!.status).toBe('starting'); }); @@ -1930,8 +1871,6 @@ describe('builds', () => { const user = await createTestUser(env.db, 'twoproj', 'github', undefined, 'pro'); const projectA = await createTestProject(env.db, user.id); const projectB = await createTestProject(env.db, user.id); - const limit = 3; - const a = await createBuild(env.db, { projectId: projectA.id, userId: user.id }); const b = await createBuild(env.db, { projectId: projectB.id, userId: user.id }); @@ -1940,37 +1879,23 @@ describe('builds', () => { buildId: a.id, userId: user.id, projectId: projectA.id, - limit, - instanceType: 'standard-4', }), - ).toBe(true); + ).not.toBeNull(); expect( await claimBuildForStartWithinLimit(env.db, { buildId: b.id, userId: user.id, projectId: projectB.id, - limit, - instanceType: 'standard-4', }), - ).toBe(true); + ).not.toBeNull(); expect((await getBuild(env.db, a.id))!.status).toBe('starting'); expect((await getBuild(env.db, b.id))!.status).toBe('starting'); }); it('claimBuildForStartWithinLimit persists the effective instance_type at claim time', async () => { - // Regression: the scheduler routes the container START on the effective - // instance_type (a paid/legacy-null row recomputes standard-4 and runs on - // BUILD_MANAGER_LARGE), but the claim used to SET only status/started_at, - // leaving instance_type NULL until onStart back-filled it. A cancel/reap in - // the START->onStart window then read the still-NULL value and addressed the - // WRONG (base) namespace, leaking the paid container. The claim must persist - // the effective size in the SAME tx so the size-of-record is correct the - // instant the row goes `starting`. const user = await createTestUser(env.db, 'claiminst', 'github', undefined, 'pro'); const project = await createTestProject(env.db, user.id); - // A legacy row with no persisted instance_type (queued before enqueue- - // persistence). The scheduler recomputes standard-4 from the paid plan. const build = await createBuild(env.db, { projectId: project.id, userId: user.id, @@ -1982,13 +1907,9 @@ describe('builds', () => { buildId: build.id, userId: user.id, projectId: project.id, - limit: 3, - instanceType: 'standard-4', }); - expect(claimed).toBe(true); + expect(claimed).toEqual({ instanceType: 'standard-4', ownerPlan: 'pro' }); - // The effective size is persisted at CLAIM time — before any onStart — so a - // cancel/reap in the start window addresses BUILD_MANAGER_LARGE, not base. const row = await getBuild(env.db, build.id); expect(row!.status).toBe('starting'); expect(row!.instance_type).toBe('standard-4'); @@ -2109,14 +2030,6 @@ describe('builds', () => { }); }); -// --------------------------------------------------------------------------- -// The GitHub Actions executor has no per-project active-build -// serialization (unlike the container scheduler's claimBuildForStartWithinLimit), -// so two DIFFERENT run_ids for one project+branch could both go active and race -// `void deploy`. Latest-wins via supersede: a newer run marks older active -// Actions builds terminal so they free the slot and can't race. -// --------------------------------------------------------------------------- - describe('createGithubActionsBuildWithinLimit', () => { it('rejects manual owner suspension but permits usage suspension', async () => { const user = await createTestUser(env.db, 'actions-suspended-owner'); @@ -2132,8 +2045,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const args = { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: 'suspended-owner-1', installationId: 'actions-suspended-owner', @@ -2202,8 +2113,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const args = { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: staleOwner.id, - limit: 3, branch: 'main', githubRunId: 'owner-race-1', installationId: 'actions-owner-race', @@ -2247,8 +2156,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const result = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, githubRunId: '9001', installationId: 'inst-wf', repositoryId: DEFAULT_REPO_ID, @@ -2272,8 +2179,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const first = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '1001', installationId: 'inst-supersede', @@ -2289,8 +2194,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const second = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '1002', installationId: 'inst-supersede', @@ -2326,8 +2229,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const first = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '2001', installationId: 'inst-reuse', @@ -2342,8 +2243,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const repeat = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '2001', installationId: 'inst-reuse', @@ -2390,8 +2289,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const onMain = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '3002', installationId: 'inst-branch', @@ -2420,8 +2317,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const first = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 1, branch: 'main', githubRunId: '4001', installationId: 'inst-cap', @@ -2438,8 +2333,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const second = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 1, branch: 'main', githubRunId: '4002', installationId: 'inst-cap', @@ -2465,10 +2358,8 @@ describe('createGithubActionsBuildWithinLimit', () => { const capped = await createGithubActionsBuildWithinLimit(env.db, { projectId: otherProject.id, connectionId: (await getGitHubConnection(env.db, otherProject.id))!.id, - userId: user.id, // Simulate a stale pro-plan snapshot; the locked DB owner is free and // already occupies its single slot. - limit: 3, branch: 'main', githubRunId: '4003', installationId: 'inst-cap-other', @@ -2491,8 +2382,6 @@ describe('createGithubActionsBuildWithinLimit', () => { // No connection is seeded, so any id is fine — the helper returns // `connection-removed` from the locked re-read before the id-match check. connectionId: 'ghc_absent', - userId: user.id, - limit: 3, branch: 'main', githubRunId: '5001', installationId: 'none', @@ -2524,8 +2413,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const result = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '5002', installationId: 'inst-container', @@ -2558,8 +2445,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const result = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'feature', githubRunId: '7001', installationId: 'inst-branch-mismatch', @@ -2590,8 +2475,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const result = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, githubRunId: '7002', installationId: 'inst-no-branch', repositoryId: DEFAULT_REPO_ID, @@ -2622,8 +2505,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const attempt1 = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '6001', githubRunAttempt: '1', @@ -2641,8 +2522,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const attempt2 = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '6001', githubRunAttempt: '2', @@ -2661,8 +2540,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const repeat = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '6001', githubRunAttempt: '2', @@ -2697,8 +2574,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const attempt1 = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '6101', githubRunAttempt: '1', @@ -2716,8 +2591,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const attempt2 = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '6101', githubRunAttempt: '2', @@ -2759,8 +2632,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const result = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '8001', installationId: 'inst-revoked-susp', @@ -2787,8 +2658,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const result = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '8002', installationId: 'inst-revoked-del', @@ -2820,8 +2689,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const result = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '8003', installationId: 'inst-revoked-sel', @@ -2853,8 +2720,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const result = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '8004', installationId: 'inst-revoked-ok', @@ -2904,8 +2769,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const replaced = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: cOld.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '9001', installationId: 'inst-replaced', @@ -2919,8 +2782,6 @@ describe('createGithubActionsBuildWithinLimit', () => { const ok = await createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, connectionId: cNew.id, - userId: user.id, - limit: 3, branch: 'main', githubRunId: '9001', installationId: 'inst-replaced', diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index 34c1092a1..9810b8ea4 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -424,6 +424,7 @@ describe('operator tenant mutations', () => { body: { projectId: project.id, userId: owner.id, + billingPeriodStart: expect.any(String), ownershipStartedAt: former.starts_at, ownershipEndedAt: current.starts_at, }, @@ -665,7 +666,7 @@ describe('operator tenant mutations', () => { } }); - test('reports post-commit counter failure and resumes convergence on retry', async () => { + test('resumes post-commit counter convergence after the former owner is deleted', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const owner = await createTestUser(env.db, 'partial-transfer-owner'); const target = await createTestUser(env.db, 'partial-transfer-target'); @@ -707,11 +708,26 @@ describe('operator tenant mutations', () => { }); expect(recoveryPreview.status).toBe(200); expect(await recoveryPreview.json()).toMatchObject({ + currentOwner: { id: target.id }, previousOwner: { id: owner.id }, newOwner: { id: target.id }, + changed: false, formerOwnerRole: 'admin', counterUpdates: { detachUserId: owner.id, attachUserId: target.id }, }); + expect((await req(`/users/${owner.id}/delete`, {})).status).toBe(200); + + const deletedOwnerPreview = await req(`/projects/${project.id}/owner`, { + userId: target.id, + dryRun: true, + }); + expect(deletedOwnerPreview.status).toBe(200); + expect(await deletedOwnerPreview.json()).toMatchObject({ + currentOwner: { id: target.id }, + previousOwner: { id: owner.id, deleted: true }, + newOwner: { id: target.id }, + changed: false, + }); const retryCalls: Array<{ ownerId: string; @@ -728,6 +744,10 @@ describe('operator tenant mutations', () => { changed: false, convergence: { countersComplete: true, routingComplete: true }, }); + expect(retryCalls.map(({ ownerId, path }) => ({ ownerId, path }))).toEqual([ + { ownerId: owner.id, path: '/detach-project' }, + { ownerId: target.id, path: '/attach-project' }, + ]); expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ userId: target.id }); }); @@ -893,7 +913,7 @@ describe('operator tenant mutations', () => { // while the authoritative project row already names the new owner. The // teardown lock must treat this as a stale snapshot and skip all cleanup. await env.db.update(projects).set({ user_id: newOwner.id }).where(eq(projects.id, project.id)); - vi.spyOn(dbModule, 'getProjectsByUserId').mockResolvedValue([staleProject!]); + vi.spyOn(dbModule, 'getOwnedProjectsByUserId').mockResolvedValue([staleProject!]); const cf = mockCfApi(); const response = await req(`/users/${formerOwner.id}/delete`, {}); diff --git a/platform/packages/api/test/integration/project-access.test.ts b/platform/packages/api/test/integration/project-access.test.ts index c6c3d2ead..2e6257f50 100644 --- a/platform/packages/api/test/integration/project-access.test.ts +++ b/platform/packages/api/test/integration/project-access.test.ts @@ -6,7 +6,6 @@ import { createOrRefreshProjectInvitation, createProject, getAccessibleProjectsByUserId, - getProjectMember, removeProjectMember, setProjectMemberRole, } from '../../src/db.ts'; @@ -48,7 +47,9 @@ describe('project access', () => { const access = await requireProjectCapability(env.db, project.id, owner.id, 'delete'); expect(access.role).toBe('owner'); expect(access.owner.id).toBe(owner.id); - expect(await getProjectMember(env.db, project.id, owner.id)).toBeNull(); + expect( + await env.db.select().from(projectMembers).where(eq(projectMembers.project_id, project.id)), + ).toEqual([]); }); it('enforces reader and collaborator capabilities using the owner for billing', async () => { diff --git a/platform/packages/api/test/integration/project-team-lifecycle.test.ts b/platform/packages/api/test/integration/project-team-lifecycle.test.ts index 99e4f079e..7e4e4ed23 100644 --- a/platform/packages/api/test/integration/project-team-lifecycle.test.ts +++ b/platform/packages/api/test/integration/project-team-lifecycle.test.ts @@ -12,7 +12,6 @@ import { getBuild, getGitHubConnection, getProject, - getProjectsByUserId, respondToProjectInvitation, } from '../../src/db.ts'; import { @@ -115,9 +114,6 @@ describe('project team lifecycle', () => { expect( (await getOwnedProjectsByUserId(env.db, member.id)).map((project) => project.id), ).toEqual([owned.id]); - expect((await getProjectsByUserId(env.db, member.id)).map((project) => project.id)).toEqual([ - owned.id, - ]); expect( (await getAccessibleProjectsByUserId(env.db, member.id)).map(({ project, role }) => ({ id: project.id, diff --git a/platform/packages/api/test/integration/scheduled.test.ts b/platform/packages/api/test/integration/scheduled.test.ts index 70af89a37..007bcecde 100644 --- a/platform/packages/api/test/integration/scheduled.test.ts +++ b/platform/packages/api/test/integration/scheduled.test.ts @@ -1,6 +1,7 @@ import { eq, sql } from 'drizzle-orm'; import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vite-plus/test'; import { scheduled } from '../../src/index.ts'; +import { transferOperatorProjectOwner } from '../../src/operator/tenant-service.ts'; import { builds, domains, projects } from '../../src/schema.ts'; import { setupEnv, @@ -115,15 +116,26 @@ describe('scheduled()', () => { }, ); - it('skips unlimited plan users', async () => { + it('rolls over unlimited plan counters before replacing their project set', async () => { const { id } = await createTestUser(env.db, 'sponsor', 'github', undefined, 'sponsored'); const proj = await createTestProject(env.db, id); await env.ROUTING.put(proj.slug, JSON.stringify({ worker: 'test' })); + const paths: Array = []; + const usageCounter = { + idFromName: (name: string) => name, + get: () => ({ + fetch: async (request: Request) => { + paths.push(new URL(request.url).pathname); + return new URL(request.url).pathname === '/rollover' + ? Response.json({ rolled: true, requestsSuspended: false }) + : Response.json({ ok: true }); + }, + }), + } as unknown as DurableObjectNamespace; - // Should complete without errors (skips unlimited plans for usage counting) - await scheduled(mockController, makeBindings(env), mockCtx); + await scheduled(mockController, { ...makeBindings(env), USAGE_COUNTER: usageCounter }, mockCtx); - // userId backfill still runs for all plans (needed for 5xx alerting) + expect(paths).toEqual(['/rollover', '/init']); const routing = JSON.parse((await env.ROUTING.get(proj.slug))!); expect(routing.userId).toBe(id); }); @@ -610,4 +622,45 @@ describe('scheduled()', () => { suspended: 'requests_exceeded', }); }); + + it('reconciles an empty project set after the last project transfers away', async () => { + const formerOwner = await createTestUser(env.db, 'empty-former-owner'); + const newOwner = await createTestUser(env.db, 'empty-current-owner'); + const operator = await createTestUser(env.db, 'empty-transfer-operator'); + const project = await createTestProject(env.db, formerOwner.id, 'empty-transferred'); + await transferOperatorProjectOwner( + { + env, + get: (key: string) => (key === 'db' ? env.db : operator), + } as never, + project.id, + newOwner.id, + ); + + const initBodies = new Map>(); + const usageCounter = { + idFromName: (name: string) => ({ toString: () => name }), + get: (id: { toString(): string }) => ({ + fetch: async (request: Request) => { + if (new URL(request.url).pathname === '/init') { + initBodies.set(id.toString(), await request.json>()); + return Response.json({ ok: true }); + } + return Response.json({ rolled: false, requestsSuspended: false }); + }, + }), + } as unknown as DurableObjectNamespace; + + await scheduled(mockController, { ...makeBindings(env), USAGE_COUNTER: usageCounter }, mockCtx); + + expect(initBodies.get(formerOwner.id)).toMatchObject({ + userId: formerOwner.id, + projectSlugs: {}, + projectOwnershipStarts: {}, + }); + expect(initBodies.get(newOwner.id)).toMatchObject({ + userId: newOwner.id, + projectSlugs: { [project.id]: project.slug }, + }); + }); }); diff --git a/platform/packages/api/test/unit/build-scheduler.test.ts b/platform/packages/api/test/unit/build-scheduler.test.ts index 42e4d13e5..25a2eef4d 100644 --- a/platform/packages/api/test/unit/build-scheduler.test.ts +++ b/platform/packages/api/test/unit/build-scheduler.test.ts @@ -59,6 +59,7 @@ const db = { unauthorizedConnectionIds: new Set(), // ids that should fail the atomic claim (simulate a lost race) unclaimable: new Set(), + claimPlanByBuild: new Map(), // ids a concurrent terminalizer (push-supersede / branch-executor change / // disconnect / install removal) already drove terminal while startBuild was in // flight. The guarded `requeueBuildIfStarting` CAS no-ops on these, so they are @@ -78,7 +79,6 @@ const db = { buildId: string; userId: string; projectId: string; - instanceType: string; }>, }; @@ -143,12 +143,12 @@ vi.mock('../../src/db.ts', () => ({ buildId: string; userId: string; projectId: string; - instanceType: string; }, ) => { db.claimArgs.push(args); const active = db.activeByUser.get(args.userId) ?? 0; - const plan = db.users.get(args.userId)?.plan ?? 'free'; + const plan = + db.claimPlanByBuild.get(args.buildId) ?? db.users.get(args.userId)?.plan ?? 'free'; const limit = plan === 'custom' ? 0 @@ -160,12 +160,17 @@ vi.mock('../../src/db.ts', () => ({ ? 2 : 1; if (limit > 0 && active >= limit) { - return false; + return null; } if (db.unclaimable.has(args.buildId)) { - return false; + return null; } - return true; + const build = db.queued.find((candidate) => candidate.id === args.buildId)!; + return { + instanceType: + build.instance_type ?? (plan === 'free' || plan === 'solo' ? 'standard-3' : 'standard-4'), + ownerPlan: plan, + }; }, ), getGitHubConnection: vi.fn(async (_db: unknown, projectId: string) => { @@ -300,6 +305,7 @@ function resetDb() { db.projects = new Map(); db.unauthorizedConnectionIds = new Set(); db.unclaimable = new Set(); + db.claimPlanByBuild = new Map(); db.terminalized = new Set(); db.failed = []; db.cancelled = []; @@ -353,6 +359,10 @@ describe('BuildScheduler.alarm', () => { branch: 'main', ...b, }; + if (build.instance_type === undefined) { + const plan = db.users.get(build.user_id)?.plan ?? 'free'; + build.instance_type = plan === 'free' || plan === 'solo' ? 'standard-3' : 'standard-4'; + } db.queued.push(build); if (!db.projects.has(build.project_id)) { db.projects.set(build.project_id, { slug: `slug-${build.project_id}` }); @@ -461,20 +471,14 @@ describe('BuildScheduler.alarm', () => { expect(cfg.login).toBe('octocat'); expect(cfg.repoFullName).toBe('owner/repo'); expect(cfg.installationId).toBe('inst1'); - // free plan → standard-3, 10 min expect(cfg.instanceType).toBe('standard-3'); expect(cfg.timeoutMinutes).toBe(10); - // BUILD_MANAGER keyed by build id. expect( (env as unknown as { BUILD_MANAGER: { idFromName: ReturnType } }).BUILD_MANAGER .idFromName, ).toHaveBeenCalledWith('b1'); - // the cap + claim went through the single atomic primitive holding the - // per-user FOR UPDATE lock; no stale plan-derived cap is passed in. expect(claimBuildForStartWithinLimit).toHaveBeenCalledOnce(); - expect(db.claimArgs).toEqual([ - { buildId: 'b1', userId: 'u1', projectId: 'p1', instanceType: 'standard-3' }, - ]); + expect(db.claimArgs).toEqual([{ buildId: 'b1', userId: 'u1', projectId: 'p1' }]); }); it('enforces per-user tier concurrency (free plan = 1)', async () => { @@ -488,22 +492,14 @@ describe('BuildScheduler.alarm', () => { await s.alarm(); - // At cap → the owner is excluded from the scan UP FRONT, so the - // capped build is never inspected (no claim attempt) and never started. The - // alarm still re-arms because queued work remains, picked up once u1 frees a - // slot. (Previously the build was inspected-then-skipped at claim time, - // wasting a scan slot — the exclude avoids that.) expect(getQueuedBuilds).toHaveBeenCalledWith(expect.anything(), expect.any(Number), ['u1']); expect(db.claimArgs).toEqual([]); expect(startBuild).not.toHaveBeenCalled(); expect(ctx.storage.setAlarm).toHaveBeenCalledOnce(); }); - // The scheduler starts a build only when the atomic cap+claim primitive - // returns true; false leaves it for a later tick. - it('starts only when claimBuildForStartWithinLimit returns true; leaves it otherwise', async () => { + it('starts only when claimBuildForStartWithinLimit returns a build', async () => { db.users.set('u1', { plan: 'pro', login: 'octocat' }); - // b1 will be claimed (under cap); b2 is forced unclaimable → primitive false. enqueue({ id: 'b1', user_id: 'u1', project_id: 'p1' }); enqueue({ id: 'b2', user_id: 'u1', project_id: 'p2' }); db.unclaimable.add('b2'); @@ -514,15 +510,12 @@ describe('BuildScheduler.alarm', () => { await s.alarm(); - // Both builds went through the atomic primitive (FIFO scan preserved). expect(db.claimArgs).toEqual([ - { buildId: 'b1', userId: 'u1', projectId: 'p1', instanceType: 'standard-4' }, - { buildId: 'b2', userId: 'u1', projectId: 'p2', instanceType: 'standard-4' }, + { buildId: 'b1', userId: 'u1', projectId: 'p1' }, + { buildId: 'b2', userId: 'u1', projectId: 'p2' }, ]); - // Only the claimed build (b1) was started. expect(startBuild).toHaveBeenCalledOnce(); expect((startBuild.mock.calls[0][0] as { buildId: string }).buildId).toBe('b1'); - // b2 was neither started nor failed — left queued for a later tick. expect(db.failed.some((f) => f.id === 'b2')).toBe(false); }); @@ -610,12 +603,10 @@ describe('BuildScheduler.alarm', () => { expect(env.BUILD_MANAGER.idFromName).not.toHaveBeenCalled(); }); - it('recomputes from the plan for a legacy null instance_type row (paid plan → LARGE)', async () => { - // A row queued before enqueue-persistence has instance_type === null. START - // falls back to a plan recompute; a paid plan recomputes standard-4 and routes - // LARGE (and the config carries standard-4 so onStart's CAS back-fills it). - db.users.set('u1', { plan: 'pro', login: 'octocat' }); + it('uses the locked owner plan for a legacy row when the earlier plan snapshot is stale', async () => { + db.users.set('u1', { plan: 'free', login: 'octocat' }); enqueue({ id: 'b1', user_id: 'u1', project_id: 'p1', instance_type: null }); + db.claimPlanByBuild.set('b1', 'pro'); const startBuild = vi.fn(async (_cfg: unknown) => {}); const ctx = makeStubCtx(); const env = makeEnv(startBuild) as unknown as { @@ -627,26 +618,16 @@ describe('BuildScheduler.alarm', () => { await s.alarm(); expect(startBuild).toHaveBeenCalledOnce(); - const cfg = startBuild.mock.calls[0][0] as unknown as { instanceType: string }; + const cfg = startBuild.mock.calls[0][0] as unknown as { + instanceType: string; + timeoutMinutes: number; + }; expect(cfg.instanceType).toBe('standard-4'); + expect(cfg.timeoutMinutes).toBe(15); expect(env.BUILD_MANAGER_LARGE.idFromName).toHaveBeenCalledWith('b1'); expect(env.BUILD_MANAGER.idFromName).not.toHaveBeenCalled(); }); - it('skips a build whose atomic claim is lost (primitive returns false)', async () => { - db.users.set('u1', { plan: 'free', login: 'octocat' }); - enqueue({ id: 'b1', user_id: 'u1', project_id: 'p1' }); - db.unclaimable.add('b1'); - const startBuild = vi.fn(async () => {}); - const ctx = makeStubCtx(); - const env = makeEnv(startBuild); - const s = makeScheduler(ctx, env); - - await s.alarm(); - - expect(startBuild).not.toHaveBeenCalled(); - }); - it('fails a build with a missing user and continues', async () => { // no user registered for u-missing enqueue({ id: 'b1', user_id: 'u-missing', project_id: 'p1' }); @@ -733,9 +714,7 @@ describe('BuildScheduler.alarm', () => { // Only the eligible owner's build was claimed + started; the capped owner's // build was never even inspected (no claim attempt for b1). - expect(db.claimArgs).toEqual([ - { buildId: 'b2', userId: 'u2', projectId: 'p2', instanceType: 'standard-3' }, - ]); + expect(db.claimArgs).toEqual([{ buildId: 'b2', userId: 'u2', projectId: 'p2' }]); expect(startBuild).toHaveBeenCalledOnce(); expect((startBuild.mock.calls[0][0] as { buildId: string }).buildId).toBe('b2'); }); @@ -877,9 +856,7 @@ describe('BuildScheduler.alarm', () => { expect(base).toHaveBeenCalledOnce(); expect((base.mock.calls[0][0] as { buildId: string }).buildId).toBe('b1'); // Only b1 was claimed; b2 (same saturated fleet) was never claimed or started. - expect(db.claimArgs).toEqual([ - { buildId: 'b1', userId: 'uf', projectId: 'p1', instanceType: 'standard-3' }, - ]); + expect(db.claimArgs).toEqual([{ buildId: 'b1', userId: 'uf', projectId: 'p1' }]); // Only b1 (the claimed row) was requeued; b2 stays queued; nothing dropped. expect(db.requeued).toEqual(['b1']); expect(db.failed).toEqual([]); diff --git a/platform/packages/api/test/unit/project-access.test.ts b/platform/packages/api/test/unit/project-access.test.ts deleted file mode 100644 index cd0f5398b..000000000 --- a/platform/packages/api/test/unit/project-access.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test'; -import { hasProjectCapability, PROJECT_ROLE_CAPABILITIES } from '../../src/project-access.ts'; -import type { ProjectCapability, ProjectRole } from '../../src/types.ts'; - -const capabilities: ReadonlyArray = [ - 'read', - 'deploy', - 'configure', - 'settings', - 'members', - 'delete', -]; - -describe('project role capabilities', () => { - const expected: Record> = { - reader: ['read'], - collaborator: ['read', 'deploy', 'configure'], - admin: ['read', 'deploy', 'configure', 'settings', 'members'], - owner: ['read', 'deploy', 'configure', 'settings', 'members', 'delete'], - }; - - for (const role of Object.keys(expected) as Array) { - it(`${role} has only its explicit capabilities`, () => { - expect(PROJECT_ROLE_CAPABILITIES[role]).toEqual(expected[role]); - for (const capability of capabilities) { - expect(hasProjectCapability(role, capability)).toBe(expected[role].includes(capability)); - } - }); - } -}); diff --git a/platform/packages/api/test/unit/routing.test.ts b/platform/packages/api/test/unit/routing.test.ts index 512006b47..967e5c7dc 100644 --- a/platform/packages/api/test/unit/routing.test.ts +++ b/platform/packages/api/test/unit/routing.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vite-plus/test'; +import * as dbModule from '../../src/db.ts'; import { applyRoutingSuspension, buildProjectAccountJson, @@ -32,7 +33,13 @@ function createKvStore(initial: Record = {}): { return { store, puts, kv }; } -function createRoutingDb(): Parameters[1] { +function createRoutingDb(ownerId?: string): Parameters[1] { + if (ownerId) { + vi.spyOn(dbModule, 'getProject').mockResolvedValue({ + id: 'proj_a', + user_id: ownerId, + } as Awaited>); + } return { transaction: async (fn: (tx: { execute: () => Promise }) => Promise) => fn({ execute: async () => {} }), @@ -385,12 +392,21 @@ describe('setProjectsRoutingSuspension', () => { }, }), } as unknown as Parameters[1]; + vi.spyOn(dbModule, 'getProject').mockResolvedValue({ + id: 'proj_a', + user_id: 'owner_a', + } as Awaited>); - await setProjectsRoutingSuspension(kv, db, [{ id: 'proj_a', slug: 'slug-a' }], { - op: 'suspend', - reason: 'admin_suspended', - force: true, - }); + await setProjectsRoutingSuspension( + kv, + db, + [{ id: 'proj_a', slug: 'slug-a', user_id: 'owner_a' }], + { + op: 'suspend', + reason: 'admin_suspended', + force: true, + }, + ); expect(events).toEqual(['lock-project', 'read-domains']); vi.restoreAllMocks(); @@ -420,12 +436,17 @@ describe('setProjectsRoutingSuspension', () => { ], ]) as unknown as Awaited>, ); - const db = createRoutingDb(); - await setProjectsRoutingSuspension(kv, db, [{ id: 'proj_a', slug: 'slug-a' }], { - op: 'suspend', - reason: 'requests_exceeded', - force: false, - }); + const db = createRoutingDb('owner_a'); + await setProjectsRoutingSuspension( + kv, + db, + [{ id: 'proj_a', slug: 'slug-a', user_id: 'owner_a' }], + { + op: 'suspend', + reason: 'requests_exceeded', + force: false, + }, + ); expect(JSON.parse(store.get('slug-a')!).suspended).toBe('requests_exceeded'); expect(JSON.parse(store.get('domain:active.com')!).suspended).toBe('requests_exceeded'); expect(JSON.parse(store.get('domain:crash-pending.com')!).suspended).toBe('requests_exceeded'); @@ -443,11 +464,16 @@ describe('setProjectsRoutingSuspension', () => { ['proj_a', [{ project_id: 'proj_a', hostname: 'active.com', status: 'active' }]], ]) as unknown as Awaited>, ); - const db = createRoutingDb(); - await setProjectsRoutingSuspension(kv, db, [{ id: 'proj_a', slug: 'slug-a' }], { - op: 'unsuspend', - matchReason: null, - }); + const db = createRoutingDb('owner_a'); + await setProjectsRoutingSuspension( + kv, + db, + [{ id: 'proj_a', slug: 'slug-a', user_id: 'owner_a' }], + { + op: 'unsuspend', + matchReason: null, + }, + ); expect(JSON.parse(store.get('slug-a')!).suspended).toBeUndefined(); expect(JSON.parse(store.get('domain:active.com')!).suspended).toBeUndefined(); vi.restoreAllMocks(); @@ -463,11 +489,16 @@ describe('setProjectsRoutingSuspension', () => { ['proj_a', [{ project_id: 'proj_a', hostname: 'active.com', status: 'active' }]], ]) as unknown as Awaited>, ); - const db = createRoutingDb(); - await setProjectsRoutingSuspension(kv, db, [{ id: 'proj_a', slug: 'slug-a' }], { - op: 'unsuspend', - matchReason: 'requests_exceeded', - }); + const db = createRoutingDb('owner_a'); + await setProjectsRoutingSuspension( + kv, + db, + [{ id: 'proj_a', slug: 'slug-a', user_id: 'owner_a' }], + { + op: 'unsuspend', + matchReason: 'requests_exceeded', + }, + ); expect(JSON.parse(store.get('slug-a')!).suspended).toBe('admin_suspended'); expect(JSON.parse(store.get('domain:active.com')!).suspended).toBe('admin_suspended'); vi.restoreAllMocks(); diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index bb8dba326..4b4b30d9d 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -42,6 +42,7 @@ import { getProject, getProjectOwnershipPeriodsByProjectIds, getProjectsByIds, + getUser, suspendUser, } from '../../src/db.ts'; import { queryProjectAiUsage } from '../../src/routes/usage.ts'; @@ -1109,6 +1110,15 @@ describe('UsageCounter ownership changes', () => { beforeEach(() => { vi.mocked(queryProjectAiUsage).mockReset().mockResolvedValue({ neurons: 0 }); vi.mocked(getProjectOwnershipPeriodsByProjectIds).mockReset().mockResolvedValue([]); + vi.mocked(getUser) + .mockReset() + .mockImplementation( + async (_db, id) => + ({ + id, + plan: 'pro', + }) as never, + ); }); it('bills a delayed former-owner event without reattaching the project', async () => { @@ -1178,6 +1188,28 @@ describe('UsageCounter ownership changes', () => { expect(kv.calls).toContainEqual(['delete', 'ai-block:next']); }); + it('does not attach a project after deletion has started', async () => { + vi.mocked(getProjectsByIds).mockResolvedValueOnce([ + { id: 'deleting', user_id: 'u1', slug: 'app', status: 'deleting' } as never, + ]); + const { counter, storage } = makeCounter(); + + const response = await counter.fetch( + new Request('https://do/attach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'deleting', + slug: 'app', + ownershipStartedAt: '2026-01-01T00:00:00.000Z', + userId: 'u1', + }), + }), + ); + + expect(response.status).toBe(409); + expect(storage.data.get('state')).toBeUndefined(); + }); + it('reconciles the complete pre-transfer AI window and resamples it idempotently', async () => { const ownershipStartedAt = '2026-02-01T00:00:00.000Z'; const ownershipEndedAt = '2026-03-01T00:00:00.000Z'; @@ -1241,6 +1273,50 @@ describe('UsageCounter ownership changes', () => { }); }); + it('initializes a fresh counter from the former owner billing window before detaching', async () => { + const billingPeriodStart = '2026-01-15T00:00:00.000Z'; + const ownershipStartedAt = '2026-02-01T00:00:00.000Z'; + const ownershipEndedAt = '2026-03-01T00:00:00.000Z'; + vi.mocked(getUser).mockResolvedValueOnce({ id: 'former-owner', plan: 'custom' } as never); + vi.mocked(queryProjectAiUsage).mockResolvedValueOnce({ neurons: 57 }); + const { counter, storage } = makeCounter(); + + const response = await counter.fetch( + new Request('https://do/detach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'transferred', + userId: 'former-owner', + billingPeriodStart, + ownershipStartedAt, + ownershipEndedAt, + }), + }), + ); + + expect(response.ok).toBe(true); + expect(queryProjectAiUsage).toHaveBeenCalledWith( + undefined, + undefined, + 'void', + 'transferred', + new Date(ownershipStartedAt), + new Date(ownershipEndedAt), + true, + ); + expect(storage.data.get('state')).toMatchObject({ + billingPeriodStart, + billingPeriodInitialized: true, + plan: 'custom', + totalNeurons: 57, + historicalAiWindows: { + [JSON.stringify(['transferred', ownershipStartedAt, ownershipEndedAt])]: { + neurons: 57, + }, + }, + }); + }); + it('keeps current ownership state retryable when cutoff reconciliation fails', async () => { vi.mocked(queryProjectAiUsage).mockRejectedValueOnce(new Error('analytics unavailable')); const { counter, storage } = makeCounter({ diff --git a/platform/packages/api/test/unit/usage-queue.test.ts b/platform/packages/api/test/unit/usage-queue.test.ts index a20329481..33bb88eef 100644 --- a/platform/packages/api/test/unit/usage-queue.test.ts +++ b/platform/packages/api/test/unit/usage-queue.test.ts @@ -322,6 +322,41 @@ describe('processUsageEventBatch — trusted provenance', () => { expect(batch.calls.ackAll).toBe(1); }); + it('bills queued usage without reattaching a project being deleted', async () => { + const stubFetch = vi.fn(async () => new Response(null, { status: 200 })); + getProjectsByIdsMock.mockResolvedValue([ + { + id: 'p1', + user_id: 'owner', + slug: 'canonical-slug', + status: 'deleting', + created_at: '2026-01-01T00:00:00.000Z', + }, + ]); + getProjectOwnershipPeriodsByProjectIdsMock.mockResolvedValue([ + { + id: 'period', + project_id: 'p1', + owner_user_id: 'owner', + starts_at: '2026-01-01T00:00:00.000Z', + ends_at: null, + changed_by_user_id: null, + }, + ]); + getUserMock.mockResolvedValue({ id: 'owner', plan: 'solo' }); + const batch = await makeSignedBatch([ + { projectId: 'p1', requests: 1, responseBytes: 12 } as UsageEvent, + ]); + + await processUsageEventBatch(batch as never, makeEnv(stubFetch) as never); + + const payload = (await fetchCalls(stubFetch)[0][0].json()) as { + events: Array; + }; + expect(payload.events[0]).toMatchObject({ userId: 'owner', currentOwner: false }); + expect(batch.calls.ackAll).toBe(1); + }); + it('rejects a correctly signed event with invalid quantities', async () => { const stubFetch = vi.fn(async () => new Response(null, { status: 200 })); const invalid = { From 8740999b611c5ffc6b95f41a2a93fa86e068bf48 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 18 Sep 2026 15:06:48 +0800 Subject: [PATCH 07/18] fix(platform): tighten team ownership convergence --- docs/reference/cli.md | 2 +- packages/void/src/cli/client.ts | 16 +-- packages/void/src/cli/project-args.ts | 2 +- .../test/unit/project-team-client.test.ts | 17 +--- .../meta/design-docs/0080-project-teams.md | 29 +++--- .../api/src/admin/routes/projects.tsx | 71 +++++--------- platform/packages/api/src/db.ts | 2 +- platform/packages/api/src/middleware/auth.ts | 8 +- platform/packages/api/src/operator/common.ts | 10 +- .../api/src/operator/tenant-service.ts | 97 ++++++++++++------- .../packages/api/src/routes/project-team.ts | 47 ++------- platform/packages/api/src/routes/projects.ts | 14 +-- platform/packages/api/src/routes/webhooks.ts | 1 - .../test/integration/admin-dashboard.test.ts | 5 + .../api/test/integration/api-github.test.ts | 1 - .../test/integration/operator-tenants.test.ts | 13 ++- .../api/test/unit/usage-counter.test.ts | 2 +- 17 files changed, 150 insertions(+), 187 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f350bafab..75d69bf46 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -249,7 +249,7 @@ void project team decline 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. -Readers can view the project but cannot deploy. Collaborators can deploy and manage deploy prerequisites. Project administrators can additionally configure the project and manage its team. The owner alone can delete the project. See [Project Collaboration](../guide/project-collaboration.md) for the full role boundaries. +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. Project-scoped team management commands are not available for projects deployed directly to Cloudflare. The account-scoped `pending`, `accept`, and `decline` diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 1a368c910..5b6fb33af 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -732,7 +732,7 @@ export class PlatformClient { return res.json(); } - async removeProjectMember(projectId: string, userId: string): Promise<{ ok: boolean }> { + async removeProjectMember(projectId: string, userId: string): Promise { const res = await this.request( `${this.baseUrl}/projects/${projectId}/members/${encodeURIComponent(userId)}`, { method: 'DELETE', headers: { ...this.authHeaders } }, @@ -740,10 +740,9 @@ export class PlatformClient { if (!res.ok) { await throwPlatformApiError('Failed to remove project member', res); } - return { ok: true }; } - async revokeProjectInvitation(projectId: string, invitationId: string): Promise<{ ok: boolean }> { + async revokeProjectInvitation(projectId: string, invitationId: string): Promise { const res = await this.request( `${this.baseUrl}/projects/${projectId}/invitations/${encodeURIComponent(invitationId)}`, { method: 'DELETE', headers: { ...this.authHeaders } }, @@ -751,10 +750,9 @@ export class PlatformClient { if (!res.ok) { await throwPlatformApiError('Failed to revoke project invitation', res); } - return { ok: true }; } - async leaveProject(projectId: string): Promise<{ ok: boolean }> { + async leaveProject(projectId: string): Promise { const res = await this.request(`${this.baseUrl}/projects/${projectId}/leave`, { method: 'POST', headers: { ...this.authHeaders }, @@ -762,7 +760,6 @@ export class PlatformClient { if (!res.ok) { await throwPlatformApiError('Failed to leave project', res); } - return { ok: true }; } async listPendingProjectInvitations(): Promise> { @@ -779,7 +776,7 @@ export class PlatformClient { async respondToProjectInvitation( invitationId: string, action: 'accept' | 'decline', - ): Promise<{ ok: true; member?: ProjectMemberResponse }> { + ): Promise { const res = await this.request( `${this.baseUrl}/project-invitations/${encodeURIComponent(invitationId)}/${action}`, { method: 'POST', headers: { ...this.authHeaders } }, @@ -787,11 +784,6 @@ export class PlatformClient { if (!res.ok) { await throwPlatformApiError(`Failed to ${action} project invitation`, res); } - if (action === 'decline' || res.status === 204) { - return { ok: true }; - } - const body = (await res.json()) as { member: ProjectMemberResponse }; - return { ok: true, member: body.member }; } async listDeployments(projectId: string): Promise { diff --git a/packages/void/src/cli/project-args.ts b/packages/void/src/cli/project-args.ts index 38d8a5a08..cc87f679a 100644 --- a/packages/void/src/cli/project-args.ts +++ b/packages/void/src/cli/project-args.ts @@ -7,7 +7,7 @@ const LOG_LEVELS = new Set(['error', 'warn', 'info', 'log', 'debug', 'all']); const STATUS_FILTER = /^([1-5]xx|[1-5]\d{2}|all)$/; const TEAM_ROLES = new Set(['reader', 'collaborator', 'admin']); -export type TeamRole = 'reader' | 'collaborator' | 'admin'; +type TeamRole = 'reader' | 'collaborator' | 'admin'; export const projectSubcommands: Array = [ { name: 'status', arg: '[name]', description: 'Show deployments (omit name for linked project)' }, diff --git a/packages/void/test/unit/project-team-client.test.ts b/packages/void/test/unit/project-team-client.test.ts index 395ad97d6..3a7400851 100644 --- a/packages/void/test/unit/project-team-client.test.ts +++ b/packages/void/test/unit/project-team-client.test.ts @@ -80,10 +80,8 @@ describe('PlatformClient project teams', () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); - await expect(client().revokeProjectInvitation(projectId, 'invite / 1')).resolves.toEqual({ - ok: true, - }); - await expect(client().leaveProject(projectId)).resolves.toEqual({ ok: true }); + await client().revokeProjectInvitation(projectId, 'invite / 1'); + await client().leaveProject(projectId); expect(request(0)[0]).toBe(`${apiUrl}/projects/${projectId}/invitations/invite%20%2F%201`); expect(request(1)[0]).toBe(`${apiUrl}/projects/${projectId}/leave`); }); @@ -95,15 +93,8 @@ describe('PlatformClient project teams', () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); await expect(client().listPendingProjectInvitations()).resolves.toEqual([invitation]); - await expect( - client().respondToProjectInvitation('invite / 1', 'accept'), - ).resolves.toMatchObject({ - ok: true, - member: { role: 'reader' }, - }); - await expect(client().respondToProjectInvitation('invite / 1', 'decline')).resolves.toEqual({ - ok: true, - }); + await client().respondToProjectInvitation('invite / 1', 'accept'); + await client().respondToProjectInvitation('invite / 1', 'decline'); expect(request(0)[0]).toBe(`${apiUrl}/me/project-invitations`); expect(request(1)[0]).toBe(`${apiUrl}/project-invitations/invite%20%2F%201/accept`); expect(request(2)[0]).toBe(`${apiUrl}/project-invitations/invite%20%2F%201/decline`); diff --git a/platform/meta/design-docs/0080-project-teams.md b/platform/meta/design-docs/0080-project-teams.md index 7b740eccf..503653c03 100644 --- a/platform/meta/design-docs/0080-project-teams.md +++ b/platform/meta/design-docs/0080-project-teams.md @@ -299,15 +299,17 @@ credentials retain their build-state check. invitation accepted, and records the access event. Decline follows the same target-account check without creating membership. -The role is always read from the database invitation. Acceptance is single-use -and idempotent for its target user. Forwarding or guessing an invitation ID -does not grant access to another account. A project invitation never creates an -account, bypasses restricted signup, or adds a platform allowlist entry. - -An invitation expires after seven days by default. Revoking it prevents later +The role is always read from the database invitation. Acceptance is single-use. +Retrying it as the target user returns the existing membership while that +membership remains; after removal, the old invitation is rejected and a new +invitation is required. Forwarding or guessing an invitation ID does not grant +access to another account. A project invitation never creates an account, +bypasses restricted signup, or adds a platform allowlist entry. + +An invitation expires after seven days. Revoking it prevents later acceptance. Resend uses the target account's current registered email, keeps the -same account binding, and follows the platform email rate limiter and a bounded -pending-invitation limit. +same account binding, and follows the per-project invitation email rate limiter +and a bounded pending-invitation limit. ## API and user experience @@ -328,8 +330,10 @@ POST /project-invitations/:invitationId/decline ``` Member listings are visible to all project roles. Invitation addresses and -mutation endpoints require `members`. `PATCH` accepts only a role. The owner is -returned in the member list with role `owner`, but owner mutations return 409. +project-management mutations require `members`. Leaving requires current +project access, while accepting or declining requires the authenticated target +account. `PATCH` accepts only a role. The owner is returned in the member list +with role `owner`, but owner mutations return 409. ### CLI @@ -400,12 +404,15 @@ The operator transfer performs these steps: 1. In one database transaction, lock the project then both users in stable ID order; reject active lifecycle work; close/open the ownership periods; update `projects.user_id`; remove the target's membership; add the former - owner as project admin; and record the event. + owner as project admin. 2. Detach/attach the UsageCounter project mapping. 3. Republish the project-account and active routing entries with the new owner ID, plan limits, and suspension state. Usage ingestion already uses the database period, so stale producer hints cannot misbill requests. +The surrounding operator audit records the ownership mutation separately from +the ownership transaction. + If a post-commit external update fails, the database transfer remains committed and the operator response identifies the partial state. A repeat apply resumes the idempotent convergence steps. Application requests continue to be metered diff --git a/platform/packages/api/src/admin/routes/projects.tsx b/platform/packages/api/src/admin/routes/projects.tsx index 5c1110bbd..56b17c0af 100644 --- a/platform/packages/api/src/admin/routes/projects.tsx +++ b/platform/packages/api/src/admin/routes/projects.tsx @@ -7,6 +7,7 @@ import { OperatorError } from '../../operator/common.ts'; import { deleteOperatorProject, getOperatorProject, + previewOperatorProjectOwner, transferOperatorProjectOwner, } from '../../operator/tenant-service.ts'; import { projectUrl, projectWithUrl } from '../../project-url.ts'; @@ -20,35 +21,6 @@ const PAGE_SIZE = 20; const app = new Hono(); app.onError(renderOperationError); -type OwnerSummary = Readonly<{ - id: string; - login: string; - email: string | null; - plan: string; - suspended: boolean; - suspendedReason: string | null; -}>; - -type OwnerTransferPreview = Readonly<{ - target: { id: string; slug: string; userId: string }; - currentOwner: OwnerSummary; - previousOwner: OwnerSummary | { id: string; deleted: true }; - newOwner: OwnerSummary; - changed: boolean; - formerOwnerRole: 'admin' | null; - targetPreviousRole: string | null; - blockers: ReadonlyArray<{ kind: string; id: string; status: string }>; - github: { - installationId: string; - repository: string; - authorizedByUserId: string | null; - executor: string; - } | null; - routingUpdates: ReadonlyArray; - counterUpdates: { detachUserId: string | null; attachUserId: string }; - usageTransfer: string; -}>; - function transferTarget(body: Record) { const value = body.userId; if (typeof value !== 'string' || !value.trim() || value.length > 512) { @@ -57,7 +29,7 @@ function transferTarget(body: Record) { return value.trim(); } -function suspensionLabel(owner: OwnerSummary) { +function suspensionLabel(owner: { suspended: boolean; suspendedReason: string | null }) { return owner.suspended ? `Suspended (${owner.suspendedReason ?? 'unknown reason'})` : 'Active'; } @@ -254,13 +226,11 @@ app.post('/:id/owner/preview', async (context) => { const projectId = context.req.param('id'); const body = await context.req.parseBody(); const userId = transferTarget(body); - const result = (await transferOperatorProjectOwner( - context, - projectId, - userId, - true, - )) as unknown as OwnerTransferPreview; + const result = await previewOperatorProjectOwner(context, projectId, userId); const convergenceOnly = !result.changed; + const operationBlocked = result.blockers.some( + (blocker) => !convergenceOnly || blocker.kind === 'deletion', + ); return context.html( {

)} -

Active-work blockers

+

Operation blockers

{result.blockers.length > 0 ? ( -
    - {result.blockers.map((blocker) => ( -
  • - {blocker.kind}: {blocker.id} ({blocker.status}) -
  • - ))} -
+ <> +
    + {result.blockers.map((blocker) => ( +
  • + {blocker.kind}: {blocker.id} ({blocker.status}) +
  • + ))} +
+ {convergenceOnly && !operationBlocked ? ( +

Active work does not block convergence.

+ ) : null} + ) : (

None.

)} @@ -354,7 +329,7 @@ app.post('/:id/owner/preview', async (context) => {

No GitHub connection.

)} - {result.blockers.length === 0 ? ( + {!operationBlocked ? (
) : ( -

Resolve or cancel all active work before applying this transfer.

+

+ {convergenceOnly + ? 'Project deletion blocks convergence.' + : 'Resolve or cancel all active work before applying this transfer.'} +

)}

Back to project diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 7cf54ab4e..d81956530 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -4483,7 +4483,7 @@ export async function getBuildByProjectRunIdAndAttempt( * prior status) so the caller can best-effort stop active container BuildManager * DOs. */ -export type TerminatedBuild = Readonly<{ +type TerminatedBuild = Readonly<{ id: string; executor: Build['executor']; status: Build['status']; diff --git a/platform/packages/api/src/middleware/auth.ts b/platform/packages/api/src/middleware/auth.ts index 044dc3468..db6997ca1 100644 --- a/platform/packages/api/src/middleware/auth.ts +++ b/platform/packages/api/src/middleware/auth.ts @@ -9,10 +9,10 @@ import { isAdminSuspended } from '../suspension.ts'; /** * Endpoints a scoped (`aud:'deploy'`) token may call. A deploy token - * authenticates as the project owner but is minted for one purpose only — - * running the real `void deploy` CLI against the single project named in - * `payload.project`. Without this allowlist the token would satisfy the - * owner-identity check on EVERY route mounted under `/projects/*` (delete, + * authenticates as its initiating project member but is minted for one purpose + * only — running the real `void deploy` CLI against the single project named in + * `payload.project`. Without this allowlist the token could reach every route + * mounted under `/projects/*` (delete, * secrets PUT, github connect, domains, rollback, cancel), so a leaked * 15-minute CI token could take over the project. Default-deny: anything not * listed here is rejected with 403. diff --git a/platform/packages/api/src/operator/common.ts b/platform/packages/api/src/operator/common.ts index ae0e7fded..2de98f94b 100644 --- a/platform/packages/api/src/operator/common.ts +++ b/platform/packages/api/src/operator/common.ts @@ -90,10 +90,10 @@ export function dryRun(body: Record): boolean { return body.dryRun === true; } -export function preview( - action: string, - target: string | Record, - details: Record = {}, -) { +export function preview< + const Action extends string, + const Target extends string | Record, + const Details extends Record = Record, +>(action: Action, target: Target, details: Details = {} as Details) { return { ...details, dryRun: true as const, action, target }; } diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 51ba1cff7..6511a6240 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -665,47 +665,50 @@ async function reconcileOperatorUserProjects( } } +export async function previewOperatorProjectOwner( + context: Context, + projectId: string, + targetUserId: string, +) { + const inspection = await inspectOwnershipTransfer(context.get('db'), projectId, targetUserId); + return preview( + 'project.owner', + { id: inspection.project.id, slug: inspection.project.slug, userId: targetUserId }, + { + currentOwner: accountSummary(inspection.currentOwner), + previousOwner: historicalAccountSummary(inspection.previousOwner, inspection.previousOwnerId), + newOwner: accountSummary(inspection.newOwner), + changed: inspection.currentOwner.id !== inspection.newOwner.id, + formerOwnerRole: inspection.previousOwnerId === inspection.newOwner.id ? null : 'admin', + targetPreviousRole: inspection.targetMembership?.role ?? null, + blockers: inspection.blockers, + github: inspection.github, + routingUpdates: [ + inspection.project.slug, + ...inspection.domains.map((domain) => `domain:${domain.hostname}`), + projectAccountKey(inspection.project.id), + ], + counterUpdates: { + detachUserId: + inspection.previousOwnerId === inspection.newOwner.id ? null : inspection.previousOwnerId, + attachUserId: inspection.newOwner.id, + }, + usageTransfer: 'non-retroactive', + }, + ); +} + export async function transferOperatorProjectOwner( context: Context, projectId: string, targetUserId: string, dryRun = false, ) { - const db = context.get('db'); if (dryRun) { - const inspection = await inspectOwnershipTransfer(db, projectId, targetUserId); - return preview( - 'project.owner', - { id: inspection.project.id, slug: inspection.project.slug, userId: targetUserId }, - { - currentOwner: accountSummary(inspection.currentOwner), - previousOwner: historicalAccountSummary( - inspection.previousOwner, - inspection.previousOwnerId, - ), - newOwner: accountSummary(inspection.newOwner), - changed: inspection.currentOwner.id !== inspection.newOwner.id, - formerOwnerRole: inspection.previousOwnerId === inspection.newOwner.id ? null : 'admin', - targetPreviousRole: inspection.targetMembership?.role ?? null, - blockers: inspection.blockers, - github: inspection.github, - routingUpdates: [ - inspection.project.slug, - ...inspection.domains.map((domain) => `domain:${domain.hostname}`), - projectAccountKey(inspection.project.id), - ], - counterUpdates: { - detachUserId: - inspection.previousOwnerId === inspection.newOwner.id - ? null - : inspection.previousOwnerId, - attachUserId: inspection.newOwner.id, - }, - usageTransfer: 'non-retroactive', - }, - ); + return previewOperatorProjectOwner(context, projectId, targetUserId); } + const db = context.get('db'); const transfer = await commitOwnershipTransfer( db, projectId, @@ -714,13 +717,33 @@ export async function transferOperatorProjectOwner( ); const convergence = { countersComplete: false, routingComplete: false }; let convergedOwner = transfer.newOwner; - try { - await updateOwnershipCounters(context, transfer); + // Publish immediately even if counter reconciliation fails, then repeat after + // it settles so a concurrent plan or suspension change wins in the final state. + const [counterResult, earlyRoutingResult] = await Promise.allSettled([ + updateOwnershipCounters(context, transfer), + republishTransferredProject(context, transfer), + ]); + if (counterResult.status === 'fulfilled') { convergence.countersComplete = true; - convergedOwner = await republishTransferredProject(context, transfer); + } + if (earlyRoutingResult.status === 'fulfilled') { + convergedOwner = earlyRoutingResult.value; + } + const [finalRoutingResult] = await Promise.allSettled([ + republishTransferredProject(context, transfer), + ] as const); + if (finalRoutingResult.status === 'fulfilled') { + convergedOwner = finalRoutingResult.value; convergence.routingComplete = true; - } catch (error) { - console.error('[admin] project owner convergence failed', { projectId, targetUserId, error }); + } + if (counterResult.status === 'rejected' || finalRoutingResult.status === 'rejected') { + console.error('[admin] project owner convergence failed', { + projectId, + targetUserId, + counterError: counterResult.status === 'rejected' ? counterResult.reason : undefined, + routingError: + finalRoutingResult.status === 'rejected' ? finalRoutingResult.reason : undefined, + }); throw new OperatorError( 500, 'Project ownership changed, but external accounting or routing convergence is incomplete. Inspect the project and retry this ownership transfer.', diff --git a/platform/packages/api/src/routes/project-team.ts b/platform/packages/api/src/routes/project-team.ts index cb4650400..c4c1cae40 100644 --- a/platform/packages/api/src/routes/project-team.ts +++ b/platform/packages/api/src/routes/project-team.ts @@ -17,12 +17,6 @@ import { import { sendProjectInvitationEmail } from '../email.ts'; import type { ApiEnv } from '../env.ts'; import { cancelContainerBuildDos } from '../github/cancel-builds.ts'; -import { - ProjectAccessDeniedError, - ProjectNotFoundError, - requireProjectCapability, - type ProjectAccess, -} from '../project-access.ts'; import type { ProjectInvitation, ProjectInvitationStatus, @@ -30,6 +24,7 @@ import type { ProjectMemberRole, User, } from '../types.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; const MEMBER_ROLES = new Set(['reader', 'collaborator', 'admin']); const INVITATION_EMAILS_PER_MINUTE = 5; @@ -104,9 +99,6 @@ function parseRole(value: unknown): ProjectMemberRole | null { } function mutationErrorResponse(context: Context, error: unknown): Response | null { - if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { - return context.json({ error: error.message }, error.status); - } if (!(error instanceof ProjectTeamMutationError)) { return null; } @@ -128,27 +120,6 @@ function mutationErrorResponse(context: Context, error: unknown): Respon return context.json({ error: messages[error.reason] }, error.status); } -async function requireAccess( - context: Context, - projectId: string, - capability: 'read' | 'members', -): Promise { - try { - return await requireProjectCapability( - context.get('db'), - projectId, - context.get('user').id, - capability, - ); - } catch (error) { - const response = mutationErrorResponse(context, error); - if (response) { - return response; - } - throw error; - } -} - async function deliverInvitation( context: Context, invitation: ProjectInvitation, @@ -223,7 +194,7 @@ function parseDeliveryCounter(raw: string | null): number { export const projectTeamRoutes = new Hono() .get('/:id/members', async (context) => { const projectId = context.req.param('id'); - const access = await requireAccess(context, projectId, 'read'); + const access = await requireProjectRouteCapability(context, projectId, 'read'); if (access instanceof Response) { return access; } @@ -242,7 +213,7 @@ export const projectTeamRoutes = new Hono() .get('/:id/invitations', async (context) => { const projectId = context.req.param('id'); - const access = await requireAccess(context, projectId, 'members'); + const access = await requireProjectRouteCapability(context, projectId, 'members'); if (access instanceof Response) { return access; } @@ -262,7 +233,7 @@ export const projectTeamRoutes = new Hono() .post('/:id/invitations', async (context) => { const projectId = context.req.param('id'); - const access = await requireAccess(context, projectId, 'members'); + const access = await requireProjectRouteCapability(context, projectId, 'members'); if (access instanceof Response) { return access; } @@ -307,7 +278,7 @@ export const projectTeamRoutes = new Hono() .post('/:id/invitations/:invitationId/resend', async (context) => { const projectId = context.req.param('id'); - const access = await requireAccess(context, projectId, 'members'); + const access = await requireProjectRouteCapability(context, projectId, 'members'); if (access instanceof Response) { return access; } @@ -337,7 +308,7 @@ export const projectTeamRoutes = new Hono() .delete('/:id/invitations/:invitationId', async (context) => { const projectId = context.req.param('id'); - const access = await requireAccess(context, projectId, 'members'); + const access = await requireProjectRouteCapability(context, projectId, 'members'); if (access instanceof Response) { return access; } @@ -359,7 +330,7 @@ export const projectTeamRoutes = new Hono() .patch('/:id/members/:userId', async (context) => { const projectId = context.req.param('id'); - const access = await requireAccess(context, projectId, 'members'); + const access = await requireProjectRouteCapability(context, projectId, 'members'); if (access instanceof Response) { return access; } @@ -396,7 +367,7 @@ export const projectTeamRoutes = new Hono() .delete('/:id/members/:userId', async (context) => { const projectId = context.req.param('id'); - const access = await requireAccess(context, projectId, 'members'); + const access = await requireProjectRouteCapability(context, projectId, 'members'); if (access instanceof Response) { return access; } @@ -423,7 +394,7 @@ export const projectTeamRoutes = new Hono() .post('/:id/leave', async (context) => { const projectId = context.req.param('id'); - const access = await requireAccess(context, projectId, 'read'); + const access = await requireProjectRouteCapability(context, projectId, 'read'); if (access instanceof Response) { return access; } diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index 015f61210..48fb6d920 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -829,16 +829,8 @@ const app = new Hono() const project = access.project; const deployments = await getProjectDeployments(context.get('db'), project.id, 20); - // A project-scoped deploy token (aud:'deploy') runs in the UNTRUSTED build - // environment (CF Container / GitHub Actions). The deploy manifest's `vars` - // field carries plaintext env values (uploaded as plain_text Worker - // bindings), so returning full historical manifests here would let a leaked - // build token or a compromised build dependency read every project's env - // history. Strip the secret-bearing fields for deploy-token callers while - // KEEPING `manifest.live` — the deploy CLI reads only `manifest.live` - // (bindingName/className) from this endpoint to restore live bindings on - // deploy-on-GitHub builds. Full `api` tokens (deployScope unset) leave the - // rows untouched, so the dashboard/admin still see complete manifests. + // Deploy tokens run untrusted build code, and readers lack configure access. + // Strip plaintext vars while preserving `live`, which the deploy CLI consumes. const sanitized = context.get('deployScope') || access.role === 'reader' ? deployments.map((deployment) => ({ @@ -1867,7 +1859,6 @@ const app = new Hono() if (access instanceof Response) { return access; } - const project = access.project; const body = await context.req.json<{ migrations: ReadonlyArray<{ name: string; sql: string }>; @@ -2056,7 +2047,6 @@ const app = new Hono() if (access instanceof Response) { return access; } - const project = access.project; const body = await context.req.json<{ renames: ReadonlyArray<{ from: string; to: string }> }>(); if (!body.renames || !Array.isArray(body.renames) || body.renames.length === 0) { diff --git a/platform/packages/api/src/routes/webhooks.ts b/platform/packages/api/src/routes/webhooks.ts index 1b61da96d..f225a4e56 100644 --- a/platform/packages/api/src/routes/webhooks.ts +++ b/platform/packages/api/src/routes/webhooks.ts @@ -14,7 +14,6 @@ import { getGitHubInstallation, getGitHubInstallationsByAccount, getOwnedProjectsByUserId, - getUser, getUserByOAuthAccount, markBuildTerminalIfActive, markGitHubInstallationStatus, diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index ab4d4c27b..ce465b286 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -1409,6 +1409,7 @@ describe('POST /admin/projects/:id/owner', () => { const formerOwner = await createTestUser(env.db, 'browser-convergence-former-owner'); const owner = await createTestUser(env.db, 'browser-convergence-owner'); const project = await createTestProject(env.db, owner.id, 'browser-convergence-project'); + const deployment = await createTestDeployment(env.db, project.id, 'pending'); await env.db.insert(projectOwnershipPeriods).values([ { id: 'pown_browser_convergence_former', @@ -1438,7 +1439,11 @@ describe('POST /admin/projects/:id/owner', () => { expect(html).toContain(`Converge Ownership State: ${project.slug}`); expect(html).toContain('This is a convergence-only operation'); expect(html).toContain(`detach from ${formerOwner.id}`); + expect(html).toContain(`deployment: ${deployment.id} (pending)`); + expect(html).toContain('Active work does not block convergence.'); expect(html).toContain('Apply Convergence'); + expect(html).toContain(`action="/admin/projects/${project.id}/owner"`); + expect(html).not.toContain('Resolve or cancel all active work'); expect(html).not.toContain('The former owner will become'); expect(html).not.toContain('detach from '); expect(await env.db.select().from(operatorEvents)).toEqual([]); diff --git a/platform/packages/api/test/integration/api-github.test.ts b/platform/packages/api/test/integration/api-github.test.ts index cf01c076f..f7dd02a35 100644 --- a/platform/packages/api/test/integration/api-github.test.ts +++ b/platform/packages/api/test/integration/api-github.test.ts @@ -4260,7 +4260,6 @@ describe('GET /github/callback', () => { // MEMBERSHIP row (per-repo proof required to connect), the owner pointer stays // NULL, and B is NOT the owner. it('cleared-owner org install: a different member B gets membership, not ownership (R15-1 confused-deputy closed)', async () => { - const formerOwner = await createTestUser(env.db, 'org-former-owner', 'github', '7010'); const memberB = await createTestUser(env.db, 'org-member-b', 'github', '7011'); // Cleared state member_removed leaves: null owner + tombstone for the FORMER // owner's GitHub id (7010). Member B has NO tombstone. diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index 9810b8ea4..f77b5f513 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -666,10 +666,14 @@ describe('operator tenant mutations', () => { } }); - test('resumes post-commit counter convergence after the former owner is deleted', async () => { + test('converges routing despite counter failure and resumes after the former owner is deleted', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const owner = await createTestUser(env.db, 'partial-transfer-owner'); const target = await createTestUser(env.db, 'partial-transfer-target'); + await env.db + .update(users) + .set({ suspended_reason: 'operator_review' }) + .where(eq(users.id, target.id)); const project = await createTestProject(env.db, owner.id, 'partial-transfer-project'); await env.ROUTING.put( project.slug, @@ -694,13 +698,16 @@ describe('operator tenant mutations', () => { ownershipTransferred: true, previousOwner: { id: owner.id }, newOwner: { id: target.id }, - convergence: { countersComplete: false, routingComplete: false }, + convergence: { countersComplete: false, routingComplete: true }, }, }); expect( (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], ).toMatchObject({ user_id: target.id }); - expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ userId: owner.id }); + expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ + userId: target.id, + suspended: 'operator_review', + }); const recoveryPreview = await req(`/projects/${project.id}/owner`, { userId: target.id, diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index 4b4b30d9d..f8bce446b 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -649,7 +649,7 @@ describe('UsageCounter.handleRecord — sandbox', () => { it('retains lease watermarks across rollover so delayed prior-period envelopes add nothing', async () => { const boundary = Math.floor(Date.now() / 1000) * 1000; const boundaryIso = new Date(boundary).toISOString(); - const { counter, storage, kv } = makeCounter({ + const { counter, storage } = makeCounter({ plan: 'pro', billingPeriodStart: '2026-01-01T00:00:00Z', }); From f10995c99b0b6d1ac2a1374a431dbcabfd7dccbc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 18 Sep 2026 16:47:35 +0800 Subject: [PATCH 08/18] fix(platform): revoke removed admin GitHub access --- docs/guide/project-collaboration.md | 6 +- platform/packages/api/README.md | 22 +++++-- platform/packages/api/src/routes/webhooks.ts | 60 ++++++++----------- .../api/test/integration/api-webhooks.test.ts | 57 +++++++++++++++++- 4 files changed, 103 insertions(+), 42 deletions(-) diff --git a/docs/guide/project-collaboration.md b/docs/guide/project-collaboration.md index 23bc58b2d..f8f1cb37a 100644 --- a/docs/guide/project-collaboration.md +++ b/docs/guide/project-collaboration.md @@ -15,7 +15,10 @@ A project hosted on a Void platform can have one owner and additional readers, c | Project administrator | Collaborator access plus domains, email destinations, GitHub configuration, invitations, roles, and member removal. | | Owner | Project administrator access plus project deletion. | -A collaborator's deploys use the owner's plan and limits. Changing who performs the deploy does not change the project's available usage. A project administrator is not an installation administrator and receives no platform-wide access. +The owner's plan, limits, and suspension state govern the project. A member's +own plan does not change the project's available usage, and acting through a +collaborator does not bypass an owner suspension. A project administrator is +not an installation administrator and receives no platform-wide access. ## Invite a Registered User @@ -46,6 +49,7 @@ void project team decline ``` An invitation is bound to the registered account. Forwarding its ID does not let another user accept it. + These three invitation-response commands are account-scoped and do not depend on the current project's deploy target. They can run from a direct Cloudflare project directory when it resolves your connected Void platform account. diff --git a/platform/packages/api/README.md b/platform/packages/api/README.md index d4620d95a..e2203fcc8 100644 --- a/platform/packages/api/README.md +++ b/platform/packages/api/README.md @@ -95,12 +95,19 @@ drizzle/ - project-scoped CI deploy credentials (`GET|POST /projects/:id/tokens`, `POST /projects/:id/tokens/:credentialId/renew`, and `DELETE /projects/:id/tokens/:credentialId`) + - project members and invitations - PostgreSQL and MySQL project database provisioning/update - `POST /projects/:id/prerender-token` — mint a five-minute, project-scoped token accepted only by the remote-binding proxy - - `/projects/:id/github` — connect/inspect/update/disconnect the GitHub repo (POST/GET/PATCH/DELETE; auth + ownership) + - `/projects/:id/github` — connect/inspect/update/disconnect the GitHub repo (POST/GET/PATCH/DELETE; readers may inspect, owners and project administrators may mutate) - `/projects/:id/email/*` — monthly usage, and the verified-recipient list the project may send to (`GET|POST|DELETE`). Adding a recipient triggers a verification email; the address only becomes sendable once the recipient consents. See [Email](#email). - `GET /usage` +Project authorization resolves the implicit owner from `projects.user_id` and +shared access from `project_members`. Readers can inspect, collaborators can +deploy and manage deploy prerequisites, project administrators additionally +manage domains, email, GitHub, and membership, and only the owner can delete. +The owner's plan, quota, and suspension state govern every role's work. + ### Deploy on GitHub - `GET /github/install-url` (auth) — start GitHub App installation @@ -125,6 +132,7 @@ HMAC. See [Platform Development](../../../docs/guide/platform-development.md#opt - `POST /internal/revalidate/:projectId` - `POST /internal/resolve-resource/:projectId` - `POST /internal/resolve-worker/:projectId` — resolve the active deployment's versioned worker; static active deployments report that no worker exists +- `POST /internal/verify-owner/:projectId` — legacy URL that live-checks the caller's project deploy capability ### Operator API @@ -151,6 +159,12 @@ retained receipt and recipient outcomes, with `page` and `limit` pagination. Accept a current project ID or slug; deleted projects remain inspectable by ID until ledger retention expires. Responses exclude message content and credentials. +`POST /projects/:id/owner` previews or applies an ownership transfer through the +same service used by the browser admin UI. The database transaction updates the +owner and ownership periods atomically; usage counters and routing converge +afterward, and retries repair an already-committed transfer without moving its +billing boundary. + Managed Sandbox disablement uses `void platform system sandbox-drain`. The preview inventories bounded pages of deployment manifests and container resources without writing. Apply closes the activation gate, cancels active @@ -211,7 +225,7 @@ Dashboard auth uses the same JWT model, stored in the `void_token` cookie. ### Worker deploys -1. validate ownership and deploy limits +1. validate the actor's deploy capability and the owner's deploy limits 2. provision bindings and queues from the manifest 3. upload worker modules and assets to CF via `assets-upload-session` 4. also mirror assets to platform `STATIC_ASSETS`/`STATIC_ASSETS_R2` and write a serving manifest to `ROUTING` (safety net for CF asset dedupe poisoning — see `meta/design-docs/0066`) @@ -231,7 +245,7 @@ Dashboard auth uses the same JWT model, stored in the `void_token` cookie. Connect a repo through a GitHub App; a `git push` then builds and deploys automatically. -- **Connect**: the user installs the App (`/github/install-url` -> `/github/callback`) and links a repo to a project via `/projects/:id/github`. Installation-to-user mapping lives in `github_installations`; the OAuth callback backfills it on sign-in. +- **Connect**: an owner or project administrator installs or joins the App (`/github/install-url` -> `/github/callback`) and links a repo they can access via `/projects/:id/github`. The connection records that authorizing user and is invalidated if they lose that level of project access. Installation-to-user mapping lives in `github_installations`; the OAuth callback backfills it on sign-in. - **Trigger**: `POST /webhooks/github` verifies the HMAC signature, then on `push` writes a `builds` row and pokes the `BuildScheduler`. - **Executors** (per `github_connections.build_executor`, default `container`): - **CF Container** — `BuildScheduler` (global, alarm-driven queue with per-tier concurrency from `tiers.ts`) atomically claims a build and starts a container on one of two classes by the owner's plan: `BuildManager` (free, `standard-3` = 2 vCPU / 8 GiB / 16 GB) or `BuildManagerLarge` (paid, `standard-4` = 4 vCPU / 12 GiB / 20 GB, Cloudflare's max per-container size). The class is routed by the persisted `builds.instance_type` (`standard-3` / `standard-4`, derived from the plan by `getBuildInstanceType`) via `buildManagerNamespace` in `build-namespace.ts`; cancel and reaper paths use the same picker, so they address the same class the build started on. `BuildManagerLarge` extends `BuildManager`, which extends `@cloudflare/containers` `Container`, enforces a timeout alarm, and persists status to Postgres via Hyperdrive. @@ -271,7 +285,7 @@ Only reservations that never started can be cancelled and refunded. Started or u `/admin/email/:projectId` displays current-month recipient attempts and inbound receipts, effective limits, sender identity, verified destinations, and custom-domain readiness. Operators can suspend email, change monthly or burst limits, revoke destination consent, and manage domain operations. A blank limit restores the default. -Destinations are added through the owner-facing `POST /projects/:id/email/destinations` consent flow. Operators cannot consent on a recipient’s behalf. Incomplete provider operations retain their operation ID and outcome for reconciliation or audited recovery; the page must not report them as completed. +Destinations are added through the owner/project-administrator `POST /projects/:id/email/destinations` consent flow. Operators cannot consent on a recipient’s behalf. Incomplete provider operations retain their operation ID and outcome for reconciliation or audited recovery; the page must not report them as completed. Every admin write is additionally same-origin checked in `admin/middleware/auth.ts` before authentication runs; a `POST` whose `Origin` (or, failing that, `Referer`) does not match the request host is rejected with `403`, as is one that carries neither. diff --git a/platform/packages/api/src/routes/webhooks.ts b/platform/packages/api/src/routes/webhooks.ts index f225a4e56..d3fce8e43 100644 --- a/platform/packages/api/src/routes/webhooks.ts +++ b/platform/packages/api/src/routes/webhooks.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, sql } from 'drizzle-orm'; +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { Hono } from 'hono'; import type { Context } from 'hono'; import { @@ -1458,21 +1458,8 @@ export async function handleVerifiedGitHubWebhook( // redemption) or the redemption commits first (this post-lock re-read sees // its freshly-inserted connection and severs it). // - // CONNECTIONS + install OWNER pointers are OAUTH-backed: every - // `github_connections` row has exactly ONE insert site - // (`createGitHubConnection`), reached only through the connect gate after - // the connecting user's IMMUTABLE oauth GitHub id is proven — the OWNER - // branch adopts the install under the owner's own proven oauth id, the - // MEMBER branch requires (Threat-L1) `grant.githubAccountId === the - // caller's own linked GitHub id`. So a connection is "X-backed" — and must - // be severed when GitHub id X leaves the org — IFF the connecting user's - // oauth id is X IFF `getUserByOAuthAccount('github', X)` returns that user. - // That single user is therefore the SOLE driver of connection-severing + - // owner-clearing: its oauth is X, so ALL its org connections and - // owned-installs are X-backed → severing across every orgInstallId is - // correct with no residual. When it is null there is simply no X-backed - // connection to sever (the pre-mint hole) — the tombstone written above - // already fail-closes any later X-keyed artifact redemption. + // Connections record the owner/admin whose OAuth proof authorized them. + // Legacy rows have no provenance and retain the old owner-scoped behavior. const removedUser = await getUserByOAuthAccount( txDb, 'github', @@ -1485,28 +1472,29 @@ export async function handleVerifiedGitHubWebhook( instance_type: string | null; }> = []; if (removedUser) { - const projectIds = (await getOwnedProjectsByUserId(txDb, removedUser.id)).map( + const ownedProjectIds = (await getOwnedProjectsByUserId(txDb, removedUser.id)).map( (project) => project.id, ); - if (projectIds.length > 0) { - // Single-table FOR UPDATE on githubConnections (NOT joined to - // projects) keeps the lock scope identical to the installation path; - // owner scoping is applied via `project_id IN (projectIds)`. - const conns = await tx - .select({ project_id: githubConnections.project_id }) - .from(githubConnections) - .where( - and( - inArray(githubConnections.installation_id, orgInstallIds), - inArray(githubConnections.project_id, projectIds), - ), - ) - .for('update'); - for (const conn of conns) { - // terminate BEFORE delete: revoke the token, then remove the trigger. - acc.push(...(await terminateBuildsForProject(txDb, conn.project_id, { reason }))); - await deleteGitHubConnection(txDb, conn.project_id); - } + const authorizationClause = + ownedProjectIds.length > 0 + ? or( + eq(githubConnections.authorized_by_user_id, removedUser.id), + and( + isNull(githubConnections.authorized_by_user_id), + inArray(githubConnections.project_id, ownedProjectIds), + ), + ) + : eq(githubConnections.authorized_by_user_id, removedUser.id); + const conns = await tx + .select({ project_id: githubConnections.project_id }) + .from(githubConnections) + .where( + and(inArray(githubConnections.installation_id, orgInstallIds), authorizationClause), + ) + .for('update'); + for (const conn of conns) { + acc.push(...(await terminateBuildsForProject(txDb, conn.project_id, { reason }))); + await deleteGitHubConnection(txDb, conn.project_id); } // Clear the removed user's membership cache + owner pointer across every // org install (both guard on the user id, so a different owner/bearer is diff --git a/platform/packages/api/test/integration/api-webhooks.test.ts b/platform/packages/api/test/integration/api-webhooks.test.ts index 88bc5f8b6..611a93b79 100644 --- a/platform/packages/api/test/integration/api-webhooks.test.ts +++ b/platform/packages/api/test/integration/api-webhooks.test.ts @@ -35,7 +35,13 @@ import { createGitHubWebhookIngressProof } from '../../src/github/webhook-ingres import { scheduled } from '../../src/index.ts'; import internalGitHubWebhook from '../../src/routes/internal-github-webhook.ts'; import { webhookRoutes } from '../../src/routes/webhooks.ts'; -import { builds, deployments, githubConnections, githubInstallations } from '../../src/schema.ts'; +import { + builds, + deployments, + githubConnections, + githubInstallations, + projectMembers, +} from '../../src/schema.ts'; import { clearTables, createTestDeployment, @@ -4201,6 +4207,7 @@ describe('POST /webhooks/github — organization member_removed revocation', () repositoryId: '81001', branch: 'main', buildExecutor: 'container', + authorizedByUserId: null, }); await upsertGitHubInstallationMember(env.db, { installationId: '8100', @@ -4240,6 +4247,54 @@ describe('POST /webhooks/github — organization member_removed revocation', () expect(cancelledIds).toContain(build.id); }); + it('severs a shared-project connection authorized by the removed project admin', async () => { + const owner = await createTestUser(env.db, 'org-shared-owner'); + const admin = await createTestUser(env.db, 'org-shared-admin', 'github', G); + await seedOrgInstall('8101', ORG, owner.id); + const project = await createTestProject(env.db, owner.id, 'org-shared-sever'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: '8101', + repoFullName: 'org/shared-repo', + repositoryId: '81011', + branch: 'main', + buildExecutor: 'container', + authorizedByUserId: admin.id, + }); + await upsertGitHubInstallationMember(env.db, { + installationId: '8101', + userId: admin.id, + githubAccountId: G, + ttlSeconds: 3600, + }); + const build = await createBuild(env.db, { + projectId: project.id, + userId: owner.id, + executor: 'container', + status: 'building', + commitSha: 'sha-org-shared', + branch: 'main', + }); + + const { app, appEnv, cancelledIds } = recordingApp(); + const res = await post(app, appEnv, 'organization', { + action: 'member_removed', + organization: { id: Number(ORG) }, + membership: { user: { id: Number(G) } }, + }); + + expect(res.status).toBe(200); + expect(await getGitHubConnection(env.db, project.id)).toBeNull(); + expect((await getBuild(env.db, build.id))?.status).toBe('cancelled'); + expect(cancelledIds).toContain(build.id); + }); + // EXACT-HOLE regression (Task 19): a member_removed(X) whose affected-user set // is EMPTY (X has no oauth link and no membership proof-id row) must STILL write // a tombstone for (each org install, github_account_id=X). This is the pre-mint From 913fa717c7c5e311630e6a4220a6bd8465690e12 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 18 Sep 2026 17:47:03 +0800 Subject: [PATCH 09/18] fix(platform): validate invitation landing links --- platform/packages/api/src/index.ts | 4 +++- .../integration/platform-onboarding.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index febb49d00..5c4ea8013 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -401,7 +401,9 @@ const app = new Hono() }) .get('/invite', (context) => { const name = escapeHtml(getPlatformDisplayName(context.env, context.req.url)); - const invitationId = context.req.query('project-invitation')?.trim(); + const invitationIdParam = context.req.query('project-invitation')?.trim(); + const invitationId = + invitationIdParam && /^pinv_[0-9a-z]{12}$/.test(invitationIdParam) ? invitationIdParam : null; const command = escapeHtml( platformConnectCommand(getPlatformApiUrl(context.env, context.req.url)), ); diff --git a/platform/packages/api/test/integration/platform-onboarding.test.ts b/platform/packages/api/test/integration/platform-onboarding.test.ts index c61e23307..6d4616b14 100644 --- a/platform/packages/api/test/integration/platform-onboarding.test.ts +++ b/platform/packages/api/test/integration/platform-onboarding.test.ts @@ -73,6 +73,25 @@ describe('platform onboarding pages', () => { expect(html).not.toMatch(/dashboard/i); }); + it('does not render shell instructions for a malformed invitation id', async () => { + const response = await app.fetch( + new Request( + 'https://api.example.com/invite?project-invitation=%24%28open%20https%3A%2F%2Fevil.example%29', + ), + { + ...env, + AUTH_REDIRECT_URI: 'https://api.example.com/auth/callback', + PLATFORM_INSTALLATION_ID: 'example-installation', + }, + ); + + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain('Get Started - Void Platform'); + expect(html).not.toContain('void project team accept'); + expect(html).not.toContain('evil.example'); + }); + it.each(['/invite', '/signup-restricted'])( 'uses a generic name for source deployments at %s', async (path) => { From 4ed0aff38e58e652e9ede6db9eb3f0cb65f70c72 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 18 Sep 2026 20:50:35 +0800 Subject: [PATCH 10/18] fix(platform): close project team access races --- packages/void/src/cli/project-args.ts | 2 +- packages/void/src/cli/project-team.ts | 4 -- packages/void/test/unit/project-args.test.ts | 8 ++- .../test/unit/project-team-command.test.ts | 6 +- .../meta/design-docs/0080-project-teams.md | 11 ++-- platform/packages/api/src/db.ts | 36 +++++++++++ platform/packages/api/src/middleware/auth.ts | 12 ++-- .../api/src/operator/tenant-service.ts | 25 +++++--- platform/packages/api/src/routes/deploy.ts | 14 +++-- platform/packages/api/src/suspension.ts | 6 ++ platform/packages/api/src/usage-queue.ts | 40 ++++--------- .../api/test/integration/api-deploy.test.ts | 60 +++++++++++++++++++ .../packages/api/test/unit/suspension.test.ts | 35 ++++------- .../api/test/unit/usage-queue.test.ts | 4 ++ 14 files changed, 179 insertions(+), 84 deletions(-) diff --git a/packages/void/src/cli/project-args.ts b/packages/void/src/cli/project-args.ts index cc87f679a..c187462dc 100644 --- a/packages/void/src/cli/project-args.ts +++ b/packages/void/src/cli/project-args.ts @@ -350,7 +350,7 @@ function parseTeamArgs(rest: Array): ProjectArgs { ); } const email = positionals[0]!; - if (!email.includes('@') || /\s/.test(email)) { + if (email.length > 320 || !/^[^\s@]+@[^\s@]+$/.test(email)) { throw new Error('project: Invitee must be a registered email address.'); } return { subcommand: 'team', action, email, role: role as TeamRole, projectSlug }; diff --git a/packages/void/src/cli/project-team.ts b/packages/void/src/cli/project-team.ts index 2bc35bf0a..01064c581 100644 --- a/packages/void/src/cli/project-team.ts +++ b/packages/void/src/cli/project-team.ts @@ -106,10 +106,6 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs) ); if (args.action === 'list') { const members = await client.listProjectMembers(projectId); - if (members.length === 0) { - outro('No project members found.'); - return; - } note(memberLines(members), 'Project Team'); outro(`${members.length} member(s)`); return; diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index 9f37d5c65..8f0d04670 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -100,9 +100,11 @@ describe('parseProjectArgs', () => { }); it('requires a registered email input and valid roles', () => { - expect(() => parseProjectArgs(['team', 'invite', 'member', '--role', 'reader'])).toThrow( - 'Invitee must be a registered email address', - ); + for (const email of ['member', '@example.com', 'member@']) { + expect(() => parseProjectArgs(['team', 'invite', email, '--role', 'reader'])).toThrow( + 'Invitee must be a registered email address', + ); + } expect(() => parseProjectArgs(['team', 'invite', 'member@example.com', '--role', 'owner']), ).toThrow("Invalid role 'owner'"); diff --git a/packages/void/test/unit/project-team-command.test.ts b/packages/void/test/unit/project-team-command.test.ts index 13f84fb79..dd1287123 100644 --- a/packages/void/test/unit/project-team-command.test.ts +++ b/packages/void/test/unit/project-team-command.test.ts @@ -116,7 +116,11 @@ describe('project team target selection', () => { }, ]), ) - .mockResolvedValueOnce(Response.json({ members: [] })); + .mockResolvedValueOnce( + Response.json({ + members: [{ user: { id: 'owner_1', login: 'owner' }, role: 'owner' }], + }), + ); vi.stubGlobal('fetch', fetchMock); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); diff --git a/platform/meta/design-docs/0080-project-teams.md b/platform/meta/design-docs/0080-project-teams.md index 503653c03..826669c87 100644 --- a/platform/meta/design-docs/0080-project-teams.md +++ b/platform/meta/design-docs/0080-project-teams.md @@ -264,9 +264,12 @@ project. The server, not the CLI, filters this list. ### Locks and credential revocation -Roles are never embedded as trusted JWT state. Full API, deploy, and prerender -credentials carry the actor ID and project scope, and every request resolves -current access from PostgreSQL. +Roles are never embedded as trusted JWT state. Full API credentials and manual +prerender credentials carry the actor ID. Remote-build deploy and prerender +credentials carry the build's billing-owner ID plus project and build scope. +For GitHub Actions builds, the connection records the user whose GitHub +authorization admitted the build. Every request resolves current project access +and build state from PostgreSQL. Membership writes take the project row `FOR UPDATE` before changing a member. Long or externally visible project mutations recheck the capability while @@ -352,7 +355,7 @@ void project team accept void project team decline ``` -The existing project selector and repository link identify the project when +The existing project selector and saved project link identify the project when `--project` is absent. Project-scoped commands require a connected Void Platform project and give a clear error for a direct Cloudflare target. The account-scoped `pending`, `accept`, and `decline` commands use the connected Void Platform even diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index d81956530..f136aa713 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -1085,6 +1085,42 @@ export async function getProjectOwnershipPeriodsByProjectIds( .orderBy(asc(projectOwnershipPeriods.project_id), asc(projectOwnershipPeriods.starts_at)); } +export async function getProjectUsageOwnershipSnapshot( + db: Db, + projectIds: ReadonlyArray, +): Promise< + Readonly<{ + projects: ReadonlyArray; + ownershipPeriods: ReadonlyArray; + }> +> { + const ids = [...new Set(projectIds)].sort(); + if (ids.length === 0) { + return { projects: [], ownershipPeriods: [] }; + } + return db.transaction(async (tx) => { + const lockedProjects = await tx + .select() + .from(projects) + .where(inArray(projects.id, ids)) + .orderBy(asc(projects.id)) + .for('share'); + const lockedProjectIds = lockedProjects.map((project) => project.id); + const ownershipPeriods = + lockedProjectIds.length === 0 + ? [] + : await tx + .select() + .from(projectOwnershipPeriods) + .where(inArray(projectOwnershipPeriods.project_id, lockedProjectIds)) + .orderBy( + asc(projectOwnershipPeriods.project_id), + asc(projectOwnershipPeriods.starts_at), + ); + return { projects: lockedProjects, ownershipPeriods }; + }); +} + export async function getOpenProjectOwnershipPeriodsByProjectIds( db: Db, projectIds: ReadonlyArray, diff --git a/platform/packages/api/src/middleware/auth.ts b/platform/packages/api/src/middleware/auth.ts index db6997ca1..7920bf988 100644 --- a/platform/packages/api/src/middleware/auth.ts +++ b/platform/packages/api/src/middleware/auth.ts @@ -8,12 +8,12 @@ import { projectDeployCredentials, users } from '../schema.ts'; import { isAdminSuspended } from '../suspension.ts'; /** - * Endpoints a scoped (`aud:'deploy'`) token may call. A deploy token - * authenticates as its initiating project member but is minted for one purpose - * only — running the real `void deploy` CLI against the single project named in - * `payload.project`. Without this allowlist the token could reach every route - * mounted under `/projects/*` (delete, - * secrets PUT, github connect, domains, rollback, cancel), so a leaked + * Endpoints a scoped (`aud:'deploy'`) token may call. A remote-build deploy + * token authenticates as the build's billing owner and is minted for one + * purpose only — running the real `void deploy` CLI against the single project + * named in `payload.project`. Without this allowlist the token could reach every + * route mounted under `/projects/*` (delete, secrets PUT, github connect, + * domains, rollback, cancel), so a leaked * 15-minute CI token could take over the project. Default-deny: anything not * listed here is rejected with 403. * diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 6511a6240..936c6fd53 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -65,6 +65,7 @@ import { isAdminSuspended, REASON_REQUESTS_EXCEEDED, REASON_SANDBOX_RUNTIME_EXCEEDED, + routingSuspensionReason, } from '../suspension.ts'; import { getCurrentBillingPeriodStart, PLAN_LIMITS, type PlanId } from '../tiers.ts'; import type { DeployManifest, Project, User } from '../types.ts'; @@ -483,10 +484,18 @@ async function commitOwnershipTransfer( 'ownership_state_conflict', ); } + const [clock] = await txDb + .select({ + transferredAt: sql`to_char(date_trunc('milliseconds', clock_timestamp()), 'YYYY-MM-DD HH24:MI:SS.MS')`, + }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + const transferBoundary = clock!.transferredAt; if (openPeriod) { await txDb .update(projectOwnershipPeriods) - .set({ ends_at: sql`now()` }) + .set({ ends_at: transferBoundary }) .where(eq(projectOwnershipPeriods.id, openPeriod.id)); } else { await txDb.insert(projectOwnershipPeriods).values({ @@ -494,7 +503,7 @@ async function commitOwnershipTransfer( project_id: projectId, owner_user_id: currentOwner.id, starts_at: lockedProject.created_at, - ends_at: sql`now()`, + ends_at: transferBoundary, }); } const [newPeriod] = await txDb @@ -503,10 +512,11 @@ async function commitOwnershipTransfer( id: generateId('pown'), project_id: projectId, owner_user_id: newOwner.id, - starts_at: sql`now()`, + starts_at: transferBoundary, changed_by_user_id: operatorUserId, }) - .returning(); + .returning({ startsAt: projectOwnershipPeriods.starts_at }); + const transferredAt = newPeriod!.startsAt; const [updatedProject] = await txDb .update(projects) .set({ user_id: newOwner.id }) @@ -531,7 +541,7 @@ async function commitOwnershipTransfer( previousOwnerBillingCycleStart: currentOwner.billing_cycle_start, newOwner, previousOwnershipStartedAt: openPeriod?.starts_at ?? lockedProject.created_at, - transferredAt: newPeriod!.starts_at, + transferredAt, changed: true, }; }); @@ -625,8 +635,9 @@ async function republishProjectOwnerState( delete entry.limits; } } - if (owner.suspended_reason) { - entry.suspended = owner.suspended_reason; + const suspensionReason = routingSuspensionReason(owner.suspended_reason); + if (suspensionReason) { + entry.suspended = suspensionReason; } else { delete entry.suspended; } diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index cee42f827..b764b06f6 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -4396,6 +4396,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): Promise): Promise( async (attempt) => { const attemptStart = Date.now(); diff --git a/platform/packages/api/src/suspension.ts b/platform/packages/api/src/suspension.ts index f74d8f796..f3d563709 100644 --- a/platform/packages/api/src/suspension.ts +++ b/platform/packages/api/src/suspension.ts @@ -11,3 +11,9 @@ const USAGE_LIMIT_REASONS = new Set([REASON_REQUESTS_EXCEEDED, REASON_SANDBOX_RU export function isAdminSuspended(suspendedReason: string | null): boolean { return suspendedReason !== null && !USAGE_LIMIT_REASONS.has(suspendedReason); } + +export function routingSuspensionReason(suspendedReason: string | null): string | null { + return suspendedReason === REASON_REQUESTS_EXCEEDED || isAdminSuspended(suspendedReason) + ? suspendedReason + : null; +} diff --git a/platform/packages/api/src/usage-queue.ts b/platform/packages/api/src/usage-queue.ts index 65dfc3a37..e4e583853 100644 --- a/platform/packages/api/src/usage-queue.ts +++ b/platform/packages/api/src/usage-queue.ts @@ -1,9 +1,4 @@ -import { - createDb, - getProjectOwnershipPeriodsByProjectIds, - getProjectsByIds, - getUser, -} from './db.ts'; +import { createDb, getProjectUsageOwnershipSnapshot, getUser } from './db.ts'; import type { ApiEnv } from './env.ts'; import { getCurrentBillingPeriodStart, getPlanLimits } from './tiers.ts'; import { @@ -132,35 +127,26 @@ export async function processUsageEventBatch( } const db = createDb(env.HYPERDRIVE.connectionString); - let projects: Awaited>; - try { - projects = await getProjectsByIds(db, [ - ...new Set(authenticatedRecords.map(({ event }) => event.projectId)), - ]); - } catch (error) { - console.error('[usage-queue] project identity lookup failed:', error); - batch.retryAll(); - return; - } - const projectsById = new Map(projects.map((project) => [project.id, project])); - let ownershipPeriodsByProject: Map< - string, - Array>[number]> - >; + let ownershipSnapshot: Awaited>; try { - const periods = await getProjectOwnershipPeriodsByProjectIds( + ownershipSnapshot = await getProjectUsageOwnershipSnapshot( db, - projects.map((project) => project.id), + authenticatedRecords.map(({ event }) => event.projectId), ); - ownershipPeriodsByProject = new Map(projects.map((project) => [project.id, []])); - for (const period of periods) { - ownershipPeriodsByProject.get(period.project_id)?.push(period); - } } catch (error) { console.error('[usage-queue] project ownership lookup failed:', error); batch.retryAll(); return; } + const projects = ownershipSnapshot.projects; + const projectsById = new Map(projects.map((project) => [project.id, project])); + const ownershipPeriodsByProject = new Map< + string, + Array<(typeof ownershipSnapshot.ownershipPeriods)[number]> + >(projects.map((project) => [project.id, []])); + for (const period of ownershipSnapshot.ownershipPeriods) { + ownershipPeriodsByProject.get(period.project_id)?.push(period); + } const records = authenticatedRecords.flatMap(({ event, at }) => { const project = projectsById.get(event.projectId); if (!project) { diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index e0c7cba6a..83a1c473d 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -2022,6 +2022,66 @@ describe('POST /projects/:id/deploy', () => { } }); + it('rechecks collaborator access under the project lock before dispatching migrations', async () => { + const owner = await createTestUser(env.db, 'migration-race-owner'); + const collaborator = await createTestUser(env.db, 'migration-race-collaborator'); + const project = await createTestProject(env.db, owner.id, 'migration-access-race'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + mockCfApi({ + createD1: () => cfSuccess({ uuid: 'd1-access-race', name: 'test-db' }), + }); + + let migrateCalls = 0; + const originalDispatch = env.DISPATCH; + env.DISPATCH = { + get: () => ({ + fetch: async (req: Request) => { + const url = new URL(req.url); + if (url.pathname === '/__void/ready') { + await env.db + .update(projectMembers) + .set({ role: 'reader' }) + .where(eq(projectMembers.user_id, collaborator.id)); + return Response.json({ ready: true }); + } + migrateCalls++; + return Response.json({ + ok: true, + migrations: [{ name: '001_create_table.sql', applied: true, durationMs: 1 }], + }); + }, + }), + } as unknown as DispatchNamespace; + + try { + const response = await app.fetch( + await buildDeployRequest(env, project.id, collaborator.jwt, { + manifest: { + version: 3, + bindings: { d1: ['DB'] }, + migrations: { dialect: 'sqlite', pending: 1 }, + }, + }), + env, + ); + expect(response.status).toBe(200); + const events = parseDeployStream(await response.text()); + expect(events.at(-1)).toMatchObject({ + event: 'error', + step: 'migration', + message: 'forbidden', + }); + expect(migrateCalls).toBe(0); + } finally { + env.DISPATCH = originalDispatch; + } + }); + it('treats 404 from readiness probe as compatible (older worker without /__void/ready)', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'migrate-compat-404-test'); diff --git a/platform/packages/api/test/unit/suspension.test.ts b/platform/packages/api/test/unit/suspension.test.ts index 1af5a6970..9c3a8d7ab 100644 --- a/platform/packages/api/test/unit/suspension.test.ts +++ b/platform/packages/api/test/unit/suspension.test.ts @@ -3,6 +3,7 @@ import { isAdminSuspended, REASON_REQUESTS_EXCEEDED, REASON_SANDBOX_RUNTIME_EXCEEDED, + routingSuspensionReason, } from '../../src/suspension.ts'; import { evaluateRequestLimit, @@ -11,37 +12,21 @@ import { } from '../../src/usage-counter.ts'; describe('isAdminSuspended', () => { - it('returns false for null (not suspended)', () => { + it('distinguishes usage limits from administrator suspensions', () => { expect(isAdminSuspended(null)).toBe(false); - }); - - it('returns false for usage-limit reason', () => { expect(isAdminSuspended(REASON_REQUESTS_EXCEEDED)).toBe(false); - }); - - it('returns false for sandbox runtime usage-limit reason', () => { expect(isAdminSuspended(REASON_SANDBOX_RUNTIME_EXCEEDED)).toBe(false); - }); - - it('does not treat sandbox_runtime_exceeded as admin suspension', () => { - expect(isAdminSuspended('sandbox_runtime_exceeded')).toBe(false); - }); - - it('returns true for admin_suspended', () => { expect(isAdminSuspended('admin_suspended')).toBe(true); + expect(isAdminSuspended('abuse')).toBe(true); }); +}); - it('returns true for unknown suspension reason', () => { - expect(isAdminSuspended('some_other_reason')).toBe(true); - }); - - it('does not treat requests_exceeded as admin suspension', () => { - expect(isAdminSuspended('requests_exceeded')).toBe(false); - }); - - it('treats abuse and manual as admin suspension', () => { - expect(isAdminSuspended('abuse')).toBe(true); - expect(isAdminSuspended('manual')).toBe(true); +describe('routingSuspensionReason', () => { + it('blocks app traffic only for request limits and administrator suspensions', () => { + expect(routingSuspensionReason(null)).toBeNull(); + expect(routingSuspensionReason(REASON_REQUESTS_EXCEEDED)).toBe(REASON_REQUESTS_EXCEEDED); + expect(routingSuspensionReason(REASON_SANDBOX_RUNTIME_EXCEEDED)).toBeNull(); + expect(routingSuspensionReason('admin_suspended')).toBe('admin_suspended'); }); }); diff --git a/platform/packages/api/test/unit/usage-queue.test.ts b/platform/packages/api/test/unit/usage-queue.test.ts index 33bb88eef..a73e25df1 100644 --- a/platform/packages/api/test/unit/usage-queue.test.ts +++ b/platform/packages/api/test/unit/usage-queue.test.ts @@ -18,6 +18,10 @@ vi.mock('../../src/db.ts', () => ({ getProjectsByIds: (...args: Array) => getProjectsByIdsMock(...args), getProjectOwnershipPeriodsByProjectIds: (...args: Array) => getProjectOwnershipPeriodsByProjectIdsMock(...args), + getProjectUsageOwnershipSnapshot: async (...args: Array) => ({ + projects: await getProjectsByIdsMock(...args), + ownershipPeriods: await getProjectOwnershipPeriodsByProjectIdsMock(...args), + }), suspendUser: vi.fn(), unsuspendUserIfReason: vi.fn(), })); From 92c037c27c90ac110c7993273b64a7871feac322 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 21 Sep 2026 00:54:19 +0800 Subject: [PATCH 11/18] fix(platform): reconcile project team upgrades --- docs/guide/platform-administration.md | 2 +- docs/reference/cli.md | 2 +- .../meta/design-docs/0080-project-teams.md | 13 +- .../api/drizzle/0048_project_teams.sql | 48 +- .../api/drizzle/meta/0048_snapshot.json | 454 +++++------------- platform/packages/api/src/db.ts | 16 +- .../api/src/email/connection-service.ts | 127 +++-- platform/packages/api/src/index.ts | 2 +- platform/packages/api/src/middleware/auth.ts | 4 +- .../api/src/operator/tenant-service.ts | 10 + platform/packages/api/src/routes/email.ts | 101 ++-- .../routes/internal-verify-prerender-build.ts | 43 +- .../packages/api/src/routes/project-tokens.ts | 104 ++-- .../test/integration/admin-dashboard.test.ts | 3 + .../integration/api-email-domains.test.ts | 76 +++ .../api-project-access-routes.test.ts | 3 + .../api/test/integration/api-projects.test.ts | 74 ++- .../test/integration/operator-tenants.test.ts | 27 +- .../test/integration/project-access.test.ts | 46 +- platform/packages/proxy/src/index.ts | 36 +- .../packages/proxy/test/unit/proxy.test.ts | 33 +- .../packages/proxy/test/unit/resolve.test.ts | 12 +- 22 files changed, 706 insertions(+), 530 deletions(-) diff --git a/docs/guide/platform-administration.md b/docs/guide/platform-administration.md index 77da08be4..37f30e905 100644 --- a/docs/guide/platform-administration.md +++ b/docs/guide/platform-administration.md @@ -196,7 +196,7 @@ void platform project owner --yes 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. -After the transfer, the former owner becomes a project administrator. 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. +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. New users on a self-hosted installation start with the `custom` profile, which does not cap application requests, AI usage, deployment frequency, or retained diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 75d69bf46..4d60bff70 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -557,7 +557,7 @@ void platform project owner 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. -`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, and usage already incurred remains with the former owner. +`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. #### Deployments {#operator-deployments} diff --git a/platform/meta/design-docs/0080-project-teams.md b/platform/meta/design-docs/0080-project-teams.md index 826669c87..6008a3bfb 100644 --- a/platform/meta/design-docs/0080-project-teams.md +++ b/platform/meta/design-docs/0080-project-teams.md @@ -211,6 +211,8 @@ is deferred until that hard-disabled capability is reintroduced. Add `deployments.billing_user_id` and `deployments.initiated_by_user_id`. Backfill `billing_user_id` from the current owner before transfers are enabled. +Compatibility triggers create ownership periods and fill missing deployment +billing owners for old-worker writes during a rolling upgrade. Daily deploy counts use `billing_user_id` instead of joining to the project's current owner. @@ -282,8 +284,10 @@ has `deploy` capability as well as checking the bound build. Therefore: - a leaked unexpired deploy token cannot start new work after removal. The binding proxy's existing internal `verify-owner` route checks the `deploy` -capability without changing its wire contract. Build-bound prerender -credentials retain their build-state check. +capability without changing its wire contract. Every prerender credential is +also checked against current project access on each proxy request; +build-derived and durable-CI-derived credentials additionally retain their +build-state or parent-credential check. ## Invitation flow @@ -406,8 +410,9 @@ The operator transfer performs these steps: 1. In one database transaction, lock the project then both users in stable ID order; reject active lifecycle work; close/open the ownership periods; - update `projects.user_id`; remove the target's membership; add the former - owner as project admin. + revoke the project's scoped CI deploy credentials; update + `projects.user_id`; remove the target's membership; add the former owner as + project admin. 2. Detach/attach the UsageCounter project mapping. 3. Republish the project-account and active routing entries with the new owner ID, plan limits, and suspension state. Usage ingestion already uses the diff --git a/platform/packages/api/drizzle/0048_project_teams.sql b/platform/packages/api/drizzle/0048_project_teams.sql index 49c0489fa..bac012e12 100644 --- a/platform/packages/api/drizzle/0048_project_teams.sql +++ b/platform/packages/api/drizzle/0048_project_teams.sql @@ -54,6 +54,51 @@ CREATE TABLE "project_ownership_periods" ( --> statement-breakpoint ALTER TABLE "deployments" ADD COLUMN "billing_user_id" text;--> statement-breakpoint ALTER TABLE "deployments" ADD COLUMN "initiated_by_user_id" text;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_project_ownership_periods_open" ON "project_ownership_periods" USING btree ("project_id") WHERE "project_ownership_periods"."ends_at" IS NULL;--> statement-breakpoint +CREATE FUNCTION "void_set_missing_deployment_billing_user_id"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW."billing_user_id" IS NULL THEN + SELECT "project"."user_id" + INTO NEW."billing_user_id" + FROM "public"."projects" AS "project" + WHERE "project"."id" = NEW."project_id"; + END IF; + RETURN NEW; +END; +$$;--> statement-breakpoint +CREATE TRIGGER "deployments_set_missing_billing_user_id" +BEFORE INSERT ON "deployments" +FOR EACH ROW +WHEN (NEW."billing_user_id" IS NULL) +EXECUTE FUNCTION "void_set_missing_deployment_billing_user_id"();--> statement-breakpoint +CREATE FUNCTION "void_create_initial_project_ownership_period"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO "public"."project_ownership_periods" ( + "id", + "project_id", + "owner_user_id", + "starts_at" + ) + VALUES ( + 'pown_' || NEW."id", + NEW."id", + NEW."user_id", + NEW."created_at" + ) + ON CONFLICT DO NOTHING; + RETURN NEW; +END; +$$;--> statement-breakpoint +CREATE TRIGGER "projects_create_initial_ownership_period" +AFTER INSERT ON "projects" +FOR EACH ROW +EXECUTE FUNCTION "void_create_initial_project_ownership_period"();--> statement-breakpoint UPDATE "deployments" AS "deployment" SET "billing_user_id" = "project"."user_id" FROM "projects" AS "project" @@ -85,5 +130,4 @@ CREATE INDEX "idx_project_invitations_target_status" ON "project_invitations" US CREATE UNIQUE INDEX "idx_project_invitations_pending_target" ON "project_invitations" USING btree ("project_id","target_user_id") WHERE "project_invitations"."status" = 'pending';--> statement-breakpoint CREATE INDEX "idx_project_members_user_project" ON "project_members" USING btree ("user_id","project_id");--> statement-breakpoint CREATE INDEX "idx_project_ownership_periods_owner_time" ON "project_ownership_periods" USING btree ("owner_user_id","starts_at","ends_at");--> statement-breakpoint -CREATE INDEX "idx_project_ownership_periods_project_time" ON "project_ownership_periods" USING btree ("project_id","starts_at");--> statement-breakpoint -CREATE UNIQUE INDEX "idx_project_ownership_periods_open" ON "project_ownership_periods" USING btree ("project_id") WHERE "project_ownership_periods"."ends_at" IS NULL; +CREATE INDEX "idx_project_ownership_periods_project_time" ON "project_ownership_periods" USING btree ("project_id","starts_at"); diff --git a/platform/packages/api/drizzle/meta/0048_snapshot.json b/platform/packages/api/drizzle/meta/0048_snapshot.json index 4790927e1..d3e1f2ff3 100644 --- a/platform/packages/api/drizzle/meta/0048_snapshot.json +++ b/platform/packages/api/drizzle/meta/0048_snapshot.json @@ -261,12 +261,8 @@ "name": "builds_project_id_projects_id_fk", "tableFrom": "builds", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -274,12 +270,8 @@ "name": "builds_user_id_users_id_fk", "tableFrom": "builds", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -287,12 +279,8 @@ "name": "builds_deployment_id_deployments_id_fk", "tableFrom": "builds", "tableTo": "deployments", - "columnsFrom": [ - "deployment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -510,12 +498,8 @@ "name": "deployments_project_id_projects_id_fk", "tableFrom": "deployments", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -603,12 +587,8 @@ "name": "domains_project_id_projects_id_fk", "tableFrom": "domains", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -618,9 +598,7 @@ "domains_hostname_unique": { "name": "domains_hostname_unique", "nullsNotDistinct": false, - "columns": [ - "hostname" - ] + "columns": ["hostname"] } }, "policies": {}, @@ -850,12 +828,8 @@ "name": "email_destinations_project_id_projects_id_fk", "tableFrom": "email_destinations", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -863,12 +837,8 @@ "name": "email_destinations_verified_by_user_id_users_id_fk", "tableFrom": "email_destinations", "tableTo": "users", - "columnsFrom": [ - "verified_by_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["verified_by_user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -878,10 +848,7 @@ "email_destinations_project_address_uq": { "name": "email_destinations_project_address_uq", "nullsNotDistinct": false, - "columns": [ - "project_id", - "address" - ] + "columns": ["project_id", "address"] } }, "policies": {}, @@ -1104,12 +1071,8 @@ "name": "email_domains_project_id_projects_id_fk", "tableFrom": "email_domains", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1119,9 +1082,7 @@ "email_domains_domain_uq": { "name": "email_domains_domain_uq", "nullsNotDistinct": false, - "columns": [ - "domain" - ] + "columns": ["domain"] } }, "policies": {}, @@ -1283,12 +1244,8 @@ "name": "github_connections_project_id_projects_id_fk", "tableFrom": "github_connections", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1296,12 +1253,8 @@ "name": "github_connections_authorized_by_user_id_users_id_fk", "tableFrom": "github_connections", "tableTo": "users", - "columnsFrom": [ - "authorized_by_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["authorized_by_user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1311,9 +1264,7 @@ "github_connections_project_id_unique": { "name": "github_connections_project_id_unique", "nullsNotDistinct": false, - "columns": [ - "project_id" - ] + "columns": ["project_id"] } }, "policies": {}, @@ -1412,12 +1363,8 @@ "name": "github_installation_members_user_id_users_id_fk", "tableFrom": "github_installation_members", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1608,12 +1555,8 @@ "name": "github_installations_user_id_users_id_fk", "tableFrom": "github_installations", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1623,9 +1566,7 @@ "github_installations_installation_id_unique": { "name": "github_installations_installation_id_unique", "nullsNotDistinct": false, - "columns": [ - "installation_id" - ] + "columns": ["installation_id"] } }, "policies": {}, @@ -1850,12 +1791,8 @@ "name": "migration_renames_project_id_projects_id_fk", "tableFrom": "migration_renames", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1863,11 +1800,7 @@ "compositePrimaryKeys": { "migration_renames_project_id_from_name_to_name_pk": { "name": "migration_renames_project_id_from_name_to_name_pk", - "columns": [ - "project_id", - "from_name", - "to_name" - ] + "columns": ["project_id", "from_name", "to_name"] } }, "uniqueConstraints": {}, @@ -1954,12 +1887,8 @@ "name": "oauth_accounts_user_id_users_id_fk", "tableFrom": "oauth_accounts", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2137,12 +2066,8 @@ "name": "operator_sessions_user_id_users_id_fk", "tableFrom": "operator_sessions", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2150,12 +2075,8 @@ "name": "operator_sessions_human_session_id_platform_human_sessions_id_fk", "tableFrom": "operator_sessions", "tableTo": "platform_human_sessions", - "columnsFrom": [ - "human_session_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["human_session_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2241,12 +2162,8 @@ "name": "platform_auth_attempts_connection_id_platform_login_connections_id_fk", "tableFrom": "platform_auth_attempts", "tableTo": "platform_login_connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -2324,12 +2241,8 @@ "name": "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk", "tableFrom": "platform_auth_bootstrap", "tableTo": "platform_login_connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -2469,12 +2382,8 @@ "name": "platform_company_identities_user_id_users_id_fk", "tableFrom": "platform_company_identities", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2482,10 +2391,7 @@ "compositePrimaryKeys": { "platform_company_identities_issuer_subject_pk": { "name": "platform_company_identities_issuer_subject_pk", - "columns": [ - "issuer", - "subject" - ] + "columns": ["issuer", "subject"] } }, "uniqueConstraints": {}, @@ -2735,12 +2641,8 @@ "name": "platform_human_sessions_user_id_users_id_fk", "tableFrom": "platform_human_sessions", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2748,12 +2650,8 @@ "name": "platform_human_sessions_connection_id_platform_login_connections_id_fk", "tableFrom": "platform_human_sessions", "tableTo": "platform_login_connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -2953,12 +2851,8 @@ "name": "platform_login_identities_user_id_users_id_fk", "tableFrom": "platform_login_identities", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2966,12 +2860,8 @@ "name": "platform_login_identities_connection_id_platform_login_connections_id_fk", "tableFrom": "platform_login_identities", "tableTo": "platform_login_connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -3024,12 +2914,8 @@ "name": "platform_login_revisions_connection_id_platform_login_connections_id_fk", "tableFrom": "platform_login_revisions", "tableTo": "platform_login_connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -3037,10 +2923,7 @@ "compositePrimaryKeys": { "platform_login_revisions_connection_id_revision_pk": { "name": "platform_login_revisions_connection_id_revision_pk", - "columns": [ - "connection_id", - "revision" - ] + "columns": ["connection_id", "revision"] } }, "uniqueConstraints": {}, @@ -3124,12 +3007,8 @@ "name": "platform_login_tests_user_id_users_id_fk", "tableFrom": "platform_login_tests", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3137,12 +3016,8 @@ "name": "platform_login_tests_connection_id_platform_login_connections_id_fk", "tableFrom": "platform_login_tests", "tableTo": "platform_login_connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -3294,12 +3169,8 @@ "name": "project_access_events_project_id_projects_id_fk", "tableFrom": "project_access_events", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3372,12 +3243,8 @@ "name": "project_database_credentials_project_id_projects_id_fk", "tableFrom": "project_database_credentials", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3479,12 +3346,8 @@ "name": "project_deploy_credentials_project_id_projects_id_fk", "tableFrom": "project_deploy_credentials", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3492,12 +3355,8 @@ "name": "project_deploy_credentials_issued_by_users_id_fk", "tableFrom": "project_deploy_credentials", "tableTo": "users", - "columnsFrom": [ - "issued_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["issued_by"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -3669,12 +3528,8 @@ "name": "project_invitations_project_id_projects_id_fk", "tableFrom": "project_invitations", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3682,12 +3537,8 @@ "name": "project_invitations_target_user_id_users_id_fk", "tableFrom": "project_invitations", "tableTo": "users", - "columnsFrom": [ - "target_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["target_user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3778,12 +3629,8 @@ "name": "project_members_project_id_projects_id_fk", "tableFrom": "project_members", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3791,12 +3638,8 @@ "name": "project_members_user_id_users_id_fk", "tableFrom": "project_members", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3804,10 +3647,7 @@ "compositePrimaryKeys": { "project_members_project_id_user_id_pk": { "name": "project_members_project_id_user_id_pk", - "columns": [ - "project_id", - "user_id" - ] + "columns": ["project_id", "user_id"] } }, "uniqueConstraints": {}, @@ -3885,12 +3725,8 @@ "name": "project_migrations_project_id_projects_id_fk", "tableFrom": "project_migrations", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3898,12 +3734,8 @@ "name": "project_migrations_source_deployment_id_deployments_id_fk", "tableFrom": "project_migrations", "tableTo": "deployments", - "columnsFrom": [ - "source_deployment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["source_deployment_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -3911,10 +3743,7 @@ "compositePrimaryKeys": { "project_migrations_project_id_name_pk": { "name": "project_migrations_project_id_name_pk", - "columns": [ - "project_id", - "name" - ] + "columns": ["project_id", "name"] } }, "uniqueConstraints": {}, @@ -4034,12 +3863,8 @@ "name": "project_ownership_periods_project_id_projects_id_fk", "tableFrom": "project_ownership_periods", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -4138,12 +3963,8 @@ "name": "project_secrets_project_id_projects_id_fk", "tableFrom": "project_secrets", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -4153,10 +3974,7 @@ "project_secrets_project_id_name_unique": { "name": "project_secrets_project_id_name_unique", "nullsNotDistinct": false, - "columns": [ - "project_id", - "name" - ] + "columns": ["project_id", "name"] } }, "policies": {}, @@ -4257,12 +4075,8 @@ "name": "projects_user_id_users_id_fk", "tableFrom": "projects", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -4272,10 +4086,7 @@ "projects_user_id_slug_unique": { "name": "projects_user_id_slug_unique", "nullsNotDistinct": false, - "columns": [ - "user_id", - "slug" - ] + "columns": ["user_id", "slug"] } }, "policies": {}, @@ -4337,12 +4148,8 @@ "name": "resources_project_id_projects_id_fk", "tableFrom": "resources", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -4352,11 +4159,7 @@ "resources_project_id_type_binding_name_unique": { "name": "resources_project_id_type_binding_name_unique", "nullsNotDistinct": false, - "columns": [ - "project_id", - "type", - "binding_name" - ] + "columns": ["project_id", "type", "binding_name"] } }, "policies": {}, @@ -4984,12 +4787,8 @@ "name": "email_domain_routes_connection_id_email_zone_connections_id_fk", "tableFrom": "email_domain_routes", "tableTo": "email_zone_connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -5118,12 +4917,8 @@ "name": "email_operation_steps_operation_id_email_operations_id_fk", "tableFrom": "email_operation_steps", "tableTo": "email_operations", - "columnsFrom": [ - "operation_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["operation_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -5320,12 +5115,8 @@ "name": "email_operations_connection_id_email_zone_connections_id_fk", "tableFrom": "email_operations", "tableTo": "email_zone_connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -5333,12 +5124,8 @@ "name": "email_operations_route_id_email_domain_routes_id_fk", "tableFrom": "email_operations", "tableTo": "email_domain_routes", - "columnsFrom": [ - "route_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["route_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -5720,12 +5507,8 @@ "name": "email_action_recipients_action_id_email_actions_id_fk", "tableFrom": "email_action_recipients", "tableTo": "email_actions", - "columnsFrom": [ - "action_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["action_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -5735,10 +5518,7 @@ "email_action_recipients_action_recipient_uq": { "name": "email_action_recipients_action_recipient_uq", "nullsNotDistinct": false, - "columns": [ - "action_id", - "recipient" - ] + "columns": ["action_id", "recipient"] } }, "policies": {}, @@ -5844,12 +5624,8 @@ "name": "email_actions_receipt_id_email_receipts_id_fk", "tableFrom": "email_actions", "tableTo": "email_receipts", - "columnsFrom": [ - "receipt_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["receipt_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -5859,10 +5635,7 @@ "email_actions_project_key_uq": { "name": "email_actions_project_key_uq", "nullsNotDistinct": false, - "columns": [ - "project_id", - "idempotency_key" - ] + "columns": ["project_id", "idempotency_key"] } }, "policies": {}, @@ -5912,10 +5685,7 @@ "email_monthly_usage_project_month_uq": { "name": "email_monthly_usage_project_month_uq", "nullsNotDistinct": false, - "columns": [ - "project_id", - "month" - ] + "columns": ["project_id", "month"] } }, "policies": {}, @@ -5975,12 +5745,8 @@ "name": "email_project_controls_project_id_projects_id_fk", "tableFrom": "email_project_controls", "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["project_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index f136aa713..1d0c883e7 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -427,12 +427,15 @@ export async function createProject(db: Db, userId: string, slug: string): Promi const id = generateId('proj'); const row = await db.transaction(async (tx) => { const [created] = await tx.insert(projects).values({ id, user_id: userId, slug }).returning(); - await tx.insert(projectOwnershipPeriods).values({ - id: generateId('pown'), - project_id: created.id, - owner_user_id: userId, - starts_at: created.created_at, - }); + await tx + .insert(projectOwnershipPeriods) + .values({ + id: generateId('pown'), + project_id: created.id, + owner_user_id: userId, + starts_at: created.created_at, + }) + .onConflictDoNothing(); return created; }); return row; @@ -902,6 +905,7 @@ export async function resendProjectInvitation( .select() .from(users) .where(eq(users.id, invitation.target_user_id)) + .for('share') .limit(1); if (!target?.email) { throw new ProjectTeamMutationError('target-user-not-found'); diff --git a/platform/packages/api/src/email/connection-service.ts b/platform/packages/api/src/email/connection-service.ts index fad8aa2cf..d6b4183b1 100644 --- a/platform/packages/api/src/email/connection-service.ts +++ b/platform/packages/api/src/email/connection-service.ts @@ -1,7 +1,12 @@ -import { and, count, desc, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'; +import { and, count, desc, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm'; import type { Db, DbOrTx, Tx } from '../db.ts'; import type { ApiEnv } from '../env.ts'; import { generateId } from '../id.ts'; +import { + ProjectAccessDeniedError, + ProjectNotFoundError, + requireProjectCapability, +} from '../project-access.ts'; import { decryptPlatformCredential, encryptPlatformCredential } from '../project-secrets.ts'; import { CloudflareApiError } from '../provisioning/cloudflare-api.ts'; import { projects } from '../schema.ts'; @@ -415,6 +420,71 @@ function canManageConnection( ); } +async function requireProjectManagedEmailAccess( + db: DbOrTx, + projectId: string, + actor: EmailActor, +): Promise { + if (actor.kind !== 'owner') { + return; + } + try { + const access = await requireProjectCapability( + db as unknown as Db, + projectId, + actor.userId, + 'settings', + ); + if (access.project.status === 'deleting') { + throw new EmailOperationError('email domain not available', 404); + } + } catch (error) { + if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { + throw new EmailOperationError('forbidden', 403); + } + throw error; + } +} + +async function hasDelegatedConnectionAccess( + db: DbOrTx, + connectionId: string, + actor: EmailActor, + assignedProjectId?: string | null, +): Promise { + if (actor.kind !== 'owner') { + return false; + } + const rows = await db + .select({ projectId: emailDomainRoutes.project_id }) + .from(emailDomainRoutes) + .where( + and( + eq(emailDomainRoutes.connection_id, connectionId), + ne(emailDomainRoutes.desired_state, 'removed'), + isNotNull(emailDomainRoutes.project_id), + ), + ); + const projectIds = new Set(rows.flatMap((row) => (row.projectId ? [row.projectId] : []))); + if (assignedProjectId) { + projectIds.add(assignedProjectId); + } + if (projectIds.size === 0) { + return false; + } + for (const projectId of projectIds) { + try { + await requireProjectManagedEmailAccess(db, projectId, actor); + } catch (error) { + if (error instanceof EmailOperationError && (error.status === 403 || error.status === 404)) { + return false; + } + throw error; + } + } + return true; +} + function emailStepResourceIdentity( connection: typeof emailZoneConnections.$inferSelect, kind: StepKind, @@ -547,23 +617,10 @@ async function resumeBlockedEmailOperationTx( throw new EmailOperationError('email operation is unavailable', 404); } if (route && actor.kind === 'owner') { - const project = route.project_id - ? ( - await tx - .select({ userId: projects.user_id, status: projects.status }) - .from(projects) - .where(eq(projects.id, route.project_id)) - .limit(1) - )[0] - : null; - if ( - !project || - project.status === 'deleting' || - project.userId !== actor.userId || - route.managed_by !== 'owner' - ) { + if (!route.project_id || route.managed_by !== 'owner') { throw new EmailOperationError('forbidden', 403); } + await requireProjectManagedEmailAccess(tx, route.project_id, actor); } const connection = ( await tx @@ -581,6 +638,11 @@ async function resumeBlockedEmailOperationTx( .for('update') .limit(1) )[0]; + const canManage = + !!connection && + (snapshot.kind === 'remove' || + canManageConnection(actor, connection) || + (await hasDelegatedConnectionAccess(tx, connection.id, actor))); if ( !connection || !operation || @@ -588,7 +650,7 @@ async function resumeBlockedEmailOperationTx( operation.connection_id !== snapshot.connection_id || operation.route_id !== snapshot.route_id || operation.desired_generation !== snapshot.desired_generation || - !canManageConnection(actor, connection) + !canManage ) { throw new EmailOperationError('blocked email operation cannot be resumed', 409); } @@ -1351,9 +1413,7 @@ export async function requestEmailDomainAdd( if (!project || project.status === 'deleting') { throw new EmailOperationError('project not available', 409); } - if (actor.kind === 'owner' && project.user_id !== actor.userId) { - throw new EmailOperationError('forbidden', 403); - } + await requireProjectManagedEmailAccess(tx, input.projectId, actor); await lockEmailZoneConnectionIdentity(tx, installationId, zone.account.id, zone.id); const existingRoute = ( await tx @@ -1412,7 +1472,11 @@ export async function requestEmailDomainAdd( .limit(1) )[0]; const created = !connection; - if (connection && !canManageConnection(actor, connection)) { + const canManageExistingConnection = + !connection || + canManageConnection(actor, connection) || + (await hasDelegatedConnectionAccess(tx, connection.id, actor, existingRoute?.project_id)); + if (!canManageExistingConnection) { throw new EmailOperationError('zone connection requires its manager approval', 403); } if (existingRoute && existingRoute.connection_id !== connection?.id) { @@ -1438,7 +1502,7 @@ export async function requestEmailDomainAdd( if (existingRoute) { if ( !connection || - !canManageConnection(actor, connection) || + !canManageExistingConnection || existingRoute.managed_by !== actor.kind ) { throw new EmailOperationError( @@ -2103,7 +2167,10 @@ export async function requestEmailSecretRotation( if (!connection || connection.installation_id !== installationId) { throw new EmailOperationError('connection not found', 404); } - if (!canManageConnection(actor, connection)) { + if ( + !canManageConnection(actor, connection) && + !(await hasDelegatedConnectionAccess(tx, connection.id, actor)) + ) { throw new EmailOperationError('only the connection manager can rotate ingress', 403); } const replay = ( @@ -2339,13 +2406,17 @@ async function requestExistingOperation( ) { throw new EmailOperationError('email domain not available', 404); } + if (actor.kind === 'owner') { + if (!project || route.managed_by !== 'owner') { + throw new EmailOperationError('forbidden', 403); + } + await requireProjectManagedEmailAccess(tx, project.id, actor); + } if ( - actor.kind === 'owner' && - (project?.user_id !== actor.userId || route.managed_by !== 'owner') + kind === 'sync' && + !canManageConnection(actor, connection) && + !(await hasDelegatedConnectionAccess(tx, connection.id, actor)) ) { - throw new EmailOperationError('forbidden', 403); - } - if (kind === 'sync' && !canManageConnection(actor, connection)) { throw new EmailOperationError( 'only the connection manager can reconcile shared ingress', 403, diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index 5c4ea8013..ba6dea0d0 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -96,12 +96,12 @@ import internalVerifyOwner from './routes/internal-verify-owner.ts'; import internalVerifyPrerenderBuild from './routes/internal-verify-prerender-build.ts'; import me from './routes/me.ts'; import platformBootstrapRoutes from './routes/platform-bootstrap.ts'; -import projectTokens from './routes/project-tokens.ts'; import { myProjectInvitationRoutes, projectInvitationResponseRoutes, projectTeamRoutes, } from './routes/project-team.ts'; +import projectTokens from './routes/project-tokens.ts'; import projects from './routes/projects.ts'; import revalidate from './routes/revalidate.ts'; import secrets from './routes/secrets.ts'; diff --git a/platform/packages/api/src/middleware/auth.ts b/platform/packages/api/src/middleware/auth.ts index 7920bf988..fb31fea06 100644 --- a/platform/packages/api/src/middleware/auth.ts +++ b/platform/packages/api/src/middleware/auth.ts @@ -4,7 +4,7 @@ import { verifyJwt } from '../auth/jwt.ts'; import { validateHumanSession } from '../auth/sessions.ts'; import { ACTIVE_BUILD_STATUSES, getBuild, getUser } from '../db.ts'; import { type ApiEnv, isDevBypass } from '../env.ts'; -import { projectDeployCredentials, users } from '../schema.ts'; +import { projectDeployCredentials, projects, users } from '../schema.ts'; import { isAdminSuspended } from '../suspension.ts'; /** @@ -127,11 +127,13 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = .get('db') .select({ id: projectDeployCredentials.id }) .from(projectDeployCredentials) + .innerJoin(projects, eq(projects.id, projectDeployCredentials.project_id)) .where( and( eq(projectDeployCredentials.id, payload.credential), eq(projectDeployCredentials.project_id, payload.project), eq(projectDeployCredentials.issued_by, payload.sub), + eq(projects.user_id, payload.sub), isNull(projectDeployCredentials.revoked_at), gt(projectDeployCredentials.expires_at, new Date().toISOString()), ), diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 936c6fd53..6a0197e48 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -55,6 +55,7 @@ import { builds, deployments, githubConnections, + projectDeployCredentials, projectInvitations, projectMembers, projectOwnershipPeriods, @@ -517,6 +518,15 @@ async function commitOwnershipTransfer( }) .returning({ startsAt: projectOwnershipPeriods.starts_at }); const transferredAt = newPeriod!.startsAt; + await txDb + .update(projectDeployCredentials) + .set({ revoked_at: transferBoundary }) + .where( + and( + eq(projectDeployCredentials.project_id, projectId), + isNull(projectDeployCredentials.revoked_at), + ), + ); const [updatedProject] = await txDb .update(projects) .set({ user_id: newOwner.id }) diff --git a/platform/packages/api/src/routes/email.ts b/platform/packages/api/src/routes/email.ts index 74d1a2d04..bac23817d 100644 --- a/platform/packages/api/src/routes/email.ts +++ b/platform/packages/api/src/routes/email.ts @@ -494,22 +494,19 @@ const app = new Hono() // ------------------------------------------------------------------------- // Custom email domain routes. The connection service runs durable - // operations; these routes apply the owner guard and map responses. + // operations; these routes apply project capabilities and map responses. // ------------------------------------------------------------------------- // Installation settings the CLI needs before it acts: who manages email // domains here (an `admin` platform prints the administrator's command // instead of opening a Cloudflare consent) and the shared-sender recipient - // policy. Read-only for owners; the operator API and admin UI write them. + // policy. Read-only for project members; the operator API and admin UI write them. .get('/:id/email/settings', async (context) => { const projectId = context.req.param('id'); const db = context.get('db'); - const project = await getProject(db, projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } const [management, recipientPolicy] = await Promise.all([ getEmailDomainManagement(db), @@ -518,19 +515,16 @@ const app = new Hono() // Republish a drifted recipient-policy mirror for the proxy's local gate. await repairEmailRecipientPolicyMirror(db, context.env.ROUTING); return context.json({ - domains: { management, projectSlug: project.slug }, + domains: { management, projectSlug: access.project.slug }, recipientPolicy: { mode: recipientPolicy.mode, domains: [...recipientPolicy.domains] }, }); }) .get('/:id/email/domains', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } const rows = await context @@ -570,14 +564,11 @@ const app = new Hono() ); } const db = context.get('db'); - const project = await getProject(db, projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } - return addEmailDomainForProject(context, project, { + return addEmailDomainForProject(context, access.project, { domain: typeof body.domain === 'string' ? body.domain : '', apiToken: typeof body.apiToken === 'string' ? body.apiToken : undefined, attemptId: body.attemptId, @@ -586,35 +577,30 @@ const app = new Hono() }) .get('/:id/email/operations/:operationId', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, context.req.param('id'), 'read'); + if (access instanceof Response) { + return access; } - return getEmailOperationForProject(context, project, context.req.param('operationId')); + return getEmailOperationForProject(context, access.project, context.req.param('operationId')); }) .get('/:id/email/domains/:domain', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; } - return getEmailDomainStatusForProject(context, project, context.req.param('domain')); + return getEmailDomainStatusForProject(context, access.project, context.req.param('domain')); }) .post('/:id/email/domains/:domain/rotate-secret', async (context) => { - const project = await getProject(context.get('db'), context.req.param('id')); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability( + context, + context.req.param('id'), + 'settings', + ); + if (access instanceof Response) { + return access; } let attemptId: string | undefined; if (context.req.header('content-type')?.includes('application/json')) { @@ -636,7 +622,7 @@ const app = new Hono() } return rotateEmailIngressSecretForProject( context, - project, + access.project, context.req.param('domain'), 'owner', attemptId, @@ -645,28 +631,25 @@ const app = new Hono() .post('/:id/email/domains/:domain/sync', async (context) => { const projectId = context.req.param('id'); - const db = context.get('db'); - const project = await getProject(db, projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } - return syncEmailDomainForProject(context, project, context.req.param('domain'), 'owner'); + return syncEmailDomainForProject(context, access.project, context.req.param('domain'), 'owner'); }) .delete('/:id/email/domains/:domain', async (context) => { const projectId = context.req.param('id'); - const db = context.get('db'); - const project = await getProject(db, projectId); - if (!project) { - return context.json({ error: 'project not found' }, 404); - } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } - return removeEmailDomainForProject(context, project, context.req.param('domain'), 'owner'); + return removeEmailDomainForProject( + context, + access.project, + context.req.param('domain'), + 'owner', + ); }); function parseCounter(raw: string | null): number { diff --git a/platform/packages/api/src/routes/internal-verify-prerender-build.ts b/platform/packages/api/src/routes/internal-verify-prerender-build.ts index e1f8e7438..50c2ac3b2 100644 --- a/platform/packages/api/src/routes/internal-verify-prerender-build.ts +++ b/platform/packages/api/src/routes/internal-verify-prerender-build.ts @@ -1,18 +1,21 @@ import { and, eq, gt, isNull } from 'drizzle-orm'; import { Hono } from 'hono'; import { hmacVerifyState } from '../auth/hmac.ts'; -import { ACTIVE_BUILD_STATUSES, getBuild, getProject, getUser } from '../db.ts'; +import { ACTIVE_BUILD_STATUSES, getBuild, getUser } from '../db.ts'; import type { ApiEnv } from '../env.ts'; -import { projectDeployCredentials } from '../schema.ts'; +import { + ProjectAccessDeniedError, + ProjectNotFoundError, + requireProjectCapability, +} from '../project-access.ts'; +import { projectDeployCredentials, projects } from '../schema.ts'; import { isAdminSuspended } from '../suspension.ts'; const app = new Hono(); /** - * Authoritative check for build- and durable-CI-derived prerender credentials. - * The binding proxy calls this over its service binding for every request so - * termination, credential renewal/revocation/expiry, project reassignment, and - * owner suspension take effect before the derived JWT expires. + * Authoritative check for prerender credentials. The binding proxy calls this + * for every request so access changes take effect before the JWT expires. */ app.post('/:projectId', async (context) => { const projectId = context.req.param('projectId'); @@ -29,19 +32,25 @@ app.post('/:projectId', async (context) => { buildId?: string; credentialId?: string; }>(); - if (!userId || (!buildId && !credentialId)) { - return context.json({ error: 'userId and credential provenance required' }, 400); + if (!userId) { + return context.json({ error: 'userId required' }, 400); } const db = context.get('db'); - const [user, project] = await Promise.all([getUser(db, userId), getProject(db, projectId)]); - if ( - !user || - isAdminSuspended(user.suspended_reason) || - !project || - project.status === 'deleting' || - project.user_id !== userId - ) { + const user = await getUser(db, userId); + if (!user || isAdminSuspended(user.suspended_reason)) { + return context.json({ error: 'prerender authority is no longer active' }, 403); + } + let access; + try { + access = await requireProjectCapability(db, projectId, userId, 'deploy'); + } catch (error) { + if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { + return context.json({ error: 'prerender authority is no longer active' }, 403); + } + throw error; + } + if (access.project.status === 'deleting') { return context.json({ error: 'prerender authority is no longer active' }, 403); } @@ -61,11 +70,13 @@ app.post('/:projectId', async (context) => { const credential = await db .select({ id: projectDeployCredentials.id }) .from(projectDeployCredentials) + .innerJoin(projects, eq(projects.id, projectDeployCredentials.project_id)) .where( and( eq(projectDeployCredentials.id, credentialId), eq(projectDeployCredentials.project_id, projectId), eq(projectDeployCredentials.issued_by, userId), + eq(projects.user_id, userId), isNull(projectDeployCredentials.revoked_at), gt(projectDeployCredentials.expires_at, new Date().toISOString()), ), diff --git a/platform/packages/api/src/routes/project-tokens.ts b/platform/packages/api/src/routes/project-tokens.ts index d53b9d792..2d2752014 100644 --- a/platform/packages/api/src/routes/project-tokens.ts +++ b/platform/packages/api/src/routes/project-tokens.ts @@ -1,9 +1,9 @@ import { and, desc, eq, isNull } from 'drizzle-orm'; import { Hono, type Context } from 'hono'; import { PROJECT_CREDENTIAL_MAX_DAYS, signProjectCredentialToken } from '../auth/deploy-token.ts'; -import { getProject } from '../db.ts'; +import { getProject, type DbOrTx } from '../db.ts'; import type { ApiEnv } from '../env.ts'; -import { projectDeployCredentials } from '../schema.ts'; +import { projectDeployCredentials, projects } from '../schema.ts'; const app = new Hono(); @@ -36,6 +36,16 @@ async function ownedProject(context: Context) { return project; } +async function lockOwnedProject(db: DbOrTx, projectId: string, userId: string) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + return project?.user_id === userId ? project : null; +} + function publicCredential(row: typeof projectDeployCredentials.$inferSelect) { return { id: row.id, @@ -62,8 +72,8 @@ app.get('/:projectId/tokens', async (context) => { }); app.post('/:projectId/tokens', async (context) => { - const project = await ownedProject(context); - if (!project) { + const projectId = context.req.param('projectId'); + if (!projectId) { return context.json({ error: 'project not found' }, 404); } const body = await context.req @@ -81,26 +91,35 @@ app.post('/:projectId/tokens', async (context) => { const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); const user = context.get('user'); const token = await signProjectCredentialToken( - { credentialId: id, projectId: project.id, userId: user.id, login: user.login, expiresAt }, + { credentialId: id, projectId, userId: user.id, login: user.login, expiresAt }, context.env.JWT_SECRET, ); - const [row] = await context - .get('db') - .insert(projectDeployCredentials) - .values({ - id, - project_id: project.id, - issued_by: user.id, - name, - expires_at: expiresAt.toISOString(), - }) - .returning(); - return context.json({ ...publicCredential(row!), token }, 201); + const row = await context.get('db').transaction(async (tx) => { + const project = await lockOwnedProject(tx, projectId, user.id); + if (!project) { + return null; + } + const [created] = await tx + .insert(projectDeployCredentials) + .values({ + id, + project_id: project.id, + issued_by: user.id, + name, + expires_at: expiresAt.toISOString(), + }) + .returning(); + return created!; + }); + if (!row) { + return context.json({ error: 'project not found' }, 404); + } + return context.json({ ...publicCredential(row), token }, 201); }); app.post('/:projectId/tokens/:credentialId/renew', async (context) => { - const project = await ownedProject(context); - if (!project) { + const projectId = context.req.param('projectId'); + if (!projectId) { return context.json({ error: 'project not found' }, 404); } const body = await context.req @@ -118,7 +137,7 @@ app.post('/:projectId/tokens/:credentialId/renew', async (context) => { const token = await signProjectCredentialToken( { credentialId: replacementId, - projectId: project.id, + projectId, userId: user.id, login: user.login, expiresAt, @@ -126,6 +145,10 @@ app.post('/:projectId/tokens/:credentialId/renew', async (context) => { context.env.JWT_SECRET, ); const result = await context.get('db').transaction(async (transaction) => { + const project = await lockOwnedProject(transaction, projectId, user.id); + if (!project) { + return 'project-not-found' as const; + } const [current] = await transaction .select() .from(projectDeployCredentials) @@ -156,6 +179,9 @@ app.post('/:projectId/tokens/:credentialId/renew', async (context) => { .returning(); return replacement!; }); + if (result === 'project-not-found') { + return context.json({ error: 'project not found' }, 404); + } if (!result) { return context.json({ error: 'active credential not found' }, 404); } @@ -163,22 +189,32 @@ app.post('/:projectId/tokens/:credentialId/renew', async (context) => { }); app.delete('/:projectId/tokens/:credentialId', async (context) => { - const project = await ownedProject(context); - if (!project) { + const projectId = context.req.param('projectId'); + if (!projectId) { + return context.json({ error: 'project not found' }, 404); + } + const user = context.get('user'); + const row = await context.get('db').transaction(async (tx) => { + const project = await lockOwnedProject(tx, projectId, user.id); + if (!project) { + return 'project-not-found' as const; + } + const [revoked] = await tx + .update(projectDeployCredentials) + .set({ revoked_at: new Date().toISOString() }) + .where( + and( + eq(projectDeployCredentials.id, context.req.param('credentialId')), + eq(projectDeployCredentials.project_id, project.id), + isNull(projectDeployCredentials.revoked_at), + ), + ) + .returning(); + return revoked ?? null; + }); + if (row === 'project-not-found') { return context.json({ error: 'project not found' }, 404); } - const [row] = await context - .get('db') - .update(projectDeployCredentials) - .set({ revoked_at: new Date().toISOString() }) - .where( - and( - eq(projectDeployCredentials.id, context.req.param('credentialId')), - eq(projectDeployCredentials.project_id, project.id), - isNull(projectDeployCredentials.revoked_at), - ), - ) - .returning(); if (!row) { return context.json({ error: 'active credential not found' }, 404); } diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index ce465b286..58ba0ecd1 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -1410,6 +1410,9 @@ describe('POST /admin/projects/:id/owner', () => { const owner = await createTestUser(env.db, 'browser-convergence-owner'); const project = await createTestProject(env.db, owner.id, 'browser-convergence-project'); const deployment = await createTestDeployment(env.db, project.id, 'pending'); + await env.db + .delete(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)); await env.db.insert(projectOwnershipPeriods).values([ { id: 'pown_browser_convergence_former', diff --git a/platform/packages/api/test/integration/api-email-domains.test.ts b/platform/packages/api/test/integration/api-email-domains.test.ts index 0b78c5118..64896f99a 100644 --- a/platform/packages/api/test/integration/api-email-domains.test.ts +++ b/platform/packages/api/test/integration/api-email-domains.test.ts @@ -16,6 +16,7 @@ import { emailOperations, } from '../../src/email/connection-schema.ts'; import app from '../../src/index.ts'; +import { projectMembers } from '../../src/schema.ts'; import { stubCustomerCf } from './email-domain-cf-stub.ts'; import { clearTables, @@ -63,6 +64,81 @@ afterEach(() => { }); describe('project email domain authority', () => { + it('allows members to read email state and only admins to change domains', async () => { + const owner = await createTestUser(env.db, 'email-team-owner'); + const reader = await createTestUser(env.db, 'email-team-reader'); + const collaborator = await createTestUser(env.db, 'email-team-collaborator'); + const admin = await createTestUser(env.db, 'email-team-admin'); + const project = await createTestProject(env.db, owner.id, 'email-team-project'); + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }, + ]); + stubCustomerCf({ zones }); + + for (const member of [reader, collaborator, admin]) { + expect((await call(project.id, member.jwt, 'GET', '/settings')).status).toBe(200); + expect((await call(project.id, member.jwt, 'GET', '/domains')).status).toBe(200); + } + expect( + ( + await call(project.id, collaborator.jwt, 'POST', '/domains', { + domain: 'team.example.com', + apiToken: token, + }) + ).status, + ).toBe(403); + expect( + ( + await call(project.id, owner.jwt, 'POST', '/domains', { + domain: 'team.example.com', + apiToken: token, + }) + ).status, + ).toBe(200); + expect((await call(project.id, reader.jwt, 'GET', '/domains/team.example.com')).status).toBe( + 200, + ); + expect( + ( + await call(project.id, admin.jwt, 'POST', '/domains', { + domain: 'team.example.com', + apiToken: token, + }) + ).status, + ).toBe(200); + expect( + (await call(project.id, admin.jwt, 'POST', '/domains/team.example.com/sync')).status, + ).toBe(200); + expect((await call(project.id, admin.jwt, 'DELETE', '/domains/team.example.com')).status).toBe( + 200, + ); + expect( + ( + await call(project.id, admin.jwt, 'POST', '/domains', { + domain: 'team.example.com', + apiToken: token, + }) + ).status, + ).toBe(200); + }); + it('persists token identity and expiry and reports a known expired credential locally', async () => { const owner = await createTestUser(env.db, 'expiry-owner'); const project = await createTestProject(env.db, owner.id, 'expiry-mail'); diff --git a/platform/packages/api/test/integration/api-project-access-routes.test.ts b/platform/packages/api/test/integration/api-project-access-routes.test.ts index 3e581c946..82ae44fdc 100644 --- a/platform/packages/api/test/integration/api-project-access-routes.test.ts +++ b/platform/packages/api/test/integration/api-project-access-routes.test.ts @@ -125,6 +125,9 @@ describe('ownership-period account usage', () => { .update(users) .set({ billing_cycle_start: billingStart.toISOString() }) .where(eq(users.id, formerOwner.id)); + await env.db + .delete(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)); await env.db.insert(projectOwnershipPeriods).values([ { id: 'pown_former', diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 8b9026ef4..48615571a 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -43,7 +43,7 @@ import { cfError, type TestEnv, } from './helpers.ts'; -import { setGetProjectOverride, setTestDb } from './setup.ts'; +import { setTestDb } from './setup.ts'; let env: TestEnv; @@ -57,7 +57,6 @@ afterAll(async () => { beforeEach(async () => { await clearTables(env.db); - setGetProjectOverride(null); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -424,6 +423,39 @@ describe('POST /projects/:id/prerender-token', () => { expect(payload!.exp - payload!.iat).toBe(65 * 60); }); + it('revokes a collaborator manual prerender token when the member is removed', async () => { + const owner = await createTestUser(env.db, 'prerender-removal-owner'); + const collaborator = await createTestUser(env.db, 'prerender-removal-collaborator'); + const project = await createTestProject(env.db, owner.id, 'prerender-removal-app'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + + const minted = await app.fetch( + new Request(`http://localhost/projects/${project.id}/prerender-token`, { + method: 'POST', + headers: { Authorization: `Bearer ${collaborator.jwt}` }, + }), + env, + ); + expect(minted.status).toBe(200); + const { token } = await minted.json<{ token: string }>(); + expect((await proxyRequest(token, project.id)).status).toBe(200); + + const removed = await app.fetch( + new Request(`http://localhost/projects/${project.id}/members/${collaborator.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${owner.jwt}` }, + }), + env, + ); + expect(removed.status).toBe(204); + expect((await proxyRequest(token, project.id)).status).toBe(403); + }); + it('allows the project-scoped deploy token to mint it', async () => { const user = await createTestUser(env.db, 'prerender-ci'); const project = await createTestProject(env.db, user.id, 'prerender-ci-app'); @@ -550,6 +582,36 @@ describe('POST /projects/:id/prerender-token', () => { expect((await proxyRequest(suspended.derivedToken, project.id)).status).toBe(403); }); + it('revokes durable deploy and derived prerender credentials after ownership transfer', async () => { + const formerOwner = await createTestUser(env.db, 'prerender-transfer-former-owner'); + const newOwner = await createTestUser(env.db, 'prerender-transfer-new-owner'); + const project = await createTestProject( + env.db, + formerOwner.id, + 'prerender-transfer-credential-app', + ); + const credential = await issueCiPrerenderToken(formerOwner, project, 'transfer-parent'); + + await env.db.transaction(async (tx) => { + await tx.update(projects).set({ user_id: newOwner.id }).where(eq(projects.id, project.id)); + await tx.insert(projectMembers).values({ + project_id: project.id, + user_id: formerOwner.id, + role: 'admin', + added_by_user_id: newOwner.id, + }); + }); + + const parent = await app.fetch( + new Request('http://localhost/projects', { + headers: { Authorization: `Bearer ${credential.token}` }, + }), + env, + ); + expect(parent.status).toBe(401); + expect((await proxyRequest(credential.derivedToken, project.id)).status).toBe(403); + }); + it('retains validated credential provenance when the parent JWT expires before minting', async () => { const startedAt = new Date('2026-09-18T00:00:00.000Z').getTime(); vi.useFakeTimers({ toFake: ['Date'] }); @@ -577,11 +639,12 @@ describe('POST /projects/:id/prerender-token', () => { env.JWT_SECRET, ); - setGetProjectOverride(({ original, argsList }) => { + const requireCapability = projectAccess.requireProjectCapability; + vi.spyOn(projectAccess, 'requireProjectCapability').mockImplementation(async (...args) => { // authMiddleware has already verified the real JWT and credential row; - // the route's first project read crosses the JWT expiry boundary. + // the route capability check crosses the JWT expiry boundary. vi.setSystemTime(startedAt + 2_000); - return original(...argsList); + return requireCapability(...args); }); const response = await app.fetch( new Request(`http://localhost/projects/${project.id}/prerender-token`, { @@ -605,7 +668,6 @@ describe('POST /projects/:id/prerender-token', () => { // credential-bound derived token is immediately rejected authoritatively. expect((await proxyRequest(token, project.id)).status).toBe(403); } finally { - setGetProjectOverride(null); vi.useRealTimers(); } }); diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index f77b5f513..d97d73853 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -30,6 +30,7 @@ import { platformLoginConnections, platformLoginIdentities, platformLoginRevisions, + projectDeployCredentials, projectInvitations, projectMembers, projectOwnershipPeriods, @@ -200,6 +201,9 @@ describe('operator tenant reads', () => { .update(users) .set({ billing_cycle_start: '2020-01-01T00:00:00.000Z' }) .where(eq(users.id, currentOwner.id)); + await env.db + .delete(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)); await env.db.insert(projectOwnershipPeriods).values([ { id: 'pown_operator_former', @@ -292,6 +296,10 @@ describe('operator tenant mutations', () => { expires_at: new Date(Date.now() + 86_400_000).toISOString(), }); const deployment = await createTestDeployment(env.db, project.id, 'pending'); + const ownershipPeriodsBefore = await env.db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)); const putSpy = vi.spyOn(env.ROUTING, 'put'); const doGet = vi.fn(); @@ -327,7 +335,12 @@ describe('operator tenant mutations', () => { expect( (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], ).toMatchObject({ user_id: owner.id }); - expect(await env.db.select().from(projectOwnershipPeriods)).toEqual([]); + expect( + await env.db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)), + ).toEqual(ownershipPeriodsBefore); expect( await env.db.select().from(projectMembers).where(eq(projectMembers.project_id, project.id)), ).toMatchObject([{ user_id: target.id, role: 'collaborator' }]); @@ -364,6 +377,13 @@ describe('operator tenant mutations', () => { invited_by_user_id: owner.id, expires_at: new Date(Date.now() + 86_400_000).toISOString(), }); + await env.db.insert(projectDeployCredentials).values({ + id: 'transfer-deploy-credential', + project_id: project.id, + issued_by: owner.id, + name: 'transfer credential', + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }); await env.ROUTING.put( project.slug, JSON.stringify({ workerName: 'worker', projectId: project.id, userId: owner.id }), @@ -417,6 +437,11 @@ describe('operator tenant mutations', () => { const current = periods.find((period) => period.owner_user_id === target.id)!; expect(former.ends_at).toBe(current.starts_at); expect(current).toMatchObject({ ends_at: null, changed_by_user_id: operatorAdminId }); + const [revokedCredential] = await env.db + .select() + .from(projectDeployCredentials) + .where(eq(projectDeployCredentials.id, 'transfer-deploy-credential')); + expect(Date.parse(revokedCredential!.revoked_at!)).toBe(Date.parse(current.starts_at)); expect(counterCalls).toEqual([ { ownerId: owner.id, diff --git a/platform/packages/api/test/integration/project-access.test.ts b/platform/packages/api/test/integration/project-access.test.ts index 2e6257f50..39b3bb095 100644 --- a/platform/packages/api/test/integration/project-access.test.ts +++ b/platform/packages/api/test/integration/project-access.test.ts @@ -15,7 +15,13 @@ import { ProjectOwnerSuspendedError, requireProjectCapability, } from '../../src/project-access.ts'; -import { projectMembers, projectOwnershipPeriods, users } from '../../src/schema.ts'; +import { + deployments, + projectMembers, + projectOwnershipPeriods, + projects, + users, +} from '../../src/schema.ts'; import { clearTables, createTestUser, setupEnv, teardownEnv, type TestEnv } from './helpers.ts'; let env: TestEnv; @@ -136,6 +142,36 @@ describe('project access', () => { expect(deployment.initiated_by_user_id).toBe(actor.id); }); + it('attributes projects and deployments inserted by the previous API revision', async () => { + const owner = await createTestUser(env.db, 'legacy-attribution-owner'); + const [project] = await env.db + .insert(projects) + .values({ id: 'proj_legacy_attribution', user_id: owner.id, slug: 'legacy-attribution' }) + .returning(); + const [deployment] = await env.db + .insert(deployments) + .values({ + id: 'dpl_legacy_attribution', + project_id: project.id, + manifest: '{}', + }) + .returning(); + + expect( + await env.db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)), + ).toEqual([ + expect.objectContaining({ + project_id: project.id, + owner_user_id: owner.id, + starts_at: project.created_at, + }), + ]); + expect(deployment.billing_user_id).toBe(owner.id); + }); + it('rejects a team mutation after the actor loses admin access', async () => { const owner = await userWithEmail('lock-owner'); const admin = await userWithEmail('lock-admin'); @@ -183,7 +219,11 @@ describe('project access', () => { added_by_user_id: owner.id, }, ]); - const internalAuth = await hmacSignState(project.id, env.JWT_SECRET); + const proxyOrigin = 'https://proxy.example.com'; + const internalAuth = await hmacSignState( + `void-proxy-owner-v2\n${project.id}\n${proxyOrigin}`, + env.JWT_SECRET, + ); const verify = (userId: string) => app.fetch( new Request(`http://localhost/internal/verify-owner/${project.id}`, { @@ -192,7 +232,7 @@ describe('project access', () => { 'content-type': 'application/json', 'x-void-internal': internalAuth, }, - body: JSON.stringify({ userId }), + body: JSON.stringify({ userId, proxyOrigin }), }), env, ); diff --git a/platform/packages/proxy/src/index.ts b/platform/packages/proxy/src/index.ts index c43045e04..d79e18a55 100644 --- a/platform/packages/proxy/src/index.ts +++ b/platform/packages/proxy/src/index.ts @@ -171,27 +171,27 @@ app.post('/*', async (context, next) => { if (!payload.project || payload.project !== projectId) { return context.json({ error: 'forbidden' }, 403); } - if (payload.build || payload.credential) { - const active = await verifyPrerenderAuthority( - context.env.API, - context.env.JWT_SECRET, - projectId, - payload.sub, + const active = await verifyPrerenderAuthority( + context.env.API, + context.env.JWT_SECRET, + projectId, + payload.sub, + { + ...(payload.build ? { buildId: payload.build } : {}), + ...(payload.credential ? { credentialId: payload.credential } : {}), + }, + ); + if (!active) { + return context.json( { - ...(payload.build ? { buildId: payload.build } : {}), - ...(payload.credential ? { credentialId: payload.credential } : {}), + error: payload.credential + ? 'prerender token credential is no longer active' + : payload.build + ? 'prerender token build is no longer active' + : 'prerender token authority is no longer active', }, + 403, ); - if (!active) { - return context.json( - { - error: payload.credential - ? 'prerender token credential is no longer active' - : 'prerender token build is no longer active', - }, - 403, - ); - } } } else { // Full API tokens retain the local-development ownership lookup. diff --git a/platform/packages/proxy/test/unit/proxy.test.ts b/platform/packages/proxy/test/unit/proxy.test.ts index 8248cc2ad..974b5397f 100644 --- a/platform/packages/proxy/test/unit/proxy.test.ts +++ b/platform/packages/proxy/test/unit/proxy.test.ts @@ -542,7 +542,9 @@ describe('proxy worker', () => { ); expect(allowed.status).toBe(200); expect(apiFetch).toHaveBeenCalledTimes(1); - expect(String(apiFetch.mock.calls[0]?.[0])).toContain('/internal/verify-owner/proj_1'); + expect(String(apiFetch.mock.calls[0]?.[0])).toContain( + '/internal/verify-prerender-build/proj_1', + ); const denied = await worker.fetch( post( @@ -556,6 +558,35 @@ describe('proxy worker', () => { expect(await denied.json()).toEqual({ error: 'forbidden' }); }); + it('revokes an already-minted manual prerender token when project access changes', async () => { + const token = await signJwt( + { sub: 'user_1', login: 'testuser', aud: 'prerender', project: 'proj_1' }, + JWT_SECRET, + ); + let active = true; + const api = createMockApi(async (input) => { + expect(String(input)).toContain('/internal/verify-prerender-build/proj_1'); + return active + ? Response.json({ ok: true }) + : Response.json({ error: 'forbidden' }, { status: 403 }); + }); + const env = createEnv(undefined, undefined, api); + const request = () => + post( + '/ai/run', + { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { authorization: `Bearer ${token}` }, + ); + + expect((await worker.fetch(request(), env)).status).toBe(200); + active = false; + const revoked = await worker.fetch(request(), env); + expect(revoked.status).toBe(403); + expect(await revoked.json()).toEqual({ + error: 'prerender token authority is no longer active', + }); + }); + it('revokes an already-minted build-bound prerender token when its build terminates', async () => { const token = await signJwt( { diff --git a/platform/packages/proxy/test/unit/resolve.test.ts b/platform/packages/proxy/test/unit/resolve.test.ts index c4875759d..ff27ea181 100644 --- a/platform/packages/proxy/test/unit/resolve.test.ts +++ b/platform/packages/proxy/test/unit/resolve.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vite-plus/test'; -import { resolveResource, clearResolveCache, verifyProjectOwner } from '../../src/resolve.ts'; +import { + resolveResource, + clearResolveCache, + verifyProjectDeployAccess, +} from '../../src/resolve.ts'; const JWT_SECRET = 'test-secret-key'; @@ -71,11 +75,11 @@ describe('resolveResource', () => { }); }); -describe('verifyProjectOwner', () => { +describe('verifyProjectDeployAccess', () => { it('cryptographically binds the internal check to the actual proxy origin', async () => { const api = { fetch: vi.fn(async () => Response.json({ ok: true })) } as unknown as Fetcher; await expect( - verifyProjectOwner( + verifyProjectDeployAccess( api, JWT_SECRET, 'proj_1', @@ -96,7 +100,7 @@ describe('verifyProjectOwner', () => { const missing = { fetch: vi.fn() } as unknown as Fetcher; await expect( - verifyProjectOwner(missing, JWT_SECRET, 'proj_1', 'user_1', 'jwt', 'access'), + verifyProjectDeployAccess(missing, JWT_SECRET, 'proj_1', 'user_1', 'jwt', 'access'), ).resolves.toBe(false); expect(missing.fetch).not.toHaveBeenCalled(); }); From 0b3cfd547b80cf5b89b522b18fbf54bb7357080a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 21 Sep 2026 10:42:26 +0800 Subject: [PATCH 12/18] fix(platform): serialize email destination access --- platform/packages/api/src/db.ts | 36 +++++-- platform/packages/api/src/routes/email.ts | 75 ++++++++++++-- platform/packages/api/src/routes/projects.ts | 5 +- .../api/test/integration/api-email.test.ts | 98 +++++++++++++++++++ 4 files changed, 195 insertions(+), 19 deletions(-) diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 1d0c883e7..a1be5a5a8 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -23,6 +23,7 @@ import { alias } from 'drizzle-orm/pg-core'; import { lockAuthSettings } from './auth/configuration.ts'; import { lockEmailPolicy } from './email/admission.ts'; import { generateId } from './id.ts'; +import { requireProjectCapability } from './project-access.ts'; import * as schema from './schema.ts'; import { users, @@ -2290,6 +2291,21 @@ export interface OwnerVerificationAudit { emailSnapshot: string; } +type EmailDestinationMutationOptions = Readonly<{ + ownerAudit?: OwnerVerificationAudit; + actorUserId?: string; +}>; + +async function requireEmailDestinationSettingsAccess( + db: DbOrTx, + projectId: string, + actorUserId: string | undefined, +): Promise { + if (actorUserId) { + await requireProjectCapability(db as Db, projectId, actorUserId, 'settings'); + } +} + /** * RETURNING identifies the sole inserter so only it sends a verification email. * A missing row means it was concurrently deleted; callers must retry. @@ -2297,6 +2313,7 @@ export interface OwnerVerificationAudit { * Inherited owner consent requires a FOR SHARE lock on users: an email change * must either commit before this check or revoke the inserted row afterward. * Lock projects first to match build transactions and avoid a lock-order cycle. + * A supplied actor is reauthorized after that project lock is acquired. */ export async function tryCreateEmailDestination( db: Db, @@ -2306,15 +2323,9 @@ export async function tryCreateEmailDestination( status: EmailDestination['status'] = 'pending', verifiedAt?: string | null, verificationSource: EmailDestination['verification_source'] = 'cf', - /** - * Audit columns populated only on the owner-relaxed CF reuse branch - * (Option C in design doc 0078) — when we inherit the project owner's - * prior consent on a different project for THIS project. Pass - * `undefined` for every other path; the helper writes NULL columns - * so revocation only touches inherited-consent rows. - */ - ownerAudit?: OwnerVerificationAudit, + options: EmailDestinationMutationOptions = {}, ): Promise<{ row: EmailDestination | null; inserted: boolean; ownerEmailChanged?: true }> { + const { actorUserId, ownerAudit } = options; const values = { id: generateId('edst'), project_id: projectId, @@ -2357,6 +2368,7 @@ export async function tryCreateEmailDestination( if (!project || project.status === 'deleting') { return 'project-unavailable' as const; } + await requireEmailDestinationSettingsAccess(tx, projectId, actorUserId); return insertRow(tx); }); if (outcome === 'project-unavailable') { @@ -2375,6 +2387,7 @@ export async function tryCreateEmailDestination( if (!project || project.status === 'deleting') { return 'project-unavailable' as const; } + await requireEmailDestinationSettingsAccess(tx, projectId, actorUserId); if (project.ownerUserId !== ownerAudit.userId) { return 'owner-email-changed' as const; } @@ -2616,7 +2629,11 @@ async function queueDestinationCleanupTx(tx: Tx, cfDestinationId: string): Promi } /** Revoke one project's consent and durably queue the last provider tag. */ -export async function deleteEmailDestination(db: Db, id: string): Promise { +export async function deleteEmailDestination( + db: Db, + id: string, + actorUserId?: string, +): Promise { return db.transaction(async (tx) => { await lockEmailPolicy(tx); const [existing] = await tx @@ -2632,6 +2649,7 @@ export async function deleteEmailDestination(db: Db, id: string): Promise() // marker the recovered-owner insert below carries, because both mean // "nothing went out; the row waits on a Cloudflare link mailed earlier". if (existing.status === 'pending' && existing.verification_source === 'platform') { + const accessResponse = await recheckEmailDestinationSettingsAccess(context, projectId); + if (accessResponse) { + return accessResponse; + } await dispatchPlatformVerification(context, project, existing, address); return context.json( { ...serializeDestination(existing), platformVerificationPending: true }, @@ -349,7 +352,7 @@ const app = new Hono() emailSnapshot: ownerEmail!, }; const inserted = await tryInsertWithRetry( - context.get('db'), + context, projectId, address, cfRow.tag, @@ -358,6 +361,9 @@ const app = new Hono() 'cf', ownerAudit, ); + if (inserted instanceof Response) { + return inserted; + } if (!inserted.row) { // Either a sibling DELETE ate the row mid-INSERT-then-SELECT twice, // or project teardown's cascade landed between the `status` guard @@ -394,7 +400,7 @@ const app = new Hono() // address first". It fails safe, and it self-heals the moment the // recipient clicks CF's link. No machinery for it. const inserted = await tryInsertWithRetry( - context.get('db'), + context, projectId, address, cfRow.tag, @@ -402,6 +408,9 @@ const app = new Hono() null, 'platform', ); + if (inserted instanceof Response) { + return inserted; + } if (!inserted.row) { await rollbackCfDestination( context.get('db'), @@ -438,7 +447,7 @@ const app = new Hono() // bare 201 read as "a link went out": `void email allow` prints // "Verification email sent" from the status code alone. const inserted = await tryInsertWithRetry( - context.get('db'), + context, projectId, address, cfRow.tag, @@ -446,6 +455,9 @@ const app = new Hono() null, 'cf', ); + if (inserted instanceof Response) { + return inserted; + } if (!inserted.row) { await rollbackCfDestination( context.get('db'), @@ -481,7 +493,16 @@ const app = new Hono() return context.json({ error: 'destination not found' }, 404); } - const cleanupTag = await deleteEmailDestination(context.get('db'), dest.id); + let cleanupTag: string | null; + try { + cleanupTag = await deleteEmailDestination(context.get('db'), dest.id, context.get('user').id); + } catch (error) { + const accessResponse = projectAccessErrorResponse(context, error); + if (accessResponse) { + return accessResponse; + } + throw error; + } if (cleanupTag) { await runEmailDestinationCleanup( context.get('db'), @@ -681,6 +702,29 @@ function serializeDestination(row: EmailDestination) { }; } +function projectAccessErrorResponse(context: Context, error: unknown): Response | null { + if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { + return context.json({ error: error.message }, error.status); + } + return null; +} + +async function recheckEmailDestinationSettingsAccess( + context: Context, + projectId: string, +): Promise { + return context.get('db').transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + const access = await requireProjectRouteCapability( + context, + projectId, + 'settings', + tx as unknown as Db, + ); + return access instanceof Response ? access : null; + }); +} + /** * Mint a fresh 24h confirmation token for a platform-pending row and mail * the link (Option B). Shared by the first insert and the repeat-POST @@ -727,7 +771,7 @@ async function dispatchPlatformVerification( * and lets the caller retire the provider tag. */ async function tryInsertWithRetry( - db: Db, + context: Context, projectId: string, address: string, cfDestinationId: string | null, @@ -735,7 +779,8 @@ async function tryInsertWithRetry( verifiedAt: string | null, verificationSource: EmailDestination['verification_source'], ownerAudit?: OwnerVerificationAudit, -): Promise<{ row: EmailDestination | null; inserted: boolean }> { +): Promise<{ row: EmailDestination | null; inserted: boolean } | Response> { + const db = context.get('db'); for (let attempt = 0; attempt < 2; attempt++) { let result: { row: EmailDestination | null; inserted: boolean; ownerEmailChanged?: true }; try { @@ -747,9 +792,21 @@ async function tryInsertWithRetry( status, verifiedAt, verificationSource, - ownerAudit, + { actorUserId: context.get('user').id, ownerAudit }, ); } catch (err) { + const accessResponse = projectAccessErrorResponse(context, err); + if (accessResponse) { + if (cfDestinationId) { + await rollbackCfDestination( + db, + context.env.CF_ACCOUNT_ID, + context.env.CF_API_TOKEN, + cfDestinationId, + ); + } + return accessResponse; + } if (!isForeignKeyViolation(err)) { throw err; } diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index 48fb6d920..19813c745 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -3288,7 +3288,10 @@ async function autoAddOwnerDestination( status, cfRow.verified ?? null, 'cf', - status === 'verified' ? { userId: ownerUserId, emailSnapshot: address } : undefined, + { + ownerAudit: + status === 'verified' ? { userId: ownerUserId, emailSnapshot: address } : undefined, + }, ); if (ownerEmailChanged) { // The owner's address moved while CF was answering. The consent this diff --git a/platform/packages/api/test/integration/api-email.test.ts b/platform/packages/api/test/integration/api-email.test.ts index c9e3504dc..36f75cadb 100644 --- a/platform/packages/api/test/integration/api-email.test.ts +++ b/platform/packages/api/test/integration/api-email.test.ts @@ -51,6 +51,7 @@ import { emailDestinationCleanups, emailDestinations, platformSettings, + projectMembers, projects, users, } from '../../src/schema.ts'; @@ -175,6 +176,19 @@ async function setUserEmail(userId: string, email: string): Promise { return email; } +async function createProjectWithAdmin(label: string) { + const owner = await createTestUser(env.db, `${label}-owner`); + const admin = await createTestUser(env.db, `${label}-admin`); + const project = await createTestProject(env.db, owner.id, `${label}-project`); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }); + return { owner, admin, project }; +} + async function readVerifiedDestinations(projectId: string): Promise> { return (await rowsFor(projectId)) .filter((row) => row.status === 'verified') @@ -284,6 +298,58 @@ describe('POST /projects/:id/email/destinations', () => { expect(await rowsFor(project.id)).toHaveLength(0); }); + it('rejects an add when the project admin is demoted during the provider call', async () => { + const { owner, admin, project } = await createProjectWithAdmin('add-race'); + const fetchMock = mockCfApi({ + addEmailDestination: async () => { + await dbModule.setProjectMemberRole(env.db, { + projectId: project.id, + subjectUserId: admin.id, + role: 'collaborator', + actorUserId: owner.id, + }); + return cfSuccess(cfRow('cf-tag-add-race', 'race@example.com', null)); + }, + }); + + const response = await addDestination(project.id, admin.jwt, 'race@example.com', emailEnv()); + + expect(response.status).toBe(403); + expect(await rowsFor(project.id)).toHaveLength(0); + expect(recordedVerificationSends()).toHaveLength(0); + expect(cfCalls(fetchMock, 'DELETE', '/email/routing/addresses/')).toHaveLength(1); + }); + + it('does not resend verification after the project admin is demoted', async () => { + const { owner, admin, project } = await createProjectWithAdmin('resend-race'); + await env.db.insert(emailDestinations).values({ + id: 'edst_resend_race', + project_id: project.id, + address: 'race@example.com', + status: 'pending', + cf_destination_id: 'cf-tag-resend-race', + verification_source: 'platform', + }); + mockCfApi(); + const getDestination = dbModule.getEmailDestinationByAddress; + vi.spyOn(dbModule, 'getEmailDestinationByAddress').mockImplementationOnce(async (...args) => { + const destination = await getDestination(...args); + await dbModule.setProjectMemberRole(env.db, { + projectId: project.id, + subjectUserId: admin.id, + role: 'collaborator', + actorUserId: owner.id, + }); + return destination; + }); + + const response = await addDestination(project.id, admin.jwt, 'race@example.com', emailEnv()); + + expect(response.status).toBe(403); + expect(recordedVerificationSends()).toHaveLength(0); + expect(await rowsFor(project.id)).toHaveLength(1); + }); + it('returns 409 once the project is being torn down', async () => { // Teardown snapshots the CF destination tags it has to clean up ONCE. A // destination registered after that snapshot but before the cascade drops @@ -857,6 +923,38 @@ describe('DELETE /projects/:id/email/destinations/:destId', () => { expect(await rowsFor(project.id)).toHaveLength(1); }); + it('keeps the destination when the project admin is demoted before the locked delete', async () => { + const { owner, admin, project } = await createProjectWithAdmin('delete-race'); + await env.db.insert(emailDestinations).values({ + id: 'edst_delete_race', + project_id: project.id, + address: 'race@example.com', + status: 'verified', + cf_destination_id: 'cf-tag-delete-race', + verification_source: 'platform', + }); + const fetchMock = mockCfApi(); + const getDestinations = dbModule.getEmailDestinationsByProjectId; + vi.spyOn(dbModule, 'getEmailDestinationsByProjectId').mockImplementationOnce( + async (...args) => { + const destinations = await getDestinations(...args); + await dbModule.setProjectMemberRole(env.db, { + projectId: project.id, + subjectUserId: admin.id, + role: 'collaborator', + actorUserId: owner.id, + }); + return destinations; + }, + ); + + const response = await removeDestination(project.id, 'edst_delete_race', admin.jwt, emailEnv()); + + expect(response.status).toBe(403); + expect(await rowsFor(project.id)).toHaveLength(1); + expect(cfCalls(fetchMock, 'DELETE', '/email/routing/addresses/')).toHaveLength(0); + }); + it('returns 409 once the project is being torn down', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id); From 8b0b1abd53095e3940ccffd883d82bdb9cf732ac Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 21 Sep 2026 11:03:15 +0800 Subject: [PATCH 13/18] fix(platform): address team access review --- platform/packages/api/src/db.ts | 48 +++++---- .../packages/api/src/provisioning/cleanup.ts | 98 +++++++++++++++++-- .../api/test/integration/api-delete.test.ts | 19 +++- .../project-team-lifecycle.test.ts | 43 +++++++- platform/packages/proxy/src/resolve.ts | 6 ++ .../packages/proxy/test/unit/proxy.test.ts | 25 +++++ 6 files changed, 210 insertions(+), 29 deletions(-) diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index a1be5a5a8..cd78dc8a2 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -3073,22 +3073,30 @@ export async function updateUserRole(db: Db, id: string, role: 'user' | 'admin') export async function deleteUser(db: Db, id: string): Promise> { return withAdminRemovalGuard(db, id, async (tx) => { const cancelledBuilds: Array = []; - const [memberships, existingConnections] = await Promise.all([ - tx - .select({ projectId: projectMembers.project_id }) - .from(projectMembers) - .where(eq(projectMembers.user_id, id)), - tx - .select({ projectId: githubConnections.project_id }) - .from(githubConnections) - .where(eq(githubConnections.authorized_by_user_id, id)), - ]); - const projectIds = [ - ...new Set([ - ...memberships.map(({ projectId }) => projectId), - ...existingConnections.map(({ projectId }) => projectId), - ]), - ].sort(); + // Discover every project from one statement snapshot. Separate reads could + // miss an invitation accepted between the membership and invitation reads. + const affectedProjects = await tx + .select({ projectId: projectMembers.project_id }) + .from(projectMembers) + .where(eq(projectMembers.user_id, id)) + .union( + tx + .select({ projectId: projectInvitations.project_id }) + .from(projectInvitations) + .where( + and( + eq(projectInvitations.target_user_id, id), + eq(projectInvitations.status, 'pending'), + ), + ), + ) + .union( + tx + .select({ projectId: githubConnections.project_id }) + .from(githubConnections) + .where(eq(githubConnections.authorized_by_user_id, id)), + ); + const projectIds = affectedProjects.map(({ projectId }) => projectId).sort(); if (projectIds.length > 0) { // GitHub connection CRUD and build creation take the project lock before // the connection lock. Preserve that order here so deleting an @@ -3100,10 +3108,10 @@ export async function deleteUser(db: Db, id: string): Promise, projectId: string, - ownerUserId: string, + options: Readonly<{ + ownerUserId: string; + ownerBillingCycleStart: string | null; + ownershipStartedAt: string; + ownershipEndedAt: string; + }>, ): Promise { - const stub = context.env.USAGE_COUNTER.get(context.env.USAGE_COUNTER.idFromName(ownerUserId)); + const stub = context.env.USAGE_COUNTER.get( + context.env.USAGE_COUNTER.idFromName(options.ownerUserId), + ); const response = await stub.fetch( new Request('https://do/detach-project', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ projectId, userId: ownerUserId }), + body: JSON.stringify({ + projectId, + userId: options.ownerUserId, + ...(options.ownerBillingCycleStart + ? { + billingPeriodStart: getCurrentBillingPeriodStart( + options.ownerBillingCycleStart, + ).toISOString(), + } + : {}), + ownershipStartedAt: options.ownershipStartedAt, + ownershipEndedAt: options.ownershipEndedAt, + }), }), ); if (!response.ok) { @@ -549,7 +635,7 @@ export async function teardownProject( // Detach after the durable status change and before cleanup so a failed DO // call leaves the project row available for retry. - await detachProjectUsage(context, projectId, deletion.ownerUserId); + await detachProjectUsage(context, projectId, deletion); const emailDrain = await drainProjectEmailAdmissions(context.get('db'), projectId); if (emailDrain.inFlight > 0) { diff --git a/platform/packages/api/test/integration/api-delete.test.ts b/platform/packages/api/test/integration/api-delete.test.ts index 18f6274f9..6809ccf96 100644 --- a/platform/packages/api/test/integration/api-delete.test.ts +++ b/platform/packages/api/test/integration/api-delete.test.ts @@ -22,6 +22,7 @@ import { emailDestinations, users, projectMembers, + projectOwnershipPeriods, } from '../../src/schema.ts'; import { setupEnv, @@ -208,9 +209,13 @@ describe('DELETE /projects/:id', () => { expect(await db.getProject(env.db, project.id)).not.toBeNull(); }); - it('deletes project with no resources', async () => { + it('deletes a project while retaining its final usage window', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'empty-app'); + const [ownership] = await env.db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)); await env.ROUTING.put(`ai-block:${project.id}`, '1'); mockCfApi(); const usageCalls: Array<{ ownerId: string; path: string; body: unknown }> = []; @@ -247,9 +252,19 @@ describe('DELETE /projects/:id', () => { { ownerId: user.id, path: '/detach-project', - body: { projectId: project.id, userId: user.id }, + body: { + projectId: project.id, + userId: user.id, + billingPeriodStart: expect.any(String), + ownershipStartedAt: ownership!.starts_at, + ownershipEndedAt: expect.any(String), + }, }, ]); + const detachBody = usageCalls[0]!.body as { ownershipEndedAt: string }; + expect(Date.parse(detachBody.ownershipEndedAt)).toBeGreaterThanOrEqual( + Date.parse(ownership!.starts_at), + ); }); it('deletes project with resources, assets, and routing', async () => { diff --git a/platform/packages/api/test/integration/project-team-lifecycle.test.ts b/platform/packages/api/test/integration/project-team-lifecycle.test.ts index 7e4e4ed23..e1e80f8b2 100644 --- a/platform/packages/api/test/integration/project-team-lifecycle.test.ts +++ b/platform/packages/api/test/integration/project-team-lifecycle.test.ts @@ -21,7 +21,14 @@ import { projectOwnershipPeriods, users, } from '../../src/schema.ts'; -import { clearTables, createTestUser, setupEnv, teardownEnv, type TestEnv } from './helpers.ts'; +import { + captureQueries, + clearTables, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; let env: TestEnv; @@ -99,6 +106,40 @@ describe('project team lifecycle', () => { expect(events.every((event) => event.subject_user_id === member.id)).toBe(true); }); + it('atomically discovers and locks pending-invitation projects before user deletion', async () => { + const owner = await createTestUser(env.db, 'owner'); + const invitee = await createTestUser(env.db, 'invitee'); + await setEmail(invitee.id, 'invitee@example.com'); + const project = await createProject(env.db, owner.id, 'pending-invitation-delete'); + await createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: 'invitee@example.com', + role: 'admin', + actorUserId: owner.id, + }); + + const { queries } = await captureQueries(env.db, () => deleteUser(env.db, invitee.id)); + + const discovery = queries.findIndex( + (query) => + query.includes('from "project_members"') && + query.includes('from "project_invitations"') && + query.includes('from "github_connections"') && + query.includes(' union '), + ); + const projectLock = queries.findIndex( + (query) => query.includes('from "projects"') && query.includes('for update'), + ); + expect(discovery).toBeGreaterThanOrEqual(0); + expect(projectLock).toBeGreaterThan(discovery); + expect( + await env.db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.target_user_id, invitee.id)), + ).toEqual([]); + }); + it('keeps owned-project lifecycle enumeration separate from accessible projects', async () => { const owner = await createTestUser(env.db, 'owner'); const member = await createTestUser(env.db, 'member'); diff --git a/platform/packages/proxy/src/resolve.ts b/platform/packages/proxy/src/resolve.ts index 80994203b..6d1b2fa26 100644 --- a/platform/packages/proxy/src/resolve.ts +++ b/platform/packages/proxy/src/resolve.ts @@ -104,6 +104,12 @@ export async function verifyPrerenderAuthority( }, body: JSON.stringify({ userId, ...provenance }), }); + if (res.status === 400 && !provenance.buildId && !provenance.credentialId) { + // The previous API revision required provenance here. Preserve manual + // prerenders while the proxy and API revisions overlap during rollout. + const body = await res.json<{ error?: unknown }>().catch(() => null); + return body?.error === 'userId and credential provenance required'; + } return res.ok; } catch { // Project-data access fails closed if the revocation authority is diff --git a/platform/packages/proxy/test/unit/proxy.test.ts b/platform/packages/proxy/test/unit/proxy.test.ts index 974b5397f..ffa9f0034 100644 --- a/platform/packages/proxy/test/unit/proxy.test.ts +++ b/platform/packages/proxy/test/unit/proxy.test.ts @@ -587,6 +587,31 @@ describe('proxy worker', () => { }); }); + it('accepts a manual prerender token while the previous API revision is active', async () => { + const token = await signJwt( + { sub: 'user_1', login: 'testuser', aud: 'prerender', project: 'proj_1' }, + JWT_SECRET, + ); + const api = createMockApi(async (input) => { + expect(String(input)).toContain('/internal/verify-prerender-build/proj_1'); + return Response.json( + { error: 'userId and credential provenance required' }, + { status: 400 }, + ); + }); + + const response = await worker.fetch( + post( + '/ai/run', + { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { authorization: `Bearer ${token}` }, + ), + createEnv(undefined, undefined, api), + ); + + expect(response.status).toBe(200); + }); + it('revokes an already-minted build-bound prerender token when its build terminates', async () => { const token = await signJwt( { From ac00196105433132182f3ddaf5d376aae4c8b018 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 21 Sep 2026 12:00:39 +0800 Subject: [PATCH 14/18] fix(platform): make team rollout backward compatible --- packages/platform/src/discovery.ts | 16 ++++-- packages/platform/src/installation.ts | 13 +++-- packages/platform/src/installer.ts | 9 ++- packages/platform/src/worker-config.ts | 1 + packages/void/src/cli/platform-installer.ts | 2 +- .../void/test/unit/platform-installer.test.ts | 4 +- .../void/test/unit/platform-runtime.test.ts | 6 +- platform/package.json | 4 +- platform/packages/api/src/env.ts | 1 + .../api/src/operator/tenant-service.ts | 19 +++++++ platform/packages/api/src/routes/usage.ts | 6 +- platform/packages/api/src/usage-auth.ts | 21 +++++-- .../test/integration/api-discovery.test.ts | 4 +- .../test/integration/operator-tenants.test.ts | 57 ++++++++++++++++++- .../platform-installation-manifest.test.ts | 8 ++- .../packages/api/test/unit/usage-auth.test.ts | 25 ++++++++ platform/packages/api/wrangler.jsonc | 3 + 17 files changed, 163 insertions(+), 36 deletions(-) diff --git a/packages/platform/src/discovery.ts b/packages/platform/src/discovery.ts index f9d9a454e..5aed40349 100644 --- a/packages/platform/src/discovery.ts +++ b/packages/platform/src/discovery.ts @@ -26,7 +26,7 @@ export type PlatformDiscoveryDocument = Readonly<{ platformVersion: string; proxyUrl: string; schemaVersion: typeof PLATFORM_DISCOVERY_SCHEMA_VERSION | 2 | 3; - usageProtocolVersion: 0 | 1; + usageProtocolVersion: 0 | 1 | 2; }>; function normalizeOrigin(value: string, name: 'apiUrl' | 'proxyUrl'): string { @@ -50,7 +50,7 @@ function normalizeOrigin(value: string, name: 'apiUrl' | 'proxyUrl'): string { export function createPlatformDiscoveryDocument( input: Omit & { - usageProtocolVersion?: 0 | 1; + usageProtocolVersion?: 0 | 1 | 2; }, ): PlatformDiscoveryDocument { if (!input.displayName.trim()) { @@ -76,9 +76,10 @@ export function createPlatformDiscoveryDocument( if ( input.usageProtocolVersion !== undefined && input.usageProtocolVersion !== 0 && - input.usageProtocolVersion !== 1 + input.usageProtocolVersion !== 1 && + input.usageProtocolVersion !== 2 ) { - throw new Error('usageProtocolVersion must be 0 or 1.'); + throw new Error('usageProtocolVersion must be 0, 1, or 2.'); } const authentication = input.authentication ? parseAuthentication(input.authentication) @@ -251,12 +252,15 @@ function parseAuthentication(value: unknown): PlatformAuthenticationDiscovery { }; } -function parseUsageProtocolVersion(value: unknown): 0 | 1 { +function parseUsageProtocolVersion(value: unknown): 0 | 1 | 2 { if (value === undefined || value === 0) { return 0; } if (value === 1) { return 1; } - throw new Error('usageProtocolVersion must be 0 or 1.'); + if (value === 2) { + return 2; + } + throw new Error('usageProtocolVersion must be 0, 1, or 2.'); } diff --git a/packages/platform/src/installation.ts b/packages/platform/src/installation.ts index ff4800c41..d0c7295ac 100644 --- a/packages/platform/src/installation.ts +++ b/packages/platform/src/installation.ts @@ -11,7 +11,7 @@ export const PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY = '__void/platform/instal export const PLATFORM_SANDBOX_DRAIN_KEY = '__void/platform/sandbox-drain/v1'; export const PLATFORM_R2_OWNERSHIP_MARKER_KEY = '__void/platform/ownership/v1'; export const PLATFORM_RESOURCE_OWNERSHIP_SCHEMA_VERSION = 1 as const; -export const PLATFORM_USAGE_PROTOCOL_VERSION = 1 as const; +export const PLATFORM_USAGE_PROTOCOL_VERSION = 2 as const; export type InstallationStatus = | 'draft' @@ -106,7 +106,7 @@ export type PlatformInstallationManifest = Readonly<{ steps: ReadonlyArray; updatedAt: string; /** Last usage protocol proven live across the installed Worker set. */ - usageProtocolVersion: 0 | 1; + usageProtocolVersion: 0 | 1 | 2; }>; export type PlatformResourceOwnershipMarker = Readonly<{ @@ -251,7 +251,7 @@ export function createPlatformInstallationManifest(input: { ownershipNonce: string; platformVersion: string; safetyProtocols?: Readonly<{ sandboxDrain: 0 | 1 | 2 }>; - usageProtocolVersion?: 0 | 1; + usageProtocolVersion?: 0 | 1 | 2; }): PlatformInstallationManifest { const createdAt = assertTimestamp(input.createdAt, 'createdAt'); if (!/^[a-z0-9][a-z0-9-]{7,79}$/.test(input.id)) { @@ -537,14 +537,17 @@ function parseAuthenticationResources( return resources; } -function parseUsageProtocolVersion(value: unknown): 0 | 1 { +function parseUsageProtocolVersion(value: unknown): 0 | 1 | 2 { if (value === undefined || value === 0) { return 0; } if (value === 1) { return 1; } - throw new Error('usageProtocolVersion must be 0 or 1.'); + if (value === 2) { + return 2; + } + throw new Error('usageProtocolVersion must be 0, 1, or 2.'); } function parseSafetyProtocols(value: unknown): PlatformInstallationManifest['safetyProtocols'] { diff --git a/packages/platform/src/installer.ts b/packages/platform/src/installer.ts index dc2348cd2..e1f636400 100644 --- a/packages/platform/src/installer.ts +++ b/packages/platform/src/installer.ts @@ -44,7 +44,7 @@ export type PlatformRuntimeManifest = Readonly<{ schemaVersion: 2; sourceRevision: string | null; /** Queue payload contract shared by the API consumer and trusted producers. */ - usageProtocolVersion: 0 | 1; + usageProtocolVersion: 0 | 1 | 2; }>; export type PlatformRuntime = Readonly<{ @@ -219,14 +219,17 @@ function parseSafetyProtocols(value: unknown): PlatformRuntimeManifest['safetyPr return { sandboxDrain: protocols.sandboxDrain }; } -function usageProtocolVersion(value: unknown): 0 | 1 { +function usageProtocolVersion(value: unknown): 0 | 1 | 2 { if (value === undefined || value === 0) { return 0; } if (value === 1) { return 1; } - throw new Error('runtime manifest usageProtocolVersion must be 0 or 1'); + if (value === 2) { + return 2; + } + throw new Error('runtime manifest usageProtocolVersion must be 0, 1, or 2'); } export function getPlatformRuntimeDirectory(): string { diff --git a/packages/platform/src/worker-config.ts b/packages/platform/src/worker-config.ts index a422d69d9..8aae1df5f 100644 --- a/packages/platform/src/worker-config.ts +++ b/packages/platform/src/worker-config.ts @@ -94,6 +94,7 @@ export function createPlatformWorkerConfigs(input: { const api: Record = { ...common, + version_metadata: { binding: 'VERSION_METADATA' }, workers_dev: publicEndpoints && (!deployment.authentication.accessProtection || diff --git a/packages/void/src/cli/platform-installer.ts b/packages/void/src/cli/platform-installer.ts index f79746ade..75518390c 100644 --- a/packages/void/src/cli/platform-installer.ts +++ b/packages/void/src/cli/platform-installer.ts @@ -5468,7 +5468,7 @@ export async function verifyPlatformHealth( proxyUrl: string, applicationBaseDomain: string, platformVersion: string, - usageProtocolVersion: 0 | 1, + usageProtocolVersion: 0 | 1 | 2, healthProbeToken: string, fetcher: typeof fetch = fetch, dispatchOrigin?: string, diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 9d713b47b..ed9c55a01 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -4057,7 +4057,7 @@ describe('platform installer', () => { const interruptedProtocolEvents: Array = []; const legacySourceManifest = { ...sourceManifest, - usageProtocolVersion: 0 as const, + usageProtocolVersion: 1 as const, }; const rotatedWorkerSecrets = createPlatformInstallationSecrets({ bootstrapGitHubId: '', @@ -4131,7 +4131,7 @@ describe('platform installer', () => { const interruptedManifest = listInstallationManifests(directory)[0]!; expect(interruptedManifest).toMatchObject({ status: 'failed', - usageProtocolVersion: 0, + usageProtocolVersion: 1, }); const interruptedSecretCheckpoint = readInstallationRecoverySecrets(interruptedManifest)!; const resumedWorkerSecrets = createPlatformInstallationSecrets({ diff --git a/packages/void/test/unit/platform-runtime.test.ts b/packages/void/test/unit/platform-runtime.test.ts index 2fd389a34..c00084ab0 100644 --- a/packages/void/test/unit/platform-runtime.test.ts +++ b/packages/void/test/unit/platform-runtime.test.ts @@ -41,7 +41,7 @@ describe('platform runtime manifest', () => { expect(manifest.schemaVersion).toBe(2); expect(manifest.safetyProtocols).toEqual({ sandboxDrain: 2 }); - expect(manifest.usageProtocolVersion).toBe(1); + expect(manifest.usageProtocolVersion).toBe(2); expect(manifest.database.schemaDigest).toBe( platformDatabaseSchemaDigest(manifest.database.lineage, manifest.database.migrations), ); @@ -97,8 +97,8 @@ describe('platform runtime manifest', () => { expect(() => parsePlatformRuntimeManifest({ ...manifest, sourceRevision: 'revision with spaces' }), ).toThrow('sourceRevision is invalid'); - expect(() => parsePlatformRuntimeManifest({ ...manifest, usageProtocolVersion: 2 })).toThrow( - 'usageProtocolVersion must be 0 or 1', + expect(() => parsePlatformRuntimeManifest({ ...manifest, usageProtocolVersion: 3 })).toThrow( + 'usageProtocolVersion must be 0, 1, or 2', ); expect(() => parsePlatformRuntimeManifest({ diff --git a/platform/package.json b/platform/package.json index 266f07f3f..c21a0417d 100644 --- a/platform/package.json +++ b/platform/package.json @@ -5,13 +5,13 @@ "scripts": { "build": "vp run --filter @voidcloud/dashboard build", "check": "vp run -r --parallel typecheck", - "deploy": "node scripts/wrangler-deploy.ts --preflight --env production && vp run deploy:api && vp run deploy:proxy && vp run deploy:email-gateway && vp run deploy:tail && vp run deploy:dispatch && vp run deploy:dashboard", + "deploy": "node scripts/wrangler-deploy.ts --preflight --env production && vp run deploy:proxy && vp run deploy:api && vp run deploy:email-gateway && vp run deploy:tail && vp run deploy:dispatch && vp run deploy:dashboard", "deploy:api": "node scripts/wrangler-deploy.ts --preflight --env production && vp run --filter @voidcloud/api migrate && vp run --filter @voidcloud/api deploy --env production", "deploy:dashboard": "vp run --filter @voidcloud/dashboard deploy --env production", "deploy:dispatch": "vp run --filter @voidcloud/dispatch deploy --env production", "deploy:email-gateway": "vp run --filter @voidcloud/email-gateway deploy --env production", "deploy:proxy": "vp run --filter @voidcloud/proxy deploy --env production", - "deploy:staging": "node scripts/wrangler-deploy.ts --preflight --env staging && vp run deploy:staging:api && vp run deploy:staging:proxy && vp run deploy:staging:email-gateway && vp run deploy:staging:tail && vp run deploy:staging:dispatch && vp run deploy:staging:dashboard", + "deploy:staging": "node scripts/wrangler-deploy.ts --preflight --env staging && vp run deploy:staging:proxy && vp run deploy:staging:api && vp run deploy:staging:email-gateway && vp run deploy:staging:tail && vp run deploy:staging:dispatch && vp run deploy:staging:dashboard", "deploy:staging:api": "node scripts/wrangler-deploy.ts --preflight --env staging && vp run --filter @voidcloud/api migrate:staging && vp run --filter @voidcloud/api deploy --env staging", "deploy:staging:dashboard": "vp run --filter @voidcloud/dashboard deploy --env staging", "deploy:staging:dispatch": "vp run --filter @voidcloud/dispatch deploy --env staging", diff --git a/platform/packages/api/src/env.ts b/platform/packages/api/src/env.ts index 38dd512e6..e0ad83473 100644 --- a/platform/packages/api/src/env.ts +++ b/platform/packages/api/src/env.ts @@ -145,6 +145,7 @@ export type ApiEnv = Env & { PLATFORM_TRAFFIC_STATE?: string; PLATFORM_VERSION?: string; PLATFORM_CAPABILITIES?: string; + VERSION_METADATA?: Readonly<{ id: string; tag: string; timestamp: string }>; PROXY_URL?: string; PROJECT_SECRET_KEYS_JSON: string; PROJECT_SECRET_ACTIVE_KEY_VERSION: string; diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 6a0197e48..601719b91 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -74,6 +74,24 @@ import { OperatorError, preview } from './common.ts'; type Page = { page: number; limit: number; offset: number }; +const USAGE_CONSUMER_ROLLOUT_GRACE_MS = 15 * 60_000; + +function assertOwnershipTransferRolloutComplete(env: ApiEnv['Bindings']): void { + if (env.PLATFORM_ENV !== 'production' && env.PLATFORM_ENV !== 'staging') { + return; + } + const deployedAt = Date.parse(env.VERSION_METADATA?.timestamp ?? ''); + const remainingMs = deployedAt + USAGE_CONSUMER_ROLLOUT_GRACE_MS - Date.now(); + if (!Number.isFinite(deployedAt) || remainingMs > 0) { + throw new OperatorError( + 503, + 'Project ownership transfers are temporarily unavailable while the usage consumer rollout completes.', + 'ownership_transfer_rollout_pending', + remainingMs > 0 ? { retryAfterSeconds: Math.ceil(remainingMs / 1_000) } : undefined, + ); + } +} + function pageInfo(page: Page, total: number) { return { page: page.page, @@ -728,6 +746,7 @@ export async function transferOperatorProjectOwner( if (dryRun) { return previewOperatorProjectOwner(context, projectId, targetUserId); } + assertOwnershipTransferRolloutComplete(context.env); const db = context.get('db'); const transfer = await commitOwnershipTransfer( diff --git a/platform/packages/api/src/routes/usage.ts b/platform/packages/api/src/routes/usage.ts index cce95de2f..c5f8f01af 100644 --- a/platform/packages/api/src/routes/usage.ts +++ b/platform/packages/api/src/routes/usage.ts @@ -305,7 +305,7 @@ async function queryBatchProjectUsageWindows( const predicates = windows.flatMap(({ project, intervals }) => intervals.map( ({ start, end }) => - `(blob1 = ${analyticsStringLiteral(project.id)} AND timestamp >= toDateTime(${analyticsStringLiteral(formatAnalyticsDate(start))}) AND timestamp < toDateTime(${analyticsStringLiteral(formatAnalyticsDate(end))}))`, + `(blob1 = ${analyticsStringLiteral(project.id)} AND timestamp >= toDateTime64(${analyticsStringLiteral(formatAnalyticsDateMilliseconds(start))}, 3) AND timestamp < toDateTime64(${analyticsStringLiteral(formatAnalyticsDateMilliseconds(end))}, 3))`, ), ); const query = ` @@ -400,6 +400,10 @@ function formatAnalyticsDate(date: Date): string { return date.toISOString().replace('T', ' ').slice(0, 19); } +function formatAnalyticsDateMilliseconds(date: Date): string { + return date.toISOString().replace('T', ' ').slice(0, 23); +} + function normalizeAnalyticsDate(value: string | null | undefined): string | null { if (!value) { return null; diff --git a/platform/packages/api/src/usage-auth.ts b/platform/packages/api/src/usage-auth.ts index 55a3b260b..68b534b4f 100644 --- a/platform/packages/api/src/usage-auth.ts +++ b/platform/packages/api/src/usage-auth.ts @@ -9,7 +9,7 @@ export const SANDBOX_USAGE_RETENTION_MS = 8 * 24 * 60 * 60_000; export type UsageEventSource = 'dispatch' | 'sandbox-counter'; export type SignedUsageEnvelope = Readonly<{ - version: typeof USAGE_PROTOCOL_VERSION; + version: 1 | typeof USAGE_PROTOCOL_VERSION; source: UsageEventSource; eventId: string; event: T; @@ -69,8 +69,13 @@ export async function readUsageSigningKey(routing: KVNamespace): Promise(source: UsageEventSource, eventId: string, event: T): string { - return JSON.stringify([USAGE_PROTOCOL_VERSION, source, eventId, event]); +function signedPayload( + version: SignedUsageEnvelope['version'], + source: UsageEventSource, + eventId: string, + event: T, +): string { + return JSON.stringify([version, source, eventId, event]); } export async function signUsageEvent( @@ -84,7 +89,9 @@ export async function signUsageEvent( source, eventId, event, - signature: base64url(await hmac(signedPayload(source, eventId, event), key)), + signature: base64url( + await hmac(signedPayload(USAGE_PROTOCOL_VERSION, source, eventId, event), key), + ), }; } @@ -97,7 +104,7 @@ export async function verifyUsageEnvelope( } const envelope = value as Record; if ( - envelope.version !== USAGE_PROTOCOL_VERSION || + (envelope.version !== 1 && envelope.version !== USAGE_PROTOCOL_VERSION) || (envelope.source !== 'dispatch' && envelope.source !== 'sandbox-counter') || typeof envelope.eventId !== 'string' || envelope.eventId.length === 0 || @@ -123,7 +130,9 @@ export async function verifyUsageEnvelope( 'HMAC', signingKey, signature.buffer as ArrayBuffer, - encoder.encode(signedPayload(envelope.source, envelope.eventId, envelope.event)), + encoder.encode( + signedPayload(envelope.version, envelope.source, envelope.eventId, envelope.event), + ), ); return valid ? (envelope as SignedUsageEnvelope) : null; } catch { diff --git a/platform/packages/api/test/integration/api-discovery.test.ts b/platform/packages/api/test/integration/api-discovery.test.ts index 32f958387..d7539f637 100644 --- a/platform/packages/api/test/integration/api-discovery.test.ts +++ b/platform/packages/api/test/integration/api-discovery.test.ts @@ -27,7 +27,7 @@ describe('GET /.well-known/void-platform', () => { displayName: 'Void Cloud', proxyUrl: 'https://proxy.void.cloud', schemaVersion: 1, - usageProtocolVersion: 1, + usageProtocolVersion: 2, }); const serialized = JSON.stringify(document); expect(serialized).not.toMatch(/token|secret|accountId|zoneId|resource/i); @@ -64,7 +64,7 @@ describe('GET /.well-known/void-platform', () => { installationId: 'dpx-a1b2c3d4', platformVersion: '0.10.13', proxyUrl: 'https://proxy.platform.example.com', - usageProtocolVersion: 1, + usageProtocolVersion: 2, }); const google = await app.fetch( diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index d97d73853..b2a0b897a 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -193,6 +193,7 @@ describe('operator tenant reads', () => { const project = await createTestProject(env.db, currentOwner.id, 'operator-usage-project'); const ownedFrom = new Date(Date.now() - 5 * 24 * 60 * 60 * 1_000); const transferredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1_000); + transferredAt.setMilliseconds(500); await env.db .update(users) .set({ billing_cycle_start: '2020-01-01T00:00:00.000Z' }) @@ -251,9 +252,12 @@ describe('operator tenant reads', () => { }); expect(queries).toHaveLength(2); - expect(queries[0]).toContain(ownedFrom.toISOString().replace('T', ' ').slice(0, 19)); - expect(queries[0]).toContain(transferredAt.toISOString().replace('T', ' ').slice(0, 19)); - expect(queries[1]).toContain(transferredAt.toISOString().replace('T', ' ').slice(0, 19)); + expect(queries[0]).toContain( + `toDateTime64('${transferredAt.toISOString().replace('T', ' ').slice(0, 23)}', 3)`, + ); + expect(queries[1]).toContain( + `toDateTime64('${transferredAt.toISOString().replace('T', ' ').slice(0, 23)}', 3)`, + ); }); test('rejects invalid pagination and missing detail targets', async () => { @@ -354,6 +358,53 @@ describe('operator tenant mutations', () => { expect(doGet).not.toHaveBeenCalled(); }); + test('waits for the previous usage consumer lifetime before changing ownership', async () => { + const owner = await createTestUser(env.db, 'rollout-owner'); + const target = await createTestUser(env.db, 'rollout-target'); + const project = await createTestProject(env.db, owner.id, 'rollout-project'); + const rolloutEnv = { + PLATFORM_ENV: 'production', + VERSION_METADATA: { + id: 'version-2', + tag: '', + timestamp: new Date().toISOString(), + }, + } satisfies Partial; + + const preview = await req( + `/projects/${project.id}/owner`, + { userId: target.id, dryRun: true }, + rolloutEnv, + ); + expect(preview.status).toBe(200); + + const blocked = await req(`/projects/${project.id}/owner`, { userId: target.id }, rolloutEnv); + expect(blocked.status).toBe(503); + expect(await blocked.json()).toMatchObject({ + error: 'ownership_transfer_rollout_pending', + details: { retryAfterSeconds: expect.any(Number) }, + }); + expect( + (await env.db.query.projects.findFirst({ where: eq(projects.id, project.id) }))?.user_id, + ).toBe(owner.id); + + const completed = await req( + `/projects/${project.id}/owner`, + { userId: target.id }, + { + ...rolloutEnv, + VERSION_METADATA: { + ...rolloutEnv.VERSION_METADATA, + timestamp: '2020-01-01T00:00:00.000Z', + }, + }, + ); + expect(completed.status).toBe(200); + expect( + (await env.db.query.projects.findFirst({ where: eq(projects.id, project.id) }))?.user_id, + ).toBe(target.id); + }); + test('transfers ownership atomically and converges counters and routing idempotently', async () => { const owner = await createTestUser(env.db, 'transfer-owner'); const target = await createTestUser(env.db, 'transfer-target', 'github', undefined, 'pro'); diff --git a/platform/packages/api/test/unit/platform-installation-manifest.test.ts b/platform/packages/api/test/unit/platform-installation-manifest.test.ts index cc02e3252..e6e204771 100644 --- a/platform/packages/api/test/unit/platform-installation-manifest.test.ts +++ b/platform/packages/api/test/unit/platform-installation-manifest.test.ts @@ -86,9 +86,13 @@ describe('platform installation manifest', () => { parsePlatformInstallationManifest({ ...installation(), usageProtocolVersion: 1 }) .usageProtocolVersion, ).toBe(1); + expect( + parsePlatformInstallationManifest({ ...installation(), usageProtocolVersion: 2 }) + .usageProtocolVersion, + ).toBe(2); expect(() => - parsePlatformInstallationManifest({ ...installation(), usageProtocolVersion: 2 }), - ).toThrow('usageProtocolVersion must be 0 or 1'); + parsePlatformInstallationManifest({ ...installation(), usageProtocolVersion: 3 }), + ).toThrow('usageProtocolVersion must be 0, 1, or 2'); }); it('rejects invalid state transitions', () => { diff --git a/platform/packages/api/test/unit/usage-auth.test.ts b/platform/packages/api/test/unit/usage-auth.test.ts index faa5cc930..519a1fc5f 100644 --- a/platform/packages/api/test/unit/usage-auth.test.ts +++ b/platform/packages/api/test/unit/usage-auth.test.ts @@ -39,6 +39,31 @@ describe('usage signing key bootstrap', () => { }); describe('usage envelopes', () => { + it('accepts a valid protocol-v1 envelope while protocol-v2 producers roll out', async () => { + const source = 'dispatch'; + const eventId = 'event-v1'; + const event = { projectId: 'project-1', requests: 1, responseBytes: 0 }; + const key = 'key'; + const signingKey = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(key), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const signature = Buffer.from( + await crypto.subtle.sign( + 'HMAC', + signingKey, + new TextEncoder().encode(JSON.stringify([1, source, eventId, event])), + ), + ).toString('base64url'); + + await expect( + verifyUsageEnvelope({ version: 1, source, eventId, event, signature }, key), + ).resolves.toEqual({ version: 1, source, eventId, event, signature }); + }); + it('rejects a payload changed after signing', async () => { const envelope = await signUsageEvent('key', 'dispatch', 'event-1', { projectId: 'project-1', diff --git a/platform/packages/api/wrangler.jsonc b/platform/packages/api/wrangler.jsonc index 2135fc537..c2912c215 100644 --- a/platform/packages/api/wrangler.jsonc +++ b/platform/packages/api/wrangler.jsonc @@ -6,6 +6,7 @@ "observability": { "enabled": true, }, + "version_metadata": { "binding": "VERSION_METADATA" }, // Both hosted PostgreSQL origins are in AWS us-east-2. "placement": { "mode": "targeted", @@ -191,6 +192,7 @@ "env": { "production": { "name": "void-api", + "version_metadata": { "binding": "VERSION_METADATA" }, "secrets": { "required": ["EMAIL_SIGNING_SECRET"] }, "hyperdrive": [ { @@ -327,6 +329,7 @@ }, "staging": { "name": "void-api-staging", + "version_metadata": { "binding": "VERSION_METADATA" }, "secrets": { "required": ["EMAIL_SIGNING_SECRET"] }, "hyperdrive": [ { From bbe84866d683aea672081805ef5ff43274c7b5ee Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 21 Sep 2026 13:02:13 +0800 Subject: [PATCH 15/18] fix(platform): harden team transfer accounting --- platform/packages/api/src/db.ts | 26 ++-- platform/packages/api/src/usage-counter.ts | 137 +++++++++++------- .../project-team-lifecycle.test.ts | 31 ++++ .../api/test/unit/usage-counter.test.ts | 97 +++++++++++++ 4 files changed, 231 insertions(+), 60 deletions(-) diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index cd78dc8a2..7152a32fd 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -789,19 +789,27 @@ export async function createOrRefreshProjectInvitation( return db.transaction(async (tx) => { const txDb = tx as unknown as Db; const project = await lockProject(txDb, args.projectId); - await requireLockedMembersCapability(txDb, project, args.actorUserId); - const [lockedTarget] = await txDb + const lockedUsers = await txDb .select() .from(users) - .where(and(eq(users.id, target.id), sql`lower(${users.email}) = ${normalizedEmail}`)) - // The invitation is bound to the account resolved from this registered - // email. Prevent a concurrent profile update from changing the address - // between resolution and the invitation commit. - .for('update') - .limit(1); - if (!lockedTarget) { + .where(inArray(users.id, [...new Set([project.user_id, target.id])].sort())) + .orderBy(asc(users.id)) + .for('update'); + const lockedById = new Map(lockedUsers.map((user) => [user.id, user])); + const lockedOwner = lockedById.get(project.user_id); + const lockedTarget = lockedById.get(target.id); + if (!lockedTarget || lockedTarget.email?.trim().toLowerCase() !== normalizedEmail) { throw new ProjectTeamMutationError('target-user-not-found'); } + if (lockedOwner && isAdminSuspended(lockedOwner.suspended_reason)) { + throw new ProjectTeamMutationError('owner-suspended'); + } + if (project.user_id !== args.actorUserId) { + const actorMember = await getProjectMember(txDb, project.id, args.actorUserId); + if (actorMember?.role !== 'admin') { + throw new ProjectTeamMutationError('forbidden'); + } + } if (project.user_id === target.id) { throw new ProjectTeamMutationError('owner-conflict'); } diff --git a/platform/packages/api/src/usage-counter.ts b/platform/packages/api/src/usage-counter.ts index 7b258e3e1..bda3b12ed 100644 --- a/platform/packages/api/src/usage-counter.ts +++ b/platform/packages/api/src/usage-counter.ts @@ -414,59 +414,14 @@ export class UsageCounter extends DurableObject { }); } - const wasRequestsSuspended = state.requestsSuspended; - const wasAiSuspended = state.aiSuspended; - const wasSandboxRuntimeSuspended = state.sandboxRuntimeSuspended; - - // Finish idempotent external cleanup before committing the new period. - // A failure leaves the cached and persisted period unchanged for retry. - if (wasAiSuspended) { - // The alarm's delete branch requires aiSuspended to still be true AND returns - // early for unlimited-AI plans, so without this the proxy keeps 429ing AI - // routes after rollover. - const ownedProjectIds = await this.ownedProjectIds( - createDb(this.env.HYPERDRIVE.connectionString), - Object.keys(state.projectSlugs), - body.userId ?? state.ownerUserId, - ); - for (const projectId of ownedProjectIds) { - await this.env.ROUTING.delete(`ai-block:${projectId}`); - } - } - if (wasSandboxRuntimeSuspended) { - const db = createDb(this.env.HYPERDRIVE.connectionString); - await settleWrites([ - this.env.ROUTING.delete(sandboxBlockKey(body.userId)), - unsuspendUserIfReason(db, body.userId, REASON_SANDBOX_RUNTIME_EXCEEDED), - ]); - } - - // All external cleanup succeeded — persist the operation's new state. - state.totalRequests = 0; - state.totalNeurons = 0; - state.totalSandboxSeconds = 0; - state.historicalNeurons = 0; - state.historicalAiWindows = {}; - state.projectNeurons = {}; - state.plan = body.plan; - state.billingPeriodStart = body.billingPeriodStart; - state.billingPeriodInitialized = true; - if (wasRequestsSuspended) { - state.requestsSuspended = false; - } - if (wasAiSuspended) { - state.aiSuspended = false; - } - if (wasSandboxRuntimeSuspended) { - state.sandboxRuntimeSuspended = false; - } + const previousSuspensions = await this.advanceBillingPeriod(state, body, false); await this.saveState(state); return Response.json({ rolled: true, - unsuspendRequests: wasRequestsSuspended, - unsuspendAi: wasAiSuspended, + unsuspendRequests: previousSuspensions.requests, + unsuspendAi: previousSuspensions.ai, // Post-rollover latch (false after a request rollover). Returned every // response so the scheduler reconciles KV against it each pass — see // processUserRollover in index.ts. @@ -612,9 +567,23 @@ export class UsageCounter extends DurableObject { if (owner) { state.plan = owner.plan; } - if (!state.billingPeriodInitialized && body.billingPeriodStart) { - state.billingPeriodStart = body.billingPeriodStart; - state.billingPeriodInitialized = true; + if (body.billingPeriodStart) { + const incomingPeriodStart = Date.parse(body.billingPeriodStart); + const currentPeriodStart = Date.parse(state.billingPeriodStart); + if (!state.billingPeriodInitialized) { + state.billingPeriodStart = body.billingPeriodStart; + state.billingPeriodInitialized = true; + } else if (!Number.isFinite(currentPeriodStart) || incomingPeriodStart > currentPeriodStart) { + await this.advanceBillingPeriod( + state, + { + billingPeriodStart: body.billingPeriodStart, + plan: state.plan, + userId: state.ownerUserId ?? '', + }, + true, + ); + } } if (body.ownershipEndedAt) { const windowKey = this.retainHistoricalAiWindow( @@ -913,6 +882,72 @@ export class UsageCounter extends DurableObject { state.totalNeurons = state.historicalNeurons + historicalWindows + current; } + private async advanceBillingPeriod( + state: CounterState, + next: { billingPeriodStart: string; plan: string; userId: string }, + reconcileRequestSuspension: boolean, + ): Promise> { + const requests = state.requestsSuspended; + const ai = state.aiSuspended; + const sandbox = state.sandboxRuntimeSuspended; + const db = createDb(this.env.HYPERDRIVE.connectionString); + + // Finish idempotent external cleanup before committing the new period. + // A failure leaves the cached and persisted period unchanged for retry. + if (ai) { + const ownedProjectIds = await this.ownedProjectIds( + db, + Object.keys(state.projectSlugs), + next.userId || state.ownerUserId, + ); + for (const projectId of ownedProjectIds) { + await this.env.ROUTING.delete(`ai-block:${projectId}`); + } + } + if (sandbox) { + await settleWrites([ + this.env.ROUTING.delete(sandboxBlockKey(next.userId)), + unsuspendUserIfReason(db, next.userId, REASON_SANDBOX_RUNTIME_EXCEEDED), + ]); + } + if (requests && reconcileRequestSuspension) { + const ownerUserId = next.userId || state.ownerUserId; + if (!ownerUserId) { + throw new Error('cannot reconcile request suspension without an owner'); + } + const ownedProjectIds = await this.ownedProjectIds( + db, + Object.keys(state.projectSlugs), + ownerUserId, + ); + const ownedProjects = Object.entries(state.projectSlugs).flatMap(([id, slug]) => + ownedProjectIds.has(id) ? [{ id, slug, user_id: ownerUserId }] : [], + ); + await settleWrites([ + unsuspendUserIfReason(db, ownerUserId, REASON_REQUESTS_EXCEEDED), + setProjectsRoutingSuspension(this.env.ROUTING, db, ownedProjects, { + op: 'unsuspend', + matchReason: REASON_REQUESTS_EXCEEDED, + }), + ]); + } + + state.totalRequests = 0; + state.totalNeurons = 0; + state.totalSandboxSeconds = 0; + state.historicalNeurons = 0; + state.historicalAiWindows = {}; + state.projectNeurons = {}; + state.plan = next.plan; + state.billingPeriodStart = next.billingPeriodStart; + state.billingPeriodInitialized = true; + state.requestsSuspended = false; + state.aiSuspended = false; + state.sandboxRuntimeSuspended = false; + + return { requests, ai }; + } + private async suspendProjects( db: Db, projectSlugs: Record, diff --git a/platform/packages/api/test/integration/project-team-lifecycle.test.ts b/platform/packages/api/test/integration/project-team-lifecycle.test.ts index e1e80f8b2..49cc32536 100644 --- a/platform/packages/api/test/integration/project-team-lifecycle.test.ts +++ b/platform/packages/api/test/integration/project-team-lifecycle.test.ts @@ -49,6 +49,37 @@ async function setEmail(userId: string, email: string): Promise { } describe('project team lifecycle', () => { + it('locks invitation owner and target accounts together in stable order', async () => { + const owner = await createTestUser(env.db, 'lock-owner'); + const target = await createTestUser(env.db, 'lock-target'); + await setEmail(target.id, 'lock-target@example.com'); + const project = await createProject(env.db, owner.id, 'invitation-user-locks'); + + const { queries } = await captureQueries(env.db, () => + createOrRefreshProjectInvitation(env.db, { + projectId: project.id, + email: 'lock-target@example.com', + role: 'reader', + actorUserId: owner.id, + }), + ); + + const projectLock = queries.findIndex( + (query) => query.includes('from "projects"') && query.includes('for update'), + ); + const accountLock = queries.findIndex( + (query) => + query.includes('from "users"') && + query.includes('order by "users"."id" asc') && + query.includes('for update'), + ); + expect(projectLock).toBeGreaterThanOrEqual(0); + expect(accountLock).toBeGreaterThan(projectLock); + expect( + queries.some((query) => query.includes('from "users"') && query.includes('for share')), + ).toBe(false); + }); + it('deleting a non-owner revokes their GitHub authority and preserves access audit history', async () => { const owner = await createTestUser(env.db, 'owner'); const member = await createTestUser(env.db, 'member'); diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index f8bce446b..366ba76a3 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -1108,8 +1108,17 @@ describe('UsageCounter.handleRollover — request latch in response', () => { describe('UsageCounter ownership changes', () => { beforeEach(() => { + dbCalls.unsuspendIfReason.length = 0; vi.mocked(queryProjectAiUsage).mockReset().mockResolvedValue({ neurons: 0 }); vi.mocked(getProjectOwnershipPeriodsByProjectIds).mockReset().mockResolvedValue([]); + vi.mocked(getProject) + .mockReset() + .mockImplementation(async (_db, id) => ({ id, user_id: 'u1', slug: id }) as never); + vi.mocked(getProjectsByIds) + .mockReset() + .mockImplementation(async (_db, ids) => + ids.map((id) => ({ id, user_id: 'u1', slug: id }) as never), + ); vi.mocked(getUser) .mockReset() .mockImplementation( @@ -1317,6 +1326,94 @@ describe('UsageCounter ownership changes', () => { }); }); + it('advances an initialized counter before retaining a current-period transfer window', async () => { + const billingPeriodStart = '2026-02-01T00:00:00.000Z'; + const ownershipStartedAt = '2026-01-15T00:00:00.000Z'; + const ownershipEndedAt = '2026-03-01T00:00:00.000Z'; + vi.mocked(queryProjectAiUsage).mockResolvedValueOnce({ neurons: 57 }); + vi.mocked(getProjectsByIds).mockResolvedValueOnce([ + { id: 'transferred', user_id: 'new-owner', slug: 'app' } as never, + { id: 'retained', user_id: 'u1', slug: 'retained-app' } as never, + ]); + const { counter, kv, storage } = makeCounter({ + ownerUserId: 'u1', + plan: 'solo', + billingPeriodStart: '2026-01-01T00:00:00.000Z', + billingPeriodInitialized: true, + totalRequests: 10_000_000, + requestsSuspended: true, + totalNeurons: 42, + historicalNeurons: 11, + projectSlugs: { transferred: 'app', retained: 'retained-app' }, + projectOwnershipStarts: { + transferred: ownershipStartedAt, + retained: '2026-01-20T00:00:00.000Z', + }, + projectNeurons: { transferred: 31 }, + }); + kv.store.set( + 'retained-app', + JSON.stringify({ + workerName: 'retained-worker', + projectId: 'retained', + userId: 'u1', + suspended: 'requests_exceeded', + }), + ); + + const response = await counter.fetch( + new Request('https://do/detach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'transferred', + userId: 'u1', + billingPeriodStart, + ownershipStartedAt, + ownershipEndedAt, + }), + }), + ); + + expect(response.ok).toBe(true); + expect(dbCalls.unsuspendIfReason).toContainEqual({ + id: 'u1', + reason: 'requests_exceeded', + }); + expect(JSON.parse(kv.store.get('retained-app')!)).not.toHaveProperty('suspended'); + expect(queryProjectAiUsage).toHaveBeenCalledWith( + undefined, + undefined, + 'void', + 'transferred', + new Date(billingPeriodStart), + new Date(ownershipEndedAt), + true, + ); + expect(storage.data.get('state')).toMatchObject({ + billingPeriodStart, + totalRequests: 0, + requestsSuspended: false, + totalNeurons: 57, + historicalNeurons: 0, + historicalAiWindows: { + [JSON.stringify(['transferred', ownershipStartedAt, ownershipEndedAt])]: { + neurons: 57, + }, + }, + }); + + const rollover = await counter.fetch(rolloverRequest(billingPeriodStart, 'solo', 'u1')); + expect(await rollover.json()).toMatchObject({ rolled: false }); + expect(storage.data.get('state')).toMatchObject({ + totalNeurons: 57, + historicalAiWindows: { + [JSON.stringify(['transferred', ownershipStartedAt, ownershipEndedAt])]: { + neurons: 57, + }, + }, + }); + }); + it('keeps current ownership state retryable when cutoff reconciliation fails', async () => { vi.mocked(queryProjectAiUsage).mockRejectedValueOnce(new Error('analytics unavailable')); const { counter, storage } = makeCounter({ From 78bcb50570cf5609f17a2b3387a03a48a3a6fa78 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 21 Sep 2026 13:21:32 +0800 Subject: [PATCH 16/18] fix(platform): retain usage across deletion retries --- .../packages/api/src/provisioning/cleanup.ts | 22 +++++--- platform/packages/api/src/usage-counter.ts | 18 ++++++- .../api/test/integration/api-delete.test.ts | 50 +++++++++++++++++++ .../api/test/unit/usage-counter.test.ts | 43 ++++++++++++++-- 4 files changed, 120 insertions(+), 13 deletions(-) diff --git a/platform/packages/api/src/provisioning/cleanup.ts b/platform/packages/api/src/provisioning/cleanup.ts index a4023966e..f81a4e5a2 100644 --- a/platform/packages/api/src/provisioning/cleanup.ts +++ b/platform/packages/api/src/provisioning/cleanup.ts @@ -34,7 +34,7 @@ import { projects as projectsTable, } from '../schema.ts'; import { getCurrentBillingPeriodStart } from '../tiers.ts'; -import type { Build, DeployManifest, Project, Resource } from '../types.ts'; +import type { Build, DeployManifest, Project, ProjectOwnershipPeriod, Resource } from '../types.ts'; import { deleteCustomHostname, deleteD1Database, @@ -429,18 +429,28 @@ async function lockAndMarkDeleting( ), ) .orderBy(desc(projectOwnershipPeriods.ends_at)) + .for('update') .limit(1); - let ownership = - lockedProject.status === 'deleting' && - latestClosedOwnership?.owner_user_id === lockedProject.user_id - ? latestClosedOwnership - : undefined; + let ownership: ProjectOwnershipPeriod | undefined; if (openOwnership) { [ownership] = await txDb .update(projectOwnershipPeriods) .set({ ends_at: drizzleSql`clock_timestamp()` }) .where(eq(projectOwnershipPeriods.id, openOwnership.id)) .returning(); + } else if ( + lockedProject.status === 'deleting' && + latestClosedOwnership?.owner_user_id === lockedProject.user_id + ) { + // A previous teardown attempt may have stopped after detaching usage. + // Extend its cutoff so the retry reconciles activity through this attempt. + [ownership] = await txDb + .update(projectOwnershipPeriods) + .set({ + ends_at: drizzleSql`greatest(${projectOwnershipPeriods.ends_at}, clock_timestamp())`, + }) + .where(eq(projectOwnershipPeriods.id, latestClosedOwnership.id)) + .returning(); } else if (!ownership) { [ownership] = await txDb .insert(projectOwnershipPeriods) diff --git a/platform/packages/api/src/usage-counter.ts b/platform/packages/api/src/usage-counter.ts index bda3b12ed..13dd0113c 100644 --- a/platform/packages/api/src/usage-counter.ts +++ b/platform/packages/api/src/usage-counter.ts @@ -787,10 +787,24 @@ export class UsageCounter extends DurableObject { return null; } const normalizedStart = startedAt.toISOString(); - const normalizedEnd = endedAt.toISOString(); + const matchingWindows = Object.entries(state.historicalAiWindows).filter( + ([, window]) => + window.projectId === projectId && + Date.parse(window.ownershipStartedAt) === startedAt.getTime(), + ); + const effectiveEnd = Math.max( + endedAt.getTime(), + ...matchingWindows + .map(([, window]) => Date.parse(window.ownershipEndedAt)) + .filter(Number.isFinite), + ); + const normalizedEnd = new Date(effectiveEnd).toISOString(); const key = JSON.stringify([projectId, normalizedStart, normalizedEnd]); - const existing = state.historicalAiWindows[key]; + const existing = state.historicalAiWindows[key] ?? matchingWindows[0]?.[1]; const sampled = state.projectNeurons[projectId]; + for (const [matchingKey] of matchingWindows) { + delete state.historicalAiWindows[matchingKey]; + } state.historicalAiWindows[key] = { projectId, ownershipStartedAt: normalizedStart, diff --git a/platform/packages/api/test/integration/api-delete.test.ts b/platform/packages/api/test/integration/api-delete.test.ts index 6809ccf96..ef815451d 100644 --- a/platform/packages/api/test/integration/api-delete.test.ts +++ b/platform/packages/api/test/integration/api-delete.test.ts @@ -267,6 +267,56 @@ describe('DELETE /projects/:id', () => { ); }); + it('advances the retained usage cutoff when a failed teardown is retried', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'retry-usage-cutoff'); + await env.db.insert(resources).values({ + id: 'res_retry_container', + project_id: project.id, + type: 'container', + cf_id: 'container-retry', + binding_name: 'SANDBOX', + }); + let containerDeleteAttempts = 0; + mockCfApi({ + deleteContainerApp: () => { + containerDeleteAttempts += 1; + return containerDeleteAttempts === 1 + ? cfError('temporary container API failure', 500) + : cfError('not found', 404); + }, + }); + const usageCalls: Array<{ body: { ownershipEndedAt: string } }> = []; + const usageCounter = { + idFromName: (name: string) => name, + get: () => ({ + fetch: async (request: Request) => { + usageCalls.push({ body: await request.json() }); + return Response.json({ ok: true }); + }, + }), + } as unknown as DurableObjectNamespace; + const request = () => + app.fetch( + new Request(`http://localhost/projects/${project.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${user.jwt}` }, + }), + { ...env, USAGE_COUNTER: usageCounter }, + ); + + expect((await request()).status).toBe(500); + expect(await db.getProject(env.db, project.id)).toMatchObject({ status: 'deleting' }); + expect(usageCalls).toHaveLength(1); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect((await request()).status).toBe(200); + expect(usageCalls).toHaveLength(2); + expect(Date.parse(usageCalls[1]!.body.ownershipEndedAt)).toBeGreaterThan( + Date.parse(usageCalls[0]!.body.ownershipEndedAt), + ); + }); + it('deletes project with resources, assets, and routing', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'full-app'); diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index 366ba76a3..9174bc9a8 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -1222,9 +1222,11 @@ describe('UsageCounter ownership changes', () => { it('reconciles the complete pre-transfer AI window and resamples it idempotently', async () => { const ownershipStartedAt = '2026-02-01T00:00:00.000Z'; const ownershipEndedAt = '2026-03-01T00:00:00.000Z'; + const extendedOwnershipEndedAt = '2026-03-02T00:00:00.000Z'; vi.mocked(queryProjectAiUsage) .mockResolvedValueOnce({ neurons: 57 }) - .mockResolvedValueOnce({ neurons: 61 }); + .mockResolvedValueOnce({ neurons: 61 }) + .mockResolvedValueOnce({ neurons: 63 }); const { counter, storage } = makeCounter({ ownerUserId: 'former-owner', plan: 'solo', @@ -1234,14 +1236,14 @@ describe('UsageCounter ownership changes', () => { projectOwnershipStarts: { transferred: ownershipStartedAt }, projectNeurons: { transferred: 42 }, }); - const detachRequest = () => + const detachRequest = (endedAt = ownershipEndedAt) => new Request('https://do/detach-project', { method: 'POST', body: JSON.stringify({ projectId: 'transferred', userId: 'former-owner', ownershipStartedAt, - ownershipEndedAt, + ownershipEndedAt: endedAt, }), }); @@ -1270,16 +1272,47 @@ describe('UsageCounter ownership changes', () => { }, }); - expect((await counter.fetch(detachRequest())).ok).toBe(true); + expect((await counter.fetch(detachRequest(extendedOwnershipEndedAt))).ok).toBe(true); + expect(queryProjectAiUsage).toHaveBeenLastCalledWith( + undefined, + undefined, + 'void', + 'transferred', + new Date(ownershipStartedAt), + new Date(extendedOwnershipEndedAt), + true, + ); expect(storage.data.get('state')).toMatchObject({ totalNeurons: 61, historicalNeurons: 0, historicalAiWindows: { - [JSON.stringify(['transferred', ownershipStartedAt, ownershipEndedAt])]: { + [JSON.stringify(['transferred', ownershipStartedAt, extendedOwnershipEndedAt])]: { neurons: 61, }, }, }); + + expect((await counter.fetch(detachRequest())).ok).toBe(true); + expect(queryProjectAiUsage).toHaveBeenLastCalledWith( + undefined, + undefined, + 'void', + 'transferred', + new Date(ownershipStartedAt), + new Date(extendedOwnershipEndedAt), + true, + ); + expect(storage.data.get('state')).toMatchObject({ + totalNeurons: 63, + historicalAiWindows: { + [JSON.stringify(['transferred', ownershipStartedAt, extendedOwnershipEndedAt])]: { + neurons: 63, + }, + }, + }); + expect( + Object.keys((storage.data.get('state') as CounterState).historicalAiWindows), + ).toHaveLength(1); }); it('initializes a fresh counter from the former owner billing window before detaching', async () => { From bc0785b6564c6b7e44c304d58d11d59b2fedd00e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 11:47:43 +0800 Subject: [PATCH 17/18] fix(platform): close project team review gaps --- docs/guide/project-collaboration.md | 4 + docs/reference/cli.md | 2 +- packages/void/src/cli/platform-management.ts | 8 + packages/void/src/cli/project-team.ts | 36 +++- .../void/test/unit/platform-rollback.test.ts | 35 +++- .../test/unit/project-team-command.test.ts | 180 +++++++++++++++++- platform/packages/api/README.md | 2 +- .../api/src/email/connection-service.ts | 102 ++++++++-- .../packages/api/src/email/domain-service.ts | 8 + .../api/src/operator/tenant-service.ts | 3 + platform/packages/api/src/routes/email.ts | 1 - platform/packages/api/src/usage-counter.ts | 30 ++- .../integration/email-connections.test.ts | 138 +++++++++++++- .../test/integration/operator-tenants.test.ts | 1 + .../api/test/unit/usage-counter.test.ts | 83 ++++++++ 15 files changed, 599 insertions(+), 34 deletions(-) diff --git a/docs/guide/project-collaboration.md b/docs/guide/project-collaboration.md index f8f1cb37a..791c04779 100644 --- a/docs/guide/project-collaboration.md +++ b/docs/guide/project-collaboration.md @@ -30,6 +30,10 @@ void project team invite teammate@example.com --role collaborator Pass `--project ` 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. +Invitations expire after seven days. If the platform cannot send invitation +email, the invitation still appears when the invited user runs +`void project team pending`. + List the roster and sent invitations with: ```sh diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4d60bff70..60a3a5d50 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -815,7 +815,7 @@ Platform migrations only move forward. Void checks compatibility before updating 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. -`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. +`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. 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. diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 7a6414ca3..9893e8497 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -533,6 +533,14 @@ export function assertPlatformRollbackCompatibility( 'platform: The rollback target cannot enforce the installed authentication configuration and session revocation protocol. Use a compatible runtime.', ); } + if ( + Math.max(installation.usageProtocolVersion, source.manifest.usageProtocolVersion) >= 2 && + target.manifest.usageProtocolVersion < 2 + ) { + throw new Error( + 'platform: The rollback target predates ownership-aware usage attribution and cannot safely account for transferred projects. Use a runtime with usage protocol version 2 or newer.', + ); + } if (target.manifest.database.lineage !== source.manifest.database.lineage) { throw new Error('platform: The rollback target belongs to a different database lineage.'); } diff --git a/packages/void/src/cli/project-team.ts b/packages/void/src/cli/project-team.ts index 01064c581..c7a6f2008 100644 --- a/packages/void/src/cli/project-team.ts +++ b/packages/void/src/cli/project-team.ts @@ -1,13 +1,15 @@ import { intro, log, note, outro } from '@clack/prompts'; -import { getToken } from './auth.ts'; +import { getToken, readPlatformToken } from './auth.ts'; import { PlatformClient, type ProjectInvitationResponse, type ProjectMemberResponse, } from './client.ts'; +import { VOID_CLOUD_PLATFORM } from './hosted-platform.ts'; import { cliTitle } from './output.ts'; +import { getActivePlatformConnection } from './platform-registry.ts'; import type { ProjectArgs } from './project-args.ts'; -import { readProjectConfig } from './project.ts'; +import { readDeploymentPlatform, readProjectConfig } from './project.ts'; import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; type ProjectTeamArgs = Exclude, { action: '' }>; @@ -72,16 +74,38 @@ async function resolveProjectId( return linked.projectId; } +function resolveCredential( + root: string, + accountScoped: boolean, +): { apiUrl?: string; token: string | null } { + if (accountScoped && readDeploymentPlatform(root) === 'cloudflare' && !process.env.VOID_API_URL) { + if (process.env.VOID_TOKEN) { + return { apiUrl: VOID_CLOUD_PLATFORM.apiUrl, token: process.env.VOID_TOKEN }; + } + const connection = getActivePlatformConnection(); + if (connection) { + return { apiUrl: connection.apiUrl, token: readPlatformToken(connection.apiUrl) }; + } + } + return { token: getToken(root) }; +} + export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs): Promise { console.log(); intro(cliTitle(`project team ${args.action}`)); - const token = getToken(root); + const accountScoped = + args.action === 'pending' || args.action === 'accept' || args.action === 'decline'; + const { apiUrl, token } = resolveCredential(root, accountScoped); if (!token) { - log.error('Not logged in. Run `void auth login` first.'); + log.error( + apiUrl + ? `Not logged in to ${apiUrl}. Run \`void connect ${apiUrl}\` to sign in.` + : 'Not logged in. Run `void auth login` first.', + ); process.exit(1); } - const client = new PlatformClient(token, { root }); + const client = new PlatformClient(token, apiUrl ? { apiUrl } : { root }); if (args.action === 'pending') { const invitations = await client.listPendingProjectInvitations(); @@ -127,7 +151,7 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs) ? '' : ` (email delivery: ${result.delivery.status.replace('_', ' ')})`; const action = result.refreshed ? 'Updated invitation for' : 'Invited'; - outro(`${action} ${invitationEmail(result.invitation)} as ${args.role}${delivery}.`); + outro(`${action} ${clean(invitationEmail(result.invitation))} as ${args.role}${delivery}.`); return; } if (args.action === 'role') { diff --git a/packages/void/test/unit/platform-rollback.test.ts b/packages/void/test/unit/platform-rollback.test.ts index 32878ff0e..4f61bada9 100644 --- a/packages/void/test/unit/platform-rollback.test.ts +++ b/packages/void/test/unit/platform-rollback.test.ts @@ -28,6 +28,7 @@ function runtime(input: { rollbackSafeFrom?: ReadonlyArray<{ platformVersion: string; schemaDigest: string }>; safety?: 0 | 1; schemaDigest: string; + usageProtocolVersion?: 0 | 1 | 2; }): PlatformRuntime { return { directory: `/runtime/${input.digest.slice(0, 4)}`, @@ -45,7 +46,7 @@ function runtime(input: { safetyProtocols: { sandboxDrain: input.safety ?? 1 }, schemaVersion: 2, sourceRevision: null, - usageProtocolVersion: 1, + usageProtocolVersion: input.usageProtocolVersion ?? 1, }, manifestSha256: input.digest, source: 'custom', @@ -66,6 +67,7 @@ function installed(source: PlatformRuntime) { ownershipNonce: '0123456789abcdef0123456789abcdef', platformVersion: source.manifest.platformVersion, safetyProtocols: source.manifest.safetyProtocols, + usageProtocolVersion: source.manifest.usageProtocolVersion, }), databaseSchemaDigest: source.manifest.database.schemaDigest, runtime: { @@ -164,6 +166,37 @@ describe('platform post-success rollback', () => { ), ).not.toThrow(); }); + it('blocks rollback below the installed ownership-aware usage protocol', () => { + const target = runtime({ + digest: 'a'.repeat(64), + migrations: [firstMigration], + platformVersion: '1.0.0', + schemaDigest: 'c'.repeat(64), + usageProtocolVersion: 1, + }); + const source = runtime({ + digest: 'b'.repeat(64), + migrations: [firstMigration, secondMigration], + platformVersion: '1.1.0', + rollbackSafeFrom: [{ platformVersion: '1.0.0', schemaDigest: 'c'.repeat(64) }], + schemaDigest: 'd'.repeat(64), + usageProtocolVersion: 2, + }); + + expect(() => assertPlatformRollbackCompatibility(installed(source), target, source)).toThrow( + 'ownership-aware usage attribution', + ); + expect(() => + assertPlatformRollbackCompatibility( + installed(source), + { + ...target, + manifest: { ...target.manifest, usageProtocolVersion: 2 }, + }, + source, + ), + ).not.toThrow(); + }); it('accepts an exact compatible prefix while preserving forward schema provenance', () => { const target = runtime({ digest: 'a'.repeat(64), diff --git a/packages/void/test/unit/project-team-command.test.ts b/packages/void/test/unit/project-team-command.test.ts index dd1287123..218e3c645 100644 --- a/packages/void/test/unit/project-team-command.test.ts +++ b/packages/void/test/unit/project-team-command.test.ts @@ -1,22 +1,54 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'pathslash'; -import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + platformCredentialAccount, + savePlatformConnection, +} from '../../src/cli/platform-registry.ts'; import { runProjectCommand } from '../../src/cli/project-cmd.ts'; +const keyring = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn(), remove: vi.fn() })); +vi.mock('../../src/cli/keyring.ts', () => ({ + readKeyringPassword: keyring.read, + writeKeyringPassword: keyring.write, + deleteKeyringPassword: keyring.remove, +})); + const roots: Array = []; -function rootWithPlatform(platform: 'cloudflare' | 'void'): string { +function rootWithPlatform( + platform: 'cloudflare' | 'void', + metadata: Record = {}, +): string { const root = mkdtempSync(join(tmpdir(), 'void-project-team-')); roots.push(root); mkdirSync(join(root, '.void')); writeFileSync( join(root, '.void', 'project.json'), - JSON.stringify({ platform, projectId: 'project_1', slug: 'my-app' }), + JSON.stringify({ platform, projectId: 'project_1', slug: 'my-app', ...metadata }), ); return root; } +function connect(apiUrl: string, displayName: string): void { + savePlatformConnection({ + accountId: null, + apiUrl, + applicationDomain: null, + displayName, + installationId: null, + kind: 'connected', + proxyUrl: null, + }); +} + +beforeEach(() => { + keyring.read.mockReset().mockReturnValue(null); + keyring.write.mockReset().mockReturnValue(true); + keyring.remove.mockReset().mockReturnValue(true); +}); + afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); @@ -43,7 +75,7 @@ describe('project team target selection', () => { const root = rootWithPlatform('cloudflare'); vi.stubEnv('VOID_TOKEN', 'test-token'); vi.stubEnv('VOID_API_URL', 'https://api.example.test'); - const fetchMock = vi.fn(async (input: string | URL | Request) => { + const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; return url.endsWith('/me/project-invitations') ? Response.json({ invitations: [] }) @@ -72,10 +104,101 @@ describe('project team target selection', () => { ]); }); + it('uses the active connection for account invitations when a Cloudflare project retains another platform link', async () => { + const platformA = 'https://platform-a.example.test'; + const platformB = 'https://platform-b.example.test'; + const root = rootWithPlatform('cloudflare', { platformUrl: platformA }); + vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); + connect(platformA, 'Platform A'); + connect(platformB, 'Platform B'); + keyring.read.mockImplementation((service: string, account: string) => { + if (service !== 'dev.void.cli') { + return null; + } + if (account === platformCredentialAccount(platformA)) { + return 'platform-a-token'; + } + return account === platformCredentialAccount(platformB) ? 'platform-b-token' : null; + }); + const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + return url.endsWith('/me/project-invitations') + ? Response.json({ invitations: [] }) + : Response.json({}); + }); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await runProjectCommand(root, { subcommand: 'team', action: 'pending' }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'accept', + invitationId: 'pinv_1', + }); + await runProjectCommand(root, { + subcommand: 'team', + action: 'decline', + invitationId: 'pinv_1', + }); + + expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual([ + `${platformB}/me/project-invitations`, + `${platformB}/project-invitations/pinv_1/accept`, + `${platformB}/project-invitations/pinv_1/decline`, + ]); + for (const [, init] of fetchMock.mock.calls) { + expect(init).toMatchObject({ + headers: expect.objectContaining({ Authorization: 'Bearer platform-b-token' }), + }); + } + }); + + it('keeps an unscoped VOID_TOKEN bound to Void Cloud for account invitations', async () => { + const platformA = 'https://platform-a.example.test'; + const platformB = 'https://platform-b.example.test'; + const root = rootWithPlatform('cloudflare', { platformUrl: platformA }); + vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); + vi.stubEnv('VOID_TOKEN', 'void-cloud-token'); + vi.stubEnv('VOID_API_URL', undefined); + connect(platformA, 'Platform A'); + connect(platformB, 'Platform B'); + keyring.read.mockImplementation((service: string, account: string) => + service === 'dev.void.cli' && account === platformCredentialAccount(platformB) + ? 'platform-b-token' + : null, + ); + const fetchMock = vi.fn(async () => Response.json({ invitations: [] })); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await runProjectCommand(root, { subcommand: 'team', action: 'pending' }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.void.cloud/me/project-invitations', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer void-cloud-token' }), + }), + ); + }); + it('uses the linked Void platform project for member listing', async () => { - const root = rootWithPlatform('void'); - vi.stubEnv('VOID_TOKEN', 'test-token'); - vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + const platformA = 'https://platform-a.example.test'; + const platformB = 'https://platform-b.example.test'; + const root = rootWithPlatform('void', { platformUrl: platformA }); + vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); + connect(platformA, 'Platform A'); + connect(platformB, 'Platform B'); + keyring.read.mockImplementation((service: string, account: string) => { + if (service !== 'dev.void.cli') { + return null; + } + if (account === platformCredentialAccount(platformA)) { + return 'platform-a-token'; + } + return account === platformCredentialAccount(platformB) ? 'platform-b-token' : null; + }); const fetchMock = vi.fn(async () => Response.json({ members: [{ user: { id: 'user_1', login: 'alex' }, role: 'owner' }], @@ -92,9 +215,9 @@ describe('project team target selection', () => { }); expect(fetchMock).toHaveBeenCalledWith( - 'https://api.example.test/projects/project_1/members', + `${platformA}/projects/project_1/members`, expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer test-token' }), + headers: expect.objectContaining({ Authorization: 'Bearer platform-a-token' }), }), ); }); @@ -136,4 +259,43 @@ describe('project team target selection', () => { 'https://api.example.test/projects/project_2/members', ]); }); + + it('scrubs control characters from invitation confirmation output', async () => { + const root = rootWithPlatform('void'); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + invitation: { + id: 'pinv_1', + target: { id: 'user_1', login: 'target', email: 'target\u0007@example.com' }, + role: 'reader', + created_at: '2026-09-22T00:00:00Z', + expires_at: '2026-09-29T00:00:00Z', + }, + refreshed: false, + delivery: { status: 'sent' }, + }), + ), + ); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const output: Array = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await runProjectCommand(root, { + subcommand: 'team', + action: 'invite', + email: 'target@example.com', + role: 'reader', + projectSlug: '', + }); + + expect(output.join('')).not.toContain('\u0007'); + expect(output.join('')).toContain('target @example.com'); + }); }); diff --git a/platform/packages/api/README.md b/platform/packages/api/README.md index e2203fcc8..0f34ee29f 100644 --- a/platform/packages/api/README.md +++ b/platform/packages/api/README.md @@ -159,7 +159,7 @@ retained receipt and recipient outcomes, with `page` and `limit` pagination. Accept a current project ID or slug; deleted projects remain inspectable by ID until ledger retention expires. Responses exclude message content and credentials. -`POST /projects/:id/owner` previews or applies an ownership transfer through the +`POST /operator/v1/projects/:id/owner` previews or applies an ownership transfer through the same service used by the browser admin UI. The database transaction updates the owner and ownership periods atomically; usage counters and routing converge afterward, and retries repair an already-committed transfer without moving its diff --git a/platform/packages/api/src/email/connection-service.ts b/platform/packages/api/src/email/connection-service.ts index d6b4183b1..92a3fb90e 100644 --- a/platform/packages/api/src/email/connection-service.ts +++ b/platform/packages/api/src/email/connection-service.ts @@ -446,6 +446,22 @@ async function requireProjectManagedEmailAccess( } } +async function lockProjectManagedEmailAccess( + tx: Tx, + projectId: string, + actor: EmailActor, +): Promise { + if (actor.kind !== 'owner') { + return; + } + await tx + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .for('update'); + await requireProjectManagedEmailAccess(tx, projectId, actor); +} + async function hasDelegatedConnectionAccess( db: DbOrTx, connectionId: string, @@ -713,9 +729,19 @@ async function resumeBlockedEmailOperation( expectedRouteId?: string, expectedProjectId?: string, ): Promise { - return db.transaction((tx) => - resumeBlockedEmailOperationTx(tx, env, operationId, actor, expectedRouteId, expectedProjectId), - ); + return db.transaction(async (tx) => { + if (expectedProjectId) { + await lockProjectManagedEmailAccess(tx, expectedProjectId, actor); + } + return resumeBlockedEmailOperationTx( + tx, + env, + operationId, + actor, + expectedRouteId, + expectedProjectId, + ); + }); } export async function resolveEmailDomain(db: DbOrTx, rawDomain: string) { @@ -2090,8 +2116,15 @@ export async function requestEmailDomainSync( routeId: string, actor: EmailActor, key = generateId('esync'), + expectedProjectId?: string, ): Promise { - return requestExistingOperation(db, env, routeId, actor, 'sync', key); + const operationId = await (expectedProjectId === undefined + ? requestExistingOperation(db, env, routeId, actor, 'sync', key) + : requestExistingOperation(db, env, routeId, actor, 'sync', key, expectedProjectId)); + if (!operationId) { + throw new EmailOperationError('email domain not available', 404); + } + return operationId; } export function requestEmailDomainRemove( db: Db, @@ -2105,7 +2138,7 @@ export function requestEmailDomainRemove( env: Bindings, routeId: string, actor: EmailActor, - key: string, + key: string | undefined, expectedProjectId: string, ): Promise; export async function requestEmailDomainRemove( @@ -2128,6 +2161,7 @@ export async function requestEmailSecretRotation( connectionId: string, actor: EmailActor, key = generateId('erot'), + expectedProjectId?: string, ): Promise { const installationId = emailInstallationId(env); const actorScope = @@ -2146,7 +2180,7 @@ export async function requestEmailSecretRotation( ) .limit(1) )[0]; - if (prior) { + if (prior && expectedProjectId === undefined) { if (prior.request_digest !== requestDigest) { throw new EmailOperationError('email operation key conflict'); } @@ -2156,6 +2190,9 @@ export async function requestEmailSecretRotation( } const secret = randomSecret(); return db.transaction(async (tx) => { + if (expectedProjectId) { + await lockProjectManagedEmailAccess(tx, expectedProjectId, actor); + } const connection = ( await tx .select() @@ -2167,6 +2204,24 @@ export async function requestEmailSecretRotation( if (!connection || connection.installation_id !== installationId) { throw new EmailOperationError('connection not found', 404); } + if (actor.kind === 'owner' && expectedProjectId) { + const assignedRoute = ( + await tx + .select({ id: emailDomainRoutes.id }) + .from(emailDomainRoutes) + .where( + and( + eq(emailDomainRoutes.connection_id, connectionId), + eq(emailDomainRoutes.project_id, expectedProjectId), + eq(emailDomainRoutes.desired_state, 'active'), + ), + ) + .limit(1) + )[0]; + if (!assignedRoute) { + throw new EmailOperationError('connection not found', 404); + } + } if ( !canManageConnection(actor, connection) && !(await hasDelegatedConnectionAccess(tx, connection.id, actor)) @@ -2190,7 +2245,9 @@ export async function requestEmailSecretRotation( if (replay.request_digest !== requestDigest) { throw new EmailOperationError('email operation key conflict'); } - await resumeBlockedEmailOperationTx(tx, env, replay.id, actor); + if (replay.status === 'blocked') { + await resumeBlockedEmailOperationTx(tx, env, replay.id, actor); + } return replay.id; } if (isSharedGatewayConnection(env, connection)) { @@ -2329,7 +2386,7 @@ async function requestExistingOperation( routeId, kind, actor: actor.kind, - ...(expectedProjectId === undefined ? {} : { expectedProjectId }), + ...(actor.kind === 'owner' || expectedProjectId === undefined ? {} : { expectedProjectId }), }); const existing = ( await db @@ -2352,10 +2409,29 @@ async function requestExistingOperation( return existing.id; } } + const authorizationProjectId = + actor.kind === 'owner' + ? (expectedProjectId ?? + ( + await db + .select({ projectId: emailDomainRoutes.project_id }) + .from(emailDomainRoutes) + .where(eq(emailDomainRoutes.id, routeId)) + .limit(1) + )[0]?.projectId ?? + null) + : null; + if (actor.kind === 'owner' && !authorizationProjectId) { + throw new EmailOperationError('email domain not available', 404); + } + const expectedRouteProjectId = authorizationProjectId ?? expectedProjectId ?? null; return db.transaction(async (tx) => { if (kind === 'remove') { await lockEmailPolicy(tx); } + if (authorizationProjectId) { + await lockProjectManagedEmailAccess(tx, authorizationProjectId, actor); + } let route = ( await tx .select() @@ -2367,7 +2443,10 @@ async function requestExistingOperation( if (!route) { throw new EmailOperationError('email domain not available', 404); } - if (expectedProjectId !== undefined && route.project_id !== expectedProjectId) { + if (expectedRouteProjectId && route.project_id !== expectedRouteProjectId) { + if (expectedProjectId === undefined) { + throw new EmailOperationError('email domain not available', 404); + } return null; } if (kind === 'remove') { @@ -2410,7 +2489,6 @@ async function requestExistingOperation( if (!project || route.managed_by !== 'owner') { throw new EmailOperationError('forbidden', 403); } - await requireProjectManagedEmailAccess(tx, project.id, actor); } if ( kind === 'sync' && @@ -2439,7 +2517,9 @@ async function requestExistingOperation( if (replay.request_digest !== requestDigest) { throw new EmailOperationError('email operation key conflict'); } - await resumeBlockedEmailOperationTx(tx, env, replay.id, actor, routeId); + if (replay.status === 'blocked') { + await resumeBlockedEmailOperationTx(tx, env, replay.id, actor, routeId); + } return replay.id; } const existingRemoval = diff --git a/platform/packages/api/src/email/domain-service.ts b/platform/packages/api/src/email/domain-service.ts index fb8c34b05..083225db1 100644 --- a/platform/packages/api/src/email/domain-service.ts +++ b/platform/packages/api/src/email/domain-service.ts @@ -321,6 +321,8 @@ export async function syncEmailDomainForProject( context.env, resolved.route.id, { kind: actor, userId: context.get('user').id }, + undefined, + project.id, ); await runEmailOperation(context.get('db'), context.env, operationId); return domainResult(context, project, rawDomain, operationId); @@ -350,6 +352,7 @@ export async function rotateEmailIngressSecretForProject( resolved.connection.id, { kind: actor, userId: context.get('user').id }, attemptId, + project.id, ); await runEmailOperation(context.get('db'), context.env, operationId); const operation = ( @@ -389,7 +392,12 @@ export async function removeEmailDomainForProject( context.env, resolved.route.id, { kind: actor, userId: context.get('user').id }, + undefined, + project.id, ); + if (!operationId) { + return context.json({ error: 'email domain not found' }, 404); + } await runEmailOperation(context.get('db'), context.env, operationId); const op = ( await context diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 601719b91..aebdef0f3 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -611,6 +611,9 @@ async function updateOwnershipCounters( projectId: transfer.project.id, userId: transfer.newOwner.id, slug: transfer.project.slug, + billingPeriodStart: getCurrentBillingPeriodStart( + transfer.newOwner.billing_cycle_start, + ).toISOString(), ownershipStartedAt: transfer.transferredAt, }); } diff --git a/platform/packages/api/src/routes/email.ts b/platform/packages/api/src/routes/email.ts index 10a98f254..9afe452a8 100644 --- a/platform/packages/api/src/routes/email.ts +++ b/platform/packages/api/src/routes/email.ts @@ -584,7 +584,6 @@ const app = new Hono() 400, ); } - const db = context.get('db'); const access = await requireProjectRouteCapability(context, projectId, 'settings'); if (access instanceof Response) { return access; diff --git a/platform/packages/api/src/usage-counter.ts b/platform/packages/api/src/usage-counter.ts index 13dd0113c..edd6b733f 100644 --- a/platform/packages/api/src/usage-counter.ts +++ b/platform/packages/api/src/usage-counter.ts @@ -492,13 +492,16 @@ export class UsageCounter extends DurableObject { projectId: string; slug: string; ownershipStartedAt: string; + billingPeriodStart?: string; userId?: string; }; if ( !body.projectId || !body.slug || !body.ownershipStartedAt || - !Number.isFinite(Date.parse(body.ownershipStartedAt)) + !Number.isFinite(Date.parse(body.ownershipStartedAt)) || + (body.billingPeriodStart !== undefined && + !Number.isFinite(Date.parse(body.billingPeriodStart))) ) { return Response.json({ error: 'invalid project attachment' }, { status: 400 }); } @@ -515,12 +518,30 @@ export class UsageCounter extends DurableObject { if (!owner) { return Response.json({ error: 'project owner changed' }, { status: 409 }); } - state.projectSlugs[body.projectId] = body.slug; - state.projectOwnershipStarts[body.projectId] = body.ownershipStartedAt; // Ownership transfer requests may have waited behind a concurrent plan // change. PostgreSQL is authoritative; never restore the caller's stale // pre-transfer snapshot into this counter. state.plan = owner.plan; + if (body.billingPeriodStart !== undefined) { + const incomingPeriodStart = Date.parse(body.billingPeriodStart); + const currentPeriodStart = Date.parse(state.billingPeriodStart); + if (!state.billingPeriodInitialized) { + state.billingPeriodStart = body.billingPeriodStart; + state.billingPeriodInitialized = true; + } else if (!Number.isFinite(currentPeriodStart) || incomingPeriodStart > currentPeriodStart) { + await this.advanceBillingPeriod( + state, + { + billingPeriodStart: body.billingPeriodStart, + plan: owner.plan, + userId: state.ownerUserId ?? '', + }, + true, + ); + } + } + state.projectSlugs[body.projectId] = body.slug; + state.projectOwnershipStarts[body.projectId] = body.ownershipStartedAt; if (state.requestsSuspended) { await this.suspendProjects( db, @@ -537,6 +558,9 @@ export class UsageCounter extends DurableObject { await this.env.ROUTING.delete(`ai-block:${body.projectId}`); } await this.saveState(state); + if (!(await this.ctx.storage.getAlarm())) { + await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000); + } return Response.json({ ok: true }); } diff --git a/platform/packages/api/test/integration/email-connections.test.ts b/platform/packages/api/test/integration/email-connections.test.ts index 05304f2a3..6a20c7b73 100644 --- a/platform/packages/api/test/integration/email-connections.test.ts +++ b/platform/packages/api/test/integration/email-connections.test.ts @@ -40,7 +40,7 @@ import { } from '../../src/email/connection-service.ts'; import type { ApiEnv } from '../../src/env.ts'; import { app } from '../../src/index.ts'; -import { deployments, projects } from '../../src/schema.ts'; +import { deployments, projectMembers, projects } from '../../src/schema.ts'; import { callsByTag, stubCustomerCf } from './email-domain-cf-stub.ts'; import { clearTables, @@ -725,14 +725,150 @@ describe('durable zone email connections', () => { ); expect(new Set(rotations).size).toBe(1); await runEmailOperation(env.db, bindings(), rotations[0]); + expect( + await requestEmailSecretRotation( + env.db, + bindings(), + connection.id, + actor, + 'same-rotation', + project.id, + ), + ).toBe(rotations[0]); const syncs = await Promise.all( Array.from({ length: 3 }, () => requestEmailDomainSync(env.db, bindings(), add.routeId, actor, 'same-sync'), ), ); expect(new Set(syncs).size).toBe(1); + expect( + await requestEmailDomainSync(env.db, bindings(), add.routeId, actor, 'same-sync', project.id), + ).toBe(syncs[0]); }); + it.runIf(Boolean(process.env.VOID_TEST_DATABASE_URL))( + 'orders project-admin demotion before durable email-domain mutations', + async () => { + const failures: NonNullable[0]>['failOn'] = {}; + stubCustomerCf({ zones, failOn: failures }); + const owner = await createTestUser(env.db, 'email-mutation-race-owner'); + const admin = await createTestUser(env.db, 'email-mutation-race-admin'); + const project = await createTestProject(env.db, owner.id, 'email-mutation-race-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }); + const added = await requestEmailDomainAdd(env.db, bindings(), { + projectId: project.id, + domain: 'mutation-race.example.com', + actor: { kind: 'owner', userId: owner.id }, + token: TOKEN, + }); + await runEmailOperation(env.db, bindings(), added.operationId); + const connection = (await resolveEmailDomain(env.db, 'mutation-race.example.com'))! + .connection; + const replayInput = { + projectId: project.id, + domain: 'mutation-replay.example.com', + actor: { kind: 'owner' as const, userId: admin.id }, + token: TOKEN, + attemptId: 'mutation-race-replay', + }; + const blocked = await requestEmailDomainAdd(env.db, bindings(), replayInput); + failures.routingDns = { message: 'permission denied', status: 403 }; + await runEmailOperation(env.db, bindings(), blocked.operationId); + delete failures.routingDns; + expect( + ( + await env.db + .select() + .from(emailOperations) + .where(eq(emailOperations.id, blocked.operationId)) + )[0], + ).toMatchObject({ status: 'blocked' }); + + for (const kind of ['add-replay', 'sync', 'remove', 'rotate'] as const) { + await env.db + .update(projectMembers) + .set({ role: 'admin' }) + .where( + and(eq(projectMembers.project_id, project.id), eq(projectMembers.user_id, admin.id)), + ); + const operationCount = (await env.db.select().from(emailOperations)).length; + let mutation: + | Promise< + { status: 'fulfilled'; value: string | null } | { status: 'rejected'; error: unknown } + > + | undefined; + + await env.db.transaction(async (tx) => { + await tx + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, project.id)) + .for('update'); + await tx + .update(projectMembers) + .set({ role: 'collaborator' }) + .where( + and(eq(projectMembers.project_id, project.id), eq(projectMembers.user_id, admin.id)), + ); + const actor = { kind: 'owner' as const, userId: admin.id }; + const request = + kind === 'add-replay' + ? requestEmailDomainAdd(env.db, bindings(), replayInput).then( + ({ operationId }) => operationId, + ) + : kind === 'sync' + ? requestEmailDomainSync( + env.db, + bindings(), + added.routeId, + actor, + undefined, + project.id, + ) + : kind === 'remove' + ? requestEmailDomainRemove( + env.db, + bindings(), + added.routeId, + actor, + undefined, + project.id, + ) + : requestEmailSecretRotation( + env.db, + bindings(), + connection.id, + actor, + undefined, + project.id, + ); + mutation = request.then( + (value) => ({ status: 'fulfilled' as const, value }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); + await waitForPostgresLockWaiters(1); + }); + + const result = await mutation!; + expect(result.status).toBe('rejected'); + if (result.status !== 'rejected') { + throw new Error(`${kind} unexpectedly committed after project-admin demotion`); + } + expect(result.error).toMatchObject({ message: 'forbidden', status: 403 }); + expect(await env.db.select().from(emailOperations)).toHaveLength(operationCount); + expect( + (await resolveEmailDomain(env.db, 'mutation-race.example.com'))?.route, + ).toMatchObject({ desired_state: 'active' }); + } + }, + 20_000, + ); + it.runIf(Boolean(process.env.VOID_TEST_DATABASE_URL))( 'serializes concurrent first assignments on the same Cloudflare zone', async () => { diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index b2a0b897a..801c46f7b 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -512,6 +512,7 @@ describe('operator tenant mutations', () => { projectId: project.id, userId: target.id, slug: project.slug, + billingPeriodStart: expect.any(String), ownershipStartedAt: current.starts_at, }, }, diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index 9174bc9a8..8eedec7bf 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -1180,6 +1180,7 @@ describe('UsageCounter ownership changes', () => { body: JSON.stringify({ projectId: 'next', slug: 'next-app', + billingPeriodStart: '2026-01-01T00:00:00Z', ownershipStartedAt: startedAt, // Simulate the transfer caller's pre-plan-change snapshot. The DO // must use PostgreSQL's current `pro` plan from the getUser mock. @@ -1195,6 +1196,88 @@ describe('UsageCounter ownership changes', () => { historicalNeurons: 42, }); expect(kv.calls).toContainEqual(['delete', 'ai-block:next']); + expect(await storage.getAlarm()).toEqual(expect.any(Number)); + }); + + it('advances a stale recipient period before attaching a transferred project', async () => { + const billingPeriodStart = '2026-02-01T00:00:00.000Z'; + const ownershipStartedAt = '2026-02-02T00:00:00.000Z'; + const { counter, kv, storage } = makeCounter({ + ownerUserId: 'u1', + plan: 'solo', + billingPeriodStart: '2026-01-01T00:00:00.000Z', + billingPeriodInitialized: true, + totalRequests: 10_000_000, + totalNeurons: 100_001, + totalSandboxSeconds: 30 * 3_600, + requestsSuspended: true, + aiSuspended: true, + sandboxRuntimeSuspended: true, + projectSlugs: { retained: 'retained-app' }, + projectOwnershipStarts: { retained: '2026-01-01T00:00:00.000Z' }, + projectNeurons: { retained: 100_001 }, + historicalNeurons: 17, + }); + kv.store.set( + 'retained-app', + JSON.stringify({ + workerName: 'retained-worker', + projectId: 'retained', + userId: 'u1', + suspended: 'requests_exceeded', + }), + ); + kv.store.set( + 'next-app', + JSON.stringify({ workerName: 'next-worker', projectId: 'next', userId: 'u1' }), + ); + kv.store.set('ai-block:retained', '1'); + kv.store.set('ai-block:next', '1'); + kv.store.set('sandbox-block:u1', '1'); + + const response = await counter.fetch( + new Request('https://do/attach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'next', + slug: 'next-app', + billingPeriodStart, + ownershipStartedAt, + userId: 'u1', + }), + }), + ); + + expect(response.ok).toBe(true); + expect(dbCalls.unsuspendIfReason).toEqual( + expect.arrayContaining([ + { id: 'u1', reason: 'requests_exceeded' }, + { id: 'u1', reason: 'sandbox_runtime_exceeded' }, + ]), + ); + expect(JSON.parse(kv.store.get('retained-app')!)).not.toHaveProperty('suspended'); + expect(JSON.parse(kv.store.get('next-app')!)).not.toHaveProperty('suspended'); + expect(kv.store.has('ai-block:retained')).toBe(false); + expect(kv.store.has('ai-block:next')).toBe(false); + expect(kv.store.has('sandbox-block:u1')).toBe(false); + expect(storage.data.get('state')).toMatchObject({ + billingPeriodStart, + plan: 'pro', + totalRequests: 0, + totalNeurons: 0, + totalSandboxSeconds: 0, + requestsSuspended: false, + aiSuspended: false, + sandboxRuntimeSuspended: false, + projectSlugs: { retained: 'retained-app', next: 'next-app' }, + projectOwnershipStarts: { + retained: '2026-01-01T00:00:00.000Z', + next: ownershipStartedAt, + }, + projectNeurons: {}, + historicalNeurons: 0, + }); + expect(await storage.getAlarm()).toEqual(expect.any(Number)); }); it('does not attach a project after deletion has started', async () => { From d97540e3749be8bedb954c91e8ef3f9ec5c9d5a1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 18:02:11 +0800 Subject: [PATCH 18/18] fix(platform): address project team review findings --- docs/guide/project-collaboration.md | 6 + packages/void/src/cli/auth.ts | 55 ++++--- packages/void/test/unit/auth-legacy.test.ts | 17 +++ .../meta/design-docs/0080-project-teams.md | 6 + platform/packages/api/src/db.ts | 20 +-- platform/packages/api/src/routes/deploy.ts | 20 ++- platform/packages/api/src/routes/email.ts | 2 +- .../packages/api/src/routes/project-tokens.ts | 56 +++++++- platform/packages/api/src/routes/projects.ts | 3 + platform/packages/api/src/routing.ts | 41 ++++++ platform/packages/api/src/usage-counter.ts | 128 ++++++++++------- .../api/test/integration/api-deploy.test.ts | 104 ++++++++++++++ .../api/test/integration/api-email.test.ts | 37 +++++ .../test/integration/api-project-team.test.ts | 14 ++ .../api/test/integration/api-projects.test.ts | 134 ++++++++++++++++++ .../packages/api/test/unit/routing.test.ts | 66 +++++++++ .../api/test/unit/usage-counter.test.ts | 114 ++++++++++++++- 17 files changed, 722 insertions(+), 101 deletions(-) diff --git a/docs/guide/project-collaboration.md b/docs/guide/project-collaboration.md index 791c04779..5f498a9eb 100644 --- a/docs/guide/project-collaboration.md +++ b/docs/guide/project-collaboration.md @@ -15,6 +15,12 @@ A project hosted on a Void platform can have one owner and additional readers, c | Project administrator | Collaborator access plus domains, email destinations, GitHub configuration, invitations, roles, and member removal. | | Owner | Project administrator access plus project deletion. | +Only the owner can create, list, renew, or revoke the project's CI deploy +credentials. Those credentials run unattended deployments as the billing owner +and remain independent of a member's login session, so collaborators and +project administrators deploy with their own account instead of minting a +durable owner credential. + The owner's plan, limits, and suspension state govern the project. A member's own plan does not change the project's available usage, and acting through a collaborator does not bypass an owner suspension. A project administrator is diff --git a/packages/void/src/cli/auth.ts b/packages/void/src/cli/auth.ts index 53105b24e..54cae48c0 100644 --- a/packages/void/src/cli/auth.ts +++ b/packages/void/src/cli/auth.ts @@ -70,6 +70,32 @@ function writeConfig(config: Config): void { writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); } +function readOriginScopedToken(apiUrl: string, config: Config): string | null { + const normalizedApiUrl = normalizePlatformApiUrl(apiUrl); + const account = platformCredentialAccount(normalizedApiUrl); + const keyringToken = keyringGet(account); + if (keyringToken) { + return keyringToken; + } + + const fileToken = config.tokens?.[normalizedApiUrl]; + if (!fileToken) { + return null; + } + if (keyringSet(account, fileToken)) { + delete config.tokens![normalizedApiUrl]; + if (Object.keys(config.tokens!).length === 0) { + delete config.tokens; + } + try { + writeConfig(config); + } catch { + // Migration is best-effort; the file remains a usable fallback. + } + } + return fileToken; +} + export function isStagingMode(root: string = process.cwd()): boolean { return isVoidInternalStaging(root); } @@ -120,32 +146,15 @@ export function getToken(root: string = process.cwd()): string | null { } return envToken; } - const account = platformCredentialAccount(apiUrl); - - // Try keyring first - const keyringToken = keyringGet(account); - if (keyringToken) { - return keyringToken; - } - const config = readConfig(); const normalizedApiUrl = normalizePlatformApiUrl(apiUrl); - const fileToken = config.tokens?.[normalizedApiUrl]; - if (fileToken) { - if (keyringSet(account, fileToken)) { - delete config.tokens![normalizedApiUrl]; - if (Object.keys(config.tokens!).length === 0) { - delete config.tokens; - } - try { - writeConfig(config); - } catch { - // Ignore — migration is best-effort. - } - } - return fileToken; + const originToken = readOriginScopedToken(normalizedApiUrl, config); + if (originToken) { + return originToken; } + const account = platformCredentialAccount(normalizedApiUrl); + const legacyAccount = legacyCredentialAccount(root, normalizedApiUrl, config); if (legacyAccount) { const legacyKeyringToken = keyringGet(legacyAccount); @@ -192,7 +201,7 @@ export function saveToken( } export function readPlatformToken(apiUrl: string): string | null { - return keyringGet(platformCredentialAccount(normalizePlatformApiUrl(apiUrl))); + return readOriginScopedToken(apiUrl, readConfig()); } export function savePlatformToken( diff --git a/packages/void/test/unit/auth-legacy.test.ts b/packages/void/test/unit/auth-legacy.test.ts index 8b4c56dfd..7089fa009 100644 --- a/packages/void/test/unit/auth-legacy.test.ts +++ b/packages/void/test/unit/auth-legacy.test.ts @@ -95,6 +95,23 @@ describe('legacy Void Cloud authentication', () => { expect(getToken(root)).toBe('legacy-token'); }); + it('reads and migrates an origin-scoped config token without project context', async () => { + const origin = 'https://platform.example.test'; + const configDirectory = join(state.home, '.void'); + mkdirSync(configDirectory, { recursive: true }); + const configPath = join(configDirectory, 'config.json'); + writeFileSync(configPath, JSON.stringify({ tokens: { [origin]: 'origin-file-token' } })); + const { readPlatformToken } = await import('../../src/cli/auth.ts'); + const { platformCredentialAccount } = await import('../../src/cli/platform-registry.ts'); + + expect(readPlatformToken(`${origin}/`)).toBe('origin-file-token'); + expect(state.passwords.get(`dev.void.cli:${platformCredentialAccount(origin)}`)).toBe( + 'origin-file-token', + ); + expect(JSON.parse(readFileSync(configPath, 'utf8'))).toEqual({}); + expect(readPlatformToken(origin)).toBe('origin-file-token'); + }); + it('lets an explicit platform connection supersede a legacy hosted selection', async () => { const root = mkdtempSync(join(tmpdir(), 'void-auth-platform-selection-')); directories.push(root); diff --git a/platform/meta/design-docs/0080-project-teams.md b/platform/meta/design-docs/0080-project-teams.md index 6008a3bfb..6b4eee654 100644 --- a/platform/meta/design-docs/0080-project-teams.md +++ b/platform/meta/design-docs/0080-project-teams.md @@ -90,6 +90,12 @@ administrative capabilities. These role boundaries are confirmed product policy. Project administrators can manage any non-owner member, including another project administrator. +Project-scoped CI deploy credentials remain owner-only. They authenticate +unattended deployments as the billing owner and outlive a human session, so +letting a member mint one would let their access survive a later membership +removal unless the credential model gained a separate membership revocation +binding. Ownership transfer revokes every active project CI credential. + ### Owner-based limits For every project operation, authorization has two independent inputs: diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 7152a32fd..d2e29c2bd 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -752,17 +752,20 @@ export async function getPendingProjectInvitationsByUserId( db: Db, userId: string, ): Promise> { - return db - .select() + const rows = await db + .select({ invitation: projectInvitations }) .from(projectInvitations) + .innerJoin(projects, eq(projects.id, projectInvitations.project_id)) .where( and( eq(projectInvitations.target_user_id, userId), eq(projectInvitations.status, 'pending'), gt(projectInvitations.expires_at, sql`now()`), + eq(projects.status, 'active'), ), ) .orderBy(desc(projectInvitations.created_at)); + return rows.map((row) => row.invitation); } export async function createOrRefreshProjectInvitation( @@ -1037,15 +1040,14 @@ export async function respondToProjectInvitation( .where(eq(projectInvitations.id, invitation.id)); return { error: 'invitation-expired' as const }; } - if (project.user_id === args.actorUserId) { - throw new ProjectTeamMutationError('owner-conflict'); - } - if (await getProjectMember(txDb, project.id, args.actorUserId)) { - throw new ProjectTeamMutationError('member-conflict'); - } - let member: ProjectMember | null = null; if (args.response === 'accept') { + if (project.user_id === args.actorUserId) { + throw new ProjectTeamMutationError('owner-conflict'); + } + if (await getProjectMember(txDb, project.id, args.actorUserId)) { + throw new ProjectTeamMutationError('member-conflict'); + } await requireLockedProjectOwnerActive(txDb, project); [member] = await txDb .insert(projectMembers) diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index b764b06f6..9887a5547 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -4412,12 +4412,6 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise { + await tx.execute( + drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`, + ); + await requireActiveDeployAccess(tx as unknown as Db, projectId, actor.id); + }); + await dispatchStableAuthMigration( + context.env.DISPATCH.get(workerName), + proxyTokenBinding?.text ?? '', + ); + } } else { const migrationRetry = probeResult === 'ready' ? CF_MIGRATION_RETRY : CF_READINESS_RETRY; diff --git a/platform/packages/api/src/routes/email.ts b/platform/packages/api/src/routes/email.ts index 9afe452a8..97673095d 100644 --- a/platform/packages/api/src/routes/email.ts +++ b/platform/packages/api/src/routes/email.ts @@ -120,7 +120,7 @@ const app = new Hono() // ------------------------------------------------------------------------- .get('/:id/email/destinations', async (context) => { const projectId = context.req.param('id'); - const access = await requireProjectRouteCapability(context, projectId, 'read'); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); if (access instanceof Response) { return access; } diff --git a/platform/packages/api/src/routes/project-tokens.ts b/platform/packages/api/src/routes/project-tokens.ts index 2d2752014..a24d339bc 100644 --- a/platform/packages/api/src/routes/project-tokens.ts +++ b/platform/packages/api/src/routes/project-tokens.ts @@ -4,6 +4,7 @@ import { PROJECT_CREDENTIAL_MAX_DAYS, signProjectCredentialToken } from '../auth import { getProject, type DbOrTx } from '../db.ts'; import type { ApiEnv } from '../env.ts'; import { projectDeployCredentials, projects } from '../schema.ts'; +import type { Project } from '../types.ts'; const app = new Hono(); @@ -24,26 +25,39 @@ function parseName(value: unknown): string { return name; } -async function ownedProject(context: Context) { +async function ownedProject(context: Context): Promise { const projectId = context.req.param('projectId'); if (!projectId) { return null; } const project = await getProject(context.get('db'), projectId); - if (!project || project.user_id !== context.get('user').id) { + if (!project) { return null; } + if (project.user_id !== context.get('user').id) { + return 'forbidden'; + } return project; } -async function lockOwnedProject(db: DbOrTx, projectId: string, userId: string) { +async function lockOwnedProject( + db: DbOrTx, + projectId: string, + userId: string, +): Promise { const [project] = await db .select() .from(projects) .where(eq(projects.id, projectId)) .for('update') .limit(1); - return project?.user_id === userId ? project : null; + if (!project) { + return null; + } + if (project.user_id !== userId) { + return 'forbidden'; + } + return project; } function publicCredential(row: typeof projectDeployCredentials.$inferSelect) { @@ -58,6 +72,9 @@ function publicCredential(row: typeof projectDeployCredentials.$inferSelect) { app.get('/:projectId/tokens', async (context) => { const project = await ownedProject(context); + if (project === 'forbidden') { + return context.json({ error: 'forbidden' }, 403); + } if (!project) { return context.json({ error: 'project not found' }, 404); } @@ -96,8 +113,11 @@ app.post('/:projectId/tokens', async (context) => { ); const row = await context.get('db').transaction(async (tx) => { const project = await lockOwnedProject(tx, projectId, user.id); - if (!project) { - return null; + if (typeof project === 'string' || !project) { + return project; + } + if (project.status === 'deleting') { + return 'project-deleting' as const; } const [created] = await tx .insert(projectDeployCredentials) @@ -111,6 +131,12 @@ app.post('/:projectId/tokens', async (context) => { .returning(); return created!; }); + if (row === 'forbidden') { + return context.json({ error: 'forbidden' }, 403); + } + if (row === 'project-deleting') { + return context.json({ error: 'project is being deleted' }, 409); + } if (!row) { return context.json({ error: 'project not found' }, 404); } @@ -146,9 +172,15 @@ app.post('/:projectId/tokens/:credentialId/renew', async (context) => { ); const result = await context.get('db').transaction(async (transaction) => { const project = await lockOwnedProject(transaction, projectId, user.id); + if (project === 'forbidden') { + return project; + } if (!project) { return 'project-not-found' as const; } + if (project.status === 'deleting') { + return 'project-deleting' as const; + } const [current] = await transaction .select() .from(projectDeployCredentials) @@ -182,6 +214,12 @@ app.post('/:projectId/tokens/:credentialId/renew', async (context) => { if (result === 'project-not-found') { return context.json({ error: 'project not found' }, 404); } + if (result === 'forbidden') { + return context.json({ error: 'forbidden' }, 403); + } + if (result === 'project-deleting') { + return context.json({ error: 'project is being deleted' }, 409); + } if (!result) { return context.json({ error: 'active credential not found' }, 404); } @@ -196,6 +234,9 @@ app.delete('/:projectId/tokens/:credentialId', async (context) => { const user = context.get('user'); const row = await context.get('db').transaction(async (tx) => { const project = await lockOwnedProject(tx, projectId, user.id); + if (typeof project === 'string') { + return project; + } if (!project) { return 'project-not-found' as const; } @@ -215,6 +256,9 @@ app.delete('/:projectId/tokens/:credentialId', async (context) => { if (row === 'project-not-found') { return context.json({ error: 'project not found' }, 404); } + if (row === 'forbidden') { + return context.json({ error: 'forbidden' }, 403); + } if (!row) { return context.json({ error: 'active credential not found' }, 404); } diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index 19813c745..da1b09a10 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -3105,6 +3105,9 @@ const app = new Hono() if (access instanceof Response) { return access; } + if (access.role !== 'owner') { + return context.json({ error: 'forbidden' }, 403); + } const row = await getProjectDatabaseUrl(context.get('db'), projectId); if (!row) { diff --git a/platform/packages/api/src/routing.ts b/platform/packages/api/src/routing.ts index 551017b01..e5883aca4 100644 --- a/platform/packages/api/src/routing.ts +++ b/platform/packages/api/src/routing.ts @@ -25,6 +25,47 @@ export function sandboxBlockKey(userId: string): string { return `sandbox-block:${userId}`; } +async function withLockedProjectOwner( + db: Db, + projectId: string, + expectedOwnerUserId: string, + update: () => Promise, +): Promise { + return db.transaction(async (tx) => { + await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + const project = await getProject(tx as unknown as Db, projectId); + if (!project || project.user_id !== expectedOwnerUserId || project.status === 'deleting') { + return false; + } + await update(); + return true; + }); +} + +export async function verifyActiveProjectOwner( + db: Db, + projectId: string, + expectedOwnerUserId: string, +): Promise { + return withLockedProjectOwner(db, projectId, expectedOwnerUserId, async () => {}); +} + +export async function setProjectAiBlock( + routing: KVNamespace, + db: Db, + projectId: string, + expectedOwnerUserId: string, + blocked: boolean, +): Promise { + return withLockedProjectOwner(db, projectId, expectedOwnerUserId, async () => { + if (blocked) { + await routing.put(`ai-block:${projectId}`, '1'); + } else { + await routing.delete(`ai-block:${projectId}`); + } + }); +} + export function buildProjectAccountJson(entry: ProjectAccountEntry): string { return JSON.stringify({ userId: entry.userId, plan: entry.plan }); } diff --git a/platform/packages/api/src/usage-counter.ts b/platform/packages/api/src/usage-counter.ts index edd6b733f..89751ec71 100644 --- a/platform/packages/api/src/usage-counter.ts +++ b/platform/packages/api/src/usage-counter.ts @@ -10,7 +10,12 @@ import { } from './db.ts'; import type { ApiEnv } from './env.ts'; import { queryProjectAiUsage } from './routes/usage.ts'; -import { sandboxBlockKey, setProjectsRoutingSuspension } from './routing.ts'; +import { + sandboxBlockKey, + setProjectAiBlock, + setProjectsRoutingSuspension, + verifyActiveProjectOwner, +} from './routing.ts'; import { REASON_REQUESTS_EXCEEDED, REASON_SANDBOX_RUNTIME_EXCEEDED } from './suspension.ts'; import { getPlanLimits, isWithinLimit } from './tiers.ts'; import { SANDBOX_USAGE_RETENTION_MS } from './usage-auth.ts'; @@ -322,10 +327,33 @@ export class UsageCounter extends DurableObject { state.totalSandboxSeconds += incomingSandboxSeconds; // Only the current owner may keep a project in the active enforcement set. - // A delayed event from a closed ownership period still contributes to the - // former owner's already-billed totals, but must not reattach the project. + // Recheck the queue consumer's snapshot here so a transfer that completed + // before this serialized operation cannot be undone by a delayed record. + let db: ReturnType | undefined; + const getDb = (): ReturnType => { + db ??= createDb(this.env.HYPERDRIVE.connectionString); + return db; + }; + const attachmentCandidates = [ + ...new Set( + body.events + .filter( + (event) => + event.currentOwner !== false && !Object.hasOwn(state.projectSlugs, event.projectId), + ) + .map((event) => event.projectId), + ), + ]; + const approvedAttachments = + attachmentCandidates.length > 0 + ? await this.ownedProjectIds(getDb(), attachmentCandidates, body.userId) + : new Set(); for (const event of body.events) { - if (event.currentOwner !== false) { + if ( + event.currentOwner !== false && + (Object.hasOwn(state.projectSlugs, event.projectId) || + approvedAttachments.has(event.projectId)) + ) { state.projectSlugs[event.projectId] = event.slug; if (event.ownershipStartedAt) { state.projectOwnershipStarts[event.projectId] = event.ownershipStartedAt; @@ -338,13 +366,6 @@ export class UsageCounter extends DurableObject { state.plan = body.plan; } - // Lazily create one db handle shared by any suspension paths below. - let db: ReturnType | undefined; - const getDb = (): ReturnType => { - db ??= createDb(this.env.HYPERDRIVE.connectionString); - return db; - }; - // Commit each suspension latch only after every enforcement write succeeds. const exceedsLimit = evaluateRequestLimit(state, 0); // already added above if (exceedsLimit && !state.requestsSuspended) { @@ -453,7 +474,13 @@ export class UsageCounter extends DurableObject { const db = createDb(this.env.HYPERDRIVE.connectionString); await this.retainTransferredProjectWindows(db, state, removedProjectIds); state.projectSlugs = { ...body.projectSlugs }; - state.projectOwnershipStarts = { ...(body.projectOwnershipStarts ?? {}) }; + state.projectOwnershipStarts = Object.fromEntries( + Object.keys(body.projectSlugs).flatMap((projectId) => { + const startsAt = + body.projectOwnershipStarts?.[projectId] ?? state.projectOwnershipStarts[projectId]; + return startsAt ? [[projectId, startsAt]] : []; + }), + ); state.plan = body.plan; state.billingPeriodStart = body.billingPeriodStart; state.billingPeriodInitialized = true; @@ -467,16 +494,15 @@ export class UsageCounter extends DurableObject { REASON_REQUESTS_EXCEEDED, ); } - const ownedProjectIds = await this.ownedProjectIds( - db, - Object.keys(state.projectSlugs), - state.ownerUserId, - ); - for (const projectId of ownedProjectIds) { - if (state.aiSuspended) { - await this.env.ROUTING.put(`ai-block:${projectId}`, '1'); - } else { - await this.env.ROUTING.delete(`ai-block:${projectId}`); + if (state.ownerUserId) { + for (const projectId of Object.keys(state.projectSlugs)) { + await setProjectAiBlock( + this.env.ROUTING, + db, + projectId, + state.ownerUserId, + state.aiSuspended, + ); } } @@ -510,8 +536,10 @@ export class UsageCounter extends DurableObject { state.ownerUserId = body.userId; } const db = createDb(this.env.HYPERDRIVE.connectionString); - const ownedProjectIds = await this.ownedProjectIds(db, [body.projectId], state.ownerUserId); - if (!ownedProjectIds.has(body.projectId)) { + if ( + !state.ownerUserId || + !(await verifyActiveProjectOwner(db, body.projectId, state.ownerUserId)) + ) { return Response.json({ error: 'project owner changed' }, { status: 409 }); } const owner = state.ownerUserId ? await getUser(db, state.ownerUserId) : null; @@ -540,6 +568,17 @@ export class UsageCounter extends DurableObject { ); } } + if ( + !(await setProjectAiBlock( + this.env.ROUTING, + db, + body.projectId, + state.ownerUserId, + state.aiSuspended, + )) + ) { + return Response.json({ error: 'project owner changed' }, { status: 409 }); + } state.projectSlugs[body.projectId] = body.slug; state.projectOwnershipStarts[body.projectId] = body.ownershipStartedAt; if (state.requestsSuspended) { @@ -550,13 +589,6 @@ export class UsageCounter extends DurableObject { REASON_REQUESTS_EXCEEDED, ); } - if (state.aiSuspended) { - await this.env.ROUTING.put(`ai-block:${body.projectId}`, '1'); - } else { - // The key is project-scoped and may have been left by the former - // owner's counter. The new owner's latch is authoritative after attach. - await this.env.ROUTING.delete(`ai-block:${body.projectId}`); - } await this.saveState(state); if (!(await this.ctx.storage.getAlarm())) { await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000); @@ -622,12 +654,6 @@ export class UsageCounter extends DurableObject { } else { this.retainHistoricalAiSample(state, body.projectId); } - if (state.aiSuspended) { - const ownedProjectIds = await this.ownedProjectIds(db, [body.projectId], state.ownerUserId); - if (ownedProjectIds.has(body.projectId)) { - await this.env.ROUTING.delete(`ai-block:${body.projectId}`); - } - } this.recomputeTotalNeurons(state); await this.saveState(state); if (Object.keys(state.historicalAiWindows).length > 0 && !(await this.ctx.storage.getAlarm())) { @@ -654,13 +680,11 @@ export class UsageCounter extends DurableObject { state.sandboxRuntimeSuspended = false; if (state.aiSuspended) { // Keep the durable latch set until every idempotent delete succeeds. - const ownedProjectIds = await this.ownedProjectIds( - createDb(this.env.HYPERDRIVE.connectionString), - Object.keys(state.projectSlugs), - state.ownerUserId, - ); - for (const projectId of ownedProjectIds) { - await this.env.ROUTING.delete(`ai-block:${projectId}`); + const db = createDb(this.env.HYPERDRIVE.connectionString); + if (state.ownerUserId) { + for (const projectId of Object.keys(state.projectSlugs)) { + await setProjectAiBlock(this.env.ROUTING, db, projectId, state.ownerUserId, false); + } } state.aiSuspended = false; } @@ -764,13 +788,13 @@ export class UsageCounter extends DurableObject { state.aiSuspended = true; // Write ai-block KV keys for each project for (const projectId of projectIds) { - await this.env.ROUTING.put(`ai-block:${projectId}`, '1'); + await setProjectAiBlock(this.env.ROUTING, db, projectId, state.ownerUserId, true); } } else if (!exceedsAi && state.aiSuspended) { state.aiSuspended = false; // Delete ai-block KV keys for (const projectId of projectIds) { - await this.env.ROUTING.delete(`ai-block:${projectId}`); + await setProjectAiBlock(this.env.ROUTING, db, projectId, state.ownerUserId, false); } } @@ -933,13 +957,11 @@ export class UsageCounter extends DurableObject { // Finish idempotent external cleanup before committing the new period. // A failure leaves the cached and persisted period unchanged for retry. if (ai) { - const ownedProjectIds = await this.ownedProjectIds( - db, - Object.keys(state.projectSlugs), - next.userId || state.ownerUserId, - ); - for (const projectId of ownedProjectIds) { - await this.env.ROUTING.delete(`ai-block:${projectId}`); + const ownerUserId = next.userId || state.ownerUserId; + if (ownerUserId) { + for (const projectId of Object.keys(state.projectSlugs)) { + await setProjectAiBlock(this.env.ROUTING, db, projectId, ownerUserId, false); + } } } if (sandbox) { diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index 83a1c473d..0c36757b5 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -2367,6 +2367,110 @@ describe('POST /projects/:id/deploy', () => { } }); + it('persists confirmed app migrations before a protocol v2 auth failure', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'migrate-v2-auth-failure'); + mockCfApi({ + createD1: () => cfSuccess({ uuid: 'd1-v2-auth-failure', name: 'test-db' }), + }); + + const originalDispatch = env.DISPATCH; + env.DISPATCH = { + get: () => ({ + fetch: async (req: Request) => { + const url = new URL(req.url); + if (url.pathname === '/__void/ready') { + return Response.json({ ready: true }); + } + if (url.pathname === '/__void/migrate/capabilities') { + return Response.json({ + protocolVersion: 2, + endpoints: { + app: '/__void/migrate/app', + auth: '/__void/migrate/auth', + reconcile: '/__void/migrate/app/attempt/:attemptId', + }, + dialect: 'sqlite', + classifierVersion: 1, + migrationListHash: 'sha256:auth-failure', + destructiveValidation: 'passed', + }); + } + if (url.pathname === '/__void/migrate/app') { + return Response.json({ + ok: true, + protocolVersion: 2, + attemptId: 'attempt_auth_failure', + confirmedApplied: ['001_create_table.sql'], + confirmedSkipped: [], + stability: 'stable', + migrations: [ + { + name: '001_create_table.sql', + applied: true, + status: 'applied', + stability: 'stable', + durationMs: 2, + }, + ], + }); + } + if (url.pathname === '/__void/migrate/auth') { + return Response.json({ ok: false, error: 'auth schema rejected' }, { status: 500 }); + } + return new Response('unexpected', { status: 500 }); + }, + }), + } as unknown as DispatchNamespace; + + try { + const response = await app.fetch( + await buildDeployRequest(env, project.id, user.jwt, { + manifest: { + version: 3, + auth: true, + bindings: { d1: ['DB'] }, + migrations: { + dialect: 'sqlite', + pending: 1, + protocolVersion: 2, + classifierVersion: 1, + migrationListHash: 'sha256:auth-failure', + stability: 'stable', + }, + }, + }), + env, + ); + expect(response.status).toBe(200); + expect(parseDeployStream(await response.text()).at(-1)).toMatchObject({ + event: 'error', + step: 'migration', + message: 'auth migration failed: auth schema rejected', + }); + + const migrationRows = await env.db + .select({ name: projectMigrations.name }) + .from(projectMigrations) + .where(eq(projectMigrations.project_id, project.id)); + expect(migrationRows).toEqual([{ name: '001_create_table.sql' }]); + + const [failedDeployment] = await env.db + .select({ + appliedMigrations: deployments.applied_migrations, + status: deployments.status, + }) + .from(deployments) + .where(eq(deployments.project_id, project.id)) + .orderBy(desc(deployments.created_at)) + .limit(1); + expect(failedDeployment?.status).toBe('failed'); + expect(JSON.parse(failedDeployment!.appliedMigrations!)).toEqual(['001_create_table.sql']); + } finally { + env.DISPATCH = originalDispatch; + } + }); + it('negotiates protocol v2 and runs MySQL migrations', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'migrate-v2-mysql-test'); diff --git a/platform/packages/api/test/integration/api-email.test.ts b/platform/packages/api/test/integration/api-email.test.ts index 36f75cadb..0a5605b74 100644 --- a/platform/packages/api/test/integration/api-email.test.ts +++ b/platform/packages/api/test/integration/api-email.test.ts @@ -604,6 +604,43 @@ describe('GET /projects/:id/email/destinations', () => { expect(await res.text()).not.toContain('secret@example.com'); }); + it('requires settings access to list destination addresses', async () => { + const owner = await createTestUser(env.db, 'destination-roles-owner'); + const collaborator = await createTestUser(env.db, 'destination-roles-collaborator'); + const admin = await createTestUser(env.db, 'destination-roles-admin'); + const project = await createTestProject(env.db, owner.id, 'destination-roles-project'); + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }, + ]); + await env.db.insert(emailDestinations).values({ + id: 'edst_role_private', + project_id: project.id, + address: 'private@example.com', + status: 'verified', + cf_destination_id: 'cf-tag-role-private', + verified_at: '2026-04-29T00:00:00Z', + }); + + const denied = await listDestinations(project.id, collaborator.jwt); + expect(denied.status).toBe(403); + expect(await denied.text()).not.toContain('private@example.com'); + + const allowed = await listDestinations(project.id, admin.jwt); + expect(allowed.status).toBe(200); + expect(await allowed.text()).toContain('private@example.com'); + }); + it('refreshes pending rows that CF now reports verified', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id); diff --git a/platform/packages/api/test/integration/api-project-team.test.ts b/platform/packages/api/test/integration/api-project-team.test.ts index 411390a46..dda2beae2 100644 --- a/platform/packages/api/test/integration/api-project-team.test.ts +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -785,6 +785,20 @@ describe('project team API', () => { ); }); + it('hides pending invitations once project deletion starts', async () => { + const owner = await createTestUser(env.db, 'deleting-invitation-owner'); + const target = await createTestUser(env.db, 'deleting-invitation-target'); + await setEmail(target.id, 'deleting-target@example.com'); + const project = await createTestProject(env.db, owner.id, 'deleting-invitation-project'); + await invite(project.id, owner.jwt, 'deleting-target@example.com', 'reader'); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + + const response = await app.fetch(request('/me/project-invitations', target.jwt), env); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ invitations: [] }); + }); + it('filters expired invitations and allows the target to decline a live invitation', async () => { const owner = await createTestUser(env.db, 'owner'); const target = await createTestUser(env.db, 'target'); diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 48615571a..26a3e9eb4 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -330,6 +330,118 @@ describe('GET /projects/:id', () => { }); }); +describe('project CI credential ownership', () => { + function credentialRequest( + projectId: string, + jwt: string, + method: 'GET' | 'POST' | 'DELETE', + suffix = '', + body?: Record, + ): Request { + const init: RequestInit = { + method, + headers: { + Authorization: `Bearer ${jwt}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + }; + if (body) { + init.body = JSON.stringify(body); + } + return new Request(`http://localhost/projects/${projectId}/tokens${suffix}`, init); + } + + it('returns 403 to project members and 404 only for missing projects', async () => { + const owner = await createTestUser(env.db, 'credential-owner'); + const admin = await createTestUser(env.db, 'credential-admin'); + const project = await createTestProject(env.db, owner.id, 'credential-owner-only'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }); + await env.db.insert(projectDeployCredentials).values({ + id: 'credential-owner-only-row', + project_id: project.id, + issued_by: owner.id, + name: 'Owner CI', + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }); + const operations = [ + { method: 'GET' as const }, + { method: 'POST' as const, body: { name: 'Admin CI' } }, + { + method: 'POST' as const, + suffix: '/credential-owner-only-row/renew', + body: { expiresInDays: 30 }, + }, + { method: 'DELETE' as const, suffix: '/credential-owner-only-row' }, + ]; + + for (const operation of operations) { + const memberResponse = await app.fetch( + credentialRequest( + project.id, + admin.jwt, + operation.method, + operation.suffix, + operation.body, + ), + env, + ); + expect(memberResponse.status).toBe(403); + expect(await memberResponse.json()).toEqual({ error: 'forbidden' }); + + const missingResponse = await app.fetch( + credentialRequest( + 'proj_missing', + admin.jwt, + operation.method, + operation.suffix, + operation.body, + ), + env, + ); + expect(missingResponse.status).toBe(404); + expect(await missingResponse.json()).toEqual({ error: 'project not found' }); + } + }); + + it('does not mint or renew a credential after project teardown begins', async () => { + const owner = await createTestUser(env.db, 'deleting-credential-owner'); + const project = await createTestProject(env.db, owner.id, 'deleting-credential-project'); + await env.db.insert(projectDeployCredentials).values({ + id: 'deleting-credential-row', + project_id: project.id, + issued_by: owner.id, + name: 'Existing CI', + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + + const createResponse = await app.fetch( + credentialRequest(project.id, owner.jwt, 'POST', '', { name: 'Too late' }), + env, + ); + const renewResponse = await app.fetch( + credentialRequest(project.id, owner.jwt, 'POST', '/deleting-credential-row/renew', { + expiresInDays: 30, + }), + env, + ); + + expect(createResponse.status).toBe(409); + expect(renewResponse.status).toBe(409); + expect( + await env.db + .select() + .from(projectDeployCredentials) + .where(eq(projectDeployCredentials.project_id, project.id)), + ).toMatchObject([{ id: 'deleting-credential-row', revoked_at: null }]); + }); +}); + describe('POST /projects/:id/prerender-token', () => { async function proxyRequest(token: string, projectId: string) { const proxyModuleUrl = new URL('../../../proxy/src/index.ts', import.meta.url).href; @@ -1098,6 +1210,28 @@ describe('GET /projects/:id/database-url', () => { expect(res.status).toBe(403); }); + it('does not expose the plaintext URL to a project member', async () => { + const owner = await createTestUser(env.db, 'database-url-owner'); + const collaborator = await createTestUser(env.db, 'database-url-collaborator'); + const project = await createTestProject(env.db, owner.id, 'database-url-member-read'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}/database-url`, { + headers: { Authorization: `Bearer ${collaborator.jwt}` }, + }), + env, + ); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'forbidden' }); + }); + it('round-trips the connection string via POST /:id/database', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'pg-app-get-rt-post'); diff --git a/platform/packages/api/test/unit/routing.test.ts b/platform/packages/api/test/unit/routing.test.ts index 967e5c7dc..aa43cdf3e 100644 --- a/platform/packages/api/test/unit/routing.test.ts +++ b/platform/packages/api/test/unit/routing.test.ts @@ -8,6 +8,7 @@ import { computeDispatchLimits, projectAccountKey, resolveRoutingUndo, + setProjectAiBlock, setProjectsRoutingSuspension, } from '../../src/routing.ts'; import { PLAN_LIMITS, type PlanLimits } from '../../src/tiers.ts'; @@ -550,3 +551,68 @@ describe('setProjectsRoutingSuspension', () => { vi.restoreAllMocks(); }); }); + +describe('setProjectAiBlock', () => { + it('stands down after a transfer wins the project lock', async () => { + const events: Array = []; + const { store, kv } = createKvStore({ 'ai-block:proj_a': '1' }); + vi.spyOn(dbModule, 'getProject').mockImplementation(async () => { + events.push('read-owner'); + return { + id: 'proj_a', + user_id: 'new_owner', + status: 'active', + } as Awaited>; + }); + const db = { + transaction: async (fn: (tx: { execute: () => Promise }) => Promise) => + fn({ + execute: async () => { + events.push('lock-project'); + }, + }), + } as unknown as Parameters[1]; + + const changed = await setProjectAiBlock(kv, db, 'proj_a', 'former_owner', false); + + expect(changed).toBe(false); + expect(events).toEqual(['lock-project', 'read-owner']); + expect(store.get('ai-block:proj_a')).toBe('1'); + vi.restoreAllMocks(); + }); + + it('writes only after confirming the owner under the project lock', async () => { + const events: Array = []; + const store = new Map(); + const kv = { + put: async (key: string, value: string) => { + events.push('put-block'); + store.set(key, value); + }, + delete: async () => {}, + } as unknown as KVNamespace; + vi.spyOn(dbModule, 'getProject').mockImplementation(async () => { + events.push('read-owner'); + return { + id: 'proj_a', + user_id: 'owner_a', + status: 'active', + } as Awaited>; + }); + const db = { + transaction: async (fn: (tx: { execute: () => Promise }) => Promise) => + fn({ + execute: async () => { + events.push('lock-project'); + }, + }), + } as unknown as Parameters[1]; + + const changed = await setProjectAiBlock(kv, db, 'proj_a', 'owner_a', true); + + expect(changed).toBe(true); + expect(events).toEqual(['lock-project', 'read-owner', 'put-block']); + expect(store.get('ai-block:proj_a')).toBe('1'); + vi.restoreAllMocks(); + }); +}); diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index 8eedec7bf..c7587e3ad 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -1154,6 +1154,69 @@ describe('UsageCounter ownership changes', () => { }); }); + it('does not reattach from a stale current-owner queue snapshot after transfer', async () => { + vi.mocked(getProjectsByIds).mockResolvedValueOnce([ + { id: 'transferred', user_id: 'new-owner', slug: 'app' } as never, + ]); + const { counter, storage } = makeCounter({ + ownerUserId: 'former-owner', + plan: 'solo', + }); + + await counter.fetch( + recordRequest( + [ + { + projectId: 'transferred', + userId: 'former-owner', + slug: 'app', + requests: 1, + responseBytes: 10, + currentOwner: true, + ownershipStartedAt: '2026-01-01T00:00:00.000Z', + }, + ], + 'former-owner', + ), + ); + + expect(storage.data.get('state')).toMatchObject({ + totalRequests: 1, + projectSlugs: {}, + projectOwnershipStarts: {}, + }); + }); + + it('preserves retained ownership boundaries from a legacy init payload', async () => { + const retainedStart = '2026-02-01T00:00:00.000Z'; + const { counter, storage } = makeCounter({ + ownerUserId: 'u1', + projectSlugs: { retained: 'retained-app', removed: 'removed-app' }, + projectOwnershipStarts: { + retained: retainedStart, + removed: '2026-01-01T00:00:00.000Z', + }, + }); + + const response = await counter.fetch( + new Request('https://do/init', { + method: 'POST', + body: JSON.stringify({ + plan: 'solo', + billingPeriodStart: '2026-01-01T00:00:00.000Z', + projectSlugs: { retained: 'retained-app' }, + userId: 'u1', + }), + }), + ); + + expect(response.ok).toBe(true); + expect(storage.data.get('state')).toMatchObject({ + projectSlugs: { retained: 'retained-app' }, + projectOwnershipStarts: { retained: retainedStart }, + }); + }); + it('detaches and attaches a project without erasing already billed AI usage', async () => { const { counter, storage, kv } = makeCounter({ plan: 'solo', @@ -1281,9 +1344,12 @@ describe('UsageCounter ownership changes', () => { }); it('does not attach a project after deletion has started', async () => { - vi.mocked(getProjectsByIds).mockResolvedValueOnce([ - { id: 'deleting', user_id: 'u1', slug: 'app', status: 'deleting' } as never, - ]); + vi.mocked(getProject).mockResolvedValueOnce({ + id: 'deleting', + user_id: 'u1', + slug: 'app', + status: 'deleting', + } as never); const { counter, storage } = makeCounter(); const response = await counter.fetch( @@ -1302,6 +1368,48 @@ describe('UsageCounter ownership changes', () => { expect(storage.data.get('state')).toBeUndefined(); }); + it('rejects a stale attach before advancing the former owner billing period', async () => { + vi.mocked(getProject).mockResolvedValueOnce({ + id: 'transferred', + user_id: 'new-owner', + slug: 'app', + status: 'active', + } as never); + const { counter, kv, storage } = makeCounter({ + ownerUserId: 'former-owner', + plan: 'solo', + billingPeriodStart: '2026-01-01T00:00:00.000Z', + billingPeriodInitialized: true, + totalRequests: 10, + aiSuspended: true, + projectSlugs: { retained: 'retained-app' }, + }); + kv.store.set('ai-block:retained', '1'); + + const response = await counter.fetch( + new Request('https://do/attach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'transferred', + slug: 'app', + billingPeriodStart: '2026-02-01T00:00:00.000Z', + ownershipStartedAt: '2026-02-02T00:00:00.000Z', + userId: 'former-owner', + }), + }), + ); + + expect(response.status).toBe(409); + expect(kv.store.get('ai-block:retained')).toBe('1'); + expect(storage.data.get('state')).toMatchObject({ + billingPeriodStart: '2026-01-01T00:00:00.000Z', + totalRequests: 10, + aiSuspended: true, + projectSlugs: { retained: 'retained-app' }, + }); + expect(dbCalls.unsuspendIfReason).toEqual([]); + }); + it('reconciles the complete pre-transfer AI window and resamples it idempotently', async () => { const ownershipStartedAt = '2026-02-01T00:00:00.000Z'; const ownershipEndedAt = '2026-03-01T00:00:00.000Z';