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..37f30e905 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, or build before retrying. + +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 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..5f498a9eb --- /dev/null +++ b/docs/guide/project-collaboration.md @@ -0,0 +1,77 @@ +--- +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. | + +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 +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. + +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 +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. + +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 + +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..60a3a5d50 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -228,7 +228,33 @@ 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, 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` + +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 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` +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 ` @@ -526,10 +552,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 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} Find a deployment, inspect its manifest, or request cancellation: @@ -786,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/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/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 401c5770f..061ed2ca6 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 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. 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/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/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..5b6fb33af 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -115,6 +115,54 @@ 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 = { + role: ProjectRole; + created_at: string; + user: { + id: string; + login: string | null; + }; +}; + +export type ProjectInvitationResponse = { + id: 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 | null; + }; + }; + target?: { + id: string; + login: string | null; + email: string | null; + }; + invited_by?: { + id: string; + login: string | null; + }; +}; + +type ProjectInvitationDelivery = { + status: 'sent' | 'not_configured' | 'rate_limited' | 'failed'; }; export type ProjectDeployCredentialResponse = { @@ -593,6 +641,48 @@ 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 { members: Array }; + return 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 { invitations: Array }; + return 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 +702,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 +732,60 @@ export class PlatformClient { return res.json(); } + async removeProjectMember(projectId: string, userId: string): Promise { + 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); + } + } + + async revokeProjectInvitation(projectId: string, invitationId: string): Promise { + 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); + } + } + + async leaveProject(projectId: string): Promise { + 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); + } + } + + 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 { invitations: Array }; + return body.invitations; + } + + async respondToProjectInvitation( + invitationId: string, + action: 'accept' | 'decline', + ): Promise { + 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); + } + } + 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/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/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-args.ts b/packages/void/src/cli/project-args.ts index 194242f80..c187462dc 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']); + +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.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 }; + } + 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..fd8c5bee4 100644 --- a/packages/void/src/cli/project-cmd.ts +++ b/packages/void/src/cli/project-cmd.ts @@ -33,6 +33,22 @@ 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; + } + 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.', + ); + } + 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..c7a6f2008 --- /dev/null +++ b/packages/void/src/cli/project-team.ts @@ -0,0 +1,174 @@ +import { intro, log, note, outro } from '@clack/prompts'; +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 { readDeploymentPlatform, readProjectConfig } from './project.ts'; +import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; + +type ProjectTeamArgs = Exclude, { action: '' }>; + +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, ' '); +} + +function memberLines(members: Array): string { + const rows = members.map((member) => ({ + id: member.user.id, + login: member.user.login ?? '—', + 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)), + }; + return [ + `${'ID'.padEnd(widths.id)} ${'LOGIN'.padEnd(widths.login)} ROLE`, + ...rows.map( + (row) => + `${clean(row.id).padEnd(widths.id)} ${clean(row.login).padEnd(widths.login)} ${row.role}`, + ), + ].join('\n'); +} + +function invitationEmail(invitation: ProjectInvitationResponse): string { + return invitation.target?.email ?? invitation.target_email ?? '—'; +} + +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; +} + +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 accountScoped = + args.action === 'pending' || args.action === 'accept' || args.action === 'decline'; + const { apiUrl, token } = resolveCredential(root, accountScoped); + if (!token) { + 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, apiUrl ? { apiUrl } : { 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); + 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('_', ' ')})`; + const action = result.refreshed ? 'Updated invitation for' : 'Invited'; + outro(`${action} ${clean(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/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/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/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/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-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/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/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index dda3b2982..8f0d04670 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -41,6 +41,85 @@ 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', () => { + 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'"); + 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..3a7400851 --- /dev/null +++ b/packages/void/test/unit/project-team-client.test.ts @@ -0,0 +1,102 @@ +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' }, + refreshed: false, + 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 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`); + }); + + 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 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/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..218e3c645 --- /dev/null +++ b/packages/void/test/unit/project-team-command.test.ts @@ -0,0 +1,301 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathslash'; +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', + 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', ...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(); + 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('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, _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([ + '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 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 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' }], + }), + ); + 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( + `${platformA}/projects/project_1/members`, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer platform-a-token' }), + }), + ); + }); + + 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: [{ 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); + + 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', + ]); + }); + + 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/meta/design-docs/0080-project-teams.md b/platform/meta/design-docs/0080-project-teams.md new file mode 100644 index 000000000..6b4eee654 --- /dev/null +++ b/platform/meta/design-docs/0080-project-teams.md @@ -0,0 +1,456 @@ +--- +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. + +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: + +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, 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 +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, 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 + +### `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, 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 + +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. + +`builds.user_id` remains the billing owner for compatibility with the build +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 + +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 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 +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 existing internal `verify-owner` route checks the `deploy` +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 + +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. +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 per-project invitation 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 +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 + +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 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 +when the current directory targets Cloudflare directly. Project list output +gains a role column so shared projects are distinguishable. + +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 + +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. In one database transaction, lock the project then both users in stable ID + order; reject active lifecycle work; close/open the ownership periods; + 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 + 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 +to the database-authoritative owner during recovery. + +GitHub connection authorization provenance may name any member who had +`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 +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 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 + 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. 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/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/README.md b/platform/packages/api/README.md index d4620d95a..0f34ee29f 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 /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 +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/drizzle/0048_project_teams.sql b/platform/packages/api/drizzle/0048_project_teams.sql new file mode 100644 index 000000000..bac012e12 --- /dev/null +++ b/platform/packages/api/drizzle/0048_project_teams.sql @@ -0,0 +1,133 @@ +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 "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" +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"); 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..d3e1f2ff3 --- /dev/null +++ b/platform/packages/api/drizzle/meta/0048_snapshot.json @@ -0,0 +1,5913 @@ +{ + "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 + }, + "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": {} + } +} 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..56b17c0af 100644 --- a/platform/packages/api/src/admin/routes/projects.tsx +++ b/platform/packages/api/src/admin/routes/projects.tsx @@ -3,7 +3,13 @@ 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, + previewOperatorProjectOwner, + 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 +21,18 @@ const PAGE_SIZE = 20; const app = new Hono(); app.onError(renderOperationError); +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: { suspended: boolean; suspendedReason: string | null }) { + return owner.suspended ? `Suspended (${owner.suspendedReason ?? 'unknown reason'})` : 'Active'; +} + // GET /projects app.get('/', async (context) => { const search = context.req.query('search'); @@ -175,6 +193,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 previewOperatorProjectOwner(context, projectId, userId); + const convergenceOnly = !result.changed; + const operationBlocked = result.blockers.some( + (blocker) => !convergenceOnly || blocker.kind === 'deletion', + ); + + return context.html( + +

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

+ + + + + + + + + + + + + + + + + + + + + + + +
AccountUserPlanStatus
Current owner + {result.currentOwner.login} ({result.currentOwner.id}) + {result.currentOwner.plan}{suspensionLabel(result.currentOwner)}
New owner + {result.newOwner.login} ({result.newOwner.id}) + {result.newOwner.plan}{suspensionLabel(result.newOwner)}
+ + {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}. +

+ )} + +

Operation blockers

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

Active work does not block convergence.

+ ) : null} + + ) : ( +

None.

+ )} + +

Convergence

+

+ {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(', ')}

+ +

GitHub authorization provenance

+ {result.github ? ( +

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

+ ) : ( +

No GitHub connection.

+ )} + + {!operationBlocked ? ( + + + + + ) : ( +

+ {convergenceOnly + ? 'Project deletion blocks convergence.' + : '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/build-scheduler.ts b/platform/packages/api/src/build-scheduler.ts index 0604a07d9..1dead80e9 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,7 +18,8 @@ import { updateBuildStatus, } from './db.ts'; import type { ApiEnv } from './env.ts'; -import { getBuildInstanceType, getPlanLimits } from './tiers.ts'; +import { isAdminSuspended } from './suspension.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; @@ -66,18 +68,18 @@ 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. + // 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)) + .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 +121,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 — @@ -176,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). `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 - // 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. - const limit = getPlanLimits(user.plan).concurrentBuilds; + // 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, - limit, - 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, @@ -244,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 27b4bef03..d2e29c2bd 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -7,6 +7,7 @@ import { eq, ne, lt, + gt, gte, inArray, notInArray, @@ -22,11 +23,16 @@ 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, 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,13 @@ import type { Invitation, OAuthAccount, PlatformEvent, + ProjectAccessEventAction, ProjectDatabaseCredentials, + ProjectInvitation, + ProjectMember, + ProjectMemberRole, + ProjectOwnershipPeriod, + ProjectRole, ProjectSecret, Project, Resource, @@ -399,7 +411,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 +426,19 @@ 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, + }) + .onConflictDoNothing(); + return created; + }); return row; } @@ -425,7 +452,10 @@ export async function getProjectBySlug(db: Db, slug: string): Promise> { +export async function getOwnedProjectsByUserId( + db: Db, + userId: string, +): Promise> { return db .select() .from(projects) @@ -433,6 +463,697 @@ export async function getProjectsByUserId(db: Db, userId: string): Promise; + +const accessibleProjectOwner = alias(users, 'accessible_project_owner'); + +/** Accessible projects whose billing owner is not manually suspended. */ +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 + .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 --- + +type ProjectMemberWithUser = Readonly<{ member: ProjectMember; user: User }>; + +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)); +} + +type ProjectTeamMutationErrorReason = + | 'project-not-found' + | 'project-deleting' + | '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' + | 'owner-suspended' + | '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 === 'owner-suspended' || 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'); + } + if (project.status === 'deleting') { + throw new ProjectTeamMutationError('project-deleting'); + } + return project; +} + +/** Re-check team-management access while the project lock is held. */ +async function requireLockedMembersCapability( + db: Db, + project: Project, + actorUserId: string, +): Promise { + await requireLockedProjectOwnerActive(db, project); + if (project.user_id === actorUserId) { + return; + } + const actorMember = await getProjectMember(db, project.id, actorUserId); + if (actorMember?.role !== 'admin') { + throw new ProjectTeamMutationError('forbidden'); + } +} + +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'); + } +} + +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 { + 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( + db: Db, + args: { + projectId: string; + subjectUserId: string; + role: ProjectMemberRole; + actorUserId: string; + }, +): Promise<{ member: ProjectMember; cancelledBuilds: ReadonlyArray }> { + 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 { member: current, cancelledBuilds: [] }; + } + 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, + }); + 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<{ cancelledBuilds: ReadonlyArray }> { + 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); + } else { + await requireLockedProjectOwnerActive(txDb, project); + } + 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, + }); + const cancelledBuilds = await invalidateGitHubConnectionAuthorizedBy( + txDb, + args.projectId, + args.subjectUserId, + 'github connection authorizer removed from project', + ); + return { cancelledBuilds }; + }); +} + +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> { + 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( + db: Db, + args: { + projectId: string; + email: string; + role: ProjectMemberRole; + actorUserId: 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 = 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); + const lockedUsers = await txDb + .select() + .from(users) + .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'); + } + 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 }, +): Promise> { + 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); + 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)) + .for('share') + .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 { + 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); + 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'); + } + await txDb + .update(projectInvitations) + .set({ + status: 'revoked', + revoked_at: sql`now()`, + revoked_by_user_id: args.actorUserId, + }) + .where(eq(projectInvitations.id, invitation.id)); + 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, + }); + }); +} + +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 { 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 }; + } + 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) + .values({ + project_id: project.id, + user_id: args.actorUserId, + role: invitation.role, + added_by_user_id: invitation.invited_by_user_id, + }) + .returning(); + } + 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)); + 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 { member }; + }); + + if ('error' in result) { + throw new ProjectTeamMutationError(result.error!); + } + return result; +} + +// --- Project ownership history --- + +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 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, +): Promise> { + if (projectIds.length === 0) { + return []; + } + return db + .select() + .from(projectOwnershipPeriods) + .where( + and( + inArray(projectOwnershipPeriods.project_id, projectIds), + isNull(projectOwnershipPeriods.ends_at), + ), + ); +} + export async function getLatestDeploymentsByProjectIds( db: Db, projectIds: ReadonlyArray, @@ -533,9 +1254,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 +1274,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; @@ -1574,6 +2301,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. @@ -1581,6 +2323,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, @@ -1590,15 +2333,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, @@ -1641,6 +2378,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') { @@ -1651,7 +2389,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 +2397,10 @@ 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; + } const [owner] = await tx .select({ email: users.email }) .from(users) @@ -1897,7 +2639,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 @@ -1913,6 +2659,7 @@ export async function deleteEmailDestination(db: Db, id: string): 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) @@ -2330,9 +3080,71 @@ 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 = []; + // 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 + // 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'); + + // Pending invitations are in the lock set so acceptance cannot add a + // membership after discovery. Re-read the connections under every lock: + // a creator that acquired a project lock first may have inserted after + // discovery and must not be reduced to legacy NULL provenance by the 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; }); } @@ -2477,10 +3289,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 }) - .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) { @@ -2489,10 +3312,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( @@ -3181,7 +4009,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 @@ -3193,13 +4021,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. @@ -3222,10 +4056,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 @@ -3242,57 +4116,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` @@ -3305,27 +4128,66 @@ 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 []; + } + const cancelledBuilds = await terminateBuildsForProject(db, projectId, { reason }); + await db.delete(githubConnections).where(eq(githubConnections.id, connection.id)); + return cancelledBuilds; +} + async function installationCoversRepoInTx( tx: Db, connectionId: string, @@ -3424,23 +4286,21 @@ 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: { projectId: string; - 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. - */ - instanceType?: string | null; }, ): Promise<{ build: Build | null; @@ -3451,7 +4311,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 @@ -3466,7 +4331,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 +4339,19 @@ export async function supersedeAndCreateContainerBuild( if (!proj || proj.status === 'deleting') { return { build: null, reused: false, cancelled: [], skipped: 'project-deleting' }; } + const [ownerState] = await tx + .select({ plan: users.plan, 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' }; + } + // 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 @@ -3629,12 +4507,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 }; @@ -3681,6 +4559,13 @@ export async function getBuildByProjectRunIdAndAttempt( * prior status) so the caller can best-effort stop active container BuildManager * DOs. */ +type TerminatedBuild = Readonly<{ + id: string; + executor: Build['executor']; + status: Build['status']; + instance_type: string | null; +}>; + export async function terminateBuildsForProject( db: Db, projectId: string, @@ -3705,14 +4590,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']), @@ -3868,8 +4746,6 @@ export async function createGithubActionsBuildWithinLimit( db: Db, args: { projectId: string; - userId: string; - limit: number; commitSha?: string | null; branch?: string | null; githubRunId: string; @@ -3893,6 +4769,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 @@ -3907,7 +4784,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 +4796,16 @@ 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`); + const [ownerState] = await tx + .select({ plan: users.plan, 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' }; + } + 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 @@ -4057,12 +4943,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 +4961,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, @@ -4088,41 +4976,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). - * 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 at cap, when the project already has an active build, or when - * 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, @@ -4130,36 +4986,33 @@ export async function claimBuildForStartWithinLimit( buildId: string; userId: string; projectId: string; - 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. - await tx.execute(sql`SELECT id FROM ${users} WHERE id = ${args.userId} FOR UPDATE`); + const [owner] = await tx + .select({ plan: users.plan, suspendedReason: users.suspended_reason }) + .from(users) + .where(eq(users.id, args.userId)) + .for('update') + .limit(1); + if (!owner || isAdminSuspended(owner.suspendedReason)) { + return null; + } - 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) { - return false; + if ((result?.count ?? 0) >= limit) { + 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) @@ -4167,21 +5020,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, + }; }); } @@ -4218,9 +5078,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 3b8acfa98..17a6ddfeb 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)}.

+

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\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.`, + }); +} + export async function sendWelcomeEmail( env: PlatformEmailEnv, to: string, diff --git a/platform/packages/api/src/email/connection-service.ts b/platform/packages/api/src/email/connection-service.ts index fad8aa2cf..92a3fb90e 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,87 @@ 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 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, + 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 +633,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 +654,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 +666,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); } @@ -651,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) { @@ -1351,9 +1439,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 +1498,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 +1528,7 @@ export async function requestEmailDomainAdd( if (existingRoute) { if ( !connection || - !canManageConnection(actor, connection) || + !canManageExistingConnection || existingRoute.managed_by !== actor.kind ) { throw new EmailOperationError( @@ -2026,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, @@ -2041,7 +2138,7 @@ export function requestEmailDomainRemove( env: Bindings, routeId: string, actor: EmailActor, - key: string, + key: string | undefined, expectedProjectId: string, ): Promise; export async function requestEmailDomainRemove( @@ -2064,6 +2161,7 @@ export async function requestEmailSecretRotation( connectionId: string, actor: EmailActor, key = generateId('erot'), + expectedProjectId?: string, ): Promise { const installationId = emailInstallationId(env); const actorScope = @@ -2082,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'); } @@ -2092,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() @@ -2103,7 +2204,28 @@ export async function requestEmailSecretRotation( if (!connection || connection.installation_id !== installationId) { throw new EmailOperationError('connection not found', 404); } - if (!canManageConnection(actor, connection)) { + 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)) + ) { throw new EmailOperationError('only the connection manager can rotate ingress', 403); } const replay = ( @@ -2123,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)) { @@ -2262,7 +2386,7 @@ async function requestExistingOperation( routeId, kind, actor: actor.kind, - ...(expectedProjectId === undefined ? {} : { expectedProjectId }), + ...(actor.kind === 'owner' || expectedProjectId === undefined ? {} : { expectedProjectId }), }); const existing = ( await db @@ -2285,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() @@ -2300,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') { @@ -2339,13 +2485,16 @@ 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); + } + } 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, @@ -2368,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/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/index.ts b/platform/packages/api/src/index.ts index 6edddc48a..ba6dea0d0 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -42,6 +42,7 @@ import { failStaleActiveGithubActionsBuilds, hasQueuedContainerBuilds, getProject, + getOpenProjectOwnershipPeriodsByProjectIds, getProjectsByIds, getUsersByIds, type Db, @@ -95,6 +96,11 @@ 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 { + 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'; @@ -110,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'; @@ -395,20 +401,32 @@ const app = new Hono() }) .get('/invite', (context) => { const name = escapeHtml(getPlatformDisplayName(context.env, context.req.url)); + 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)), ); + 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.

@@ -439,10 +457,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) @@ -733,7 +756,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; @@ -928,7 +953,8 @@ 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 }>, + 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. @@ -975,19 +1001,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 getOpenProjectOwnershipPeriodsByProjectIds( + db, + userProjects.map((project) => project.id), + ); + const ownershipStarts = new Map( + ownershipPeriods.map((period) => [period.project_id, period.starts_at]), + ); for (const p of userProjects) { projectSlugs[p.id] = p.slug; + projectOwnershipStarts[p.id] = ownershipStarts.get(p.id) ?? p.created_at; } const doId = env.USAGE_COUNTER.idFromName(user.id); @@ -1032,10 +1062,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, + }, + ); } } @@ -1044,7 +1079,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/middleware/auth.ts b/platform/packages/api/src/middleware/auth.ts index 044dc3468..fb31fea06 100644 --- a/platform/packages/api/src/middleware/auth.ts +++ b/platform/packages/api/src/middleware/auth.ts @@ -4,16 +4,16 @@ 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'; /** - * 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, - * 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. * @@ -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/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/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-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..aebdef0f3 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, @@ -9,8 +24,9 @@ import { getProjectBuilds, getProjectDeployments, getProjectResources, - getProjectsByUserId, + getOwnedProjectsByUserId, getUser, + TERMINAL_DEPLOYMENT_STATUSES, LastAdminError, prepareUserRemoval, suspendUser, @@ -19,24 +35,63 @@ 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'; import { - refreshProjectKvForUser, + getAccountUsageProjectWindows, + getRequestsDataset, + queryBatchProjectUsageWindows, + ZERO_USAGE, +} from '../routes/usage.ts'; +import { + buildProjectAccountJson, + computeDispatchLimits, + projectAccountKey, sandboxBlockKey, setProjectsRoutingSuspension, } from '../routing.ts'; -import { deployments, projects, users } from '../schema.ts'; +import { + builds, + deployments, + githubConnections, + projectDeployCredentials, + projectInvitations, + projectMembers, + projectOwnershipPeriods, + projects, + users, +} from '../schema.ts'; 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'; 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, @@ -146,23 +201,28 @@ 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 ? 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, @@ -189,6 +249,571 @@ 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, + }; +} + +function historicalAccountSummary(user: User | null, userId: string) { + return user ? accountSummary(user) : { id: userId, deleted: true as const }; +} + +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, + ]; + const previousTransfer = + currentOwner.id === newOwner.id ? await getPreviousOwnershipTransfer(db, projectId) : null; + return { + project, + currentOwner, + previousOwner: previousTransfer ? previousTransfer.owner : currentOwner, + previousOwnerId: previousTransfer?.ownerId ?? currentOwner.id, + newOwner, + blockers, + targetMembership, + github, + domains, + }; +} + +async function getPreviousOwnershipTransfer( + db: Db, + projectId: string, +): Promise<{ owner: User | null; ownerId: string; 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 ?? null, ownerId: period.owner_user_id, startsAt: period.starts_at }; +} + +type OwnershipTransferCommit = Readonly<{ + project: Project; + previousOwner: User | null; + previousOwnerId: string; + previousOwnerBillingCycleStart: string | null; + 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 previousTransfer = await getPreviousOwnershipTransfer(txDb, projectId); + return { + project: lockedProject, + 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, + 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', + ); + } + 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: transferBoundary }) + .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: transferBoundary, + }); + } + const [newPeriod] = await txDb + .insert(projectOwnershipPeriods) + .values({ + id: generateId('pown'), + project_id: projectId, + owner_user_id: newOwner.id, + starts_at: transferBoundary, + changed_by_user_id: operatorUserId, + }) + .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 }) + .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, + previousOwnerId: currentOwner.id, + previousOwnerBillingCycleStart: currentOwner.billing_cycle_start, + newOwner, + previousOwnershipStartedAt: openPeriod?.starts_at ?? lockedProject.created_at, + transferredAt, + 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.previousOwnerId !== transfer.newOwner.id) { + await request(transfer.previousOwnerId, '/detach-project', { + projectId: transfer.project.id, + userId: transfer.previousOwnerId, + ...(transfer.previousOwnerBillingCycleStart + ? { + billingPeriodStart: getCurrentBillingPeriodStart( + transfer.previousOwnerBillingCycleStart, + ).toISOString(), + } + : {}), + 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, + billingPeriodStart: getCurrentBillingPeriodStart( + transfer.newOwner.billing_cycle_start, + ).toISOString(), + ownershipStartedAt: transfer.transferredAt, + }); +} + +async function republishProjectOwnerState( + context: Context, + projectId: string, + expectedOwnerUserId: string, +): Promise { + const db = context.get('db'); + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const [project] = await txDb + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + if (!project || project.user_id !== expectedOwnerUserId || project.status === 'deleting') { + return null; + } + const [owner, activeDeployment, domains] = await Promise.all([ + requireUser(txDb, project.user_id), + 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[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 = owner.id; + if ('workerName' in entry) { + if (dispatchLimits) { + entry.limits = dispatchLimits; + } else { + delete entry.limits; + } + } + const suspensionReason = routingSuspensionReason(owner.suspended_reason); + if (suspensionReason) { + entry.suspended = suspensionReason; + } else { + delete entry.suspended; + } + await context.env.ROUTING.put(key, JSON.stringify(entry)); + } + await context.env.ROUTING.put( + projectAccountKey(project.id), + 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 getOwnedProjectsByUserId(context.get('db'), userId); + for (const project of projects) { + await republishProjectOwnerState(context, project.id, userId); + } +} + +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, +) { + if (dryRun) { + return previewOperatorProjectOwner(context, projectId, targetUserId); + } + assertOwnershipTransferRolloutComplete(context.env); + + const db = context.get('db'); + const transfer = await commitOwnershipTransfer( + db, + projectId, + targetUserId, + context.get('user').id, + ); + const convergence = { countersComplete: false, routingComplete: false }; + let convergedOwner = transfer.newOwner; + // 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; + } + 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; + } + 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.', + 'partial_failure', + { + partial: true, + target: { id: transfer.project.id, slug: transfer.project.slug, userId: targetUserId }, + ownershipTransferred: true, + transferredAt: transfer.transferredAt, + previousOwner: historicalAccountSummary(transfer.previousOwner, transfer.previousOwnerId), + newOwner: accountSummary(convergedOwner), + convergence, + }, + ); + } + return { + action: 'project.owner', + target: { id: transfer.project.id, slug: transfer.project.slug, userId: targetUserId }, + previousOwner: historicalAccountSummary(transfer.previousOwner, transfer.previousOwnerId), + newOwner: accountSummary(convergedOwner), + transferredAt: transfer.transferredAt, + formerOwnerRole: transfer.previousOwnerId === transfer.newOwner.id ? null : 'admin', + changed: transfer.changed, + convergence, + }; +} + export async function changeOperatorUserPlan( context: Context, id: string, @@ -201,9 +826,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 getOwnedProjectsByUserId(db, id); return preview('user.plan', target, { previousPlan: user.plan, plan, @@ -216,11 +841,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), @@ -236,10 +856,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( @@ -265,7 +889,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) { @@ -283,6 +907,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( @@ -300,7 +925,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, { @@ -328,6 +953,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( @@ -366,7 +992,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) { @@ -381,12 +1007,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 new file mode 100644 index 000000000..13ffde753 --- /dev/null +++ b/platform/packages/api/src/project-access.ts @@ -0,0 +1,103 @@ +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<{ + project: Project; + role: ProjectRole; + owner: User; +}>; + +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>; + +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, + }; +} + +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(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 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, + 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(); + } + 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/provisioning/cleanup.ts b/platform/packages/api/src/provisioning/cleanup.ts index 2253821aa..f81a4e5a2 100644 --- a/platform/packages/api/src/provisioning/cleanup.ts +++ b/platform/packages/api/src/provisioning/cleanup.ts @@ -1,4 +1,4 @@ -import { sql as drizzleSql, and, eq, ne, notInArray } from 'drizzle-orm'; +import { sql as drizzleSql, and, desc, eq, isNotNull, isNull, ne, notInArray } from 'drizzle-orm'; import type { Context } from 'hono'; import { deleteProjectAssets, deleteWorkerBundle } from '../assets.ts'; import { DEFAULT_DISPATCH_NAMESPACE } from '../constants.ts'; @@ -13,6 +13,7 @@ import { getDomainsByProjectId, getProject, getProjectResources, + getUser, terminateBuildsForProject, } from '../db.ts'; import { drainProjectEmailAdmissions } from '../email/admission.ts'; @@ -20,13 +21,20 @@ import { emailDomainRoutes, emailZoneConnections } from '../email/connection-sch import { requestEmailDomainRemove, runEmailOperation } from '../email/connection-service.ts'; import type { ApiEnv } from '../env.ts'; import { cancelContainerBuildDos } from '../github/cancel-builds.ts'; +import { generateId } from '../id.ts'; import { getLegacyProjectWorkerName, getProjectDeploymentWorkerName, getProjectQueueName, } from '../naming.ts'; -import { deployments as deploymentsTable, projects as projectsTable } from '../schema.ts'; -import type { Build, DeployManifest, Project, Resource } from '../types.ts'; +import { requireProjectCapability } from '../project-access.ts'; +import { + deployments as deploymentsTable, + projectOwnershipPeriods, + projects as projectsTable, +} from '../schema.ts'; +import { getCurrentBillingPeriodStart } from '../tiers.ts'; +import type { Build, DeployManifest, Project, ProjectOwnershipPeriod, Resource } from '../types.ts'; import { deleteCustomHostname, deleteD1Database, @@ -287,6 +295,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` @@ -351,14 +364,20 @@ async function cleanupBestEffortResources( async function lockAndMarkDeleting( db: Db, projectId: string, -): Promise< - ReadonlyArray<{ + 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`); const txDb = tx as unknown as Db; @@ -366,10 +385,84 @@ async function lockAndMarkDeleting( if (!lockedProject) { throw new Error('project disappeared during deletion'); } + // 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; + } + // 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'); + } const activeDeployment = await getActiveDeployment(txDb, projectId); if (activeDeployment?.source === 'rollback-lease') { throw new ProjectOperationInProgressError(); } + const owner = await getUser(txDb, lockedProject.user_id); + if (!owner) { + throw new Error('project owner disappeared during deletion'); + } + const [openOwnership] = await txDb + .select() + .from(projectOwnershipPeriods) + .where( + and( + eq(projectOwnershipPeriods.project_id, projectId), + isNull(projectOwnershipPeriods.ends_at), + ), + ) + .for('update') + .limit(1); + if (openOwnership && openOwnership.owner_user_id !== lockedProject.user_id) { + throw new Error('project ownership history does not match the current owner'); + } + const [latestClosedOwnership] = openOwnership + ? [] + : await txDb + .select() + .from(projectOwnershipPeriods) + .where( + and( + eq(projectOwnershipPeriods.project_id, projectId), + isNotNull(projectOwnershipPeriods.ends_at), + ), + ) + .orderBy(desc(projectOwnershipPeriods.ends_at)) + .for('update') + .limit(1); + 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) + .values({ + id: generateId('pown'), + project_id: projectId, + owner_user_id: lockedProject.user_id, + starts_at: latestClosedOwnership?.ends_at ?? lockedProject.created_at, + ends_at: drizzleSql`clock_timestamp()`, + }) + .returning(); + } if (lockedProject.status !== 'deleting') { await tx .update(projectsTable) @@ -397,10 +490,55 @@ 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, + ownerBillingCycleStart: owner.billing_cycle_start, + ownershipStartedAt: ownership!.starts_at, + ownershipEndedAt: ownership!.ends_at!, + cancelledBuilds: await terminateBuildsForProject(txDb, projectId, { + reason: 'project deleted', + }), + }; }); } +async function detachProjectUsage( + context: Context, + projectId: 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(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: options.ownerUserId, + ...(options.ownerBillingCycleStart + ? { + billingPeriodStart: getCurrentBillingPeriodStart( + options.ownerBillingCycleStart, + ).toISOString(), + } + : {}), + ownershipStartedAt: options.ownershipStartedAt, + ownershipEndedAt: options.ownershipEndedAt, + }), + }), + ); + 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); @@ -485,16 +623,29 @@ export async function teardownProject( context: Context, projectId: string, project: Pick, -): 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 // attempt crashed before the cancellations committed. - const cancelledOnLock = await lockAndMarkDeleting(context.get('db'), projectId); + const deletion = await lockAndMarkDeleting( + context.get('db'), + projectId, + options.actorUserId, + options.expectedOwnerUserId, + ); + 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); const emailDrain = await drainProjectEmailAdmissions(context.get('db'), projectId); if (emailDrain.inFlight > 0) { 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/auth-oidc.ts b/platform/packages/api/src/routes/auth-oidc.ts index 1b4a865d9..8f675fa81 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(); @@ -159,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 @@ -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 @@ -263,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, @@ -305,7 +306,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); @@ -316,7 +317,22 @@ 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 + // 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 +441,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..9887a5547 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -87,6 +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, + ProjectOwnerSuspendedError, + requireProjectCapability, +} from '../project-access.ts'; import { ensureProjectIngress } from '../project-ingress.ts'; import { BETTER_AUTH_SECRET_NAME, @@ -120,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, @@ -132,6 +138,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 +151,10 @@ const cutoverReadyKey = (deploymentId: string): string => `cutover-ready:${deplo const cutoverCompensatedKey = (deploymentId: string): string => `cutover-compensated:${deploymentId}`; +async function requireActiveDeployAccess(db: Db, projectId: string, actorUserId: string) { + return requireProjectCapability(db, projectId, actorUserId, 'deploy'); +} + export function databaseTimestampMs(value: string): number | null { const withSeparator = value.includes('T') ? value : value.replace(' ', 'T'); const withZone = /[+-]\d{2}$/.test(withSeparator) @@ -1802,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. @@ -1828,13 +1839,11 @@ 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); - } - 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; // 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 +1978,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 +2006,12 @@ app.post('/:id/deploy/upload-urls', async (context) => { }); }); } catch (err) { + if (err instanceof ProjectAccessDeniedError) { + 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 +2067,11 @@ 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 +2402,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 +2417,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 +2575,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): Promise 0 && { reconciledMarkerKeys }), } as const; }); } catch (err) { + if (err instanceof ProjectAccessDeniedError) { + 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 +2750,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; @@ -3517,7 +3542,11 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): Promise): 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 ?? '', @@ -4417,6 +4456,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise( async (attempt) => { const attemptStart = Date.now(); @@ -4713,7 +4753,11 @@ async function handleManifestOnlyDeploy(context: Context): Promise 0 ? deployment.id : undefined, assetConfig: assetCount > 0 ? assetConfig : undefined, @@ -4727,7 +4771,7 @@ async function handleManifestOnlyDeploy(context: Context): 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 +5593,11 @@ 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); - } - - 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; // 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 +5661,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 ProjectOwnerSuspendedError) { + return { kind: 'owner-suspended' as const }; + } + if (error instanceof ProjectAccessDeniedError) { + return { kind: 'forbidden' as const }; + } + throw error; } if (lockedProject.status === 'deleting') { return { kind: 'deleting' as const }; @@ -5641,6 +5691,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..97673095d 100644 --- a/platform/packages/api/src/routes/email.ts +++ b/platform/packages/api/src/routes/email.ts @@ -7,16 +7,13 @@ import { deleteEmailDestination, getEmailDestinationByAddress, getEmailDestinationsByProjectId, - getProject, getUser, promoteEmailDestinationWithOwnerAudit, queueUnreferencedEmailDestinationCleanup, tryCreateEmailDestination, updateEmailDestinationStatus, type Db, - type DbOrTx, type OwnerVerificationAudit, - type Tx, } from '../db.ts'; import { getProjectEmailUsage, getProjectEmailLogs } from '../email/admission.ts'; import { emailDomainRoutes, emailZoneConnections } from '../email/connection-schema.ts'; @@ -37,6 +34,7 @@ import { } from '../email/settings.ts'; import type { ApiEnv } from '../env.ts'; import { isForeignKeyViolation, isUniqueViolation } from '../pg-errors.ts'; +import { ProjectAccessDeniedError, ProjectNotFoundError } from '../project-access.ts'; import { runEmailDestinationCleanup } from '../provisioning/email-destination-cleanup.ts'; import { addDestination, @@ -45,6 +43,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 +89,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 +99,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 +120,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, 'settings'); + 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 +226,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 @@ -287,6 +276,10 @@ const app = new Hono() // 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 }, @@ -359,7 +352,7 @@ const app = new Hono() emailSnapshot: ownerEmail!, }; const inserted = await tryInsertWithRetry( - context.get('db'), + context, projectId, address, cfRow.tag, @@ -368,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 @@ -404,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, @@ -412,6 +408,9 @@ const app = new Hono() null, 'platform', ); + if (inserted instanceof Response) { + return inserted; + } if (!inserted.row) { await rollbackCfDestination( context.get('db'), @@ -448,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, @@ -456,6 +455,9 @@ const app = new Hono() null, 'cf', ); + if (inserted instanceof Response) { + return inserted; + } if (!inserted.row) { await rollbackCfDestination( context.get('db'), @@ -476,13 +478,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); } @@ -493,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'), @@ -506,22 +515,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), @@ -530,19 +536,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 @@ -581,15 +584,11 @@ const app = new Hono() 400, ); } - 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, @@ -598,35 +597,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')) { @@ -648,7 +642,7 @@ const app = new Hono() } return rotateEmailIngressSecretForProject( context, - project, + access.project, context.req.param('domain'), 'owner', attemptId, @@ -657,28 +651,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); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; } - if (project.user_id !== context.get('user').id) { - return context.json({ error: 'forbidden' }, 403); - } - 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 { @@ -710,6 +701,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 @@ -756,7 +770,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, @@ -764,7 +778,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 { @@ -776,9 +791,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/github.ts b/platform/packages/api/src/routes/github.ts index a34678740..776d9e469 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'; @@ -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 ( @@ -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,29 @@ 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; + } + 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 @@ -1724,13 +1744,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 +1763,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 +1787,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 +1829,26 @@ 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; + } + if (lockedAccess.project.status === 'deleting') { + return context.json({ error: 'project is being deleted' }, 409); + } await tx .select({ id: githubConnections.id }) .from(githubConnections) @@ -1878,6 +1913,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 +1932,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 +1946,26 @@ 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; + } + 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 @@ -1927,6 +1982,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..f7277adae 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,11 @@ 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 { + await requireProjectCapability(context.get('db'), projectId, userId, 'deploy'); + } 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/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-access.ts b/platform/packages/api/src/routes/project-access.ts new file mode 100644 index 000000000..e24d0c160 --- /dev/null +++ b/platform/packages/api/src/routes/project-access.ts @@ -0,0 +1,26 @@ +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'; + +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..c4c1cae40 --- /dev/null +++ b/platform/packages/api/src/routes/project-team.ts @@ -0,0 +1,500 @@ +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 { cancelContainerBuildDos } from '../github/cancel-builds.ts'; +import type { + ProjectInvitation, + ProjectInvitationStatus, + ProjectMember, + 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; +const INVITATION_EMAILS_PER_DAY = 50; + +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 userSummary(user: User): Pick { + return { id: user.id, login: user.login }; +} + +function memberJson(member: ProjectMember, user: User): MemberJson { + return { + user: userSummary(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: { ...userSummary(target), email: target.email }, + 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 ProjectTeamMutationError)) { + return null; + } + 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', + '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', + 'owner-suspended': 'project owner suspended', + forbidden: 'forbidden', + }; + return context.json({ error: messages[error.reason] }, error.status); +} + +async function deliverInvitation( + context: Context, + invitation: ProjectInvitation, + target: User, + projectSlug: string, +): 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, + 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' }; + } +} + +/** + * 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'); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; + } + const members = await getProjectMembers(context.get('db'), projectId); + return context.json({ + members: [ + { + user: userSummary(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 requireProjectRouteCapability(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 requireProjectRouteCapability(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 requireProjectRouteCapability(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 requireProjectRouteCapability(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 requireProjectRouteCapability(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, 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); + } + 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 requireProjectRouteCapability(context, projectId, 'members'); + if (access instanceof Response) { + return access; + } + try { + 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); + if (response) { + return response; + } + throw error; + } + }) + + .post('/:id/leave', async (context) => { + const projectId = context.req.param('id'); + const access = await requireProjectRouteCapability(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 { + 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); + 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 ? userSummary(owner) : { id: project.user_id, login: null }, + }, + invited_by: inviter + ? userSummary(inviter) + : { id: invitation.invited_by_user_id, login: 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/project-tokens.ts b/platform/packages/api/src/routes/project-tokens.ts index d53b9d792..a24d339bc 100644 --- a/platform/packages/api/src/routes/project-tokens.ts +++ b/platform/packages/api/src/routes/project-tokens.ts @@ -1,9 +1,10 @@ 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'; +import type { Project } from '../types.ts'; const app = new Hono(); @@ -24,15 +25,38 @@ 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, +): Promise { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + if (!project) { return null; } + if (project.user_id !== userId) { + return 'forbidden'; + } return project; } @@ -48,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); } @@ -62,8 +89,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 +108,44 @@ 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 (typeof project === 'string' || !project) { + return project; + } + if (project.status === 'deleting') { + return 'project-deleting' as const; + } + 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 === '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); + } + 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 +163,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 +171,16 @@ 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 === '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) @@ -156,6 +211,15 @@ 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 === '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); } @@ -163,22 +227,38 @@ 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 [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(); + 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; + } + 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); + } + 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 e8242df04..da1b09a10 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'; /** @@ -179,14 +179,14 @@ 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. */ 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,33 @@ class RollbackError extends Error { } } -type ProjectListItem = Awaited>[number] & { +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 }); + this.name = 'DatabaseUrlPersistenceError'; + } +} + +type ProjectListItem = Project & { + role: ProjectRole; + owner: Pick; url?: string; custom_domain: string | null; latest_deployment: Awaited> extends Map< @@ -543,7 +569,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 +578,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 +600,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 }, custom_domain: domain?.hostname ?? null, latest_deployment: latestDeployments.get(p.id) ?? null, traffic: { @@ -695,28 +725,31 @@ 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, + }, + }); }) // 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 +770,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 +787,18 @@ 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, + ); + if (lockedAccess instanceof Response) { + return lockedAccess; + } const boundBuild = await getBuild(txDb, deployBuild); if ( - !lockedProject || - lockedProject.status === 'deleting' || - lockedProject.user_id !== user.id || + lockedAccess.project.status === 'deleting' || !boundBuild || boundBuild.project_id !== project.id || !ACTIVE_BUILD_STATUSES.includes(boundBuild.status) @@ -768,6 +807,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); } @@ -780,32 +822,22 @@ 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 - // 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. - const sanitized = context.get('deployScope') - ? deployments.map((deployment) => ({ - ...deployment, - manifest: sanitizeManifestForDeployToken(deployment.manifest), - })) - : deployments; + // 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) => ({ + ...deployment, + manifest: sanitizeDeploymentManifest(deployment.manifest), + })) + : deployments; return context.json({ deployments: sanitized, activeDeploymentId: project.active_deployment_id, @@ -816,40 +848,44 @@ 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); - 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 .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,8 +924,6 @@ const app = new Hono() ); } - const planLimits = getPlanLimits(context.get('user').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 @@ -931,10 +965,19 @@ 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, + await getProjectAccessErrorMessage(lockedAccess), + ); } + const lockedProject = lockedAccess.project; if (lockedProject.status === 'deleting') { throw new RollbackError(409, 'project is being deleted'); } @@ -1026,6 +1069,18 @@ 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, + await getProjectAccessErrorMessage(lockedAccess), + ); + } // Re-read target INSIDE the lock. applied_migrations can be // rewritten by a concurrent /rename; manifest and retained are @@ -1054,10 +1109,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'); } @@ -1138,12 +1190,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 @@ -1702,14 +1752,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,12 +1855,9 @@ 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 body = await context.req.json<{ @@ -1863,6 +1907,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: await getProjectAccessErrorMessage(lockedAccess), + }, + }; + } const resources = await getProjectResources(txDb, projectId); const d1Resource = resources.find((r) => r.type === 'd1'); @@ -1900,7 +1958,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,12 +2043,9 @@ 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 body = await context.req.json<{ renames: ReadonlyArray<{ from: string; to: string }> }>(); @@ -2051,6 +2106,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: await getProjectAccessErrorMessage(lockedAccess), + }, + }; + } const resources = await getProjectResources(txDb, projectId); const d1Resource = resources.find((r) => r.type === 'd1'); @@ -2326,26 +2396,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 +2564,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 +2696,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,21 +2787,13 @@ 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); - } - // 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. + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; + } + const project = access.project; + // 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); } @@ -2762,98 +2814,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,15 +2944,12 @@ 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); - } - // 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). + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; + } + const project = access.project; + // Fast rejection; the transaction below performs the authoritative check. if (project.status === 'deleting') { return context.json({ error: 'Project is being deleted' }, 409); } @@ -2880,124 +2959,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,11 +3101,11 @@ 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); + const access = await requireProjectRouteCapability(context, projectId, 'configure'); + if (access instanceof Response) { + return access; } - if (project.user_id !== context.get('user').id) { + if (access.role !== 'owner') { return context.json({ error: 'forbidden' }, 403); } @@ -3081,23 +3176,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; } @@ -3211,7 +3291,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/src/routes/revalidate.ts b/platform/packages/api/src/routes/revalidate.ts index 8f7fc1f1c..16e3b7280 100644 --- a/platform/packages/api/src/routes/revalidate.ts +++ b/platform/packages/api/src/routes/revalidate.ts @@ -1,23 +1,36 @@ +import { sql } from 'drizzle-orm'; import { Hono } from 'hono'; -import { getProject } from '../db.ts'; +import type { Db } 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 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/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..c5f8f01af 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, isNull, lt, or } from 'drizzle-orm'; import { Hono } from 'hono'; -import { getProject, getProjectsByUserId } 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'; +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,27 +230,91 @@ async function queryProjectUsage( type UsageEntry = Readonly<{ requests: number; bandwidthBytes: number }>; const ZERO_USAGE: UsageEntry = { requests: 0, bandwidthBytes: 0 }; -async function queryBatchProjectUsage( +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. + */ +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); + } + } + + 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, - projectIds: ReadonlyArray, - since: Date, + windows: ReadonlyArray, ): Promise> { - if (projectIds.length === 0) { + if (windows.length === 0) { return new Map(); } - - const sinceStr = since.toISOString().replace('T', ' ').slice(0, 19); - const inList = projectIds.map((id) => `'${id}'`).join(', '); + const predicates = windows.flatMap(({ project, intervals }) => + intervals.map( + ({ start, end }) => + `(blob1 = ${analyticsStringLiteral(project.id)} AND timestamp >= toDateTime64(${analyticsStringLiteral(formatAnalyticsDateMilliseconds(start))}, 3) AND timestamp < toDateTime64(${analyticsStringLiteral(formatAnalyticsDateMilliseconds(end))}, 3))`, + ), + ); 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}') + WHERE ${predicates.join('\n OR ')} GROUP BY blob1 `; @@ -249,20 +329,19 @@ async function queryBatchProjectUsage( 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; + return new Map( + (json.data ?? []).map((row) => [ + row.blob1, + { + requests: Math.round(row.requests ?? 0), + bandwidthBytes: Math.round(row.bandwidth_bytes ?? 0), + }, + ]), + ); } async function queryTopProjectTraffic( @@ -321,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; @@ -463,6 +546,8 @@ async function queryProjectAiUsage( gatewayId: string, projectId: string, since: Date, + until?: Date, + failOnError = false, ): Promise<{ neurons: number }> { const query = `query { viewer { @@ -472,6 +557,7 @@ async function queryProjectAiUsage( gatewayId: "${gatewayId}" metadata_projectId: "${projectId}" datetime_geq: "${since.toISOString()}" + ${until ? `datetime_lt: "${until.toISOString()}"` : ''} } ) { sum { cost } @@ -490,11 +576,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) }; } @@ -502,10 +594,11 @@ async function queryProjectAiUsage( export { getRequestsDataset, queryProjectUsage, - queryBatchProjectUsage, + queryBatchProjectUsageWindows, queryTopProjectTraffic, queryProjectRequestOverview, queryProjectAiUsage, + getAccountUsageProjectWindows, projectUsage, accountUsage, ZERO_USAGE, diff --git a/platform/packages/api/src/routes/webhooks.ts b/platform/packages/api/src/routes/webhooks.ts index ab732789b..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 { @@ -13,8 +13,7 @@ import { getGitHubConnectionsByRepo, getGitHubInstallation, getGitHubInstallationsByAccount, - getProjectsByUserId, - getUser, + getOwnedProjectsByUserId, getUserByOAuthAccount, markBuildTerminalIfActive, markGitHubInstallationStatus, @@ -50,7 +49,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 +450,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) { @@ -514,7 +509,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', @@ -801,25 +797,18 @@ 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 - // 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) { @@ -1469,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', @@ -1496,26 +1472,29 @@ export async function handleVerifiedGitHubWebhook( instance_type: string | null; }> = []; if (removedUser) { - const projectIds = (await getProjectsByUserId(txDb, removedUser.id)).map((p) => p.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 ownedProjectIds = (await getOwnedProjectsByUserId(txDb, removedUser.id)).map( + (project) => 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/src/routing.ts b/platform/packages/api/src/routing.ts index b0200b095..e5883aca4 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, type Db } from './db.ts'; import type { PlanLimits } from './tiers.ts'; import type { AssetConfig, DeployManifest } from './types.ts'; @@ -25,33 +25,49 @@ export function sandboxBlockKey(userId: string): string { return `sandbox-block:${userId}`; } -export function buildProjectAccountJson(entry: ProjectAccountEntry): string { - return JSON.stringify({ userId: entry.userId, plan: entry.plan }); +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; + }); } -/** - * 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( +export async function verifyActiveProjectOwner( db: Db, + projectId: string, + expectedOwnerUserId: string, +): Promise { + return withLockedProjectOwner(db, projectId, expectedOwnerUserId, async () => {}); +} + +export async function setProjectAiBlock( 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 }); - await Promise.all(projects.map((project) => routing.put(projectAccountKey(project.id), payload))); + 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 }); } /** @@ -185,7 +201,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 +210,12 @@ 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`); + 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 // 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..3c95fd3b3 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), @@ -950,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', }), @@ -964,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(), 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/types.ts b/platform/packages/api/src/types.ts index 52827922a..c813cb8c6 100644 --- a/platform/packages/api/src/types.ts +++ b/platform/packages/api/src/types.ts @@ -11,6 +11,9 @@ import type { oauthAccounts, platformEvents, projectDatabaseCredentials, + projectInvitations, + projectMembers, + projectOwnershipPeriods, projects, projectSecrets, resources, @@ -49,10 +52,26 @@ 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 Deployment = InferSelectModel; export type DeploymentStatus = Deployment['status']; export type ProjectSecret = InferSelectModel; 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/src/usage-counter.ts b/platform/packages/api/src/usage-counter.ts index b1635d3ae..89751ec71 100644 --- a/platform/packages/api/src/usage-counter.ts +++ b/platform/packages/api/src/usage-counter.ts @@ -1,8 +1,21 @@ import { DurableObject } from 'cloudflare:workers'; -import { createDb, suspendUser, unsuspendUserIfReason, type Db } from './db.ts'; +import { + createDb, + getUser, + 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'; +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'; @@ -10,6 +23,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 +35,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 +84,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 +117,7 @@ export function evaluateSandboxRuntimeLimit(state: CounterState, incomingSeconds function defaultState(): CounterState { return { + ownerUserId: null, totalRequests: 0, totalNeurons: 0, totalSandboxSeconds: 0, @@ -92,6 +128,10 @@ function defaultState(): CounterState { aiSuspended: false, sandboxRuntimeSuspended: false, projectSlugs: {}, + projectOwnershipStarts: {}, + projectNeurons: {}, + historicalNeurons: 0, + historicalAiWindows: {}, }; } @@ -140,6 +180,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 +245,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 +272,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 +326,39 @@ export class UsageCounter extends DurableObject { } state.totalSandboxSeconds += incomingSandboxSeconds; - // Update projectSlugs map + // Only the current owner may keep a project in the active enforcement set. + // 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) { - state.projectSlugs[event.projectId] = event.slug; + 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; + } + } } // Update plan if provided @@ -284,20 +366,22 @@ 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) { 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), ]); + // 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; } @@ -333,6 +417,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) { @@ -348,51 +435,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. - for (const projectId of Object.keys(state.projectSlugs)) { - 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.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. @@ -406,27 +456,209 @@ 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 = 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; // 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) { + if (state.ownerUserId) { for (const projectId of Object.keys(state.projectSlugs)) { - await this.env.ROUTING.put(`ai-block:${projectId}`, '1'); + await setProjectAiBlock( + this.env.ROUTING, + db, + projectId, + state.ownerUserId, + state.aiSuspended, + ); } } 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; + billingPeriodStart?: string; + userId?: string; + }; + if ( + !body.projectId || + !body.slug || + !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 }); + } + const state = await this.getState(); + if (body.userId) { + state.ownerUserId = body.userId; + } + const db = createDb(this.env.HYPERDRIVE.connectionString); + 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; + if (!owner) { + return Response.json({ error: 'project owner changed' }, { status: 409 }); + } + // 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, + ); + } + } + 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) { + await this.suspendProjects( + db, + { [body.projectId]: body.slug }, + state.ownerUserId, + REASON_REQUESTS_EXCEEDED, + ); + } + 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 }); + } + + private async handleDetachProject(request: Request): Promise { + const body = (await request.json()) as { + projectId: string; + userId?: string; + billingPeriodStart?: string; + ownershipStartedAt?: string; + ownershipEndedAt?: string; + }; + if (!body.projectId) { + 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))) + ) { + return Response.json({ error: 'invalid ownership window' }, { status: 400 }); + } + const state = await this.getState(); + 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 (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( + state, + body.projectId, + body.ownershipStartedAt ?? state.projectOwnershipStarts[body.projectId], + body.ownershipEndedAt, + ); + if (windowKey) { + await this.refreshHistoricalAiWindows(state, [windowKey]); + } + } else { + this.retainHistoricalAiSample(state, 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,8 +680,11 @@ 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)) { - 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; } @@ -490,20 +725,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 +777,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); @@ -522,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); } } @@ -540,11 +806,217 @@ 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 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] ?? matchingWindows[0]?.[1]; + const sampled = state.projectNeurons[projectId]; + for (const [matchingKey] of matchingWindows) { + delete state.historicalAiWindows[matchingKey]; + } + 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 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 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) { + 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, + 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 +1026,37 @@ 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 && 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 a426f9547..e4e583853 100644 --- a/platform/packages/api/src/usage-queue.ts +++ b/platform/packages/api/src/usage-queue.ts @@ -1,4 +1,4 @@ -import { createDb, 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 { @@ -127,25 +127,60 @@ export async function processUsageEventBatch( } const db = createDb(env.HYPERDRIVE.connectionString); - let projects: Awaited>; + let ownershipSnapshot: Awaited>; try { - projects = await getProjectsByIds(db, [ - ...new Set(authenticatedRecords.map(({ event }) => event.projectId)), - ]); + ownershipSnapshot = await getProjectUsageOwnershipSnapshot( + db, + authenticatedRecords.map(({ event }) => event.projectId), + ); } catch (error) { - console.error('[usage-queue] project identity lookup failed:', 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) { return []; } + // 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( + (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: + project.status !== 'deleting' && + (ownershipPeriod ? ownershipPeriod.ends_at === null : true), + ownershipStartedAt: ownershipPeriod?.starts_at ?? project.created_at, + }, + 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..58ba0ecd1 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -8,6 +8,8 @@ import { invitations, platformLoginConnections, platformLoginRevisions, + operatorEvents, + projectOwnershipPeriods, projects, users, } from '../../src/schema.ts'; @@ -329,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(); @@ -964,6 +966,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 +1348,148 @@ 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('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 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 + .delete(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)); + 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', + 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(`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([]); + }); + + 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..ef815451d 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,8 @@ import { emailDestinationCleanups, emailDestinations, users, + projectMembers, + projectOwnershipPeriods, } from '../../src/schema.ts'; import { setupEnv, @@ -94,6 +97,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', { @@ -159,17 +209,36 @@ 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 }> = []; + 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 }; @@ -178,6 +247,74 @@ 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, + 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('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 () => { diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index a82cd0669..0c36757b5 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -17,10 +17,12 @@ import { failStaleDeployments, getDeployment, getProject, + removeProjectMember, updateDeploymentStatus, 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, @@ -29,6 +31,7 @@ import { domains, projects, projectMigrations, + projectMembers, projectSecrets, users, } from '../../src/schema.ts'; @@ -366,6 +369,158 @@ 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("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( + 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'); @@ -1867,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'); @@ -2152,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'); @@ -2858,6 +3177,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-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/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-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-email.test.ts b/platform/packages/api/test/integration/api-email.test.ts index c9e3504dc..0a5605b74 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 @@ -538,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); @@ -857,6 +960,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); diff --git a/platform/packages/api/test/integration/api-github.test.ts b/platform/packages/api/test/integration/api-github.test.ts index f4b472729..f7dd02a35 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,12 +43,15 @@ import { githubInstallationMembers, githubInstallationRevocations, oauthAccounts, + projectMembers, + projects, } from '../../src/schema.ts'; import { JWT_SECRET, clearTables, createTestProject, createTestUser, + recordingBuildManager, setupEnv, teardownEnv, type TestEnv, @@ -181,36 +187,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 = { @@ -458,6 +434,154 @@ 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'); + 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 +1097,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 @@ -2497,7 +2716,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', @@ -2555,7 +2774,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', @@ -2616,7 +2835,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', @@ -2673,7 +2892,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', @@ -2744,7 +2963,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', @@ -2876,7 +3095,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', @@ -4041,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/api-migrations.test.ts b/platform/packages/api/test/integration/api-migrations.test.ts index 4329101ff..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'); @@ -2546,7 +2593,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 +4380,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 +4409,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 @@ -6203,40 +6250,4 @@ describe('POST /projects/:id/migrations/rename', () => { const parsed = JSON.parse(match![1]) as Array; 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. - 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 at the three call - // sites (/apply, /rename, /rollback). - 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); - }); }); diff --git a/platform/packages/api/test/integration/api-oidc.test.ts b/platform/packages/api/test/integration/api-oidc.test.ts index edcfe7f5a..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, @@ -31,7 +30,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 +631,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 @@ -1403,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', @@ -1413,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', @@ -2411,165 +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 limit === 0 as unlimited', async () => { - const user = await createTestUser(env.db); - // 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, - limit: 0, - 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-project-access-routes.test.ts b/platform/packages/api/test/integration/api-project-access-routes.test.ts new file mode 100644 index 000000000..82ae44fdc --- /dev/null +++ b/platform/packages/api/test/integration/api-project-access-routes.test.ts @@ -0,0 +1,192 @@ +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 + .delete(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.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()}"`); + }); +}); 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..dda2beae2 --- /dev/null +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -0,0 +1,882 @@ +import { and, eq } from 'drizzle-orm'; +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 * as projectAccess from '../../src/project-access.ts'; +import { + projectAccessEvents, + projectInvitations, + projectMembers, + projects, + users, +} from '../../src/schema.ts'; +import { + clearTables, + createTestProject, + createTestUser, + recordingBuildManager, + 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(); +}); + +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('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 invitation acceptance', 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, 'Target@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 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' }, + }); + }); + + 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([]); + + 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('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'); + 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('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'); + 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('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([ + { + 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 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', + }), + 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); + + 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('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'); + 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..26a3e9eb4 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, @@ -41,7 +43,7 @@ import { cfError, type TestEnv, } from './helpers.ts'; -import { setGetProjectOverride, setTestDb } from './setup.ts'; +import { setTestDb } from './setup.ts'; let env: TestEnv; @@ -55,7 +57,6 @@ afterAll(async () => { beforeEach(async () => { await clearTables(env.db); - setGetProjectOverride(null); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -145,6 +146,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'); @@ -238,6 +277,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'); @@ -266,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; @@ -359,6 +535,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'); @@ -485,6 +694,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'] }); @@ -512,11 +751,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`, { @@ -540,7 +780,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(); } }); @@ -567,6 +806,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'); @@ -584,6 +875,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'); @@ -846,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'); @@ -1488,6 +1874,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', () => { @@ -1540,10 +1971,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( @@ -1563,6 +1991,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', @@ -1575,6 +2014,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/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/api-webhooks.test.ts b/platform/packages/api/test/integration/api-webhooks.test.ts index 4bf91af92..611a93b79 100644 --- a/platform/packages/api/test/integration/api-webhooks.test.ts +++ b/platform/packages/api/test/integration/api-webhooks.test.ts @@ -35,12 +35,19 @@ 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, createTestProject, createTestUser, + recordingBuildManager, setupEnv, teardownEnv, type TestEnv, @@ -202,29 +209,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], @@ -4223,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', @@ -4262,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 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..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,20 +971,16 @@ 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'); + // 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) => { + 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 +992,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 7135cd5d5..11a91fd2e 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, @@ -41,8 +42,10 @@ import { githubConnections, githubInstallations, githubInstallationMembers, + projectMembers, projects, resources, + users, } from '../../src/schema.ts'; import { clearTables, @@ -364,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 () => { @@ -538,7 +593,6 @@ 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', @@ -547,6 +601,7 @@ describe('builds', () => { 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 @@ -554,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', @@ -565,7 +619,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'); @@ -575,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', @@ -594,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', @@ -605,6 +662,49 @@ 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, + branch: 'main', + commitSha: 'owner-race', + installationId: 'container-owner-race', + repositoryId: DEFAULT_REPO_ID, + }); + + 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 @@ -632,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', @@ -662,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', @@ -730,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', @@ -760,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', @@ -826,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', @@ -871,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', @@ -915,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', @@ -953,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', @@ -1000,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', @@ -1028,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', @@ -1040,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', @@ -1072,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', @@ -1103,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', @@ -1140,7 +1227,6 @@ describe('builds', () => { const result = await supersedeAndCreateContainerBuild(env.db, { projectId: project.id, connectionId, - userId: user.id, branch: 'main', commitSha: 'aaa', installationId: '12345', @@ -1154,6 +1240,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); @@ -1170,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', @@ -1203,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', @@ -1232,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', @@ -1261,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', @@ -1290,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', @@ -1323,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', @@ -1355,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', @@ -1406,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', @@ -1422,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', @@ -1514,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(); @@ -1527,10 +1641,51 @@ 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 () => { + 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, + }), + ).toBeNull(); + expect((await getBuild(env.db, build.id))?.status).toBe('queued'); + }); + + 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); + 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, + }), + ).toBeNull(); + expect((await getBuild(env.db, queued.id))?.status).toBe('queued'); }); it('requeueBuildIfStarting returns a starting row to queued (CAS)', async () => { @@ -1543,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'); @@ -1563,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'); @@ -1584,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'); @@ -1608,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'); @@ -1631,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'); @@ -1670,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, @@ -1692,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), @@ -1705,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 @@ -1721,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'); }); @@ -1734,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 }); @@ -1744,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, @@ -1786,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'); @@ -1913,15 +2030,110 @@ 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'); + 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, + 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, + '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, + 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); + } + }); + // 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 @@ -1944,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, @@ -1969,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', @@ -1986,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', @@ -2023,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', @@ -2039,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', @@ -2087,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', @@ -2117,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', @@ -2135,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', @@ -2162,8 +2358,8 @@ describe('createGithubActionsBuildWithinLimit', () => { const capped = await createGithubActionsBuildWithinLimit(env.db, { 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. branch: 'main', githubRunId: '4003', installationId: 'inst-cap-other', @@ -2186,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', @@ -2219,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', @@ -2253,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', @@ -2285,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, @@ -2317,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', @@ -2336,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', @@ -2356,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', @@ -2392,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', @@ -2411,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', @@ -2454,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', @@ -2482,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', @@ -2515,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', @@ -2548,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', @@ -2599,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', @@ -2614,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/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/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/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-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..801c46f7b 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -11,18 +11,30 @@ 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 { + builds, + deployments, invitations, platformLoginConnections, platformLoginIdentities, platformLoginRevisions, + projectDeployCredentials, + projectInvitations, + projectMembers, + projectOwnershipPeriods, + projects, resources, users, } from '../../src/schema.ts'; @@ -34,6 +46,7 @@ import { createTestProject, createTestUser, mockCfApi, + recordingBuildManager, setupEnv, teardownEnv, type TestEnv, @@ -41,6 +54,7 @@ import { let env: TestEnv; let token: string; +let operatorAdminId: string; beforeAll(async () => { env = await setupEnv(); @@ -60,6 +74,7 @@ beforeEach(async () => { 'free', 'admin', ); + operatorAdminId = admin.id; token = await createOperatorToken(env.db, admin); }); afterEach(() => { @@ -85,6 +100,33 @@ 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; +} + +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']) { @@ -145,6 +187,79 @@ 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); + transferredAt.setMilliseconds(500); + 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 + .delete(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.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( + `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 () => { for (const query of ['limit=101', 'page=0', 'page=2abc', 'limit=-1']) { expect((await req(`/users?${query}`)).status).toBe(400); @@ -155,6 +270,614 @@ 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 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(); + + 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) + .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' }]); + 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('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'); + 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.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 }), + ); + 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 }); + 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, + path: '/detach-project', + body: { + projectId: project.id, + userId: owner.id, + billingPeriodStart: expect.any(String), + ownershipStartedAt: former.starts_at, + ownershipEndedAt: current.starts_at, + }, + }, + { + ownerId: target.id, + path: '/attach-project', + body: { + projectId: project.id, + userId: target.id, + slug: project.slug, + billingPeriodStart: expect.any(String), + ownershipStartedAt: current.starts_at, + }, + }, + ]); + 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('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'); + 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('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, + 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: 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: target.id, + suspended: 'operator_review', + }); + + const recoveryPreview = await req(`/projects/${project.id}/owner`, { + userId: target.id, + dryRun: true, + }); + 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; + 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(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 }); + }); + + 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'); @@ -263,6 +986,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, 'getOwnedProjectsByUserId').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..6d4616b14 100644 --- a/platform/packages/api/test/integration/platform-onboarding.test.ts +++ b/platform/packages/api/test/integration/platform-onboarding.test.ts @@ -55,6 +55,43 @@ 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('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) => { 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..39b3bb095 --- /dev/null +++ b/platform/packages/api/test/integration/project-access.test.ts @@ -0,0 +1,250 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { hmacSignState } from '../../src/auth/hmac.ts'; +import { + createDeployment, + createOrRefreshProjectInvitation, + createProject, + getAccessibleProjectsByUserId, + removeProjectMember, + setProjectMemberRole, +} from '../../src/db.ts'; +import app from '../../src/index.ts'; +import { + ProjectAccessDeniedError, + ProjectOwnerSuspendedError, + requireProjectCapability, +} from '../../src/project-access.ts'; +import { + deployments, + projectMembers, + projectOwnershipPeriods, + projects, + 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 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 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'); + expect(access.owner.id).toBe(owner.id); + 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 () => { + 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'); + 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, + ); + 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('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('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 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); + }); + + 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'); + const target = await userWithEmail('lock-target'); + const project = await createProject(env.db, owner.id, 'locked-auth'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }); + 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 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'); + 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 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}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-void-internal': internalAuth, + }, + body: JSON.stringify({ userId, proxyOrigin }), + }), + 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..49cc32536 --- /dev/null +++ b/platform/packages/api/test/integration/project-team-lifecycle.test.ts @@ -0,0 +1,264 @@ +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, + respondToProjectInvitation, +} from '../../src/db.ts'; +import { + projectAccessEvents, + projectInvitations, + projectMembers, + projectOwnershipPeriods, + users, +} from '../../src/schema.ts'; +import { + captureQueries, + 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('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'); + 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: 'admin', + actorUserId: owner.id, + }); + await respondToProjectInvitation(env.db, { + invitationId: invitation.id, + 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); + + 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([]); + 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() + .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('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'); + 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 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.toEqual([]); + + 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/integration/scheduled.test.ts b/platform/packages/api/test/integration/scheduled.test.ts index 027f7d7cc..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); }); @@ -560,4 +572,95 @@ 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', + }); + }); + + 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 da36525f6..25a2eef4d 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, @@ -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,18 +79,14 @@ const db = { buildId: string; userId: string; projectId: string; - limit: number; - instanceType: string; }>, }; // 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 @@ -114,13 +111,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 ( @@ -129,19 +143,34 @@ 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) { - return false; + const plan = + db.claimPlanByBuild.get(args.buildId) ?? 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 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) => { @@ -276,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 = []; @@ -329,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}` }); @@ -370,6 +404,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. @@ -418,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, with the plan's concurrency cap (free = 1). expect(claimBuildForStartWithinLimit).toHaveBeenCalledOnce(); - expect(db.claimArgs).toEqual([ - { buildId: 'b1', userId: 'u1', projectId: 'p1', limit: 1, instanceType: 'standard-3' }, - ]); + expect(db.claimArgs).toEqual([{ buildId: 'b1', userId: 'u1', projectId: 'p1' }]); }); it('enforces per-user tier concurrency (free plan = 1)', async () => { @@ -445,25 +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 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. - 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'); @@ -474,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', limit: 3, instanceType: 'standard-4' }, - { buildId: 'b2', userId: 'u1', projectId: 'p2', limit: 3, 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); }); @@ -570,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 { @@ -587,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' }); @@ -693,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', limit: 1, 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'); }); @@ -837,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', limit: 1, 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/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/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/routing.test.ts b/platform/packages/api/test/unit/routing.test.ts index 97336efeb..aa43cdf3e 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, @@ -6,8 +7,8 @@ import { buildStaticRoutingJson, computeDispatchLimits, projectAccountKey, - refreshProjectKvForUser, resolveRoutingUndo, + setProjectAiBlock, setProjectsRoutingSuspension, } from '../../src/routing.ts'; import { PLAN_LIMITS, type PlanLimits } from '../../src/tiers.ts'; @@ -33,7 +34,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 () => {} }), @@ -275,63 +282,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 = {} as Parameters[0]; - 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>); - - 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(); - }); -}); - describe('applyRoutingSuspension', () => { it('stamps suspended on each existing key and skips keys with no entry', async () => { const { store, kv } = createKvStore({ @@ -443,12 +393,21 @@ describe('setProjectsRoutingSuspension', () => { }, }), } as unknown as Parameters[1]; - - await setProjectsRoutingSuspension(kv, db, [{ id: 'proj_a', slug: 'slug-a' }], { - op: 'suspend', - reason: 'admin_suspended', - force: true, - }); + vi.spyOn(dbModule, 'getProject').mockResolvedValue({ + id: 'proj_a', + user_id: 'owner_a', + } as Awaited>); + + 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(); @@ -478,12 +437,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'); @@ -501,11 +465,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(); @@ -521,16 +490,53 @@ 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(); }); + 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'); @@ -545,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/suspension.test.ts b/platform/packages/api/test/unit/suspension.test.ts index e2f3dadd3..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,43 +12,28 @@ 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'); }); }); describe('evaluateRequestLimit (real implementation)', () => { function makeState(overrides: Partial = {}): CounterState { return { + ownerUserId: 'u1', totalRequests: 0, totalNeurons: 0, totalSandboxSeconds: 0, @@ -57,6 +43,10 @@ describe('evaluateRequestLimit (real implementation)', () => { aiSuspended: false, sandboxRuntimeSuspended: false, projectSlugs: {}, + projectOwnershipStarts: {}, + projectNeurons: {}, + historicalNeurons: 0, + historicalAiWindows: {}, ...overrides, }; } @@ -83,6 +73,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 +83,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-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/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index 362c402b4..c7587e3ad 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -26,14 +26,25 @@ 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 })), + ), + 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, + getUser, + 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 +62,7 @@ vi.mock('../../src/routes/usage.ts', () => ({ function makeState(overrides: Partial = {}): CounterState { return { + ownerUserId: 'u1', totalRequests: 0, totalNeurons: 0, totalSandboxSeconds: 0, @@ -60,6 +72,10 @@ function makeState(overrides: Partial = {}): CounterState { aiSuspended: false, sandboxRuntimeSuspended: false, projectSlugs: {}, + projectOwnershipStarts: {}, + projectNeurons: {}, + historicalNeurons: 0, + historicalAiWindows: {}, ...overrides, }; } @@ -987,6 +1003,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`. @@ -1004,6 +1050,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]) + .mockResolvedValueOnce([{ id: 'p1', user_id: 'new-owner', 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 +1106,641 @@ 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( + async (_db, id) => + ({ + id, + plan: 'pro', + }) as never, + ); + }); + + 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('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', + 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', + 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. + plan: 'free', + }), + }), + ); + 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']); + 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 () => { + vi.mocked(getProject).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('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'; + const extendedOwnershipEndedAt = '2026-03-02T00:00:00.000Z'; + vi.mocked(queryProjectAiUsage) + .mockResolvedValueOnce({ neurons: 57 }) + .mockResolvedValueOnce({ neurons: 61 }) + .mockResolvedValueOnce({ neurons: 63 }); + 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 = (endedAt = ownershipEndedAt) => + new Request('https://do/detach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'transferred', + userId: 'former-owner', + ownershipStartedAt, + ownershipEndedAt: endedAt, + }), + }); + + 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(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, 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 () => { + 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('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({ + 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..a73e25df1 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,12 @@ 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), + getProjectUsageOwnershipSnapshot: async (...args: Array) => ({ + projects: await getProjectsByIdsMock(...args), + ownershipPeriods: await getProjectOwnershipPeriodsByProjectIdsMock(...args), + }), suspendUser: vi.fn(), unsuspendUserIfReason: vi.fn(), })); @@ -123,9 +130,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 +187,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 +263,104 @@ 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('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 = { @@ -268,8 +382,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 +475,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/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": [ { diff --git a/platform/packages/proxy/src/index.ts b/platform/packages/proxy/src/index.ts index 507b9114c..d79e18a55 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'; @@ -171,32 +171,32 @@ 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. 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..6d1b2fa26 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, @@ -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 505c95ed8..ffa9f0034 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,10 @@ 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-prerender-build/proj_1', + ); const denied = await worker.fetch( post( @@ -555,6 +558,60 @@ 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('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( { @@ -569,6 +626,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 +668,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 +700,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); 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(); });