diff --git a/docs/guide/project-collaboration.md b/docs/guide/project-collaboration.md index 5f498a9eb..33ec4d8e0 100644 --- a/docs/guide/project-collaboration.md +++ b/docs/guide/project-collaboration.md @@ -37,8 +37,9 @@ 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`. +email, the CLI prints the invitation ID and connection and acceptance commands +to share with the invited user. The invitation also appears when that user runs +`void project team pending` on the same platform. List the roster and sent invitations with: @@ -49,9 +50,11 @@ void project team invitations ## Respond to an Invitation -The invited user can see invitations addressed to their account and respond using the invitation ID: +Connect to the platform from your invitation, then see invitations addressed to +your account and respond using the invitation ID: ```sh +void connect https://api.example.com void project team pending void project team accept # or @@ -60,9 +63,23 @@ 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. +These three commands use the active platform connection selected by +`void connect`, even inside a directory linked to another project or deployed +directly to Cloudflare. `VOID_API_URL` takes precedence when set. If you use +`VOID_TOKEN`, set `VOID_API_URL` to the matching platform; a token without that +URL selects Void Cloud. Each command shows the platform it contacts. + +Accepting an invitation grants access without linking your current directory. +Open the invited application's directory and run `void project link` if it is +not linked yet. If it is already linked to another project, use a separate +checkout without `.void/project.json`, connect to the invited platform there, +and run `void project link`. + +If the invitation is missing, check the platform and signed-in account. To +switch accounts, set `VOID_API_URL` to the invitation's platform URL, unset +`VOID_TOKEN` if present, then run `void auth logout` and `void auth login` using +the invited email address. For an expired or revoked invitation, ask a project +administrator to invite you again. ## Change or Remove Access diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 60a3a5d50..160ec114a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -253,8 +253,12 @@ Readers can view the project but cannot deploy. Collaborators can deploy and man 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. +commands use the active connection selected by `void connect `, regardless +of the current project's link or deploy target. `VOID_API_URL` takes precedence; +an unscoped `VOID_TOKEN` selects Void Cloud. These commands display their +platform, and accepting an invitation leaves directory links intact. To link +the invited application, run `void project link` in an unlinked checkout of +that application. ### `void project token ` diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 061ed2ca6..6b9b60cb3 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -75,7 +75,7 @@ mutation failure before retrying. Use `void connect` for deployment onboarding: no arguments offers Cloudflare or a Void platform, `--platform cloudflare` signs in and selects an account, and a platform URL verifies discovery and signs in with a supported provider. It preserves existing project links when connecting elsewhere. `--no-login` saves only verified Void connection metadata; authenticated headless connection requires a valid origin-scoped keychain session or `VOID_TOKEN` with matching `VOID_API_URL`. Platform installation and administration use `void platform`. -For Void platform project access, use `void project team`: invite only an email already registered on that platform, assign `reader`, `collaborator`, or `admin`, and use the 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`. +For Void platform project access, use `void project team`: invite only an email already registered on that platform and assign `reader`, `collaborator`, or `admin`. The invited account uses `void connect ` followed by pending/accept/decline on that active connection, regardless of the current directory's project link or deploy target. `VOID_API_URL` overrides the connection; an unscoped `VOID_TOKEN` selects Void Cloud. Acceptance preserves directory links; run `void project link` in an unlinked checkout of the invited application. Project-scoped team management does not apply to direct Cloudflare deployments. Installation administrators transfer ownership with `void platform project owner --plan` and apply the reviewed transfer with `--yes`. Use Void commands for every user-facing workflow. Never ask the user to install, authenticate, or run Wrangler directly. Say Cloudflare or Void instead, except when naming literal `wrangler.jsonc` / `wrangler.json` files or `WRANGLER_*` environment variables the user must inspect. diff --git a/packages/void/src/cli/auth.ts b/packages/void/src/cli/auth.ts index 54cae48c0..8b193b616 100644 --- a/packages/void/src/cli/auth.ts +++ b/packages/void/src/cli/auth.ts @@ -135,6 +135,42 @@ export function getToken(root: string = process.cwd()): string | null { } return null; } + const config = readConfig(); + return readTokenForOrigin(apiUrl, config, legacyCredentialAccount(root, apiUrl, config)); +} + +/** Account actions follow the selected connection, independently of application metadata. */ +export function getAccountCredential(): { apiUrl: string; token: string | null } { + const config = readConfig(); + const apiUrl = normalizePlatformApiUrl( + process.env.VOID_API_URL || + (process.env.VOID_TOKEN + ? DEFAULT_API_URL + : (getActivePlatformConnection()?.apiUrl ?? + config.api ?? + readLegacyHostedPlatformSelection()?.apiUrl ?? + (keyringGet('token') + ? DEFAULT_API_URL + : keyringGet('stagingToken') + ? STAGING_API_URL + : DEFAULT_API_URL))), + ); + return { + apiUrl, + token: readTokenForOrigin( + apiUrl, + config, + legacyCredentialAccountForMode(apiUrl === STAGING_API_URL, apiUrl, config), + ), + }; +} + +function readTokenForOrigin( + apiUrl: string, + config: Config, + legacyAccount: 'token' | 'stagingToken' | null, +): string | null { + const envToken = process.env.VOID_TOKEN; if (envToken) { const tokenOrigin = process.env.VOID_API_URL ? normalizePlatformApiUrl(process.env.VOID_API_URL) @@ -146,7 +182,6 @@ export function getToken(root: string = process.cwd()): string | null { } return envToken; } - const config = readConfig(); const normalizedApiUrl = normalizePlatformApiUrl(apiUrl); const originToken = readOriginScopedToken(normalizedApiUrl, config); if (originToken) { @@ -155,7 +190,6 @@ export function getToken(root: string = process.cwd()): string | null { const account = platformCredentialAccount(normalizedApiUrl); - const legacyAccount = legacyCredentialAccount(root, normalizedApiUrl, config); if (legacyAccount) { const legacyKeyringToken = keyringGet(legacyAccount); if (legacyKeyringToken) { @@ -267,11 +301,19 @@ function legacyCredentialAccount( normalizedApiUrl: string, config: Config, ): 'token' | 'stagingToken' | null { - const account = isStagingMode(root) ? 'stagingToken' : 'token'; + return legacyCredentialAccountForMode(isStagingMode(root), normalizedApiUrl, config); +} + +function legacyCredentialAccountForMode( + staging: boolean, + normalizedApiUrl: string, + config: Config, +): 'token' | 'stagingToken' | null { + const account = staging ? 'stagingToken' : 'token'; let configuredUrl: string; try { configuredUrl = normalizePlatformApiUrl( - config.api ?? (isStagingMode(root) ? STAGING_API_URL : DEFAULT_API_URL), + config.api ?? (staging ? STAGING_API_URL : DEFAULT_API_URL), ); } catch { return null; diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 71f36d2e9..2199217f9 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -121,6 +121,7 @@ import { PlatformClient, type DeploymentResponse, type ManifestOnlyDeployPayload, + type ProjectResponse, } from './client.ts'; import { CloudflareAuthenticationError, @@ -6489,6 +6490,9 @@ async function runManagedDeploy( if (requestedProjectSlug) { const projects = await client.listProjects(); + assertProjectDeployAccess( + projects.find((project) => project.slug === requestedProjectSlug), + ); config = resolveProjectBySlug(projects, requestedProjectSlug); if (!config) { @@ -6509,6 +6513,18 @@ async function runManagedDeploy( } } else { config = readProjectConfig(root); + if (config) { + const projects = await client.listProjects(); + const { projectId, slug } = config; + const project = projects.find((project) => project.id === projectId); + if (!project) { + throw new Error( + `deploy: The linked project "${slug}" is unavailable or you no longer have access. ` + + 'Ask a project admin or owner to restore access, or choose another project with `--project `.', + ); + } + assertProjectDeployAccess(project); + } } if (!config && !process.stdin.isTTY) { @@ -6666,14 +6682,30 @@ async function runManagedDeploy( } } +function assertProjectDeployAccess(project: ProjectResponse | undefined): void { + // Older platforms omit roles; the API remains authoritative for every deploy. + if (project?.role === 'reader') { + throw new Error( + `deploy: You have reader access to "${project.slug}" and cannot deploy. ` + + 'Ask a project admin or owner to grant collaborator access, or choose another project with `--project `.', + ); + } +} + export async function promptDeployProjectConfig( root: string, client: PlatformClient, cliLog?: CliLogger, ): Promise { const projects = await client.listProjects(); + const deployableProjects = projects.filter((project) => project.role !== 'reader'); + if (deployableProjects.length !== projects.length) { + log.warn( + 'Projects with reader access are hidden from deploy selection. Ask a project admin or owner to grant collaborator access to deploy to them.', + ); + } const action = await promptProjectSetupAction('Set up a Void project for deploy:', { - includeLink: projects.length > 0, + includeLink: deployableProjects.length > 0, }); if (action === 'create') { @@ -6681,10 +6713,12 @@ export async function promptDeployProjectConfig( } // Project names are only shown after the user opts into linking. - const result = await promptProjectSelection(projects, 'Select a project to deploy to:', root, { - confirmExisting: true, - includeCreate: false, - }); + const result = await promptProjectSelection( + deployableProjects, + 'Select a project to deploy to:', + root, + { confirmExisting: true, includeCreate: false }, + ); if (result.action === 'create') { throw new Error('Unexpected project selection result.'); diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index a177b584e..dc06e5c5a 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -541,15 +541,15 @@ const HELP_TREE: HelpTree = { ), leave: withProjectOption('Leave a shared project.', 'void project team leave [options]'), pending: basicSubcommand( - 'List project invitations awaiting your response.', + 'List invitations on the active platform connection (VOID_API_URL overrides it).', 'void project team pending', ), accept: basicSubcommand( - 'Accept an invitation addressed to your account.', + 'Accept your invitation on the active platform connection, preserving project links.', 'void project team accept ', ), decline: basicSubcommand( - 'Decline an invitation addressed to your account.', + 'Decline your invitation on the active platform connection (VOID_API_URL overrides it).', 'void project team decline ', ), }, diff --git a/packages/void/src/cli/project-team.ts b/packages/void/src/cli/project-team.ts index c7a6f2008..4e3d7fa2b 100644 --- a/packages/void/src/cli/project-team.ts +++ b/packages/void/src/cli/project-team.ts @@ -1,15 +1,14 @@ import { intro, log, note, outro } from '@clack/prompts'; -import { getToken, readPlatformToken } from './auth.ts'; +import { getAccountCredential, getToken } from './auth.ts'; import { + PlatformApiError, 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 { readProjectConfig } from './project.ts'; import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; type ProjectTeamArgs = Exclude, { action: '' }>; @@ -74,20 +73,25 @@ async function resolveProjectId( return linked.projectId; } -function resolveCredential( - root: string, - accountScoped: boolean, -): { apiUrl?: string; token: string | null } { - if (accountScoped && readDeploymentPlatform(root) === 'cloudflare' && !process.env.VOID_API_URL) { - if (process.env.VOID_TOKEN) { - return { apiUrl: VOID_CLOUD_PLATFORM.apiUrl, token: process.env.VOID_TOKEN }; - } - const connection = getActivePlatformConnection(); - if (connection) { - return { apiUrl: connection.apiUrl, token: readPlatformToken(connection.apiUrl) }; - } +function invitationResponseError(error: unknown, apiUrl: string): unknown { + if (!(error instanceof PlatformApiError)) { + return error; + } + let message: string; + if (error.code === 'project invitation not found') { + message = `Invitation not found on ${apiUrl}. Check VOID_API_URL if set, connect to the platform in your invitation with \`void connect \`, then run \`void project team pending\` to check the invitation ID. If it is missing, ask a project administrator for a new invitation.`; + } else if (error.code === 'forbidden') { + message = `Your account cannot respond to this invitation on ${apiUrl}. Sign in with the account whose registered email received the invitation. To switch accounts, set VOID_API_URL to ${apiUrl}, unset VOID_TOKEN if present, then run \`void auth logout\` and \`void auth login\`. Run \`void project team pending\` to see that account's invitations.`; + } else if (error.code === 'project invitation has expired') { + message = + 'This invitation has expired. Ask a project administrator to invite you again, then run `void project team pending`.'; + } else if (error.code === 'project invitation is no longer pending') { + message = + 'This invitation has already been answered or revoked. Run `void project team pending` to see invitations awaiting your response, or ask a project administrator to check your access.'; + } else { + return error; } - return { token: getToken(root) }; + return new Error(message, { cause: error }); } export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs): Promise { @@ -96,7 +100,12 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs) const accountScoped = args.action === 'pending' || args.action === 'accept' || args.action === 'decline'; - const { apiUrl, token } = resolveCredential(root, accountScoped); + const { apiUrl, token } = accountScoped + ? getAccountCredential() + : { apiUrl: undefined, token: getToken(root) }; + if (apiUrl) { + log.info(`Platform: ${apiUrl}`); + } if (!token) { log.error( apiUrl @@ -118,7 +127,16 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs) return; } if (args.action === 'accept' || args.action === 'decline') { - await client.respondToProjectInvitation(args.invitationId, args.action); + try { + await client.respondToProjectInvitation(args.invitationId, args.action); + } catch (error) { + throw invitationResponseError(error, client.apiUrl); + } + if (args.action === 'accept') { + log.info( + 'Open the invited application’s directory and run `void project link` if it is not linked yet. If it is linked to another project, use a separate checkout. Accepting an invitation keeps existing project links.', + ); + } outro(`Project invitation ${args.action === 'accept' ? 'accepted' : 'declined'}.`); return; } @@ -151,6 +169,12 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs) ? '' : ` (email delivery: ${result.delivery.status.replace('_', ' ')})`; const action = result.refreshed ? 'Updated invitation for' : 'Invited'; + if (result.delivery.status !== 'sent') { + note( + `Invitation: ${clean(result.invitation.id)}\nShare these steps with the invited user:\n\nvoid connect '${client.apiUrl.replaceAll("'", "'\\''")}'\nvoid project team pending\nvoid project team accept ${clean(result.invitation.id)}`, + 'Invitation saved; email was not sent', + ); + } outro(`${action} ${clean(invitationEmail(result.invitation))} as ${args.role}${delivery}.`); return; } diff --git a/packages/void/src/cli/prompt.ts b/packages/void/src/cli/prompt.ts index 5c2f41e4d..f6ddf1cbc 100644 --- a/packages/void/src/cli/prompt.ts +++ b/packages/void/src/cli/prompt.ts @@ -50,6 +50,7 @@ export async function promptProjectSelection( ...projects.map((p) => ({ value: p.id, label: `${p.slug} (${p.url ?? `${p.slug}.${getSiteDomain(root)}`})`, + ...(p.role ? { hint: p.role === 'reader' ? 'reader (read-only)' : p.role } : {}), })), ]; @@ -71,8 +72,10 @@ export async function promptProjectSelection( if (options.confirmExisting) { const confirmed = await confirm({ message: - `Linking to an existing project. Running \`void deploy\` will overwrite the current deployment of ` + - `${project.url ?? `${project.slug}.${getSiteDomain(root)}`}. Continue?`, + project.role === 'reader' + ? `Link to ${project.slug} with read-only access? Deploying requires collaborator access or higher.` + : `Linking to an existing project. Running \`void deploy\` will overwrite the current deployment of ` + + `${project.url ?? `${project.slug}.${getSiteDomain(root)}`}. Continue?`, }); if (isCancel(confirmed) || !confirmed) { cancel('Project link cancelled.'); diff --git a/packages/void/test/integration/full-deploy-manifest.test.ts b/packages/void/test/integration/full-deploy-manifest.test.ts index 9608b3bc6..db83c0808 100644 --- a/packages/void/test/integration/full-deploy-manifest.test.ts +++ b/packages/void/test/integration/full-deploy-manifest.test.ts @@ -15,6 +15,7 @@ import { createIsolatedPlayground, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; +import type { ProjectRole } from '../../src/cli/client.ts'; import type { DetectedFramework } from '../../src/plugin-inference.ts'; // @clack/prompts + the output-spinner wrapper are TTY-bound; noop them (same as @@ -102,7 +103,7 @@ type Captured = { finalizeBody: { manifest?: { pages?: unknown } } | null }; // Minimal platform mock: report every asset as already-present so the // upload-urls + R2 PUT phase is skipped and runFullDeploy goes straight to the // finalize POST, whose JSON body we capture. -function installPlatformMocks(): Captured { +function installPlatformMocks(role: ProjectRole = 'owner'): Captured { const captured: Captured = { finalizeBody: null }; fetchMock.mockImplementation(async (input: RequestInfo, init?: RequestInit) => { @@ -110,6 +111,21 @@ function installPlatformMocks(): Captured { const reqInit = init ?? {}; const method = (reqInit.method ?? 'GET').toUpperCase(); + if (url.endsWith('/projects') && method === 'GET') { + return json([ + { + id: 'proj_named_layouts_test', + user_id: 'user_owner', + slug: 'named-layouts-test', + type: 'worker', + created_at: '2026-09-01T00:00:00.000Z', + custom_domain: null, + role, + owner: { id: 'user_owner', login: 'owner' }, + }, + ]); + } + if (url.endsWith('/deploy/preflight') && method === 'POST') { const body = JSON.parse(reqInit.body as string) as { assetManifest?: Record; @@ -215,7 +231,7 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () = beforeEach(() => { // Default: no framework detected → the Void pages path. The framework test // overrides this to a Class-A framework before driving runDeploy. - mockDetectFramework.mockReturnValue(undefined); + mockDetectFramework.mockReset().mockReturnValue(undefined); originalFetch = globalThis.fetch; globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; @@ -226,7 +242,7 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () = savedProject = process.env.VOID_PROJECT; // Explicit token bypasses getToken() + the GitHub-Actions OIDC branch. process.env.VOID_TOKEN = 'test-token-do-not-use'; - // Force resolution via .void/project.json (avoid the listProjects() path). + // Select the saved project; runDeploy verifies its current role through the API. delete process.env.VOID_PROJECT; }); @@ -315,4 +331,20 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () = // this RED (pages becomes the populated array instead of undefined). expect(captured.finalizeBody!.manifest?.pages).toBeUndefined(); }); + + it('rejects a saved reader project before building or uploading', async () => { + const captured = installPlatformMocks('reader'); + + // Do not skip the build: access must be checked before framework detection + // can select a build path, regardless of the fixture's existing artifacts. + await expect(runDeploy(fixture)).rejects.toThrow( + 'You have reader access to "named-layouts-test" and cannot deploy.', + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0]?.[0]).toMatch(/\/projects$/); + expect(mockDetectFramework).not.toHaveBeenCalled(); + expect(mockCollectPrerenderPaths).not.toHaveBeenCalled(); + expect(captured.finalizeBody).toBeNull(); + }); }); diff --git a/packages/void/test/unit/auth-legacy.test.ts b/packages/void/test/unit/auth-legacy.test.ts index 7089fa009..087db1bca 100644 --- a/packages/void/test/unit/auth-legacy.test.ts +++ b/packages/void/test/unit/auth-legacy.test.ts @@ -56,6 +56,67 @@ afterEach(() => { }); describe('legacy Void Cloud authentication', () => { + it.each([ + { account: 'token', apiUrl: 'https://api.void.cloud' }, + { account: 'stagingToken', apiUrl: 'https://api.staging.void.cloud' }, + ])( + 'keeps legacy $account keychain login available for account invitation commands', + async ({ account, apiUrl }) => { + state.passwords.set(`dev.void.cli:${account}`, 'legacy-account-token'); + const { getAccountCredential } = await import('../../src/cli/auth.ts'); + const { platformCredentialAccount } = await import('../../src/cli/platform-registry.ts'); + + expect(getAccountCredential()).toEqual({ apiUrl, token: 'legacy-account-token' }); + expect(state.passwords.get(`dev.void.cli:${platformCredentialAccount(apiUrl)}`)).toBe( + 'legacy-account-token', + ); + }, + ); + + it.each([ + { account: 'token', apiUrl: 'https://api.void.cloud' }, + { account: 'stagingToken', apiUrl: 'https://api.staging.void.cloud' }, + { account: 'token', apiUrl: 'https://legacy-platform.example.test' }, + ])( + 'migrates legacy file login for account invitation commands at $apiUrl', + async ({ account, apiUrl }) => { + const configDirectory = join(state.home, '.void'); + mkdirSync(configDirectory, { recursive: true }); + const configPath = join(configDirectory, 'config.json'); + writeFileSync(configPath, JSON.stringify({ api: apiUrl, [account]: 'legacy-account-token' })); + const { getAccountCredential } = await import('../../src/cli/auth.ts'); + + expect(getAccountCredential()).toEqual({ apiUrl, token: 'legacy-account-token' }); + expect(JSON.parse(readFileSync(configPath, 'utf8'))).toEqual({ api: apiUrl }); + }, + ); + + it('does not migrate a legacy credential for another active platform', async () => { + const configDirectory = join(state.home, '.void'); + mkdirSync(configDirectory, { recursive: true }); + writeFileSync( + join(configDirectory, 'config.json'), + JSON.stringify({ token: 'cloud-only-token' }), + ); + const { savePlatformConnection } = await import('../../src/cli/platform-registry.ts'); + savePlatformConnection({ + accountId: null, + apiUrl: 'https://platform.example.test', + applicationDomain: null, + displayName: 'Platform', + installationId: null, + kind: 'connected', + proxyUrl: null, + }); + const { getAccountCredential } = await import('../../src/cli/auth.ts'); + + expect(getAccountCredential()).toEqual({ + apiUrl: 'https://platform.example.test', + token: null, + }); + expect(state.passwords.size).toBe(0); + }); + it('preserves token-only Void Cloud CI on clean runners', async () => { const root = mkdtempSync(join(tmpdir(), 'void-auth-legacy-ci-')); directories.push(root); diff --git a/packages/void/test/unit/deploy-auth.test.ts b/packages/void/test/unit/deploy-auth.test.ts index 3a6149117..74d916250 100644 --- a/packages/void/test/unit/deploy-auth.test.ts +++ b/packages/void/test/unit/deploy-auth.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +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'; @@ -155,6 +155,110 @@ describe('runDeploy auth failures', () => { }); }); +describe('runDeploy project access', () => { + let root: string; + let savedProject: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'void-deploy-project-access-')); + savedProject = process.env.VOID_PROJECT; + delete process.env.VOID_PROJECT; + // Stop allowed projects at app configuration, before building or uploading. + // Reader errors must precede this deliberately invalid configuration. + writeFileSync(join(root, 'void.json'), '{'); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + if (savedProject === undefined) { + delete process.env.VOID_PROJECT; + } else { + process.env.VOID_PROJECT = savedProject; + } + }); + + function linkProject(slug = 'my-app'): void { + mkdirSync(join(root, '.void')); + writeFileSync( + join(root, '.void', 'project.json'), + JSON.stringify({ projectId: 'proj_1', slug, platform: 'void' }), + ); + } + + it.each(['flag', 'environment', 'saved'])( + 'rejects a reader selected by %s before loading app configuration or building', + async (source) => { + mockListProjects.mockResolvedValue([{ id: 'proj_1', slug: 'my-app', role: 'reader' }]); + if (source === 'environment') { + process.env.VOID_PROJECT = 'my-app'; + } else if (source === 'saved') { + // Linked identity is the project ID, even if the saved slug is outdated. + linkProject('old-slug'); + } + + await expect( + runDeploy(root, { + platform: 'void', + ...(source === 'flag' ? { projectSlug: 'my-app' } : {}), + }), + ).rejects.toThrow( + 'You have reader access to "my-app" and cannot deploy. Ask a project admin or owner to grant collaborator access', + ); + + expect(mockListProjects).toHaveBeenCalledOnce(); + }, + ); + + it.each(['collaborator', 'admin', 'owner', undefined])( + 'continues to app configuration for an explicit project with role %s', + async (role) => { + mockListProjects.mockResolvedValue([{ id: 'proj_1', slug: 'my-app', role }]); + + await expect(runDeploy(root, { platform: 'void', projectSlug: 'my-app' })).rejects.toThrow( + 'config: Invalid JSON in void.json.', + ); + + expect(mockListProjects).toHaveBeenCalledOnce(); + }, + ); + + it.each(['collaborator', 'admin', 'owner', undefined])( + 'continues to app configuration for a saved project with role %s', + async (role) => { + linkProject(); + mockListProjects.mockResolvedValue([{ id: 'proj_1', slug: 'my-app', role }]); + + await expect(runDeploy(root, { platform: 'void' })).rejects.toThrow( + 'config: Invalid JSON in void.json.', + ); + + expect(mockListProjects).toHaveBeenCalledOnce(); + }, + ); + + it('rejects a stale saved project before building, even when another project shares its slug', async () => { + linkProject(); + mockListProjects.mockResolvedValue([{ id: 'proj_2', slug: 'my-app', role: 'owner' }]); + + await expect(runDeploy(root, { platform: 'void' })).rejects.toThrow( + 'The linked project "my-app" is unavailable or you no longer have access.', + ); + + expect(mockListProjects).toHaveBeenCalledOnce(); + }); + + it('does not fetch Void project roles for a direct Cloudflare deployment', async () => { + linkProject(); + + await expect( + runDeploy(root, { platform: 'cloudflare', projectSlug: 'my-app' }), + ).rejects.toThrow('--project is not supported with platform cloudflare'); + + expect(mockListProjects).not.toHaveBeenCalled(); + expect(mockClientCtor).not.toHaveBeenCalled(); + }); +}); + describe('runDeploy GitHub Actions OIDC exchange', () => { it('exchanges an OIDC token for a deploy token when no VOID_TOKEN and in Actions', async () => { mockGetToken.mockReturnValue(null); diff --git a/packages/void/test/unit/deploy-project-setup.test.ts b/packages/void/test/unit/deploy-project-setup.test.ts index da50fbae7..8c82df187 100644 --- a/packages/void/test/unit/deploy-project-setup.test.ts +++ b/packages/void/test/unit/deploy-project-setup.test.ts @@ -259,4 +259,94 @@ describe('promptDeployProjectConfig', () => { expect(config).toEqual({ projectId: 'proj_1', slug: 'existing-app' }); expect(client.createProject).not.toHaveBeenCalled(); }); + + it('hides reader projects and identifies roles when selecting a deployment destination', async () => { + const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const client = createClient(); + client.listProjects.mockResolvedValueOnce([ + { id: 'proj_reader', slug: 'read-only-app', role: 'reader' }, + { id: 'proj_collaborator', slug: 'shared-app', role: 'collaborator' }, + { id: 'proj_admin', slug: 'admin-app', role: 'admin' }, + { id: 'proj_owner', slug: 'owned-app', role: 'owner' }, + { id: 'proj_legacy', slug: 'legacy-app' }, + ]); + mockSelect.mockResolvedValueOnce('link').mockResolvedValueOnce('proj_collaborator'); + mockConfirm.mockResolvedValueOnce(true); + + const config = await promptDeployProjectConfig(root, client); + + expect(mockSelect.mock.calls[1]?.[0]).toEqual({ + message: 'Select a project to deploy to:', + options: [ + { + value: 'proj_collaborator', + label: 'shared-app (shared-app.void.app)', + hint: 'collaborator', + }, + { value: 'proj_admin', label: 'admin-app (admin-app.void.app)', hint: 'admin' }, + { value: 'proj_owner', label: 'owned-app (owned-app.void.app)', hint: 'owner' }, + { value: 'proj_legacy', label: 'legacy-app (legacy-app.void.app)' }, + ], + }); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('Projects with reader access are hidden from deploy selection.'), + ); + expect(config).toEqual({ projectId: 'proj_collaborator', slug: 'shared-app' }); + }); + + it('offers project creation without an empty link picker when every project is read-only', async () => { + const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const client = createClient(); + client.listProjects.mockResolvedValueOnce([ + { id: 'proj_reader', slug: 'read-only-app', role: 'reader' }, + ]); + client.createProject.mockResolvedValueOnce({ id: 'proj_new', slug: 'new-app' }); + mockSelect.mockResolvedValueOnce('create'); + mockText.mockResolvedValueOnce('new-app'); + + const config = await promptDeployProjectConfig(root, client); + + expect(mockSelect).toHaveBeenCalledOnce(); + expect(mockSelect).toHaveBeenCalledWith({ + message: 'Set up a Void project for deploy:', + options: [{ value: 'create', label: 'Create a new project' }], + }); + expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('grant collaborator access')); + expect(config).toEqual({ projectId: 'proj_new', slug: 'new-app' }); + }); + + it('keeps reader projects linkable and describes read-only access in the shared picker', async () => { + const { promptProjectSelection } = await import('../../src/cli/prompt.ts'); + const project = { + id: 'proj_reader', + user_id: 'user_owner', + slug: 'read-only-app', + type: 'worker', + created_at: '2026-04-24T00:00:00.000Z', + custom_domain: null, + role: 'reader' as const, + }; + mockSelect.mockResolvedValueOnce(project.id); + mockConfirm.mockResolvedValueOnce(true); + + await expect( + promptProjectSelection([project], 'Select a project:', root, { confirmExisting: true }), + ).resolves.toEqual({ action: 'select', project }); + + expect(mockSelect).toHaveBeenCalledWith({ + message: 'Select a project:', + options: [ + { value: '__create__', label: 'Create new project' }, + { + value: 'proj_reader', + label: 'read-only-app (read-only-app.void.app)', + hint: 'reader (read-only)', + }, + ], + }); + expect(mockConfirm).toHaveBeenCalledWith({ + message: + 'Link to read-only-app with read-only access? Deploying requires collaborator access or higher.', + }); + }); }); diff --git a/packages/void/test/unit/project-team-command.test.ts b/packages/void/test/unit/project-team-command.test.ts index 218e3c645..08f33e848 100644 --- a/packages/void/test/unit/project-team-command.test.ts +++ b/packages/void/test/unit/project-team-command.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, 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'; @@ -104,83 +104,152 @@ describe('project team target selection', () => { ]); }); - it('uses the active connection for account invitations when a Cloudflare project retains another platform link', async () => { - const platformA = 'https://platform-a.example.test'; - const platformB = 'https://platform-b.example.test'; - const root = rootWithPlatform('cloudflare', { platformUrl: platformA }); - vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); - connect(platformA, 'Platform A'); - connect(platformB, 'Platform B'); - keyring.read.mockImplementation((service: string, account: string) => { - if (service !== 'dev.void.cli') { - return null; + it.each(['cloudflare', 'void'] as const)( + 'uses the active connection for account invitations from a read-only %s project linked elsewhere', + async (platform) => { + const platformA = 'https://platform-a.example.test'; + const platformB = 'https://platform-b.example.test'; + const root = rootWithPlatform(platform, { platformUrl: platformA }); + const projectPath = join(root, '.void', 'project.json'); + const originalProject = readFileSync(projectPath, 'utf8'); + 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); + + chmodSync(projectPath, 0o444); + chmodSync(join(root, '.void'), 0o555); + try { + 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(readFileSync(projectPath, 'utf8')).toBe(originalProject); + } finally { + chmodSync(join(root, '.void'), 0o755); + chmodSync(projectPath, 0o644); } - if (account === platformCredentialAccount(platformA)) { - return 'platform-a-token'; + + 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' }), + }); } - 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({}); - }); + }, + ); + + it.each(['cloudflare', 'void'] as const)( + 'keeps an unscoped VOID_TOKEN bound to Void Cloud for account invitations from a %s project', + async (platform) => { + const platformA = 'https://platform-a.example.test'; + const platformB = 'https://platform-b.example.test'; + const root = rootWithPlatform(platform, { 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 an explicit environment origin and its matching token over saved project and active connections', async () => { + const root = rootWithPlatform('void', { platformUrl: 'https://platform-a.example.test' }); + vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); + connect('https://platform-b.example.test', 'Platform B'); + vi.stubEnv('VOID_API_URL', 'https://platform-c.example.test/'); + vi.stubEnv('VOID_TOKEN', 'platform-c-token'); + 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); + const output: Array = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + output.push(String(chunk)); + return 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' }), - }); - } + expect(fetchMock).toHaveBeenCalledWith( + 'https://platform-c.example.test/me/project-invitations', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer platform-c-token' }), + }), + ); + expect(output.join('')).toContain('Platform: https://platform-c.example.test'); }); - it('keeps an unscoped VOID_TOKEN bound to Void Cloud for account invitations', async () => { + it('never falls back to the linked project token when the active platform has no login', async () => { const platformA = 'https://platform-a.example.test'; const platformB = 'https://platform-b.example.test'; - const root = rootWithPlatform('cloudflare', { platformUrl: platformA }); + const root = rootWithPlatform('void', { 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' + service === 'dev.void.cli' && account === platformCredentialAccount(platformA) + ? 'platform-a-token' : null, ); - const fetchMock = vi.fn(async () => Response.json({ invitations: [] })); + const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('missing login'); + }); - 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' }), - }), - ); + await expect( + runProjectCommand(root, { subcommand: 'team', action: 'pending' }), + ).rejects.toThrow('missing login'); + expect(fetchMock).not.toHaveBeenCalled(); }); it('uses the linked Void platform project for member listing', async () => { @@ -298,4 +367,88 @@ describe('project team target selection', () => { expect(output.join('')).not.toContain('\u0007'); expect(output.join('')).toContain('target @example.com'); }); + + it.each(['failed', 'not_configured', 'rate_limited'])( + 'shows recovery instructions when invitation email is %s', + async (status) => { + 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_a1b2c3d4e5f6', target_email: 'target@example.com' }, + refreshed: false, + delivery: { status }, + }), + ), + ); + 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('')).toContain('Invitation saved; email was not sent'); + expect(output.join('')).toContain('Invitation: pinv_a1b2c3d4e5f6'); + expect(output.join('')).toContain("void connect 'https://api.example.test'"); + expect(output.join('')).toContain('void project team pending'); + expect(output.join('')).toContain('void project team accept pinv_a1b2c3d4e5f6'); + }, + ); + + it.each([ + { + error: 'project invitation not found', + status: 404, + message: 'Invitation not found on https://api.example.test', + recovery: 'void connect ', + }, + { + error: 'forbidden', + status: 403, + message: 'Your account cannot respond', + recovery: 'void auth logout', + }, + { + error: 'project invitation has expired', + status: 409, + message: 'This invitation has expired', + recovery: 'invite you again', + }, + { + error: 'project invitation is no longer pending', + status: 409, + message: 'already been answered or revoked', + recovery: 'check your access', + }, + ])('explains $error with a recovery action', async ({ error, status, message, recovery }) => { + 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({ error }, { status })), + ); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const result = runProjectCommand(root, { + subcommand: 'team', + action: 'accept', + invitationId: 'pinv_1', + }); + await expect(result).rejects.toThrow(message); + await expect(result).rejects.toThrow(recovery); + }); }); diff --git a/platform/meta/design-docs/0080-project-teams.md b/platform/meta/design-docs/0080-project-teams.md index 6b4eee654..21054b8ff 100644 --- a/platform/meta/design-docs/0080-project-teams.md +++ b/platform/meta/design-docs/0080-project-teams.md @@ -322,7 +322,9 @@ 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. +and a bounded pending-invitation limit. Creation, refresh, and resend enforce +the same 100-live-invitation cap under the project lock. Renewing a live +invitation retains its slot; reviving an expired invitation requires capacity. ## API and user experience @@ -368,9 +370,17 @@ 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. +`pending`, `accept`, and `decline` commands use the active platform connection, +independently of the current directory's project or direct Cloudflare target. +An explicit API origin overrides that connection, with credentials restricted +to the selected origin. Acceptance preserves directory links. The landing +page directs users to link an unlinked application checkout; email delivery +failures still return a saved invitation and CLI recovery commands. + +Project lists and selectors show each role. Deployment selection excludes +readers, while ordinary project linking supports their read-only access. +Explicit and saved deploy targets are checked before building so lost access +or a reader role produces an actionable error. The API-owned invitation landing route shows the CLI connection and acceptance commands, so self-hosted installations do not require the optional dashboard. @@ -407,6 +417,21 @@ 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. +Detachment durably saves the closed ownership window, its last sampled AI +usage, and a reconciliation alarm in one transaction before acknowledging. +Project deletion can then cascade SQL history without depending on Analytics +availability. The alarm re-queries the exact half-open window and preserves +the saved amount when Analytics is unavailable or returns incomplete data. +Failed alarms schedule another wakeup beyond the provider's bounded native +retries. Strict accounting rejects partial GraphQL errors and invalid costs; +diagnostics retain bounded provider messages without authentication values. + +Closed windows are reconciled for unlimited plans as well. Because deleting +an owner's last project removes the scheduler's ownership rows, a counter +with closed windows checks the owner's current billing period itself. It +expires prior-period windows through the existing rollover path and stops +querying once the account has been deleted. + 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 diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index d2e29c2bd..5f31f74e0 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -768,6 +768,29 @@ export async function getPendingProjectInvitationsByUserId( return rows.map((row) => row.invitation); } +/** The project lock must be held while checking capacity and writing the invitation. */ +async function requireLockedProjectInvitationCapacity( + db: Db, + projectId: string, + existingInvitationId?: string, +): Promise { + const [pendingCount] = await db + .select({ count: count() }) + .from(projectInvitations) + .where( + and( + eq(projectInvitations.project_id, projectId), + eq(projectInvitations.status, 'pending'), + gt(projectInvitations.expires_at, sql`now()`), + // A live invitation already occupies its slot; an expired one needs a free slot. + existingInvitationId ? ne(projectInvitations.id, existingInvitationId) : undefined, + ), + ); + if ((pendingCount?.count ?? 0) >= 100) { + throw new ProjectTeamMutationError('pending-invitation-limit'); + } +} + export async function createOrRefreshProjectInvitation( db: Db, args: { @@ -832,21 +855,7 @@ export async function createOrRefreshProjectInvitation( .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'); - } - } + await requireLockedProjectInvitationCapacity(txDb, args.projectId, pending?.id); let invitation: ProjectInvitation; if (pending) { @@ -922,6 +931,7 @@ export async function resendProjectInvitation( if (!target?.email) { throw new ProjectTeamMutationError('target-user-not-found'); } + await requireLockedProjectInvitationCapacity(txDb, args.projectId, invitation.id); const normalizedEmail = target.email.trim().toLowerCase(); const [updated] = await txDb .update(projectInvitations) diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index ba6dea0d0..c57d43b66 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -423,11 +423,12 @@ const app = new Hono()
${command}
${ invitationCommand - ? `

Then accept the project invitation:

+ ? `

Then accept the project invitation. The CLI checks that it is still valid and addressed to your signed-in account:

${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.

+

The CLI opens your browser to sign in when needed. Invitation commands use your connected platform account and can run from any directory.

+

To work on an application on this platform, open its directory and run void project link if it is not linked yet. If that directory is already linked to another project, use a separate checkout without a .void/project.json file, connect to this platform there, and run void project link. Accepting an invitation keeps existing project links.

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

`); diff --git a/platform/packages/api/src/routes/usage.ts b/platform/packages/api/src/routes/usage.ts index c5f8f01af..80744bf7e 100644 --- a/platform/packages/api/src/routes/usage.ts +++ b/platform/packages/api/src/routes/usage.ts @@ -529,17 +529,48 @@ async function queryProjectRequestOverview( } type AiGatewayResponse = Readonly<{ - data: { - viewer: { - accounts: ReadonlyArray<{ - aiGatewayRequests: ReadonlyArray<{ sum: { cost: number } }>; - }>; - }; - }; + data?: { + viewer?: { + accounts?: ReadonlyArray<{ + aiGatewayRequests?: ReadonlyArray<{ sum?: { cost?: number | null } }> | null; + }> | null; + } | null; + } | null; + errors?: unknown; }>; const COST_PER_NEURON = 0.000011; +function aiUsageGraphqlErrors(errors: ReadonlyArray, apiToken: string): string { + return errors + .slice(0, 3) + .map((error) => { + if ( + !error || + typeof error !== 'object' || + !('message' in error) || + typeof error.message !== 'string' + ) { + return 'Unknown GraphQL error'; + } + // Provider messages are useful; response extensions, echoed documents, + // and authentication values do not belong in persisted billing diagnostics. + let message = error.message; + if (apiToken) { + message = message.replaceAll(apiToken, '[redacted]'); + } + return ( + message + .replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]') + .replace(/\s+/g, ' ') + .replace(/\{.*$/, '[query omitted]') + .trim() + .slice(0, 200) || 'Unknown GraphQL error' + ); + }) + .join('; '); +} + async function queryProjectAiUsage( accountId: string, apiToken: string, @@ -582,11 +613,48 @@ async function queryProjectAiUsage( 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'); + let result: AiGatewayResponse | null; + try { + result = (await response.json()) as AiGatewayResponse | null; + } catch (error) { + if (failOnError) { + throw new Error('AI usage query returned invalid JSON'); + } + throw error; + } + + if (failOnError && result?.errors != null) { + if (!Array.isArray(result.errors)) { + throw new Error('AI usage query returned invalid GraphQL errors'); + } + if (result.errors.length > 0) { + throw new Error(`AI usage query failed: ${aiUsageGraphqlErrors(result.errors, apiToken)}`); + } } + + const accounts = result?.data?.viewer?.accounts; + const requests = accounts?.[0]?.aiGatewayRequests; + if (failOnError) { + if (!Array.isArray(accounts) || accounts.length !== 1) { + throw new Error('AI usage query returned no account data'); + } + if (!Array.isArray(requests) || requests.length > 1) { + throw new Error('AI usage query returned invalid request data'); + } + if (requests.length === 0) { + return { neurons: 0 }; + } + const cost = requests[0]?.sum?.cost; + if (typeof cost !== 'number' || !Number.isFinite(cost) || cost < 0) { + throw new Error('AI usage query returned invalid cost data'); + } + const neurons = Math.round(cost / COST_PER_NEURON); + if (!Number.isFinite(neurons)) { + throw new Error('AI usage query returned invalid cost data'); + } + return { neurons }; + } + const cost = requests?.[0]?.sum?.cost ?? 0; return { neurons: Math.round(cost / COST_PER_NEURON) }; } diff --git a/platform/packages/api/src/usage-counter.ts b/platform/packages/api/src/usage-counter.ts index 89751ec71..205b2c5d3 100644 --- a/platform/packages/api/src/usage-counter.ts +++ b/platform/packages/api/src/usage-counter.ts @@ -17,7 +17,7 @@ import { verifyActiveProjectOwner, } from './routing.ts'; import { REASON_REQUESTS_EXCEEDED, REASON_SANDBOX_RUNTIME_EXCEEDED } from './suspension.ts'; -import { getPlanLimits, isWithinLimit } from './tiers.ts'; +import { getCurrentBillingPeriodStart, getPlanLimits, isWithinLimit } from './tiers.ts'; import { SANDBOX_USAGE_RETENTION_MS } from './usage-auth.ts'; // --- Exported types --- @@ -188,13 +188,16 @@ export class UsageCounter extends DurableObject { private async saveState( state: CounterState, - watermarks: ReadonlyMap = new Map(), + options: { + watermarks?: ReadonlyMap; + scheduleAiAlarm?: boolean; + } = {}, ): Promise { const cutoff = Date.now() - SANDBOX_USAGE_RETENTION_MS; const hasWatermarks = await this.ctx.storage.transaction(async (transaction) => { // Receipts live in separate bounded-size records. Never evict a fresh // generation to make room: its next cumulative frame would rebill it. - for (const [key, through] of watermarks) { + for (const [key, through] of options.watermarks ?? []) { await transaction.put(key, through); } @@ -222,6 +225,13 @@ export class UsageCounter extends DurableObject { // Counters, receipts, and pruning progress commit together. Publishing // the isolate cache afterwards also keeps a rejected commit retryable. await transaction.put('state', state); + if (options.scheduleAiAlarm) { + const nextAlarm = Date.now() + 15 * 60 * 1000; + const alarm = await transaction.getAlarm(); + if (alarm === null || alarm > nextAlarm) { + await transaction.setAlarm(nextAlarm); + } + } return page.size > expired.length || page.size === SANDBOX_PRUNE_BATCH_SIZE || !!cursor; }); this.state = state; @@ -400,7 +410,7 @@ export class UsageCounter extends DurableObject { await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000); } - await this.saveState(state, watermarks); + await this.saveState(state, { watermarks }); return Response.json({ totalRequests: state.totalRequests, @@ -642,23 +652,22 @@ export class UsageCounter extends DurableObject { } } if (body.ownershipEndedAt) { - const windowKey = this.retainHistoricalAiWindow( + 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); - } + // Teardown may remove the SQL ownership history once this returns. Retain + // both the cutoff and its next reconciliation in one durable transaction; + // provider analytics availability must not hold deletion or transfer open. + await this.saveState(state, { + scheduleAiAlarm: Object.keys(state.historicalAiWindows).length > 0, + }); return Response.json({ ok: true }); } @@ -718,12 +727,20 @@ export class UsageCounter extends DurableObject { // --- AI alarm handler --- async alarm(): Promise { - return this.serialize(() => this.handleAlarm()); + return this.serialize(async () => { + try { + await this.handleAlarm(); + } catch (error) { + // Native alarm retries are bounded. Preserve a future wakeup through + // longer provider outages without replacing persisted usage with zero. + await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000); + throw error; + } + }); } private async handleAlarm(): Promise { const state = await this.getState(); - const limits = getPlanLimits(state.plan); // Older persisted states do not identify their account. Wait for the next // trusted record/rollover/init instead of risking a cross-owner KV write. @@ -743,12 +760,38 @@ export class UsageCounter extends DurableObject { (projectId) => !ownedProjectIds.has(projectId), ); await this.retainTransferredProjectWindows(db, state, transferredProjectIds); + if (Object.keys(state.historicalAiWindows).length > 0) { + // Deleting the last project also deletes its ownership history, so the + // scheduler can no longer discover this counter for billing rollover. + const owner = await getUser(db, state.ownerUserId); + if (!owner) { + await this.ctx.storage.deleteAlarm(); + return; + } + state.plan = owner.plan; + if (owner.billing_cycle_start) { + const billingPeriodStart = getCurrentBillingPeriodStart(owner.billing_cycle_start); + if (billingPeriodStart.getTime() > Date.parse(state.billingPeriodStart)) { + await this.advanceBillingPeriod( + state, + { + billingPeriodStart: billingPeriodStart.toISOString(), + plan: owner.plan, + userId: owner.id, + }, + true, + ); + } + } + } + const limits = getPlanLimits(state.plan); 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. + // Closed windows still need reconciliation on unlimited plans: detachment + // preserves their samples without depending on provider availability. + // Otherwise inactive accounts only wake while receipt pruning has work. if ( - limits.aiNeuronsPerMonth === 0 || - (projectIds.length === 0 && Object.keys(state.historicalAiWindows).length === 0) + Object.keys(state.historicalAiWindows).length === 0 && + (limits.aiNeuronsPerMonth === 0 || projectIds.length === 0) ) { if (await this.saveState(state)) { await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000); @@ -763,7 +806,7 @@ export class UsageCounter extends DurableObject { // Query AI Gateway for each currently owned project's neuron usage. const billingSince = new Date(state.billingPeriodStart); - for (const projectId of projectIds) { + for (const projectId of limits.aiNeuronsPerMonth === 0 ? [] : projectIds) { const ownershipStartedAt = state.projectOwnershipStarts[projectId]; const ownershipSince = ownershipStartedAt ? new Date(ownershipStartedAt) : billingSince; const since = @@ -901,13 +944,9 @@ export class UsageCounter extends DurableObject { } } - private async refreshHistoricalAiWindows( - state: CounterState, - onlyKeys?: ReadonlyArray, - ): Promise { + private async refreshHistoricalAiWindows(state: CounterState): Promise { const billingSince = new Date(state.billingPeriodStart); - const keys = onlyKeys ?? Object.keys(state.historicalAiWindows); - for (const key of keys) { + for (const key of Object.keys(state.historicalAiWindows)) { const window = state.historicalAiWindows[key]; if (!window) { continue; diff --git a/platform/packages/api/test/integration/api-delete-accounting.test.ts b/platform/packages/api/test/integration/api-delete-accounting.test.ts new file mode 100644 index 000000000..64edc6979 --- /dev/null +++ b/platform/packages/api/test/integration/api-delete-accounting.test.ts @@ -0,0 +1,225 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vite-plus/test'; +import app from '../../src/index.ts'; +import { projects, projectOwnershipPeriods, users } from '../../src/schema.ts'; +import { getCurrentBillingPeriodStart } from '../../src/tiers.ts'; +import { UsageCounter, type CounterState } from '../../src/usage-counter.ts'; +import { + 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 () => { + // PostgreSQL ownership timestamps are stored without a zone; Workers interpret them in UTC. + vi.stubEnv('TZ', 'UTC'); + // Only remove the orphan sweep's provider-settling delay for this in-memory provider. + const schedule = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation((callback, delay, ...args) => + schedule(callback, delay === 2000 ? 0 : delay, ...args), + ); + await clearTables(env.db); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +function makeStorage() { + let data = new Map(); + let alarm: number | null = null; + let pending: { data: Map; alarm: number | null } | null = null; + type Transaction = Pick< + DurableObjectTransaction, + 'get' | 'put' | 'delete' | 'list' | 'getAlarm' | 'setAlarm' + >; + const storage = { + get: async (key: string): Promise => + structuredClone((pending?.data ?? data).get(key)) as T | undefined, + put: async (key: string, value: unknown) => { + (pending?.data ?? data).set(key, structuredClone(value)); + }, + delete: async (keys: string | Array) => { + for (const key of Array.isArray(keys) ? keys : [keys]) { + (pending?.data ?? data).delete(key); + } + }, + list: async ({ prefix, startAfter, limit }: DurableObjectListOptions) => + new Map( + Array.from(pending?.data ?? data) + .filter( + ([key]) => (!prefix || key.startsWith(prefix)) && (!startAfter || key > startAfter), + ) + .sort(([left], [right]) => left.localeCompare(right)) + .slice(0, limit), + ), + getAlarm: async () => (pending ? pending.alarm : alarm), + setAlarm: async (time: number) => { + if (pending) { + pending.alarm = time; + } else { + alarm = time; + } + }, + transaction: async (callback: (transaction: Transaction) => Promise): Promise => { + pending = { data: structuredClone(data), alarm }; + try { + const result = await callback(storage as unknown as Transaction); + data = pending.data; + alarm = pending.alarm; + return result; + } finally { + pending = null; + } + }, + }; + return storage; +} + +it.each(['fresh', 'initialized'] as const)( + 'deletes through the real %s counter during an analytics outage and reconciles after restart', + async (counterMode) => { + const owner = await createTestUser(env.db, `delete-accounting-${counterMode}`); + const cycleStart = new Date(Date.now() - 86400000).toISOString(); + const billingPeriodStart = getCurrentBillingPeriodStart(cycleStart).toISOString(); + const ownershipStartedAt = new Date(Date.now() - 3600000).toISOString(); + await env.db + .update(users) + .set({ billing_cycle_start: cycleStart }) + .where(eq(users.id, owner.id)); + const project = await createTestProject(env.db, owner.id, `delete-accounting-${counterMode}`); + await env.db + .update(projects) + .set({ created_at: ownershipStartedAt }) + .where(eq(projects.id, project.id)); + await env.db + .update(projectOwnershipPeriods) + .set({ starts_at: ownershipStartedAt }) + .where(eq(projectOwnershipPeriods.project_id, project.id)); + + const storage = makeStorage(); + const initialNeurons = counterMode === 'initialized' ? 17 : 0; + if (counterMode === 'initialized') { + await storage.put('state', { + ownerUserId: owner.id, + totalRequests: 0, + totalNeurons: initialNeurons, + totalSandboxSeconds: 0, + plan: 'free', + billingPeriodStart, + billingPeriodInitialized: true, + requestsSuspended: false, + aiSuspended: false, + sandboxRuntimeSuspended: false, + projectSlugs: { [project.id]: project.slug }, + projectOwnershipStarts: { [project.id]: ownershipStartedAt }, + projectNeurons: { [project.id]: initialNeurons }, + historicalNeurons: 0, + historicalAiWindows: {}, + } satisfies CounterState); + } + const makeCounter = () => { + const ctx = { storage }; + const counter = new UsageCounter(ctx as never, env as never); + // The integration fixture replaces only the Workers base class and storage. + Object.assign(counter, { ctx, env }); + return counter; + }; + const counter = makeCounter(); + const namespace = { + idFromName: (name: string) => name, + get: () => ({ fetch: (request: Request) => counter.fetch(request) }), + } as unknown as DurableObjectNamespace; + mockCfApi(); + const provider = globalThis.fetch; + let unavailable = true; + const queries: 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 === 'https://api.cloudflare.com/client/v4/graphql') { + queries.push(JSON.parse(String(init?.body)).query); + return Response.json({ + data: { + viewer: { + accounts: unavailable + ? [] + : [ + { + aiGatewayRequests: [{ sum: { cost: 0.00033 } }], + }, + ], + }, + }, + }); + } + return provider(input, init); + }), + ); + + const response = await app.fetch( + new Request(`http://localhost/projects/${project.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${owner.jwt}` }, + }), + { ...env, USAGE_COUNTER: namespace }, + ); + expect(response.status).toBe(200); + expect(queries).toEqual([]); + expect(await env.db.select().from(projects).where(eq(projects.id, project.id))).toEqual([]); + expect( + await env.db + .select() + .from(projectOwnershipPeriods) + .where(eq(projectOwnershipPeriods.project_id, project.id)), + ).toEqual([]); + + const saved = (await storage.get('state'))!; + const [window] = Object.values(saved.historicalAiWindows); + expect(saved).toMatchObject({ + ownerUserId: owner.id, + billingPeriodStart, + projectSlugs: {}, + projectNeurons: {}, + totalNeurons: initialNeurons, + }); + expect(Object.keys(saved.historicalAiWindows)).toHaveLength(1); + expect(window).toMatchObject({ + projectId: project.id, + ownershipStartedAt, + neurons: initialNeurons, + }); + expect(Date.parse(window!.ownershipEndedAt)).toBeGreaterThan(Date.parse(ownershipStartedAt)); + expect(await storage.getAlarm()).toBeGreaterThan(Date.now()); + + const restarted = makeCounter(); + await expect(restarted.alarm()).rejects.toThrow('AI usage query returned no account data'); + expect(await storage.get('state')).toEqual(saved); + expect(await storage.getAlarm()).toBeGreaterThan(Date.now()); + unavailable = false; + await restarted.alarm(); + expect(queries).toHaveLength(2); + for (const query of queries) { + expect(query).toContain(`projectId: "${project.id}"`); + expect(query).toContain(`datetime_geq: "${ownershipStartedAt}"`); + expect(query).toContain(`datetime_lt: "${window!.ownershipEndedAt}"`); + } + const recovered = (await storage.get('state'))!; + expect(recovered).toMatchObject({ totalNeurons: 30, projectSlugs: {}, projectNeurons: {} }); + expect(Object.values(recovered.historicalAiWindows)).toEqual([{ ...window, neurons: 30 }]); + }, +); diff --git a/platform/packages/api/test/integration/api-project-team.test.ts b/platform/packages/api/test/integration/api-project-team.test.ts index dda2beae2..032a9f4e3 100644 --- a/platform/packages/api/test/integration/api-project-team.test.ts +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm'; +import { and, count, eq, gt, sql } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { createBuild, @@ -839,7 +839,7 @@ describe('project team API', () => { expect(declinedAgain.status).toBe(204); }); - it('refreshes an expired pending invitation instead of consuming another pending slot', async () => { + it('refreshes an expired pending invitation using the same invitation ID', async () => { const owner = await createTestUser(env.db, 'owner'); const target = await createTestUser(env.db, 'target'); await setEmail(target.id, 'target@example.com'); @@ -861,6 +861,220 @@ describe('project team API', () => { expect(await env.db.select().from(projectInvitations)).toHaveLength(1); }); + describe('pending invitation capacity', () => { + async function seedInvitations( + projectId: string, + ownerId: string, + liveCount: number, + ): Promise { + const targets = Array.from({ length: liveCount + 1 }, (_, index) => ({ + id: `${projectId}-target-${index}`, + provider: 'github', + provider_id: `${projectId}-provider-${index}`, + login: `target-${index}`, + email: `target-${index}@example.com`, + })); + await env.db.insert(users).values(targets); + await env.db.insert(projectInvitations).values( + targets.map((target, index) => ({ + id: `${projectId}-invitation-${index}`, + project_id: projectId, + target_user_id: target.id, + target_email_snapshot: target.email, + role: 'reader' as const, + invited_by_user_id: ownerId, + expires_at: + index < liveCount ? sql`now() + interval '1 day'` : sql`now() - interval '1 day'`, + })), + ); + } + + async function liveInvitationCount(projectId: string): Promise { + const [result] = await env.db + .select({ count: count() }) + .from(projectInvitations) + .where( + and( + eq(projectInvitations.project_id, projectId), + eq(projectInvitations.status, 'pending'), + gt(projectInvitations.expires_at, sql`now()`), + ), + ); + return result!.count; + } + + it.each(['create', 'refresh', 'resend'] as const)( + 'rejects %s at capacity without changing invitations, auditing, or emailing', + async (action) => { + const owner = await createTestUser(env.db, 'capacity-owner'); + const target = await createTestUser(env.db, 'capacity-new-target'); + await setEmail(target.id, 'new-target@example.com'); + const project = await createTestProject(env.db, owner.id); + await seedInvitations(project.id, owner.id, 100); + const before = await env.db.select().from(projectInvitations); + const send = vi.fn().mockResolvedValue(undefined); + + const response = await app.fetch( + action === 'resend' + ? request( + `/projects/${project.id}/invitations/${project.id}-invitation-100/resend`, + owner.jwt, + { + method: 'POST', + }, + ) + : request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { + email: action === 'create' ? 'new-target@example.com' : 'target-100@example.com', + role: 'admin', + }, + }), + { ...env, EMAIL: { send } as unknown as SendEmail }, + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: 'project has too many pending invitations', + }); + expect(await env.db.select().from(projectInvitations)).toEqual(before); + expect(await liveInvitationCount(project.id)).toBe(100); + expect(await env.db.select().from(projectAccessEvents)).toEqual([]); + expect(send).not.toHaveBeenCalled(); + }, + ); + + it.each(['create', 'refresh', 'resend'] as const)( + 'allows %s to consume the final live slot despite expired and historical invitations', + async (action) => { + const owner = await createTestUser(env.db, 'capacity-owner'); + const target = await createTestUser(env.db, 'capacity-new-target'); + await setEmail(target.id, 'new-target@example.com'); + const project = await createTestProject(env.db, owner.id); + await seedInvitations(project.id, owner.id, 99); + await env.db.insert(projectInvitations).values( + (['accepted', 'declined', 'revoked', 'expired'] as const).map((status) => ({ + id: `${project.id}-history-${status}`, + project_id: project.id, + target_user_id: target.id, + target_email_snapshot: 'new-target@example.com', + role: 'reader' as const, + invited_by_user_id: owner.id, + status, + expires_at: sql`now() + interval '1 day'`, + })), + ); + + const response = await app.fetch( + action === 'resend' + ? request( + `/projects/${project.id}/invitations/${project.id}-invitation-99/resend`, + owner.jwt, + { + method: 'POST', + }, + ) + : request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { + email: action === 'create' ? 'new-target@example.com' : 'target-99@example.com', + role: 'admin', + }, + }), + env, + ); + + expect(response.status).toBe(action === 'create' ? 201 : 200); + expect(await response.json()).toMatchObject({ + invitation: { + ...(action === 'create' ? {} : { id: `${project.id}-invitation-99` }), + status: 'pending', + role: action === 'resend' ? 'reader' : 'admin', + }, + }); + expect(await liveInvitationCount(project.id)).toBe(100); + expect(await env.db.select().from(projectAccessEvents)).toMatchObject([ + { action: action === 'resend' ? 'invitation_resent' : 'invited' }, + ]); + }, + ); + + it.each(['refresh', 'resend'] as const)( + 'allows %s of an existing live invitation at capacity without consuming another slot', + async (action) => { + const owner = await createTestUser(env.db, 'capacity-owner'); + const project = await createTestProject(env.db, owner.id); + await seedInvitations(project.id, owner.id, 100); + const invitationId = `${project.id}-invitation-0`; + const [before] = await env.db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.id, invitationId)); + + const response = await app.fetch( + action === 'resend' + ? request(`/projects/${project.id}/invitations/${invitationId}/resend`, owner.jwt, { + method: 'POST', + }) + : request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { email: 'target-0@example.com', role: 'admin' }, + }), + env, + ); + + expect(response.status).toBe(200); + const [after] = await env.db + .select() + .from(projectInvitations) + .where(eq(projectInvitations.id, invitationId)); + expect(Date.parse(after!.expires_at)).toBeGreaterThan(Date.parse(before!.expires_at)); + expect(after!.role).toBe(action === 'resend' ? 'reader' : 'admin'); + expect(await liveInvitationCount(project.id)).toBe(100); + expect(await env.db.select().from(projectInvitations)).toHaveLength(101); + expect(await env.db.select().from(projectAccessEvents)).toHaveLength(1); + }, + ); + + it('serializes competing expired invitation renewals for the final live slot', async () => { + const owner = await createTestUser(env.db, 'capacity-owner'); + const target = await createTestUser(env.db, 'capacity-resend-target'); + await setEmail(target.id, 'resend-target@example.com'); + const project = await createTestProject(env.db, owner.id); + await seedInvitations(project.id, owner.id, 99); + const resendId = `${project.id}-resend`; + await env.db.insert(projectInvitations).values({ + id: resendId, + project_id: project.id, + target_user_id: target.id, + target_email_snapshot: 'resend-target@example.com', + role: 'reader', + invited_by_user_id: owner.id, + expires_at: sql`now() - interval '1 day'`, + }); + + const responses = await Promise.all([ + app.fetch( + request(`/projects/${project.id}/invitations`, owner.jwt, { + method: 'POST', + body: { email: 'target-99@example.com', role: 'admin' }, + }), + env, + ), + app.fetch( + request(`/projects/${project.id}/invitations/${resendId}/resend`, owner.jwt, { + method: 'POST', + }), + env, + ), + ]); + + expect(responses.map((response) => response.status).sort()).toEqual([200, 409]); + expect(await liveInvitationCount(project.id)).toBe(100); + expect(await env.db.select().from(projectAccessEvents)).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'); diff --git a/platform/packages/api/test/integration/platform-onboarding.test.ts b/platform/packages/api/test/integration/platform-onboarding.test.ts index 6d4616b14..203bff4f1 100644 --- a/platform/packages/api/test/integration/platform-onboarding.test.ts +++ b/platform/packages/api/test/integration/platform-onboarding.test.ts @@ -70,6 +70,11 @@ describe('platform onboarding pages', () => { 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).toContain('The CLI checks that it is still valid'); + expect(html).toContain('run void project link if it is not linked yet'); + expect(html).toContain('use a separate checkout without a .void/project.json'); + expect(html).toContain('Accepting an invitation keeps existing project links.'); + expect(html).not.toContain('choose a project on this platform before deploying'); expect(html).not.toMatch(/dashboard/i); }); diff --git a/platform/packages/api/test/unit/usage-ai-query.test.ts b/platform/packages/api/test/unit/usage-ai-query.test.ts new file mode 100644 index 000000000..15aa83bb1 --- /dev/null +++ b/platform/packages/api/test/unit/usage-ai-query.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { queryProjectAiUsage } from '../../src/routes/usage.ts'; + +const apiToken = 'private-cloudflare-token'; +const since = new Date('2026-09-01T00:00:00.000Z'); +const until = new Date('2026-09-02T00:00:00.000Z'); + +function payload(requests: unknown): unknown { + return { data: { viewer: { accounts: [{ aiGatewayRequests: requests }] } } }; +} + +function respond(body: unknown, status = 200): void { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json(body, { status })); +} + +function query(failOnError = true): Promise<{ neurons: number }> { + return queryProjectAiUsage('account-1', apiToken, 'void', 'project-1', since, until, failOnError); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('queryProjectAiUsage strict accounting', () => { + it('queries the requested ownership interval and converts valid costs to neurons', async () => { + respond(payload([{ sum: { cost: 0.000022 } }])); + + await expect(query()).resolves.toEqual({ neurons: 2 }); + + const [url, init] = vi.mocked(fetch).mock.calls[0]; + expect(url).toBe('https://api.cloudflare.com/client/v4/graphql'); + const request = JSON.parse(String(init?.body)); + expect(request.query).toContain('metadata_projectId: "project-1"'); + expect(request.query).toContain(`datetime_geq: "${since.toISOString()}"`); + expect(request.query).toContain(`datetime_lt: "${until.toISOString()}"`); + }); + + it.each([{ requests: [] }, { requests: [{ sum: { cost: 0 } }] }])( + 'accepts a valid zero-usage result $requests', + async ({ requests }) => { + respond(payload(requests)); + await expect(query()).resolves.toEqual({ neurons: 0 }); + }, + ); + + it.each([ + null, + {}, + { data: null }, + { data: { viewer: { accounts: [] } } }, + { data: { viewer: { accounts: {} } } }, + ])('rejects missing account data %j', async (body) => { + respond(body); + await expect(query()).rejects.toThrow('AI usage query returned no account data'); + }); + + it.each([undefined, null, {}, false, [null, null]])( + 'rejects malformed request aggregates %j', + async (requests) => { + respond(payload(requests)); + await expect(query()).rejects.toThrow('AI usage query returned invalid request data'); + }, + ); + + it.each([null, {}, { sum: null }, { sum: {} }])('rejects missing costs %j', async (row) => { + respond(payload([row])); + await expect(query()).rejects.toThrow('AI usage query returned invalid cost data'); + }); + + it.each([-1, '0.000022', null, Number.MAX_VALUE])('rejects invalid cost %j', async (cost) => { + respond(payload([{ sum: { cost } }])); + await expect(query()).rejects.toThrow('AI usage query returned invalid cost data'); + }); + + it('rejects a nonfinite cost decoded from valid JSON', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + '{"data":{"viewer":{"accounts":[{"aiGatewayRequests":[{"sum":{"cost":1e400}}]}]}}}', + ), + ); + await expect(query()).rejects.toThrow('AI usage query returned invalid cost data'); + }); + + it.each([null, { viewer: { accounts: [{ aiGatewayRequests: [{ sum: { cost: 0.000022 } }] }] } }])( + 'rejects HTTP 200 GraphQL errors even alongside data %j', + async (data) => { + respond({ data, errors: [{ message: 'not authorized to access aiGatewayRequests' }] }); + await expect(query()).rejects.toThrow( + 'AI usage query failed: not authorized to access aiGatewayRequests', + ); + }, + ); + + it('retains bounded provider messages without echoed queries, credentials, or extensions', async () => { + respond({ + errors: [ + { + message: `not authorized ${apiToken}\nBearer unrelated-secret; query { viewer { secretQueryField } }`, + extensions: { token: 'secret-extension-value', query: 'secret-query-value' }, + }, + { message: `rate limit exceeded ${'detail '.repeat(100)}` }, + { message: 'third error' }, + { message: 'fourth error is omitted' }, + ], + }); + + const error = await query().catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('not authorized'); + expect(message).toContain('rate limit exceeded'); + expect(message).toContain('third error'); + expect(message).not.toMatch(/secret|private-cloudflare-token|fourth error|[\r\n]/); + expect(message.length).toBeLessThan(650); + }); + + it.each([{ message: 'unexpected shape' }, [null], [{ message: 123 }]])( + 'rejects malformed GraphQL errors %j without reporting zero usage', + async (errors) => { + respond({ data: { viewer: { accounts: [{ aiGatewayRequests: [] }] } }, errors }); + await expect(query()).rejects.toThrow('AI usage query'); + }, + ); + + it.each([null, []])('accepts an error-free response with errors %j', async (errors) => { + respond({ data: { viewer: { accounts: [{ aiGatewayRequests: [] }] } }, errors }); + await expect(query()).resolves.toEqual({ neurons: 0 }); + }); + + it('reports HTTP failures without exposing the response body', async () => { + respond({ error: apiToken }, 403); + await expect(query()).rejects.toThrow('AI usage query failed with HTTP 403'); + }); + + it('reports invalid JSON without echoing the provider body', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(`invalid ${apiToken}`)); + await expect(query()).rejects.toThrow('AI usage query returned invalid JSON'); + }); +}); + +describe('queryProjectAiUsage best-effort compatibility', () => { + it.each([{}, { data: { viewer: { accounts: [] } } }, { errors: [{ message: 'unavailable' }] }])( + 'preserves the default zero fallback for unavailable data %j', + async (body) => { + respond(body); + await expect(query(false)).resolves.toEqual({ neurons: 0 }); + }, + ); + + it('preserves usable partial data when GraphQL also reports errors', async () => { + respond({ + data: { viewer: { accounts: [{ aiGatewayRequests: [{ sum: { cost: 0.000022 } }] }] } }, + errors: [{ message: 'partial result' }], + }); + await expect(query(false)).resolves.toEqual({ neurons: 2 }); + }); + + it('preserves the default zero fallback for HTTP failures', async () => { + respond({}, 503); + await expect(query(false)).resolves.toEqual({ neurons: 0 }); + }); +}); diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index c7587e3ad..ed69e2a81 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -264,6 +264,7 @@ function makeFakeStorage() { let pending: Map | null = null; let rejectCommit = false; let alarm: number | null = null; + let pendingAlarm: number | null = null; type ListOptions = { prefix: string; startAfter?: string; limit: number }; type Transaction = { get: (key: string) => Promise; @@ -300,6 +301,7 @@ function makeFakeStorage() { }), transaction: async (callback: (transaction: Transaction) => Promise): Promise => { pending = new Map(data); + pendingAlarm = alarm; try { const result = await callback({ ...storage, @@ -313,14 +315,26 @@ function makeFakeStorage() { for (const [key, value] of pending) { data.set(key, value); } + alarm = pendingAlarm; return result; } finally { pending = null; } }, - getAlarm: async () => alarm, + getAlarm: async () => (pending ? pendingAlarm : alarm), setAlarm: async (ts: number) => { - alarm = ts; + if (pending) { + pendingAlarm = ts; + } else { + alarm = ts; + } + }, + deleteAlarm: async () => { + if (pending) { + pendingAlarm = null; + } else { + alarm = null; + } }, }; return storage; @@ -1439,6 +1453,9 @@ describe('UsageCounter ownership changes', () => { }); expect((await counter.fetch(detachRequest())).ok).toBe(true); + expect(queryProjectAiUsage).not.toHaveBeenCalled(); + expect(storage.data.get('state')).toMatchObject({ totalNeurons: 42, projectSlugs: {} }); + await counter.alarm(); expect(queryProjectAiUsage).toHaveBeenLastCalledWith( undefined, undefined, @@ -1464,6 +1481,7 @@ describe('UsageCounter ownership changes', () => { }); expect((await counter.fetch(detachRequest(extendedOwnershipEndedAt))).ok).toBe(true); + await counter.alarm(); expect(queryProjectAiUsage).toHaveBeenLastCalledWith( undefined, undefined, @@ -1484,6 +1502,7 @@ describe('UsageCounter ownership changes', () => { }); expect((await counter.fetch(detachRequest())).ok).toBe(true); + await counter.alarm(); expect(queryProjectAiUsage).toHaveBeenLastCalledWith( undefined, undefined, @@ -1510,7 +1529,7 @@ describe('UsageCounter ownership changes', () => { 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(getUser).mockResolvedValue({ id: 'former-owner', plan: 'custom' } as never); vi.mocked(queryProjectAiUsage).mockResolvedValueOnce({ neurons: 57 }); const { counter, storage } = makeCounter(); @@ -1528,6 +1547,8 @@ describe('UsageCounter ownership changes', () => { ); expect(response.ok).toBe(true); + expect(queryProjectAiUsage).not.toHaveBeenCalled(); + await counter.alarm(); expect(queryProjectAiUsage).toHaveBeenCalledWith( undefined, undefined, @@ -1599,6 +1620,8 @@ describe('UsageCounter ownership changes', () => { ); expect(response.ok).toBe(true); + expect(queryProjectAiUsage).not.toHaveBeenCalled(); + await counter.alarm(); expect(dbCalls.unsuspendIfReason).toContainEqual({ id: 'u1', reason: 'requests_exceeded', @@ -1638,9 +1661,9 @@ describe('UsageCounter ownership changes', () => { }); }); - it('keeps current ownership state retryable when cutoff reconciliation fails', async () => { - vi.mocked(queryProjectAiUsage).mockRejectedValueOnce(new Error('analytics unavailable')); - const { counter, storage } = makeCounter({ + it('persists a detached window and retries reconciliation without losing its last sample', async () => { + vi.mocked(queryProjectAiUsage).mockRejectedValue(new Error('analytics unavailable')); + const { counter, storage, env } = makeCounter({ ownerUserId: 'former-owner', plan: 'solo', totalNeurons: 42, @@ -1648,26 +1671,68 @@ describe('UsageCounter ownership changes', () => { 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', - }), + const response = await 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({ + }), + ); + expect(response.ok).toBe(true); + expect(queryProjectAiUsage).not.toHaveBeenCalled(); + const saved = structuredClone(storage.data.get('state')); + expect(saved).toMatchObject({ totalNeurons: 42, projectSlugs: {}, projectNeurons: {} }); + expect(Object.values((saved as CounterState).historicalAiWindows)).toEqual([ + { + projectId: 'transferred', + ownershipStartedAt: '2026-02-01T00:00:00.000Z', + ownershipEndedAt: '2026-03-01T00:00:00.000Z', + neurons: 42, + }, + ]); + expect(await storage.getAlarm()).toEqual(expect.any(Number)); + const restarted = new UsageCounter({ storage } as never, env as never); + Object.assign(restarted, { ctx: { storage }, env }); + // Exhaust the native retry count: the durable schedule must still survive. + for (let attempt = 0; attempt < 7; attempt++) { + await storage.setAlarm(0); + await expect(restarted.alarm()).rejects.toThrow('analytics unavailable'); + expect(await storage.getAlarm()).toBeGreaterThan(Date.now()); + expect(storage.data.get('state')).toEqual(saved); + } + vi.mocked(queryProjectAiUsage).mockResolvedValue({ neurons: 57 }); + await restarted.alarm(); + expect(storage.data.get('state')).toMatchObject({ totalNeurons: 57, projectSlugs: {} }); + }); + + it('commits the detached window and reconciliation alarm together before acknowledging', async () => { + const { counter, storage } = makeCounter({ totalNeurons: 42, - projectSlugs: { transferred: 'app' }, projectNeurons: { transferred: 42 }, - historicalAiWindows: {}, + projectSlugs: { transferred: 'app' }, + projectOwnershipStarts: { transferred: '2026-02-01T00:00:00.000Z' }, }); + const before = structuredClone(storage.data.get('state')); + const request = () => + new Request('https://do/detach-project', { + method: 'POST', + body: JSON.stringify({ + projectId: 'transferred', + ownershipEndedAt: '2026-03-01T00:00:00.000Z', + }), + }); + storage.failNextCommit(); + await expect(counter.fetch(request())).rejects.toThrow('transaction commit failed'); + expect(storage.data.get('state')).toEqual(before); + expect(await storage.getAlarm()).toBeNull(); + expect((await counter.fetch(request())).ok).toBe(true); + expect(storage.data.get('state')).toMatchObject({ totalNeurons: 42, projectSlugs: {} }); + expect(await storage.getAlarm()).toBeGreaterThan(Date.now()); + expect(queryProjectAiUsage).not.toHaveBeenCalled(); }); it('queries AI only from the current ownership boundary', async () => { @@ -1690,6 +1755,211 @@ describe('UsageCounter ownership changes', () => { ); }); + describe('detached window lifecycle', () => { + const billingPeriodStart = '2026-01-01T00:00:00.000Z'; + const ownershipEndedAt = '2026-01-20T00:00:00.000Z'; + const windowKey = JSON.stringify(['deleted', billingPeriodStart, ownershipEndedAt]); + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime('2026-01-22T12:00:00.000Z'); + vi.mocked(getUser).mockImplementation( + async (_db, id) => + ({ + id, + plan: 'solo', + billing_cycle_start: billingPeriodStart, + }) as never, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function detachedState(overrides: Partial = {}): Partial { + return { + ownerUserId: 'u1', + plan: 'solo', + billingPeriodStart, + totalRequests: 100, + totalNeurons: 42, + historicalAiWindows: { + [windowKey]: { + projectId: 'deleted', + ownershipStartedAt: billingPeriodStart, + ownershipEndedAt, + neurons: 42, + }, + }, + ...overrides, + }; + } + + it.each(['solo', 'sponsored'] as const)( + 'ends %s historical reconciliation at rollover after the final project is deleted', + async (plan) => { + vi.mocked(getUser).mockResolvedValue({ + id: 'u1', + plan, + billing_cycle_start: billingPeriodStart, + } as never); + const { counter, storage } = makeCounter(detachedState({ plan })); + const setAlarm = vi.spyOn(storage, 'setAlarm'); + vi.setSystemTime('2026-02-02T12:00:00.000Z'); + + await counter.alarm(); + + expect(queryProjectAiUsage).not.toHaveBeenCalled(); + expect(storage.data.get('state')).toMatchObject({ + billingPeriodStart: '2026-02-01T00:00:00.000Z', + totalRequests: 0, + totalNeurons: 0, + historicalAiWindows: {}, + projectSlugs: {}, + }); + expect(setAlarm).not.toHaveBeenCalled(); + expect(await storage.getAlarm()).toBeNull(); + }, + ); + + it('stops reconciliation when the account has been deleted', async () => { + vi.mocked(getUser).mockResolvedValue(null); + const { counter, storage } = makeCounter(detachedState()); + await storage.setAlarm(Date.now() + 60_000); + const before = structuredClone(storage.data.get('state')); + const setAlarm = vi.spyOn(storage, 'setAlarm'); + + await counter.alarm(); + + expect(queryProjectAiUsage).not.toHaveBeenCalled(); + expect(storage.data.get('state')).toEqual(before); + expect(setAlarm).not.toHaveBeenCalled(); + expect(await storage.getAlarm()).toBeNull(); + }); + + it('reconciles unlimited account history without querying or blocking active projects', async () => { + vi.mocked(getUser).mockResolvedValue({ + id: 'u1', + plan: 'sponsored', + billing_cycle_start: billingPeriodStart, + } as never); + vi.mocked(queryProjectAiUsage).mockResolvedValue({ neurons: 57 }); + const { counter, storage, kv } = makeCounter( + detachedState({ + projectSlugs: { active: 'active-app' }, + projectOwnershipStarts: { active: billingPeriodStart }, + projectNeurons: { active: 100 }, + totalNeurons: 142, + }), + ); + + await counter.alarm(); + + expect(queryProjectAiUsage).toHaveBeenCalledExactlyOnceWith( + undefined, + undefined, + 'void', + 'deleted', + new Date(billingPeriodStart), + new Date(ownershipEndedAt), + true, + ); + expect(storage.data.get('state')).toMatchObject({ + plan: 'sponsored', + totalNeurons: 157, + projectNeurons: { active: 100 }, + aiSuspended: false, + historicalAiWindows: { [windowKey]: { neurons: 57 } }, + }); + expect(kv.calls).toEqual([]); + expect(await storage.getAlarm()).toBeGreaterThan(Date.now()); + }); + + it('preserves an earlier alarm when detaching a project', async () => { + const { counter, storage } = makeCounter({ + ownerUserId: 'u1', + plan: 'solo', + billingPeriodStart, + totalNeurons: 42, + projectSlugs: { deleted: 'app' }, + projectNeurons: { deleted: 42 }, + projectOwnershipStarts: { deleted: billingPeriodStart }, + }); + const earlierAlarm = Date.now() + 60_000; + await storage.setAlarm(earlierAlarm); + const setAlarm = vi.spyOn(storage, 'setAlarm'); + + const response = await counter.fetch( + new Request('https://do/detach-project', { + method: 'POST', + body: JSON.stringify({ projectId: 'deleted', ownershipEndedAt }), + }), + ); + + expect(response.ok).toBe(true); + expect(queryProjectAiUsage).not.toHaveBeenCalled(); + expect(await storage.getAlarm()).toBe(earlierAlarm); + expect(setAlarm).not.toHaveBeenCalled(); + expect(storage.data.get('state')).toMatchObject({ + historicalAiWindows: { [windowKey]: { neurons: 42 } }, + }); + }); + + it('clears active project suspensions while rolling over detached history', async () => { + const { counter, storage, kv } = makeCounter( + detachedState({ + requestsSuspended: true, + aiSuspended: true, + sandboxRuntimeSuspended: true, + projectSlugs: { retained: 'retained-app' }, + projectOwnershipStarts: { retained: billingPeriodStart }, + }), + ); + kv.store.set( + 'retained-app', + JSON.stringify({ + workerName: 'retained-worker', + projectId: 'retained', + userId: 'u1', + suspended: 'requests_exceeded', + }), + ); + kv.store.set('ai-block:retained', '1'); + kv.store.set('sandbox-block:u1', '1'); + vi.mocked(queryProjectAiUsage).mockResolvedValue({ neurons: 7 }); + vi.setSystemTime('2026-02-02T12:00:00.000Z'); + + await counter.alarm(); + + expect(queryProjectAiUsage).toHaveBeenCalledExactlyOnceWith( + undefined, + undefined, + 'void', + 'retained', + new Date('2026-02-01T00:00:00.000Z'), + ); + expect(storage.data.get('state')).toMatchObject({ + billingPeriodStart: '2026-02-01T00:00:00.000Z', + totalRequests: 0, + totalNeurons: 7, + historicalAiWindows: {}, + requestsSuspended: false, + aiSuspended: false, + sandboxRuntimeSuspended: false, + }); + expect(JSON.parse(kv.store.get('retained-app')!)).not.toHaveProperty('suspended'); + expect(kv.store.has('ai-block:retained')).toBe(false); + expect(kv.store.has('sandbox-block:u1')).toBe(false); + expect(dbCalls.unsuspendIfReason).toEqual( + expect.arrayContaining([ + { id: 'u1', reason: 'requests_exceeded' }, + { id: 'u1', reason: 'sandbox_runtime_exceeded' }, + ]), + ); + }); + }); + 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';